Skip to content

AOT region backend, and the reason the LLVM path was slower than the C backend - #16

Open
dougchansan wants to merge 83 commits into
ExpansionPak:mainfrom
dougchansan:feature/llvm-aot-regions
Open

AOT region backend, and the reason the LLVM path was slower than the C backend#16
dougchansan wants to merge 83 commits into
ExpansionPak:mainfrom
dougchansan:feature/llvm-aot-regions

Conversation

@dougchansan

@dougchansan dougchansan commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an AOT region backend (--backend llvm-aot) and, in the course of measuring it, finds and fixes the reason the LLVM path was slower than the C backend.

On Mario Kart, one pinned scene throughout:

fps module build
fixed-chunk llvm (current LLVM path) 29.80 320.0 MB 351 s
llvm-aot as first built 33.24 424.1 MB ~930 s
llvm-aot as it now stands 53.49 85.8 MB 44 s
C backend (semantic reference) 50.63–53.01 65.3 MB

The brief's gate — llvm-aot must reach parity with fixed-chunk before replacing it — is met by +79.5%. The existing C and fixed-chunk llvm backends are unchanged and still build.

The finding worth reading first

The C backend's throughput had never been measured. Every runtime number in this project compared LLVM builds to other LLVM builds, so "faster than the previous llvm-aot build" silently stood in for "fast".

When it was finally measured, the C backend was 60–70% ahead of both LLVM configurations, on a module 4.9× smaller than even the fixed-chunk build.

The cause: the emitter promoted every guest slot it touched to an alloca at region entry. x86-64 has ~14 usable registers; a region touching 30+ guest slots can't hold them, so the allocator spilled them straight back — turning "load from CPUState when needed" into "load at entry, store to stack, reload from stack". 33% of emitted instructions touched the stack; the C backend's figure was 5%.

Leaving guest state in CPUState and letting the optimizer hoist what pays drops that to 2.5% and closes the whole gap. It also deletes the materialization barriers, both dataflow analyses, and 584 net lines that existed only to manage hoisted state.

This explains E002/E003 (unexplained since Phase 0): larger chunks touch more slots, so they spill more.

What landed

measured
Guest state stays in CPUState +60.9% / +26.7% / +30.9% fps across three titles
--memory-mode fast (folds MEM1 bound, drops write-journal branch) +6.7% / +6.7% / +5.0%, 43/49 pairs, p = 5.7e-08
Codegen PGO (DOLRECOMP_LLVM_PGO) +5.6% to +18.9%, 34/36 pairs, p = 1.9e-08
Adaptive dispatch lookup 8× on irregular region plans
Whole-title CFG + region planner, --lto thin, differential suite see the report

Two of the three PGO validations use held-out measurement scenes, so generalization is measured rather than assumed.

What was measured and rejected

Listed because the pattern is the finding — most of these reshape control flow that was already direct calls, i.e. they rearranged a structure whose dominant cost was the structure itself.

result
Larger regions (256/512/1024) dispatcher rate flat within 1%, 33 runs
PGO-driven region formation plans a different program, moves nothing
bctr / jump-table specialisation 0.17% of weighted execution
Address-adjacency merging 2.2× build time, +6.3% size
Barrier store narrowing −4.3% size for +50% build; two earlier versions unsound
Emitter-level cross-region inlining +0.017% module size
Register-passed GPR3–GPR10 −2.3% fps, +6.1% size
LLVM's own -O3 pipeline module +0.07%, spill traffic unchanged
ThinLTO (--lto thin) ~6% smaller; runtime effect title-dependent (−4.1% LM, +2.5% MKDD)

Correctness

  • 23/23 ctest; differential suite green across 5 seeds. Test count rose 19 → 23 — coverage was added (MEM1 boundary, differential call paths), none weakened.
  • --memory-mode fast's two assumptions are verified at runtime, not assumed. If either fails the module refuses native execution and the chassis keeps interpreting: a violated assumption costs speed, never guest memory.
  • Patchability is now explicit. A direct cross-region call bypasses dolrecomp_dispatch_replacement, which is only sound because ModernGekko never defines DOLRECOMP_ENABLE_REPLACEMENTS. Setting it now suppresses every direct external transfer and emits the matching header define. The previous state would have been a mod that installs and silently does nothing.
  • Lockstep needs --memory-mode safe — it is the one consumer that installs a write journal.
  • ModernGekko ABI unchanged: void func_XXXXXXXX(CPUState*) wrappers, dispatcher, hooks, staticrecomp_get_module.

Also fixes: the C backend did not build

The C emitter called ppc_fp_available_inline, ppc_psq_load_inline and ppc_psq_store_inline, which are declared in DolRecomp's own cpu.h — but generated modules compile against GXRuntime's, which declares only the plain functions. The C backend would not build against any ModernGekko checkout tested. The differential suite never noticed because it links DolRecomp's own header.

The paired-single wrappers were pure pass-throughs, so the plain names are equivalent; the FP one's MSR[FP] fast path is now spelled out in the generated C. No runtime change required.

Measurement methodology

Three claims were made and retracted during this work. The method that came out of them is described in the report; the harness itself is not included here.

  • Outliers are rejected on the invariants, not on fps. A run whose cycles_per_frame or bursts_per_mcycle strays from the median executed a different scene. One run read 134 fps at 92.6 bursts/Mcycle against everyone else's 153.8 — a different execution, not a fast one. Including it moved a −4.3% result to +46.4%.
  • Arms alternate and are compared pairwise with a sign test, which cancels the drift behind the 17–25% unpaired spreads. Where the paired and unpaired tests disagree, both are stated.
  • Neither invariant survives crossing backends, so cross-backend comparisons reject within each arm against its own median.

Reviewing this

Start with docs/AOT-ENGINEERING-REPORT.md — 290 lines, self-contained, covers the whole thing including what was rejected and what is still owed.

Then the code, which is where the review effort belongs:

src/backend/llvm/ the emitter change that closed the gap, and the deletions it enabled
src/analysis/cfg.c, regions.c whole-title CFG and the region planner
src/app/pipeline.c, cli.c --backend llvm-aot, --memory-mode, --lto, region options
tests/ MEM1 boundary coverage and the differential call paths, both added

The benchmark harness is deliberately not included — it is developer tooling and the method it encodes is written up in the report instead.

The tests are included and are the shortest path to confidence in the rest: test_cfg.c and test_regions.c cover the two new analyses, and tests/differential/ runs the same random guest programs through both backends and compares bit patterns. That suite is what caught two state-narrowing attempts that passed everything else and then hung Mario Kart at boot.

83 commits, kept as they happened rather than squashed, so the three retracted claims stay visible in sequence.

Known gaps

  • AArch64 is untested for this branch's changes. It is not blocked: Allow the LLVM backend to target AArch64 #15 already relaxes the x86-64-only guard and validates the fixed-chunk LLVM backend on a Pi 4 (AArch64 ELF, module loads, correct rendering, 19.51 vs 19.46 fps against the C backend). What is unmeasured is regions, the state-in-memory emitter and --memory-mode fast on that target. The IR is target-neutral by the same argument Allow the LLVM backend to target AArch64 #15 makes, so it should work — but that is an expectation, not a measurement. This branch merges cleanly with Allow the LLVM backend to target AArch64 #15.
  • stfs diverges between backends on overflow and denormal inputs (--stfs reproduces). One backend is wrong about Gekko; which is unknown. Oldest open correctness item, pre-existing.
  • The object cache key does not hash emitter source, so codegen changes still need DOLLLVM_CACHE_VERSION bumped by hand.
  • An unexplained ~2.5% dispatcher-rate difference between the state-in-memory arm and the C backend.
  • Luigi's Mansion is a noisy rig; its results rest on fewer surviving pairs than the other titles.

