← board

An accumulator value tracker — the scaffold -O passes keep waiting for

Why it exists as its own ticket

feature-opt-store-reload-elimination has sat in backlog/ since 2026-07-03 with "BLOCKED on a register-liveness scaffold" in its body but no blocked-by: field, so progress.sh ready has been offering it as pullable work the whole time. It is not pullable: landing it without this scaffold means a tracker that can believe a stale value, and that is a SILENT MISCOMPILE — the one outcome this repo will not trade speed for.

The blocker was also worth re-checking rather than inheriting. [[feature-callconv-register-args]] is now done, and its write-up mentions liveness — but what it actually built is measurement (InlineMeasureBody, RegcallMeasureBody: makes-a-call, early-exit, addr-taken-param counters over the IR). Useful, and not a value tracker. The blocker is real and unchanged.

The problem, precisely

The win is ordinary:

x := a + b;    { mov [rbp+xoff], rax }
y := x * 2;    { mov rax, [rbp+xoff]   <- rax already holds it }

The reason it cannot be taken today is that correctness needs the tracker INVALIDATED the instant anything writes rax, and rax is written from hundreds of scattered raw EmitB($48); EmitB($B8); ... sites across ir_codegen.inc with no choke point. Miss one and the tracker hands out a stale value.

The concrete case that killed the naive attempt, worth keeping in front of whoever picks this up: y := 5 * x evaluates the CONST left leaf first (MovRaxImm(5) clobbers rax) and only then reaches IR_LOAD_SYM(x). A tracker that knows only "rax held x after the store" skips the load and multiplies by 5.

Shape A — route every accumulator write through primitives. RULED OUT.

The obvious design, and the one the parent ticket suggests: funnel every rax write through AccumClobber / AccumHolds(sym) so the fact can only change in one place. MovRaxImm already is such a primitive; EmitLoadVar, the arithmetic emitters, the call sequences and the prologue/epilogue are not.

This collides with a standing policy (feedback_asm_emitb_rewrite_policy): do NOT run a campaign converting existing raw EmitB runs in ir_codegen.inc — conversion is mechanically trivial but every converted block shifts the emitted bytes, so it is pure reseed / byte-identity regression risk for zero behavioural gain. Retargeting is opportunistic only, when a block is being edited and retested anyway.

Shape A IS that campaign, with a silent-miscompile hazard attached if it is done incompletely. So it is not the route.

Shape B — decide at the IR level, touch no emitter. PREFERRED.

Make the redundancy decision before codegen and hand the emitter a single bit, so nothing about how instructions are emitted has to change.

Per basic block (delimited by IR_LABEL / IR_JUMP*), walk the statement roots. When root i is IR_STORE_SYM(S, v) and root i+1's expression tree would evaluate IR_LOAD_SYM(S) first, that load is redundant: nothing has run in between. Mark it, and let EmitLoadVar skip a load that carries the mark.

"Would evaluate first" is the entire correctness question, and it is answerable exactly: IREmitNode's recursion order is one function, so a small IRFirstEvaluated(node) mirroring it gives the answer without guessing. That is also precisely what the y := 5 * x counter-example tests — there the first evaluated node is the const leaf, not the load, so the mark is never set and the reload stands.

The drift risk is that the mirror and IREmitNode disagree after a future edit. Mitigate it the cheap way: the mirror lives next to IREmitNode with a comment on both saying they must move together, and the negative test below is in the suite permanently. Conservative by construction — anything the mirror is unsure about is simply not marked, which costs a missed optimisation and nothing else.

The invariant to write down

A load may be marked redundant only when nothing at all is emitted between the store and it. Not "nothing that writes rax" — nothing. That is a stronger condition than the optimisation strictly needs, and it is the one that can be checked by reading a single basic block instead of by trusting every emitter site.

Unblocks

Scope note

x86-64 only, deliberately. Per-backend register work is x86-64 + aarch64 only by standing policy, and the scaffold should prove itself on one target before the second. Nothing here changes the IR, so no other backend is touched.

Gate