C backend: call across chunks directly instead of returning to the chassis - #13
C backend: call across chunks directly instead of returning to the chassis#13dougchansan wants to merge 3 commits into
Conversation
…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.
Re-measured on a clean machine: +10.6%, ranges do not overlapThe +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: Every 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 wereBoth plain builds (no PGO, no LTO) from the same To get an arm with the calls genuinely absent I added an env toggle to const char* no_direct = getenv("DOLRECOMP_NO_DIRECT_CALLS");
Two caveats I'd keep attached
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.
|
Pushed the toggle as It only adds an early-out in 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 Worth being precise about why this exists, since the description already documented an opt-out and it was right: Happy to drop the commit if you'd rather keep the diff minimal — the measurement stands either way, and |
A
blwhose target lives in another chunk setctx->pcand returned, so every cross-chunk call paid a full round trip through the host: rel-section resolution, twoIsHostCallAddresslookups, aModManagerdispatch 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->pcalready 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 isexternwith one definition in the runtime: as astaticin 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:
Every
directrun beat everynodirectrun. The host was not quiet —mdworker, Discord andbuneach 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 samedolrecompbinary.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=0restores the old behaviour in a built module, and an empty chunk table viaemit_set_chunk_table(NULL, 0)suppresses emission entirely.