…ader

The AOT region work needs a trustworthy way to say whether a change helped.
Nothing in the tree reported dispatcher entries, state materializations or the
distribution of guest memory accesses across fast and slow paths, so a region
change could only be argued about, not measured.

One X-macro (DOLRECOMP_PERF_COUNTERS) generates the counter struct, the JSON
object, the console table, the reset path and the generated-code header at
once, so those cannot drift apart as counters are added in later phases.

Two populations, one mechanism:

  Compile counters are always collected -- a handful of adds against a backend
  already running an optimizer -- and only written out when --perf-report is
  given.

  Runtime counters live in the emitted dolrecomp_perf.h behind DOLRECOMP_PERF
  and expand to ((void)0) otherwise. A counter on the guest memory fast path
  would be a store per guest load, so a shipping module must carry none.

The fixed-chunk paths now record one region each, so `fixed` and the Phase 1
planner modes report through the same structure and stay comparable. LLVM
per-region timings are deliberately left zero: the POSIX path forks a worker
per batch, and a number that is whole on Windows and empty on Linux would be
worse than no number at all. The in-process region backend fills them in.

test_perf covers the round trip, and asserts compile-side counters do not leak
into the guest module's header. 20/20 ctest green.
Region formation needs to know where control actually flows before it can pick
cut points. The fixed-chunk backends cut every N instructions, so a boundary
lands wherever it lands -- through a hot loop as readily as through cold code --
and every crossing costs a state materialization plus a dispatcher round trip.

What this recovers exactly:

  Basic blocks and direct edges. Every b/bc target is a constant in the
  instruction word, so leaders and direct successors are facts, not guesses.
  Blocks cover every non-data instruction exactly once, which a test asserts.

  Functions, inferred from bl targets, an optional MAP, and section entry
  points. A map improves naming and boundaries but is never required, and its
  absence changes region quality, not correctness. A plain b into another
  function's entry is reclassified as a tail call once the entry set is known.

  Loops and SCCs, via an iterative Tarjan -- the recursive form overflows on a
  real title's graph.

What this deliberately does NOT do:

  Resolve indirect control flow. bclr/bcctr sites are recorded as indirect
  exits carrying no successors, and a conditional or linking form keeps only
  its fallthrough. Phase 4 attaches target sets. Inventing an edge here would
  corrupt the program silently rather than loudly, so a region simply ends at
  one for now.

  Assume every bclr is a return, or that any table-shaped data is a jump table.

Embedded data is excluded up front from the existing analysis flag, so a jump
table or a string never becomes a block. SMC-suspect ranges flag the blocks
that overlap them so region formation can end there.

Build inputs live in the program struct rather than a global, so two CFGs can
be built independently. Numbering is deterministic: same sections and same
known-function set always produce the same block, function and SCC indices,
which is what makes the region plan reproducible.

test_cfg covers loops, always-taken bc losing its fallthrough, calls, tail
calls, unresolved indirects, embedded data, SMC flagging, determinism, and
exact coverage. 21/21 ctest green.
Seeding function entries only from bl targets described the directly-called
part of a title, not the title. On Mario Kart that left 59.47% of the code
owned by no function, and region formation cannot place a block that belongs to
nowhere.

The roots turned out to be 22,010 blocks with no in-edge anywhere in the
program. Those are still executed -- control reaches them through a vtable
slot, a function-pointer table or a jump table, which is what a C++ title looks
like from the outside. A block no direct edge reaches is an entry point by
elimination, and treating it as one brings unowned code to 0.11%.

The residual was cycles where every member has an in-edge from inside the
cycle, so no zero-in-degree root pointed at them. Promoting the lowest-
addressed survivor and repeating closes it. Mario Kart and Luigi's Mansion both
now reach 100% block coverage with zero unowned blocks; MKDD function count
goes 6,997 -> 29,021.

This infers entries, never edges. Nothing here claims to know which indirect
site reaches which entry -- that is Phase 4.

Also fixes a real defect in the ownership traversal: it claimed blocks on pop,
so a block with several predecessors could sit on the stack more than once
while the stack was only block_count deep. Claiming on push bounds it by
construction. The forward-only cursor in the residual pass keeps it linear
rather than quadratic.

cfg_stats prints the model for a DOL, including why code is unreached --
no in-edge (indirect-only) versus reached but unowned. Synthetic fixtures
prove the shapes; this is what showed the model survives a real title.

21/21 ctest green.
A region is the unit the backend compiles as one module: control flow inside it
can be a native branch, and only leaving it costs a state materialization. The
fixed-chunk model cuts every N instructions, so a boundary lands wherever it
lands. Planning puts boundaries where control flow is already leaving.

Four modes. `fixed` is CFG-blind and exists as the comparison arm -- it must
not quietly benefit from any analysis the others use. `function` gives each
function its own region. `cfg` accretes connected functions along the call
graph while they fit. `pgo` is the same ordered by profile weight, keeping cold
functions out of hot regions.

Membership is by whole function. Splitting a function across regions
reintroduces the exact cost being removed -- a live-state handoff in the middle
of straight-line code -- so a function is split only when it alone exceeds the
limit, and then at block boundaries that keep SCCs intact. Blocks are the atom:
a single basic block over the limit is emitted whole rather than cut at a point
the CFG never chose. Both behaviours are asserted so they stay decisions.

Measured, limit 1024, crossings against the CFG-blind arm:

  Mario Kart        52,249 -> 35,035   -33.0%
  Luigi's Mansion   40,603 -> 26,965   -33.6%

Two unrelated titles within 0.6 points. `function` mode alone gives 2.7% and
2.0%, which is the useful negative result: the win is co-locating callers with
callees, not respecting function boundaries.

Call edges are resolved and counted explicitly. A CALL block's successor is its
return point, not its callee, so walking successors alone never sees the call --
on Mario Kart that hid 40,316 transfers, and merging a caller with its callee
scored as no improvement whatsoever until this was fixed.

PGO mode with no weights loaded sets profile_missing and warns. Degrading
silently would make an unprofiled build look profiled, which is the specific
failure the existing LLVM PGO staleness gate exists to prevent.

Every mode is deterministic -- functions visited in address order, ties broken
on address -- and every mode assigns every block to exactly one region, which
the tests assert directly rather than trusting.

22/22 ctest green.
--backend llvm-aot compiles planned regions instead of fixed 128-instruction
chunks. The fixed path is untouched and still selected by --backend llvm, as
the brief requires until the region path reaches parity.

It is a separate emitter rather than a flag threaded through the existing one,
because the shapes genuinely differ: the fixed path can compute every chunk
boundary from a formula before decoding anything, while region boundaries are a
result of analysis. So this decodes every section, builds one CFG across all of
them, feeds the SMC ranges in so suspect code can end a region, plans, and only
then emits.

