AOT region backend, and the reason the LLVM path was slower than the C backend - #16
Open
dougchansan wants to merge 83 commits into
Open
AOT region backend, and the reason the LLVM path was slower than the C backend#16dougchansan wants to merge 83 commits into
dougchansan wants to merge 83 commits into
Conversation
…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%.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
llvm(current LLVM path)llvm-aotas first builtllvm-aotas it now standsThe brief's gate —
llvm-aotmust reach parity with fixed-chunk before replacing it — is met by +79.5%. The existing C and fixed-chunkllvmbackends 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-aotbuild" 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
CPUStatewhen 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
CPUStateand 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
CPUState--memory-mode fast(folds MEM1 bound, drops write-journal branch)DOLRECOMP_LLVM_PGO)--lto thin, differential suiteTwo 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.
bctr/ jump-table specialisation-O3pipeline--lto thin)Correctness
--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.dolrecomp_dispatch_replacement, which is only sound because ModernGekko never definesDOLRECOMP_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.--memory-mode safe— it is the one consumer that installs a write journal.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_inlineandppc_psq_store_inline, which are declared in DolRecomp's owncpu.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.
cycles_per_frameorbursts_per_mcyclestrays from the median executed a different scene. One run read 134 fps at 92.6bursts/Mcycleagainst everyone else's 153.8 — a different execution, not a fast one. Including it moved a −4.3% result to +46.4%.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/src/analysis/cfg.c,regions.csrc/app/pipeline.c,cli.c--backend llvm-aot,--memory-mode,--lto, region optionstests/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.candtest_regions.ccover the two new analyses, andtests/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
--memory-mode faston 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.stfsdiverges between backends on overflow and denormal inputs (--stfsreproduces). One backend is wrong about Gekko; which is unknown. Oldest open correctness item, pre-existing.DOLLLVM_CACHE_VERSIONbumped by hand.