Add --state-in-memory: keep the guest register file out of SSA - #21
Add --state-in-memory: keep the guest register file out of SSA#21dougchansan wants to merge 1 commit into
Conversation
The LLVM backend loads every state slot a region touches into an alloca at entry and lets mem2reg promote it. That is the textbook move, and on this workload it loses to the C backend. Promoting a whole guest register file gives the allocator far more simultaneously live values than the machine has registers, so it spills them straight back. Measured on GM4E01, one boot chunk carries 631 state phi nodes across 228 basic blocks, 20+ per block in hot loops. x86-64 has 16 GPRs; the live set is many times that. The result is phi construction, a much larger function and worse allocation, arriving back at values in memory with more instructions around them. --state-in-memory points state_[slot] straight into CPUState instead. Every load and store site works unchanged; what disappears is the entry prologue and, with it, the materialization barriers -- those exist only to flush values that were hoisted, and nothing is hoisted. LLVM still forwards stores to loads and keeps values in registers where that pays; it is simply no longer forced to keep the entire register file live across a region. Measured against the C backend, same DOL, same module compiler (clang 20.1.8, -O2 -flto=thin), interleaved paired runs: x86-64 Windows, GM4E01 race.sav 0.8533 -> 1.2464 12/12 pairs AArch64 Pi 4, GLME01 cold boot ~1.21 -> 1.3035 6/6 pairs Both are ratios against the C backend, measured directly rather than chained. On x86-64 that is 1.46x the current LLVM backend. Module size on GM4E01 falls from 389.1 MB to 161.0 MB, and on AArch64 from 126.0 MB to 78.7 MB. The gain is larger on x86-64 than on AArch64, which is what the register pressure explanation predicts: AArch64's 31 GPRs already absorb much of the promoted live set, which is also why the LLVM backend was already ahead of C there and behind it on x86-64. Three slot families keep their allocas. CR0-CR7 are 4-bit nibbles of CPUState.cr, XER_CA-XER_SO are individual bits of xer, and XER itself writes only the low 29 bits while preserving the flags. storeContext and loadContext pack and unpack them, so they have no standalone storage to point at. slotIsPacked() keeps them on the promoted path. The option participates in the object cache key. It changes emitted code, so a cache entry built with it must not collide with one built without it; without that, toggling the flag silently returns the other configuration's objects. Off by default. Note for anyone reproducing: skipping the PromoteMemToReg call alone does nothing. optimizeModule() runs the standard -O2 pipeline before emission and SROA/mem2reg promote the allocas anyway -- verified byte-identical IR either way. The allocas have to not exist.
|
Filled in the remaining titles.
12/12 pairs on every x86-64 title, 6/6 on AArch64. Same C control per title as every other arm measured on it, same module compiler, interleaved forward and reversed blocks. Two things worth calling out from getting SOUE01 measured: It needs #18 to emit at all. Without it, all 9917 objects emit and the module verifier then rejects the IR: That is the defect #18 fixes, quoted verbatim in its own commit message, down to the same 9917 chunk count. With #18 merged it emits clean. This is a prerequisite for measuring the title, not a dependency of the change itself. It renders, which the AOT branch never managed. On this Wii title the AOT branch (#16) advances Module sizes with the option on: GM4E01 389.1 → 161.0 MB, GLME01 204.9 → 91.9 MB, SOUE01 548.0 → 221.8 MB, GLME01 on AArch64 126.0 → 78.7 MB. GC6E01 Colosseum is still excluded and unaffected by this change — no LLVM backend renders it on either architecture, including on freshly extracted retail disc data. |
|
Apple Silicon result, and it is a negative one. Reporting it because it changes how the option should be used, not despite that. Measured against the LLVM baseline (not the C backend) on an M-series Mac, macOS 26.1, Luigi's Mansion, 12 interleaved pairs, 60s sample after 20s warmup, null graphics:
Median ratio 0.9387 — 6.1% slower. 0 of 12 pairs favour the change. The ranges do not overlap. Validity checks, since a negative deserves the same scrutiny as a positive:
Where that leaves the change, all figures against the current LLVM backend:
A hypothesis consistent with that ordering, though not verified by looking at spill counts on the Apple target: the change pays where the register file cannot be kept in machine registers. x86-64 has 16 GPRs and gains most; Cortex-A72 has 31 and gains less; Apple Silicon has 31 architectural registers but a much wider machine with far deeper renaming, so it tolerates the promoted live set and the extra This is the clearest argument for the option defaulting off, which is how it is submitted. It is a per-target tuning knob rather than a universal win, and anyone enabling it should measure on their target. Separately, a note on ARM coverage in this PR: I attempted a second AArch64 title (GM4E01 on the Pi) and discarded it. Cold-boot Mario Kart on the Pi is not a controlled scene — the driver lands on logos, title or attract demo depending on timing, and ratios spanned 0.30–1.22 with the C control alone swinging 7.69–25.01 fps. A single smoke run had suggested a large win; the paired runs showed that was scene lottery. AArch64 coverage here therefore rests on GLME01 alone, and pinning a second ARM title would need a Pi-native savestate since savestates do not restore across architectures. |
|
Second AArch64 title, and it corroborates the first. GM4E01 Mario Kart, Raspberry Pi 4, from a pinned savestate, 3 forward + 3 reversed pairs, 70s each:
Median 1.4003 vs the C backend, 6/6 pairs, spread 1.3774–1.4533. A note on method, because the first attempt at this title was thrown away. Cold-booting Mario Kart on the Pi is not a controlled scene — the driver lands on logos, title or the attract demo depending on timing, and the ratios spanned 0.30–1.22 with the C control alone swinging 7.69–25.01 fps. A single smoke run had suggested a large win, and it was scene lottery. Windows savestates do not restore on aarch64, so the Pi needed its own: the game was driven into a race with the automation pad and a savestate captured there. With the scene pinned, the C control now varies by under 1% between runs (6.66–6.75 fps). Updated platform picture, all against the C backend:
Five titles-platforms favour the change at every pair; Apple Silicon regresses at every pair. The Apple Silicon result has since been repeated as an independent second series (0.9387 at a 60s window, 0.9307 at 90s, 0/24 pairs across both), so that regression is stable rather than noise. |
Apple Silicon (AArch64) follow-up: the regression is split evenly between GPRs and FPRsOn Apple Silicon this option is a regression, not a win. Luigi's Mansion Full option vs baseline on this title/state measured 0.9307 in an earlier A. baseline -> GPR-only
median 0.9625, 6/6 pairs favour baseline. B. GPR-only -> full option
median 0.9649, 6/6 pairs favour GPR-only. Decomposition. 0.9625 x 0.9649 = 0.9285, against 0.9307 measured So the ~7% Apple Silicon cost is split almost exactly in half: ~3.75 points from Scope note: this decomposition is GLME01/ |
|
Incorporated upstream: |
The LLVM backend loads every state slot a region touches into an alloca at entry and lets mem2reg promote it. That is the textbook move, and on this workload it loses to the C backend.
Why promotion backfires here
Promoting a whole guest register file gives the allocator far more simultaneously live values than the machine has registers, so it spills them straight back. Measured on GM4E01, one boot chunk carries 631 state phi nodes across 228 basic blocks, 20+ per block in hot loops:
x86-64 has 16 GPRs. The live set is many times that, so the result is phi construction, a much larger function and worse allocation — arriving back at values in memory, with more instructions around them. The C backend never had this problem because generated C operates directly on
ctx->gpr[N]and clang hoists only what pays, locally.The change
--state-in-memorypointsstate_[slot]straight intoCPUState. Every load and store site works unchanged; what disappears is the entry prologue and, with it, the materialization barriers — those exist only to flush values that were hoisted, and nothing is hoisted.Same chunk, with the option on: 0 state phis,
getelementptrstraight offctx. LLVM still forwards stores to loads and keeps values in registers where that pays; it is simply no longer forced to keep the entire register file live across a region.Measurements
Same DOL per title, same module compiler (clang 20.1.8,
-O2 -flto=thin),bench-interleaved6 forward + 6 reversed pairs, machine idle, no build overlapping a measurement.speedis the guest clock ratio;fpsis the median present rate over the sample window.With
--state-in-memory, against the C backendrace.savfoyer.savgameplay.savEvery measurable title, every pair. All are ratios against the C backend, measured directly rather than chained. On AArch64 the same option measures 1.1327 against the unmodified LLVM backend (6/6 pairs), so the win is not an artefact of the C control.
Module size falls on every title:
The gain is larger on x86-64 than on AArch64, which is what the register-pressure explanation predicts: AArch64's 31 GPRs already absorb much of the promoted live set — which is also why the LLVM backend was already ahead of C there and behind it on x86-64. The asymmetry is the mechanism showing through.
Backend landscape across the whole test set, x86-64
Context for where the backend sits per title. These arms are the existing backend and two configurations of the AOT branch (#16).
--state-in-memory--state-in-memory--state-in-memoryTwo titles do not yield a usable LLVM number, and neither is affected by this change:
frame_countto an identical 17684 while presenting exactly 1 frame.frame_countstays frozen. Worth flagging for anyone benchmarking that title:speedcounts retired guest cycles, so a non-rendering module rewards itself on that metric — on speed alone it reads as a 3.58x win over C. fps and sample count are what expose it.An Apple Silicon run is in progress and will follow as a comment.
Correctness detail
Three slot families keep their allocas.
CR0..CR7are 4-bit nibbles ofCPUState.cr,XER_CA..XER_SOare individual bits ofxer, andXERitself writes only the low 29 bits while preserving the flag bits.storeContext/loadContextpack and unpack them, so they have no standalone storage to point at. Pointing them at raw addresses corruptsCPUState— it producedInvalid read from 0x00000010and a green screen at 0 fps.slotIsPacked()keeps them on the promoted path.Cache key
The option participates in the object cache key. It changes emitted code, so an entry built with it must not collide with one built without it. Without that, toggling the flag returns the other configuration's objects and you end up benchmarking a module against itself — the emitter never runs, so nothing looks wrong.
Notes
Off by default.
Skipping the
PromoteMemToRegcall alone achieves nothing:optimizeModule()runs the standard-O2pipeline before emission and SROA/mem2reg promote the allocas anyway. Verified byte-identical IR either way. The allocas have to not exist.Tests: 26/28 pass.
codegen_compileandllvm_generated_compilefail identically on an unmodified build in this environment (rc.exenot on PATH in the nested build), so they are not related to this change.Build notes, both environmental rather than code dependencies of this change:
mainas-is.PHINode should have one entry for each predecessor of its parent basic block!on%fallback_pc— the defect Route fallback blocks through per-edge trampolines so the phi matches the CFG #18 fixes. The SOUE01 row above was measured with Route fallback blocks through per-edge trampolines so the phi matches the CFG #18 merged.