An LLVM job now carries a list of contiguous runs rather than a single one. A
region built by accreting a caller with a callee that does not sit beside it in
memory has a hole, and one DolIRFunction per run puts both sides in the same
module -- which is the entire point. Jobs with one run behave exactly as before.

Each run keeps its own public entry point, so dispatch and every ModernGekko
replacement address resolve exactly as they did.

Also fixes rangeFor(), which was a linear scan over every generated function
range. That was tolerable at a few thousand fixed chunks; regions produce one
range per run, several times as many, and it is consulted for every external
destination in every block. Region objects emitted at roughly a ninth the rate
of fixed chunks until this became a binary search -- measured 4.6x faster
afterwards. Region ranges are sorted explicitly, since they are built in region
order rather than address order.

The cache key hashes every run and the run partition itself: two regions
covering the same instructions in a different grouping generate different code
and must not collide.

22/22 ctest green.
Accretion was connectivity-bound rather than size-bound. A function reached only
indirectly that itself calls nothing has no call-graph neighbours at all, so it
became a region of one: regions averaged 70-83 instructions against a limit of
1024, and Luigi's Mansion planned 7,520 compilation units where the fixed arm
needed 909.

When no connected candidate fits, the region now extends to the next unassigned
function starting within 256 bytes of its end. That adds no crossing, keeps the
region a single contiguous run rather than several, and exploits the fact that
functions laid out next to each other generally came from one translation unit.

  MKDD              8,928 -> 2,033 regions,  83 -> 364 instructions each
  Luigi's Mansion   7,520 -> 1,724 regions,  70 -> 307 instructions each

4.4x fewer units for 1.1-1.7% more crossings. The small regression is greedy
loss -- an address merge sometimes takes a function later call-graph accretion
wanted -- and unit count is what drives object size and compile time.

A sweep shows region count and mean size plateau at ~1,630 regions of ~325
instructions beyond limit 2048 while crossings keep falling (24,287 -> 22,129 ->
20,747). Neither the instruction limit nor the function limit binds there; the
adjacency gap does, because regions stop at data holes. Left at 256 rather than
widened on a guess -- that trade needs runtime numbers, not more static
analysis.

22/22 ctest green.
Measures a recompiled title's throughput through ModernGekko.

The obvious metric does not work. In a headless run nothing presents, so
status.txt's `fps` field stays 0; in a windowed run the emulator is throttled to
real time and `speed` pins at 1.00, so a CPU-side win shows up as the emulator
waiting longer rather than as a bigger number. Reporting either would produce a
flat line no matter how good the backend gets.

So the harness writes an isolated Dolphin user directory with
EmulationSpeed = 0, which is Dolphin's unlimited setting. The runtime never sets
that key itself, so the ini wins. Throughput is then derived from `frame_count`,
which is populated even headless, over measured wall time -- the frames per
second the CPU can actually sustain.

It also captures ModernGekko's shutdown counters. `bursts` is dispatcher
re-entries, which is precisely the quantity the region work exists to reduce and
the first performance gate in the brief, and unlike frame timing it is
deterministic across runs. bursts-per-frame is derived so a host that ran hot or
cold on the day does not change the comparison.

The user directory is kept between runs on purpose: Dolphin is configured to
wait for shaders before starting, so a cold cache turns boot into minutes of
compilation that has nothing to do with the CPU work being measured. A first
attempt that wiped it timed out before reaching the measurement window.

A run only compares to another run of the same scene, so --load-state pins one
rather than measuring whatever the title screen happens to do.
Throttled and unthrottled runs land within 3.6% of each other and the
unthrottled one is marginally slower, so the real-time cap was never the limit:
the title sits at roughly 1.0x real time on this host. That makes fps a usable
metric rather than a flat line, and it sets the noise floor at ~3.5% -- a single
pair of runs cannot resolve the brief's 15% target, so comparisons need repeats
and a savestate-pinned scene.

bursts is 1,267 per frame on the fixed-chunk backend. That is dispatcher
re-entries, the first performance gate, and it is deterministic where frame
timing is not, so it leads and fps corroborates.

fallback=0 and smc_failed=0 on both arms.
Runs the title benchmark across scenes and backends and prints fps, run-to-run
spread, bursts per frame and the fallback count in one table.

Scenes are savestates rather than boot sequences. Spread on this harness is
~3.5%, so anything that varies between runs swamps the effect being measured;
repeats default to 3 because a single pair cannot resolve the 15% target the
brief asks for, let alone its 5% regression bound.

Covers Luigi's Mansion (foyer) and Mario Kart at 1 player and 4 player split
screen. There is no 2-player savestate in the MKDD project -- only race.sav and
race-4p.sav -- so 2P is absent rather than silently substituted.
Building the module measured what static analysis could not. Cutting units 4.4x
cost 2.2x build time (524s -> 1147s), +6.3% object bytes and +1.1% crossings --
strictly worse on every axis except unit count, and unit count was only ever a
proxy for build cost. The proxy was wrong.

The cause is the effect pipeline.c already documented for chunk size: a region
becomes an LLVM function, and compile time and code size grow superlinearly with
the scope the register allocator keeps the guest register file live across.
Growing regions 70 -> 307 instructions walked back onto the same curve that made
1024-instruction chunks untenable.

Worst per-region compile time was 668 seconds against a median under a second.
Region 526 is 944 instructions and 95 blocks -- unremarkable by size -- and took
397 s, so the brief's 'end at excessive compile-time size' cannot be satisfied
from instruction count alone.

Correcting this in the docs before tuning further.
booted=1 with state=running is not the same as executing guest code. With
--load-state the runtime reports running while a 30-45 MB savestate is still
being restored, so a fixed warmup could expire before a single frame advanced.
Three Luigi's Mansion runs and one Mario Kart run came back with zero frames and
were written out as 0.00 fps -- numbers that look like measurements and are not.
A mean over that row would have dragged every comparison toward zero.

Measurement now waits for frame_count to actually move before starting the
clock, with its own timeout, and a run that never advances fails loudly instead
of producing a row.

Runs are also marked valid/invalid on two conditions -- no frame progress, and a
speed reading frozen across the whole window, which is the status file going
stale rather than a perfectly steady emulator. The matrix summary excludes
invalid runs from the means and lists them separately.

With the gate in place Mario Kart 1P race measures 57.67 fps at 1,892
bursts/frame, where it previously produced a zero-frame row.
Time-boxing measured different guest work in every run: a faster arm covers more
of the game in the same wall clock, so the thing being compared changed with the
result. Mario Kart held a stable ~10.2M cycles/frame while fps swung 52-84,
which is host contention; Luigi's Mansion produced 21M cycles/frame twice and
9.3M once, which is a different scene rather than a faster one. Neither is
something a mean should be taken over -- sd was 26% and 87%.

Measurement now runs a fixed frame count and reports the wall time for it, so
every arm executes the same guest instructions and only host time varies.
Counters are additionally reported per frame (bursts, cycles, native,
native_exc, hook_fb), which removes host speed and scene length from the
comparison entirely.

