Skip to content

C backend: call across chunks directly instead of returning to the chassis - #13

Open
dougchansan wants to merge 3 commits into
ExpansionPak:mainfrom
dougchansan:c-backend-cross-chunk-calls
Open

C backend: call across chunks directly instead of returning to the chassis#13
dougchansan wants to merge 3 commits into
ExpansionPak:mainfrom
dougchansan:c-backend-cross-chunk-calls

Conversation

@dougchansan

@dougchansan dougchansan commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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_<start>() and resumes inline when the callee returns to the instruction after the call — 34,258 such calls on Mario Kart: Double Dash.

ctx->lr = 0x8006D6E8u;
ctx->pc = 0x800660C4u;
if (dolrecomp_call_enter()) {
    func_800656C0(ctx);
    dolrecomp_call_leave();
    if (ctx->pc == 0x8006D6E8u) goto label_8006D6E8;
}
return;

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.

Result: +10.6%, ranges do not overlap

Alternating A/B, unthrottled, on AC, last block reversed:

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

Every direct run beat every nodirect run. The host was not quiet — mdworker, Discord and bun each hit ~100%, 8 of 10 runs flagged — and interleaving absorbed it: within-arm sd 0.0100 and 0.0073, arms never touching.

This supersedes the +2.9% this description previously reported. That figure was taken on a machine running in macOS Low Power Mode — a 2.02× CPU throttle I only discovered today — on top of a contended host. See the comment below for detail.

Verified before measuring: 34,258 emitted func_XXXXXXXX(ctx); call sites in the direct arm against 0 in the other, plus distinct module sizes (73,706,888 vs 70,506,936 bytes) and sha256s. Both arms are plain builds (no PGO, no LTO) from the same dolrecomp binary.

The mechanism note stands: chassis round trips fell 13% (6.98M/s → 6.07M/s), and a profile puts the module's entry switch at ~1.3% of self time with ~73% inside generated code. The round trip is not where the bulk of the time goes — it is just worth more than 2.9% to remove.

Caveat: unmeasured on top of PGO. The shipping module is PGO'd, and PGO also improves call and branch layout, so the levers may overlap — in this project PGO's +12–22% became +7.5% once the hot helpers had been inlined by hand.

Still opt-out: -DDOLRECOMP_C_MAX_CALL_DEPTH=0 restores the old behaviour in a built module, and an empty chunk table via emit_set_chunk_table(NULL, 0) suppresses emission entirely.

dougchansan and others added 2 commits August 5, 2026 07:35
…assis

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_<start>() 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.
@dougchansan

Copy link
Copy Markdown
Contributor Author

Re-measured on a clean machine: +10.6%, ranges do not overlap

The +2.9% in the description was measured on a laptop that was, unknown to me at the time, running in macOS Low Power Mode — a 2.02× CPU throttle, on top of a noisy host. Every performance number I took over several days was affected; I only found it today by toggling the setting and watching a previously recorded figure reproduce to four decimal places.

Re-run unthrottled, on AC, alternating arms with the last block reversed:

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

Every direct run beat every nodirect run. The host still wasn't quiet — mdworker, Discord and bun each hit ~100% and 8 of 10 runs were flagged for contention — and the interleaving absorbed it: within-arm sd 0.0100 and 0.0073, arms never touching.

So the "I would understand a decline" framing in the description is no longer the right call on the evidence. This is the second-largest win I've measured in this project, behind only inlining the lazy-FP gate.

What the arms were

Both plain builds (no PGO, no LTO) from the same dolrecomp binary, differing only in whether the chunk table reaches the emitter. Verified before measuring: 34,258 emitted func_XXXXXXXX(ctx); call sites against 0, plus distinct module sizes (73,706,888 vs 70,506,936 bytes) and sha256s.

To get an arm with the calls genuinely absent I added an env toggle to pipeline.c, in the same idiom as DOLRECOMP_C_CHUNK_INSTRUCTIONS:

const char* no_direct = getenv("DOLRECOMP_NO_DIRECT_CALLS");

-DDOLRECOMP_C_MAX_CALL_DEPTH=0 already disables taking the path, as the description says, but it leaves the call sites and guard branches in the generated C, so it can't measure the feature's absence. Happy to push the toggle as a follow-up commit if you want it, or leave the branch untouched — your call, I didn't want to force a re-review of a diff you may already be partway through.

Two caveats I'd keep attached

  • Unmeasured on top of PGO. The shipping module here is PGO'd, and PGO also improves call and branch layout. These levers may overlap the way PGO and hand-inlining did in this project (+12–22% became +7.5% once the hot helpers were already inlined by hand).
  • Why the original read 2.9% isn't established. A throttled, contended machine producing a bad estimate is the likeliest explanation, but I can't demonstrate that the throttle specifically compressed this effect.

The mechanism note in the description still stands: chassis round trips fell 13%. What's changed is that converting that into wall clock is worth considerably more than it appeared.

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.
@dougchansan

Copy link
Copy Markdown
Contributor Author

Pushed the toggle as 0a1946b, since it's what makes the +10.6% above reproducible rather than something you have to take on trust.

It only adds an early-out in emit_code_sections_split():

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 {
    /* unchanged */
}

Default behaviour is untouched — unset, empty, or 0 all take the existing path. Verified on this branch: generation with the variable set emits 0 func_XXXXXXXX(ctx); call sites, without it 34,258.

Worth being precise about why this exists, since the description already documented an opt-out and it was right: -DDOLRECOMP_C_MAX_CALL_DEPTH=0 is the correct switch for a module that's already built, but it leaves every call site and guard branch in the generated C. It answers "what if the path is never taken", not "what does this feature cost when it isn't there" — and the second question is the one an A/B needs. That gap is, I think, why this sat at "ranges overlap" for two days: I couldn't build the control arm.

Happy to drop the commit if you'd rather keep the diff minimal — the measurement stands either way, and emit_set_chunk_table(NULL, 0) remains available to anyone editing the source.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant