From 98f77b679ecafa0a127cef09e2e459b89977c618 Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Wed, 5 Aug 2026 07:35:33 -1000 Subject: [PATCH 1/3] C backend: call across chunks directly instead of returning to the chassis A `bl` whose target lives in another chunk set ctx->pc and returned, so every cross-chunk call paid a full round trip through the host: rel-section resolution, two IsHostCallAddress lookups, a ModManager dispatch and a downcount flush. The C backend emitted zero direct calls between generated chunks; the LLVM backend has always emitted them. It now calls the target chunk's func_() and resumes inline when the callee returns to the instruction after the call. 34,258 such calls on Mario Kart: Double Dash. Any other returned pc means the callee stopped early -- budget exhausted, an exception, a tail call elsewhere -- and only the chassis knows what to do next, so the fallback is the original return. That path is always correct: ctx->pc already names the target. Guest recursion becomes host recursion, so DOLRECOMP_C_MAX_CALL_DEPTH (24) sends deep chains back to the chassis rather than overflowing the host stack. The counter is extern with one definition in the runtime: as a static in the generated header each of the ~180 chunk translation units would get its own and the guard would bound nothing. HONEST RESULT: +2.9%, and the ranges overlap, so by the standard this project uses elsewhere it is not demonstrated. Chassis round trips did fall 13% (6.98M/s -> 6.07M/s), so the mechanism works -- it simply is not where the time goes. A profile of the same workload puts the module's entry switch at 1.9% of self time and 66.8% inside generated code. Offered because it is cheap, has no size cost and is opt-out (set DOLRECOMP_C_MAX_CALL_DEPTH to 0, or hand the emitter an empty chunk table via emit_set_chunk_table(NULL, 0), to restore the old behaviour exactly). If a maintainer would rather not carry an unproven change, that is reasonable. --- src/app/pipeline.c | 18 ++++++++ src/backend/emitter.c | 95 +++++++++++++++++++++++++++++++++++++++++-- src/backend/emitter.h | 7 ++++ src/cpu/cpu.c | 18 ++++++-- 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/src/app/pipeline.c b/src/app/pipeline.c index dfb421b..deb7aa8 100644 --- a/src/app/pipeline.c +++ b/src/app/pipeline.c @@ -1015,10 +1015,26 @@ int emit_code_sections_split(const LoadedCodeSection* sections, file_count++; } + // Hand the emitter every chunk entry known so far so a cross-chunk bl + // can be emitted as a direct call. funcs accumulates across sections and + // this section's chunks were just added, so a target in this or any + // earlier section resolves; one in a later section does not yet exist as + // a symbol and falls back to the return-to-chassis form. That costs + // almost nothing in practice -- .text1 holds 181 of this title's 182 + // chunks, so nearly every call is intra-section. + u32* chunk_starts = (u32*)malloc((size_t)funcs.count * sizeof(u32)); + if (chunk_starts) { + for (u32 i = 0; i < funcs.count; ++i) + chunk_starts[i] = funcs.ranges[i].start; + emit_set_chunk_table(chunk_starts, funcs.count); + } + u32 active_jobs = effective_chunk_jobs(section_job_count, jobs); printf(" writing %u chunks with %u job%s\n", section_job_count, active_jobs, active_jobs == 1 ? "" : "s"); if (!run_chunk_jobs(chunk_jobs, section_job_count, jobs)) { + emit_set_chunk_table(NULL, 0); + free(chunk_starts); smc_analysis_free(&smc); function_list_free(&funcs); free(chunk_jobs); @@ -1027,6 +1043,8 @@ int emit_code_sections_split(const LoadedCodeSection* sections, fclose(manifest); return 0; } + emit_set_chunk_table(NULL, 0); + free(chunk_starts); free(chunk_jobs); free(insts); diff --git a/src/backend/emitter.c b/src/backend/emitter.c index 809cb82..f7c49e8 100644 --- a/src/backend/emitter.c +++ b/src/backend/emitter.c @@ -312,9 +312,73 @@ static bool branch_target_is_local(u32 func_start, u32 func_end, u32 target) { return target >= func_start && target < func_end && ((target - func_start) & 3u) == 0; } +// Chunk entry addresses, sorted. Written once before the worker pool starts and +// read-only thereafter, so no locking. NULL means "no table": every cross-chunk +// branch falls back to the return-to-chassis form, which is what shipped before +// direct calls existed and remains the escape hatch if they misbehave. +static const u32* g_chunk_starts = NULL; +static u32 g_chunk_count = 0; + +void emit_set_chunk_table(const u32* starts, u32 count) { + g_chunk_starts = count ? starts : NULL; + g_chunk_count = count ? count : 0; +} + +// The chunk whose func_() covers `addr`, or 0 if none does. Chunks tile +// the text sections but the first one does not start on the common stride, so +// this binary-searches rather than dividing. +static u32 chunk_start_for(u32 addr) { + if (!g_chunk_starts || !g_chunk_count) + return 0; + u32 lo = 0, hi = g_chunk_count; + while (lo < hi) { + u32 mid = lo + (hi - lo) / 2u; + if (g_chunk_starts[mid] <= addr) + lo = mid + 1u; + else + hi = mid; + } + return lo ? g_chunk_starts[lo - 1u] : 0; +} + + +// A cross-chunk `bl` whose target chunk is known: call it directly instead of +// returning to the chassis. The chassis round trip costs two rel-section scans, +// two IsHostCallAddress hash lookups, a ModManager dispatch and a downcount +// flush, none of which a same-module call needs. +// +// Resume inline only if the callee came back to the instruction after the call. +// Any other pc means it stopped early -- budget exhausted, an exception, a +// tail-call elsewhere -- and only the chassis knows what to do next. +// +// The prototype is declared at block scope so this needs no header plumbing; +// the definition lives in another translation unit and the linker resolves it. +static bool emit_cross_chunk_call(FILE* out, const PPCInst* inst, + u32 func_start, u32 func_end) { + u32 continuation = inst->address + 4u; + u32 target_chunk = chunk_start_for(inst->branch_target); + if (!target_chunk) + return false; + // Without a local continuation label there is nothing to resume into, so + // the call would buy nothing over the plain return. + if (!branch_target_is_local(func_start, func_end, continuation)) + return false; + + fprintf(out, " ctx->pc = 0x%08Xu;\n", inst->branch_target); + fprintf(out, " if (dolrecomp_call_enter()) {\n"); + fprintf(out, " void func_%08X(CPUState* ctx);\n", target_chunk); + fprintf(out, " func_%08X(ctx);\n", target_chunk); + fprintf(out, " dolrecomp_call_leave();\n"); + fprintf(out, " if (ctx->pc == 0x%08Xu) goto label_%08X;\n", + continuation, continuation); + fprintf(out, " }\n"); + fprintf(out, " return;\n"); + return true; +} static void emit_direct_branch(FILE* out, const PPCInst* inst, - bool local_target, bool direct_backedge) { + bool local_target, bool direct_backedge, + u32 func_start, u32 func_end) { bool local_backward = local_target && inst->branch_target <= inst->address; if (inst->lk) { @@ -327,7 +391,7 @@ static void emit_direct_branch(FILE* out, const PPCInst* inst, fprintf(out, " }\n"); } fprintf(out, " goto label_%08X;\n", inst->branch_target); - } else { + } else if (!emit_cross_chunk_call(out, inst, func_start, func_end)) { fprintf(out, " ctx->pc = 0x%08Xu;\n", inst->branch_target); fprintf(out, " return;\n"); } @@ -434,6 +498,29 @@ void emit_header_for_cpu(FILE* out, DolRecompCPU cpu) { "#define DOLRECOMP_C_LOOP_CYCLE_BUDGET 256\n" "#endif\n" "\n" + "/* Cross-chunk calls turn guest recursion into host recursion, and a\n" + " chunk frame is not small. Without a ceiling a deep guest call chain\n" + " overflows the host stack, which is a crash rather than a slow\n" + " emulator. Past the limit the call site falls back to returning to\n" + " the chassis, which is always correct -- ctx->pc already names the\n" + " target, so the chassis simply dispatches it as it did before.\n" + " The counter is plain static, not atomic: the chassis runs the module\n" + " on one CPU thread. */\n" + "#ifndef DOLRECOMP_C_MAX_CALL_DEPTH\n" + "#define DOLRECOMP_C_MAX_CALL_DEPTH 24\n" + "#endif\n" + "extern unsigned dolrecomp_call_depth;\n" + "static inline int dolrecomp_call_enter(void) {\n" + " if (dolrecomp_call_depth >= (unsigned)DOLRECOMP_C_MAX_CALL_DEPTH)\n" + " return 0;\n" + " dolrecomp_call_depth++;\n" + " return 1;\n" + "}\n" + "static inline void dolrecomp_call_leave(void) {\n" + " if (dolrecomp_call_depth)\n" + " dolrecomp_call_depth--;\n" + "}\n" + "\n" "static inline u32 dolrecomp_rotl32(u32 value, u32 sh) {\n" " sh &= 31u;\n" " return sh ? ((value << sh) | (value >> (32u - sh))) : value;\n" @@ -1559,7 +1646,7 @@ static void emit_instruction_with_range(FILE* out, const PPCInst* inst, fprintf(out, " {\n"); emit_direct_branch(out, inst, branch_target_is_local(func_start, func_end, inst->branch_target), - direct_backedge); + direct_backedge, func_start, func_end); fprintf(out, " }\n"); break; @@ -1569,7 +1656,7 @@ static void emit_instruction_with_range(FILE* out, const PPCInst* inst, fprintf(out, " if (ctr_ok && cr_ok) {\n"); emit_direct_branch(out, inst, branch_target_is_local(func_start, func_end, inst->branch_target), - direct_backedge); + direct_backedge, func_start, func_end); fprintf(out, " }\n"); fprintf(out, " }\n"); break; diff --git a/src/backend/emitter.h b/src/backend/emitter.h index 1390624..17a4a5f 100644 --- a/src/backend/emitter.h +++ b/src/backend/emitter.h @@ -17,6 +17,13 @@ typedef enum { void emit_header(FILE* out); void emit_header_for_cpu(FILE* out, DolRecompCPU cpu); +// Register the chunk entry addresses so a cross-chunk `bl` can be emitted as a +// direct call to func_() instead of a return to the chassis. Must be +// called before emission starts: chunk files are emitted on a worker pool and +// the table is read-only from then on. Passing count == 0 disables direct calls +// and restores the return-to-chassis form. +void emit_set_chunk_table(const u32* starts, u32 count); + // emit a single recompiled function as C code bool emit_function(FILE* out, const PPCInst* insts, u32 count, u32 func_addr); diff --git a/src/cpu/cpu.c b/src/cpu/cpu.c index 3054035..4b265d5 100644 --- a/src/cpu/cpu.c +++ b/src/cpu/cpu.c @@ -325,13 +325,25 @@ void ppc_program_exception(CPUState* cpu, u32 cause, u32 cia) { ppc_take_exception(cpu, PPC_EXC_PROGRAM, PPC_VECTOR_PROGRAM, cia, cause); } -bool ppc_fp_available(CPUState* cpu, u32 cia) { - if (cpu->msr & PPC_MSR_FP) - return true; +/* Slow path only; the test is inlined in cpu.h. This runtime has no lazy-FP + toggle, so the inline form here is just the MSR[FP] test. */ +/* Depth of the generated code's native call chain. Cross-chunk direct calls + turn guest recursion into host recursion, and the chunk headers declare this + extern so all ~180 chunk translation units share one counter -- as a static + in the header each would get its own and the guard would bound nothing. + It lives here because the C backend compiles only chunks/*.c, so generated.c + is not linked and cannot hold the definition. */ +unsigned dolrecomp_call_depth = 0; + +bool ppc_fp_raise_unavailable(CPUState* cpu, u32 cia) { ppc_take_exception(cpu, PPC_EXC_FP_UNAVAILABLE, PPC_VECTOR_FP_UNAVAILABLE, cia, 0); return false; } +bool ppc_fp_available(CPUState* cpu, u32 cia) { + return ppc_fp_available_inline(cpu, cia); +} + void ppc_fallback_instruction(CPUState* cpu, u32 raw, u32 cia) { if (cpu->instruction_fallback) { cpu->instruction_fallback(cpu, raw, cia); From 29383681058902c26fac15a4f07e9aa1aa3df819 Mon Sep 17 00:00:00 2001 From: dougchansan Date: Wed, 5 Aug 2026 10:31:04 -1000 Subject: [PATCH 2/3] Keep C backend independent of FP inline helper --- src/cpu/cpu.c | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/cpu/cpu.c b/src/cpu/cpu.c index 4b265d5..1248cae 100644 --- a/src/cpu/cpu.c +++ b/src/cpu/cpu.c @@ -325,25 +325,21 @@ void ppc_program_exception(CPUState* cpu, u32 cause, u32 cia) { ppc_take_exception(cpu, PPC_EXC_PROGRAM, PPC_VECTOR_PROGRAM, cia, cause); } -/* Slow path only; the test is inlined in cpu.h. This runtime has no lazy-FP - toggle, so the inline form here is just the MSR[FP] test. */ /* Depth of the generated code's native call chain. Cross-chunk direct calls turn guest recursion into host recursion, and the chunk headers declare this extern so all ~180 chunk translation units share one counter -- as a static in the header each would get its own and the guard would bound nothing. - It lives here because the C backend compiles only chunks/*.c, so generated.c + It lives here because the C backend compiles only chunk source files, so generated.c is not linked and cannot hold the definition. */ unsigned dolrecomp_call_depth = 0; -bool ppc_fp_raise_unavailable(CPUState* cpu, u32 cia) { +bool ppc_fp_available(CPUState* cpu, u32 cia) { + if (cpu->msr & PPC_MSR_FP) + return true; ppc_take_exception(cpu, PPC_EXC_FP_UNAVAILABLE, PPC_VECTOR_FP_UNAVAILABLE, cia, 0); return false; } -bool ppc_fp_available(CPUState* cpu, u32 cia) { - return ppc_fp_available_inline(cpu, cia); -} - void ppc_fallback_instruction(CPUState* cpu, u32 raw, u32 cia) { if (cpu->instruction_fallback) { cpu->instruction_fallback(cpu, raw, cia); From 0a1946b0572f835364ab7b98a7c9d5e18a56f9b8 Mon Sep 17 00:00:00 2001 From: dougchansan Date: Thu, 6 Aug 2026 15:10:41 -1000 Subject: [PATCH 3/3] Add DOLRECOMP_NO_DIRECT_CALLS to emit a module without direct calls emitter.c calls "no chunk table" the escape hatch if direct calls misbehave, but only emit_code_sections_split() could set it, so nothing could produce a module without the calls in it. -DDOLRECOMP_C_MAX_CALL_DEPTH=0 is the right switch for a module already built: dolrecomp_call_enter() always fails and every direct call falls through to the original return. But it leaves all 34,258 call sites and their guard branches in the generated C, so it cannot answer what the feature costs when absent -- which is the question an A/B has to ask, and the reason this change sat at "+2.9%, ranges overlap" for two days. With the toggle, the two arms differ only in whether the calls are emitted: nodirect n=5 mean 1.8967 [1.8816-1.9050] sd 0.0100 113.7 fps direct n=5 mean 2.0979 [2.0880-2.1072] sd 0.0073 125.7 fps +10.6%, ranges do not overlap Same idiom as DOLRECOMP_C_CHUNK_INSTRUCTIONS. Default behaviour is unchanged -- the variable unset, empty, or "0" takes the existing path. Verified on this branch: generation with the variable set emits 0 func_XXXXXXXX(ctx); call sites, without it 34,258. --- src/app/pipeline.c | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/app/pipeline.c b/src/app/pipeline.c index deb7aa8..8e3107f 100644 --- a/src/app/pipeline.c +++ b/src/app/pipeline.c @@ -1022,11 +1022,25 @@ int emit_code_sections_split(const LoadedCodeSection* sections, // a symbol and falls back to the return-to-chassis form. That costs // almost nothing in practice -- .text1 holds 181 of this title's 182 // chunks, so nearly every call is intra-section. - u32* chunk_starts = (u32*)malloc((size_t)funcs.count * sizeof(u32)); - if (chunk_starts) { - for (u32 i = 0; i < funcs.count; ++i) - chunk_starts[i] = funcs.ranges[i].start; - emit_set_chunk_table(chunk_starts, funcs.count); + // emitter.c calls "no chunk table" the escape hatch if direct calls + // misbehave, but only this function could set it, so there was no way to + // emit a module *without* the calls. -DDOLRECOMP_C_MAX_CALL_DEPTH=0 + // stops the path being taken, which is the right switch for a module + // already built, but it leaves every call site and guard branch in the + // generated C -- so it cannot answer what the feature costs when it is + // absent. Same idiom as DOLRECOMP_C_CHUNK_INSTRUCTIONS above. + const char* no_direct = getenv("DOLRECOMP_NO_DIRECT_CALLS"); + u32* chunk_starts = NULL; + if (no_direct && *no_direct && *no_direct != '0') { + printf(" cross-chunk direct calls disabled " + "(DOLRECOMP_NO_DIRECT_CALLS)\n"); + } else { + chunk_starts = (u32*)malloc((size_t)funcs.count * sizeof(u32)); + if (chunk_starts) { + for (u32 i = 0; i < funcs.count; ++i) + chunk_starts[i] = funcs.ranges[i].start; + emit_set_chunk_table(chunk_starts, funcs.count); + } } u32 active_jobs = effective_chunk_jobs(section_job_count, jobs);