Environment fallbacks: DOLRECOMP_BACKEND, DOLRECOMP_REGION_MODE,
DOLRECOMP_REGION_MAX_INSTRUCTIONS, DOLRECOMP_REGION_MAX_IR,
DOLRECOMP_REGION_REPORT, DOLRECOMP_PERF_REPORT. An explicit flag always wins;
the environment is consulted only where the command line said nothing.

These exist because moderngekko-port drives a sibling dolrecomp and forwards
only --backend=c|llvm, validated against that list, so there is otherwise no way
to build an AOT module through the existing port tool. Teaching ModernGekko to
pass a new flag through would couple the repositories over a benchmarking
concern.

22/22 ctest green.
The polite fallback added in the previous commit never fires for
moderngekko-port: it passes --backend=llvm explicitly and validates it against
its own c|llvm list, so command-line precedence correctly deferred to it and the
region options then failed their own guard.

Weakening the precedence would have been the wrong fix -- a script setting
DOLRECOMP_BACKEND must not be able to silently change what a build asked for.
So the override is a separate, differently named variable that says what it
does. DOLRECOMP_BACKEND remains a fallback; DOLRECOMP_FORCE_BACKEND wins over
an explicit flag, and exists for exactly the embedded-caller case.

22/22 ctest green.
The module template parses the generated manifest and takes everything after
'// object: ' as the object path. An appended '(N runs)' for readability made
the configure fail looking for a file literally named
'region_000000_80003100.o (16 runs)'.

The manifest line now matches the fixed path's format exactly. Run counts were
already in the region report, which is where they belong.

Also documents the moderngekko-port build procedure, including that RC must be
set -- the module template configures clang in GNU-driver mode on Windows and
CMake 4.3 cannot locate a resource compiler on its own, failing at project()
with a message that does not name the real cause.

22/22 ctest green.
Emits Markdown and JSON comparing benchmark arms per scene.

Per-frame counters lead rather than fps: fps depends on how busy the host was,
bursts/frame and cycles/frame do not, and bursts is dispatcher re-entries -- the
quantity the region work exists to reduce.

A delta is only reported when it clears the baseline arm's measured run-to-run
spread. Anything inside the noise prints as '~' rather than being dressed up
with a sign, because a 3% swing on a metric whose spread is 26% is not a result
and should not be presented as one.
Regions of 512 instructions or more are 24% of regions and 74% of instructions
but 86.4% of compile time; the 768-1100 bucket alone is 20% of regions and
76.7% of the time. Cost per instruction is 1.6ms in the smallest bucket against
17.6ms in the largest, an 11x spread.

This corrects the first read of the 397s outlier. Instruction count is a usable
predictor after all -- regions that reach the size cap dominate, and the two
extreme outliers sit on top of that trend rather than contradicting it.
Lowering the cap collapses the tail directly.
Guest cycles per frame is a property of the guest program, not of the backend
compiling it. Two arms that disagree on it for the same scene were in different
game states, and no speed comparison between them means anything.

The Luigi's Mansion head-to-head showed exactly that: fixed ran 20.16M
cycles/frame, llvm-aot ran 10.19M -- 49% apart -- and the naive reading was
'+41% fps for llvm-aot'. It was not a backend win; the aot arm landed in the
lighter of two states the foyer savestate can reach. The same llvmcur module had
already produced both 21M and 9.3M on that scene, so the state is bimodal
regardless of backend.

compare_arms now checks cycles/frame agreement first and prints NOT COMPARABLE,
naming both figures, before any delta table. The deltas are still printed
because suppressing them entirely would hide that a run happened, but the
verdict above them says not to read them.

foyer.sav is therefore unusable as a benchmark scene. Mario Kart's race states
held 10.2-10.4M cycles/frame across every run measured and are the scenes to
use.
Guest cycles measure guest work, so dividing by them normalises away host speed
and scene length together. That makes these rates the only speed-related
figures that stay meaningful when two runs did not execute identical work --
which happens more than one would like, because a savestate can drop into a
scene that behaves differently depending on timing.

The Luigi's Mansion head-to-head was unusable as a speed comparison for exactly
that reason, but the rate survives it: 156.41 bursts per guest Mcycle on the
fixed-chunk arm against 121.85 on llvm-aot, or -22.1% dispatcher entries per
unit of guest work.

That lands almost exactly on the planner's static prediction of -21.4%
crossings on Mario Kart and -23.8% on Luigi's Mansion, which makes the static
crossing count a usable proxy for the runtime dispatcher rate -- worth knowing,
since crossings cost seconds and this costs a module build plus a benchmark.

Corroboration, not proof: the arms ran different scenes and different code
mixes can carry different intrinsic dispatcher rates. A same-scene comparison
settles it.
Its cache identity is backend=<c|llvm> plus the dolrecomp binary hash. Region
settings arrive through the environment and are not in that key, so two region
configurations built into the same output directory collide and the second
silently reuses the first.

Caught when the Mario Kart fixed arm completed in 3 seconds as a cache hit. It
was legitimate in that instance -- an earlier invocation had built it -- but the
same mechanism would silently invalidate a region-size sweep.

Mitigation is a separate output directory per configuration, plus checking the
generated manifest: region builds list chunks/region_*.o and fixed builds list
chunks/chunk_*.o. Verified this way, the two Mario Kart arms are 4,017 regions
and 5,803 chunks respectively.

DolRecomp's own object cache is unaffected; its key hashes every run and the run
partition.
moderngekko-port keys its module cache on backend=<c|llvm> plus the dolrecomp
binary hash. Region settings reach dolrecomp through the environment and are not
in that key, so two region configurations built into one --output directory
collide and the second silently reuses the first. That would quietly invalidate
a sweep over region size -- every arm reporting the numbers of whichever arm
built first.

The output directory now carries a slug derived from the full configuration
(backend, region mode, max instructions, max IR), so distinct configurations
cannot share a cache entry.

And because a silent reuse is the failure that matters, the build verifies what
actually happened: region builds list chunks/region_*.o in the generated
manifest, fixed builds list chunks/chunk_*.o. Asking for llvm-aot and receiving
fixed chunks fails loudly instead of producing a module that is not what was
asked for.

Also sets RC, without which the module configure dies at project() with a
message about CMAKE_RC_COMPILER that never names the real cause.
A freshly captured Luigi's Mansion savestate spread 18.2% in guest work across
three runs from an identical starting state (14.58M / 20.20M / 15.35M cycles per
frame).

The suspicion was that --load-state had silently failed and the run fell back to
booting, since 20.197M is exactly what all three earlier foyer.sav runs
produced. A control run with no savestate settles it: 27.06M cycles/frame,
distinct from both. The state loads and the game diverges afterwards, so this is
the title being nondeterministic rather than the state or the harness being
wrong, and capturing another state will not help.

Mario Kart through the identical harness agrees to 0.9% on cycles/frame and 1.1%
on bursts/Mcycle. It is the primary benchmark; Luigi's Mansion is secondary,
measured over a longer window and always reported with its spread.
Measured on Mario Kart: the region build ran 5.98 fps against the fixed build's
47.81, roughly eight times slower, at an identical dispatcher rate
(bursts/Mcycle +2.0%). Same number of dispatches, vastly more time in each one.

The generated header says why. The fixed build emits zero address comparisons --
uniform 128-instruction chunks collapse into four equal-stride tables, so a
lookup is a couple of range tests and an index. The region build emitted 8,284
address comparisons, because variable-sized regions do not collapse and the
linear chain has to walk them.

A page-indexed lookup was already implemented and already handled this. It was
gated behind DOLRECOMP_DISPATCH_LOOKUP=indexed and defaulted to linear, so the
region backend never got it.

The default is now auto: count the runs the linear chain would emit and take the
page index above 64 of them. A uniform plan produces a handful and keeps the
linear form, which nothing beats for it; an irregular plan produces thousands
and gets the index. The threshold is far above the former and far below the
latter, so the choice is never close. linear and indexed remain forceable.

Correctness is unchanged either way: the index refuses plans it cannot represent
-- overlapping ranges, or sections scattered beyond its page budget -- and falls
back to the chain.

Also fixes --region-profile wiring, which loads execution weights onto functions
and blocks so --region-mode pgo has something to rank by, and
benchmarks/profdata_to_weights.py to convert an LLVM profile into it. That works
because generated functions are named func_<guest address>, so an IR profile
collected from the module carries guest addresses.

22/22 ctest green.
Switching the region backend to the page-indexed lookup broke the module build:
gen_module_tables.py recovers a module's address coverage by grepping
generated.h for the dispatcher's own range tests, and the page index does not
emit any. The build failed with 'no coverage ranges found'.

The ranges are now restated in a comment, in the offset-table form that tool
already recognises. Its regex scans raw text, so a comment satisfies it; nothing
in the block is compiled and the emitted lookup is unchanged.

Restating them here rather than teaching ModernGekko about a new format keeps
the two repositories uncoupled over what is a generated-header detail. It also
means any future lookup form only has to keep emitting this block, not preserve
the shape of its own code for a downstream regex.
Dolphin is configured to wait for shaders before starting, so a cold shader
cache costs the first run of a session a large fraction of its fps. The harness
already kept the user directory between runs for that reason, but it lived
inside the work directory -- so wiping the output tree before a session threw
the cache away anyway.

Visible in the Mario Kart re-measurement: the fixed arm opened at 28.59 fps and
rose to 37.53 on the second run, against 47.81 measured for the same arm in an
earlier session with a warm cache. Host load was 14%, so it was not contention.

--user-dir now places it wherever the caller wants, and the matrix keeps it
outside the results directory. bursts/Mcycle was never affected, which is
another reason it leads the comparison.

Also fixes build_module.sh: grep -c prints 0 and exits non-zero when nothing
matches, so the trailing '|| echo 0' appended a second line and the arithmetic
choked on it. The builds had succeeded; only the verification failed.
With the page-indexed dispatch in place the region arm goes from 5.98 fps to
30.15 against the fixed arm's 38.70, so the eight-fold regression was the linear
dispatch chain and is gone.

At region cap 256 there is no win: bursts/Mcycle +1.2%, and fps inside a 23-28%
noise floor that supports no claim either way. Guest work agrees to 0.8%, so the
scenes are genuinely comparable and the null result is real.

That is what the planner predicted. At cap 256 Mario Kart plans 40,316 crossings
against the fixed arm's 40,754 -- statically identical. The -22.1% dispatcher
rate measured earlier came from cap 1024. Choosing 256 collapsed the
compile-time tail and gave up the entire reason for the region backend along
with it.

Region size is not a free parameter trading build time against nothing:
crossings and compile time pull opposite ways, and a cap picked for one is
picked against the other.
A four-arm sweep on one Mario Kart scene, same pipeline throughout:

  arm            static crossings   bursts/Mcycle
  fixed (128)      40,754    0.0%     180.4   0.0%
  aot cfg @256     40,316   -1.1%     178.9  -0.8%
  aot cfg @512     35,417  -13.1%     178.7  -0.9%
  aot cfg @1024    32,027  -21.4%     179.0  -0.8%

Static crossings fall 21.4% and the runtime rate does not move, not even
monotonically. fps spread is 1.1-1.8% on three of four arms after the
shader-cache fix, against 23-28% before, and fallback=0 throughout, so this is a
null result rather than a noisy one.

This retracts the earlier claim that the static count was a usable proxy. That
came from comparing two Luigi's Mansion arms which had run different scenes;
the agreement was coincidence between scenes, not a mechanism. The docs are
corrected.

Why it fails: a static crossing counts every CFG edge once whether it executes a
billion times or never, while dispatcher entries are dominated by the hot path.
This profile is extremely concentrated -- one guest function is 22% of all
execution -- so uniform merging removes overwhelmingly cold boundaries, and the
hot loop already fit inside a single 128-instruction chunk.

Redirects the work onto two things the brief already asks for, now with evidence
behind them: profile-weighted region formation rather than uniform enlargement,
and direct cross-region linking so that a boundary which does execute costs a
native call instead of a dispatcher round trip.

Uniform enlargement is not worth its cost: cap 1024 buys nothing measurable for
+29% module size and 874s of build time against roughly 500s.
Bitcode now carries a module summary index -- llvm-bcanalyzer reports
GLOBALVAL_SUMMARY_BLOCK -- which is what a thin link needs to decide which
callees to import without reading every module's body.

Letting the pass build the summary is the fix for the previous attempt, which
called buildModuleSummaryIndex(module, nullptr, nullptr) by hand and segfaulted:
that signature wants a BlockFrequencyInfo callback and a real
ProfileSummaryInfo, not nulls. This is how clang does it.

It runs on a CLONE of the optimised module, and that is not defensive
programming. ThinLTOBitcodeWriterPass is not a pure writer -- it splits the
module into thin-importable and non-importable parts, in place. Running it on
the module itself would hand a mutated module to object emission, so the object
and the bitcode would describe different programs and the object would be the
wrong one. Verified: the emitted object is byte-identical with and without
bitcode emission.

The clone costs memory proportional to one region, bounded by the region size
cap, and only when a bitcode path is given.

test_llvm_backend takes an optional third argument for the bitcode path, so
ctest exercises this in seconds rather than a twenty-minute title build being
the first thing to find a mistake.

Cache version to v17. 23/23 ctest green.
Under --lto thin each region writes a .bc beside its .o, and the object
manifest names the bitcode. lld reads bitcode inputs natively and runs
ThinLTO on them, so this needs no in-process LTO driver and no change to
the module template -- it still forwards each listed file to the linker.

The object is still written, so falling back is a rerun with --lto off
rather than a recompile. The object cache stores the pair under one key
(the mode is part of the key), because a hit that restored only the
object would leave the link short a summary with nothing to show for it.
The port runs whatever dolrecomp.exe sits next to it. That copy had gone
stale, so a --lto thin build ran the previous recompiler and produced
1724 objects with zero bitcode and no error anywhere -- the failure mode
that produces a confident wrong measurement.
…ange

Also teaches compare_arms.py to drop runs that did not do comparable guest
work. One LM run read 134 fps at 92.6 bursts/Mcycle against everyone else's
153.8 -- a different execution, not a faster one. Taken at face value it
moved the result from -4.3% to +46.4%.

--lto thin stays off by default: 5.6% of module size for 85% of build time
and no measurable speed. It is worth keeping because the size drop confirms
cross-module inlining is happening at all, which emitter-level inlining could
not do (+0.017%).
Two titles now agree at roughly 6% smaller, which is the useful result --
cross-region inlining is happening where the emitter-level attempt managed
+0.017%. Neither title shows a readable fps change: -4.3% on LM, +4.6% on
MKDD, both well inside their noise floors and disagreeing on sign.

The MKDD link died inside the full build with exit 1 and no diagnostic, then
ran clean on an idle machine. ThinLTO's backend spawns a thread per core and
holds several modules live, and MKDD is 444 MB of objects. build_module.sh
now caps it with -Wl,/opt:lldltojobs=8.
Guest loads and stores read every bound out of CPUState on every access and
check the write journal on every MEM1 store. Two of those are constant in
practice:

  ram_size is GC_MAIN_RAM_SIZE in this tree and in GXRuntime -- assigned once
  in cpu_init, carried across cpu_reset, never given another value. Folding it
  removes a CPUState load per access and collapses the bounds check to one
  compare against a constant, because the size >= width half is constant-true.

  g_mem_write_journal is null unless a runtime installs one, so the branch can
  leave the MEM1 store path.

Both are verified once at dispatch entry rather than assumed. If either fails
the module refuses to run natively and the chassis keeps interpreting, so a
violated assumption costs speed and not guest memory. Baking in an assumption
that silently stops holding is how a recompiler corrupts a game.

Mode is in the codegen fingerprint, so a safe-mode cached object is not a
valid answer for a fast-mode build. 23/23 in both modes.
--memory-mode fast folds the MEM1 bound to a constant and drops the
size >= width half of the range check as constant-true, so the exact edge
is what that mode could plausibly get wrong. The differential harness only
ever touches a scratch offset deep inside MEM1, so it would not have caught
an off-by-one there.

Three cases at the end of RAM -- last addressable word, straddling the end,
entirely past -- each checking the value written or read rather than merely
that nothing crashed. Passes in both modes; the fast IR is one compare
against 25165821 where safe loads ram_size and does two.
Luigi's Mansion 11/12 pairs (p=0.0063), Mario Kart 15/18 (p=0.0075), and
both land on +6.7% fps independently. Combined 26/30 pairs, p=0.000059.
Module size -6.1% and -4.6%. Guest cycles per second +9.4% and +10.0%.

Analysis is paired because the arms alternate and pairing cancels the drift
behind the 17-25% unpaired spreads. The unpaired 2x-spread guard used
elsewhere still calls this unreadable, and the docs say so: switching to a
friendlier test after seeing the data is how a null result becomes a
headline, so the disagreement is recorded rather than hidden.

Adds benchmarks/paired_arms.py, which carries the bursts/Mcycle filter that
drops pairs where either run executed a different scene.

This is the first change of the effort with a real speed win. Region
formation, PGO seeding, bctr specialisation, adjacency merging, barrier
narrowing, emitter inlining and ThinLTO all reshaped already-direct control
flow and came back flat; this removes work from every guest load and store.
…n speed

Size composes almost exactly multiplicatively: -11.2% on Luigi's Mansion and
-10.5% on Mario Kart, against -11.4% and -10.4% predicted from the two
measured separately.

Speed does not compose, and the two titles point opposite ways. Measured as
combined against --memory-mode fast alone: LM loses 4.1% (1 of 21 pairs
favour combined, p=0.00001), MKDD gains 2.5% (18 of 22, p=0.0043). Both
significant. On LM the smaller module is the slower one.

An earlier explanation blaming cross-module inlining for merging live ranges
is withdrawn. It was written from LM alone and MKDD contradicts it; no
mechanism is claimed without evidence that covers both titles.

Recommendation unchanged: ship --memory-mode fast, leave --lto thin off.
+6.7% fps on both Luigi's Mansion and Mario Kart independently, combined
p=0.000059, and the two assumptions behind it are checked once at dispatch
entry: a runtime that breaks either gets the interpreter and a message on
stderr rather than corrupted guest memory.

--memory-mode safe opts out, and one case needs it. ModernGekko's lockstep
verifier installs a write journal under STATICRECOMP_LOCKSTEP, and that is
the harness which compares the module against Dolphin's interpreter -- a
fast module makes it inert. Nothing in ordinary play installs one: not
savestates, not netplay.

The guard is now emitted only by the backends that actually lower memory
this way. The C backend reads its bounds from CPUState in either mode, so
it carries no guard and stays usable as the lockstep reference; emitting one
there would have made it refuse native execution for assumptions its own
code never made.

Both modes are marked in the codegen fingerprint. Objects predating the
option were emitted in safe mode and carry no marker, so leaving the new
default unmarked would let them satisfy a fast-mode build.

23/23 in both modes.
Skyward Sword extends the claim rather than repeating it. It is a Wii title,
so exram is actually allocated and the MEM2 path executes; on both GameCube
titles that path is dead code. Fast mode folds only the MEM1 bound and leaves
MEM2 dynamic because exram_size genuinely varies, and fallback was 0 across
all 49 runs -- the guard confirms both assumptions hold on Wii too. It also
uses RELs, so relocated code is covered.

  Luigi's Mansion  +6.7% fps, 11/12 pairs, p = 0.0063, module -6.1%
  Mario Kart       +6.7% fps, 15/18 pairs, p = 0.0075, module -4.6%
  Skyward Sword    +5.0% fps, 17/19 pairs, p = 0.0007, module -4.9%

Combined 43 of 49 pairs, p = 5.7e-08. Each title clears significance alone.

Records all three workload hashes, and corrects the remaining-bottlenecks
entry that still described the mode as off by default.
A direct call goes to func_XXXXXXXX_budget and so does not pass
dolrecomp_dispatch_replacement, ppc_host_call, or the physical-alias retry.
That is sound only while nothing in the module can be replaced at runtime.
It is sound today -- the module template never defines
DOLRECOMP_ENABLE_REPLACEMENTS, the check compiles to a stub returning 0, and
StaticRecompModuleDesc offers no way to register a replacement -- but it
would stop being sound the moment replacements were switched on, and the
failure mode is a mod that installs and silently does nothing.

DOLRECOMP_ENABLE_REPLACEMENTS now suppresses every direct external transfer
and emits the matching define into the generated header, so emission and the
generated dispatcher cannot disagree. Folded into the codegen fingerprint.
Verified on the cross-chunk fixture: the two external call sites disappear
and the public wrappers do not. 23/23 with it on and off.

Closes phases 2, 3 and 4 in the status doc with what landed, what was gated
off, and what was deliberately not pursued -- blr is not addressed because a
blr already returns natively to its LLVM caller, so what it pays is the
materialize, making it the same problem as the per-call round trip rather
than a separate one.
The private internal ABI of D3. With DOLRECOMP_REG_ARGS the internal region
body also takes GPR3..GPR10, so a direct call hands them over in registers
instead of the callee loading them from CPUState. Safe by construction: the
caller materializes immediately before the call, so the parameters and
CPUState hold the same values, and the public wrapper loads them from
CPUState for dispatcher entries.

Entry side only, deliberately. Every return site in the body sits after a
helper that may have written CPUState -- fallback, external read and write,
FP-unavailable, system call, rfi -- so returning alloca values would hand the
caller stale state. Making them current means either reloading at each return
site, and there are more return sites than call sites so that costs more than
it saves, or a staleness analysis: the analysis this emitter has got wrong
twice, both times passing the suite and hanging a real title. The return side
stays on CPUState until the successor model is derived from the emitter's own
edges instead of reconstructed alongside them.

One definition of the switch in common/options.h, because caller and callee
build the signature independently in different objects and a disagreement is
a wrong call with no diagnostic. In the codegen fingerprint for the same
reason. Off by default pending a real-title measurement.

Verified: definition and call site agree at 8 extra i32 params; 23/23 on and
off; differential green across four seeds.
3 of 17 pairs favour it, p = 0.0127, on a 6.1% larger module. The entry side
saves eight loads in the callee but costs eight argument setups at every call
site, and there are more call sites than entries; the caller still has to
materialize, which is what makes the scheme safe, so the setup is added on top
of the stores rather than replacing them.

That locates the win in D3: not passing state in, but not having to
materialize it out. Which needs the return side, which needs to know staleness
at 18 return sites that all sit after helpers that may have written CPUState.

Kept behind DOLRECOMP_REG_ARGS, off by default.
53.01 fps mean against 33.24, ranges that do not overlap, on a module 6.5x
smaller. The comparison is generous to llvm-aot: it is in its best measured
configuration while the C backend is at plain baseline, because memory mode
only changes LLVM lowering.

This should have been measured in Phase 0. Every runtime number in the doc
until now compares LLVM builds to other LLVM builds, so none of them were
positioned against the reference backend.

The comparability filter used throughout is a same-backend tool and is
invalid across backends: bursts/Mcycle differs because 182 chunks is not
2,033 regions, and cycles/frame differs because the backends charge guest
cycles differently. Applied naively it kept two outliers, left the C arm at
n=1 and reported +20.8% assembled from noise. Corrected to within-arm outlier
rejection against each arm's own median.

Nothing measured earlier is retracted, but 'faster than the previous llvm-aot
build' is not 'fast', and the document had no way to tell those apart.
…xed-chunk too

  C backend                          50.63 fps   65.3 MB
  llvm-aot regions + memory fast     33.24 fps  424.1 MB
  fixed-chunk llvm                   29.80 fps  320.0 MB

Two conclusions pointing opposite ways. The region work met the brief's own
gate -- llvm-aot must reach parity with fixed-chunk, and it beats it by 11.5%.
And the LLVM path is the wrong path on this title, both configurations sitting
60-70% behind a C module 4.9x smaller than even the fixed-chunk build.

This explains the seven region-level interventions that came back flat: they
were rearranging a structure whose dominant cost lives elsewhere.

Recommendation: stop tuning region policy; establish why the LLVM path is
slower than compiled C for the same DolIR.
Two 400k-instruction samples from each linked module: 3.2-5.0% of the C
backend's instructions touch the stack against 29.1-33.5% of llvm-aot's,
roughly nine times the traffic. One region body of 17,873 instructions
allocates a 216-byte frame and spends 38% of itself on stack loads and stores.

The cause is D2's architecture. Promoting every used guest slot to an alloca
at region entry gives the allocator far more live values than x86-64 has
registers, so it spills them back, replacing 'load from CPUState when needed'
with 'load at entry, spill, reload' -- one extra copy and a large frame. The C
backend operates directly on ctx->gpr[N] and lets clang promote only where it
pays, with no barriers because nothing was hoisted.

That also explains E002/E003, unexplained since Phase 0: bigger chunks touch
more slots, so more spill. Same mechanism, measured two ways.

Two secondary findings. The C chunks compile to bitcode and get ThinLTO
(module template sets INTERPROCEDURAL_OPTIMIZATION), while region objects are
EXTERNAL_OBJECT natives that bypass it. And kPassPipeline is a single-shot
hand-rolled list with inlining last and no cleanup after it, at codegen level
Default rather than Aggressive; DOLRECOMP_LLVM_PIPELINE=o3 makes that
measurable.
…ckend

  llvm-aot promoting     33.24 fps   424.1 MB   ~930s build   33.5% spill
  llvm-aot state-in-mem  53.49 fps    85.8 MB     48s build    2.5% spill
  C backend              52.86 fps    65.3 MB        --        5.0% spill

+60.9% over the promoting default and level with the C backend; the +1.2%
against C sits inside heavily overlapping ranges, so the claim is parity.
fallback is 0 throughout, so both arms run natively.

The change is small because state_[slot] was only ever a pointer to load and
store through: entry points it into CPUState instead of at an alloca,
materialize skips the slot-store loop, and the reload paths become no-ops.

Spill fell from 33.5% to 2.5%, below the C backend's own 5.0% -- the
prediction from the root-cause analysis, which is the reason to believe the
mechanism and not just the outcome.

This retires most of the machinery that consumed this effort: the barriers,
the reaching-writes and liveness analyses, three narrowing attempts and the
register-argument ABI all existed to manage state that was hoisted in the
first place.

Off by default. Validated on one title; needs the three-title, seed-sweep and
paired-significance treatment before it could be a default, because two
earlier changes passed the full suite and then hung Mario Kart at boot.
  Mario Kart       +60.9% fps (33.24 -> 53.49), parity with the C backend
  Luigi's Mansion  +26.7% fps, 6/6 pairs, p = 0.0312
  Skyward Sword    +30.9% fps, 13/13 pairs, p = 0.0002

Unanimous on every comparable pair of all three titles, across both consoles
and across 1,724 / 2,033 / 3,589 regions. Modules are 75-80% smaller and
builds up to 19x faster. fallback is 0 on all 14 Skyward Sword runs, so the
Wii title with MEM2 populated and RELs executes natively.

Mario Kart gains most and Luigi's Mansion least, ordered by how much spill
each had to remove -- which is what the mechanism predicts.

DOLRECOMP_STATE_MEMORY=0 restores the promoting emitter, kept because the
barriers, the two dataflow analyses and the register-argument ABI all exist
to serve it. Both modes are marked in the codegen fingerprint: objects built
before this option existed came from the promoting path and carry no marker,
so leaving the new default unmarked would let a stale one satisfy the build.

Two loose ends recorded rather than buried: six of twelve Luigi's Mansion
pairs were rejected on bursts/Mcycle mismatch, and the state-in-memory arm
shows a small unexplained dispatcher-rate difference against the C backend.
584 net lines. The barriers, both dataflow analyses and their buffers, dirty_,
syncState/reloadState/reloadUsedState/reloadLiveState and their 23 call sites,
DOLRECOMP_NARROW_BARRIERS, DOLRECOMP_REG_ARGS, and the promoting emitter with
DOLRECOMP_STATE_MEMORY. materialize() is now the guest PC and the cycles owed.

Verified by emitting the test module before and after the deletion:
byte-identical IR, 79,975 bytes both ways. No generated code changed.

The fingerprint keeps a constant |state=mem marker. It selects nothing now,
but objects predating the change carry no marker and are incompatible, and
without something to tell them apart a stale one would satisfy a build.

One bug was introduced and caught. Removing a statement under an unbraced
'if (inst.op == DOLIR_OP_STATE_WRITE)' left the following 'if' as its body, so
used_[MSR] stopped being set and emitFPAvailable loaded through null. The
compiler cannot see that shape; the two other instances in the same pass were
syntax errors and obvious. Audited the rest for it -- none remain.
Covers what worked (guest state in CPUState, +26.7% to +60.9% across three
titles; --memory-mode fast, p=5.7e-08 combined), the ten interventions that
measured flat or negative, the three claims that were retracted and the two
measurement guards that came out of them, compliance against each of the
brief's constraints, and what is still owed.

The central finding gets its own section: the C backend's throughput was
never measured until late, so every number in this project compared LLVM
builds to other LLVM builds. When it was finally measured it was 60-70%
ahead, which explained eight flat results at once and pointed straight at the
spill. Measure against the reference backend in Phase 0.
The report quoted a 3-pair interim (+0.8%). Final is 7 pairs, -0.7% mean,
3 of 7 favouring post, p = 1.0, fallback 0 on every run. Still the same
conclusion -- indistinguishable -- but the number should be the one that was
actually measured.
The C emitter called ppc_fp_available_inline, ppc_psq_load_inline and
ppc_psq_store_inline. All three are declared in DolRecomp's src/cpu/cpu.h,
but generated modules compile against the runtime's include/core/cpu.h, which
declares only the plain functions. The C backend therefore did not build
against any ModernGekko checkout here -- the differential suite never noticed
because it links DolRecomp's own header.

The paired-single wrappers were pure pass-throughs, so the plain names are
exactly equivalent. The FP one had a real MSR[FP] fast path, now spelled out
in the generated C, which keeps the fast path with no external dependency.

Verified by reverting the GXRuntime edits made earlier in three vendored
checkouts and building Mario Kart's C module against pristine headers.
Those checkouts are nested git submodules, so that edit would have been
discarded by any submodule update; nothing has to be re-applied now.

codegen_compile asserted the old spelling. Updated to assert both halves of
the gate, so dropping either the fast path or the fallback still fails.
9 of 9 comparable pairs, range +10.5% to +19.9%, p = 0.0039, fallback 0
throughout. 57.6 -> 66.1 fps, guest cycles/sec +21.4%, module +6.2%.

Codegen PGO had never been measured -- every earlier result was taken with
DOLRECOMP_LLVM_PGO unset, and the only PGO tested was region seeding, which
is unrelated and was a dead end.

The caveat is recorded as prominently as the number: the profile was
collected on bench.sav and measured on bench.sav, so this is an upper bound,
not what a shipped profile would give. An honest figure needs disjoint
profile and measurement scenes.

It pays more now than it would have before because, with the spill gone, what
remains is dominated by the MEM1/MEM2/slow-path branch chain that block
placement and branch probabilities act on.

Also fixes a toolchain trap: system clang is 22.1.5, the backend links
20.1.8, and mixing their profile runtimes yields a .profraw 20.1.8 cannot
read -- failing at the use build, after the profiling run is gone. The
harness now derives the runtime from LLVM_DIR in CMakeCache.txt.
A profile built from five courses and measured on two it had never seen:
Luigi Circuit +12.5% (7/8 pairs), Yoshi Circuit +18.9% (7/7), combined 14 of
16 pairs, p = 0.0042. The same-scene figure was +14.9%, so the held-out
results bracket it and there is no overfitting penalty to subtract.

That answers the caveat attached to the previous commit rather than leaving
it standing. The profile is learning something generic -- block placement on
the MEM1/MEM2/slow-path chains every scene hits on every guest load and store
-- not memorising one execution.

bench.sav is also a much heavier scene than any course (57 fps at 169
bursts/Mcycle against 67-95 at 107-134), so the held-out set is not simply an
easier version of the same workload.

Still one title, and the profile set was all courses, so a heavy scene
remains out-of-distribution.
  Mario Kart       +12.5% / +18.9%  held out (5 courses profiled, 2 measured)
  Luigi's Mansion  +5.6%            held out (foyer profiled, bench measured)
  Skyward Sword    +11.9%           same scene, only one gameplay state exists

fallback 0 on every run. Two of three are held-out designs, so generalisation
is measured rather than assumed; the Skyward Sword figure says PGO helps on a
Wii title with MEM2 live, not that it generalises there.

The spread tracks module size, which is what block placement predicts and
matches the ordering seen for the state-in-memory change: Luigi's Mansion
smallest and gains least, Mario Kart largest and gains most.

Not a default in the sense the other options are, since it needs a per-title
profile -- the recommendation is that any title shipping a tuned module
collects one. Still unmeasured: whether the gain holds on a scene much heavier
than anything in the profile set.
Section 5 still described adding three helpers to the vendored runtimes as
the resolution. That was the first attempt and it was wrong -- the vendored
GXRuntime is a nested submodule, so the edit would not have survived a
submodule update. The emitter now calls what the runtime declares, verified
against pristine headers.
Removes two working documents and two benchmark scripts, 2,560 lines:

  docs/AOT-PERFORMANCE-RESULTS.md      1,839 lines, the full measurement log
  docs/AOT-REGION-IMPLEMENTATION.md      474 lines, design decisions per phase
  benchmarks/run_matrix.sh               region-size sweeps, a rejected approach
  benchmarks/profdata_to_weights.py      PGO region seeding, also rejected

The engineering report absorbs what those two documents were carrying that a
reviewer needs -- the PGO results now have their own section rather than a
cross-reference -- and is self-contained at 290 lines.

The four remaining benchmark scripts are the ones that reproduce the numbers
actually claimed: build a module for a configuration, measure one arm, and
compare two arms paired or unpaired.
The four scripts added here are removed; benchmarks/llvm_backend_bench.c and
benchmarks/images are upstream's and untouched.

The measurement method they encoded is now described in the engineering report
instead of referenced from it, so the numbers stay reproducible by anyone
willing to rebuild the harness: per-configuration output directories, throttle
disabled, alternating arms compared pairwise with a sign test, and outlier
rejection on cycles_per_frame and bursts_per_mcycle rather than on fps.
The report said regions, state-in-memory and --memory-mode fast were expected
to work on AArch64 but had not been measured. They have been now. Luigi's
Mansion was cross-compiled from an x86-64 Windows host and run on a Raspberry
Pi 4 against a C-backend build of the same title, in the mansion foyer, which
unlike the title screen is CPU-bound rather than pinned at the frame cap:

  single-core  13.20 fps vs 13.03, 4 of 5 pairs favouring llvm-aot
  dual-core    19.29 fps vs 19.46, 3 of 5 pairs favouring llvm-aot

Five alternating pairs per configuration, twenty samples per run, twenty of
twenty runs passing the scene guards, fallback=0 throughout. Neither direction
is significant and the within-arm spread is wider than the between-arm
difference, so this is parity -- the same conclusion the comparison reaches on
x86-64.

Section 4 gains the four ways this hardware produced confident wrong numbers:
a frame-capped scene cannot show a difference at all; uncapped, the faster arm
is looking at different scenery at every instant; booting from a savestate
leaves native=0 because the module never executes and the emulator's own core
runs the game; and a fixed script of button presses pauses the game, which then
renders cheaply at the cap. Each yields a plausible framerate rather than an
obvious failure.

It also records a sign reversal worth keeping: a first dual-core batch had
llvm-aot ahead in all three pairs at +2.0%, and extending the same comparison
to five pairs moved it to -0.9%.
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