← board

RELEASED FROM working/ 2026-08-30 by frank-optimize-b4, same day it was re-claimed. The W1 slices are landed and resolved ([[feature-opt-o3-w1-operand-folds-are-x86-64-only-aarch64-has-four-of-fifteen]]), and the session moved to a different ticket. Holding an umbrella's lock while working something else is the mirror of the failure recorded below: there the folder said backlog while the file was being edited, here it would say working while nobody was on it. Both make the folder a claim about the past.

Note the lane's real protection did not lapse: this session still holds ir_codegen.inc under [[bug-o-uforth-blocktest-runs-slower-under-pxx-than-under-cpython]], which is in working/ and is a Track A file lock in its own right. The umbrella was never what was protecting the file.

RE-CLAIMED 2026-08-30 by frank-optimize-b4 (session c1d9983f-88e9-4fba-a00a-88b3be8ff1c8). The coordinator's DO-NOT-CLAIM note below is preserved because its reasoning outlives the stopgap — the folder is the lock now, so the note is no longer load-bearing, but what it records is.

The failure mode, worth keeping: a lock is a claim about the present made by an action in the past, and nothing re-asserts it. This ticket was correctly released when the campaign parked ("RELEASED FROM working/ 2026-08-29, four of four landed") — and then the campaign RESUMED without re-claiming. The folder said backlog and was telling the truth about the last deliberate act, while eight commits landed in ir_codegen.inc over five hours. ready --track A therefore offered the hottest shared file in the tree to every idle agent, and frankA was aimed straight at it; it caught the collision only because it opened the ticket at HEAD before claiming.

Resuming parked work is the one transition with no natural prompt to re-take the lock, because you are continuing rather than starting — every other transition has a claim in front of it. If you pick this campaign back up after a park, claim it first.

SUPERSEDED 2026-08-30 — this block carried the ranker's not-dispatchable marker. It was written under the file-reservation system, which was cut the same day (91380f04b): nothing reserves a file, working/ is a status hint rather than a lock, and "do not open ir_codegen.inc" is no longer something anyone can say. It was also, by then, arguing with the release note three lines above it — one block saying the ticket was released and nobody was on it, the other saying it was actively worked, both dated 2026-08-30.

What was true in it, and still is: an O campaign has been putting commits into compiler/ir_codegen.inc, the hottest shared file in Track A, and the board did not say so because the lock lived in the folder and the folder said backlog. frankA ran next --track A, was aimed straight here, and caught it only by opening the ticket at HEAD.

So this is now information, not a prohibition. Taking this ticket is fine. Before you edit ir_codegen.inc for register-pressure work, say so to whoever is on the O campaign and read the recent history of the file — not because you need permission, but because two people rewriting a register allocator from different ends is one of the few cases git merges cleanly and wrongly. Telling them is not asking them; name what you are touching and proceed.

-O3 register-pressure tier: operand scheduler + liveness-scaffold register allocator

READ FIRST — five standing rules for every slice in this campaign

Each of these was paid for once. They are here, at the top, rather than inside the write-up of the slice that learned them, because that is where the next slice will actually read them.

1. Every -O3 pass needs its OWN control test. The self-host gate cannot see an -O3-only defect. Not "might not" — cannot: make compiler/pascal26 builds the compiler at the DEFAULT -O level, so no OptLevel >= 3 arm runs while building it. Demonstrated on purpose, not inferred: slice 5's comparison encoding was deliberately broken in the ModRM field, -O3 printed acc=0, and the fixedpoint reported converged after 1 round(s) the whole time. CLAUDE.md records this scope limit in the abstract from a defect found after the fact; this is the same limit shown with a known-bad input and a green gate.

The pattern that works, and the one to copy: run the test at BOTH -O0 and -O3 against ONE expectation. Because the pass is -O3-gated, -O0 is a control that provably cannot use the new code, so a wrong encoding shows up as two optimisation levels disagreeing rather than as a number with no oracle. Add an independent oracle (FPC) on top. Then break the pass on purpose and confirm the test goes red — a control that has never failed is not known to be a control.

2. A population count is not a firing count. A census measures what COULD be affected and reads as evidence about what WILL be. Slice 5's census said CMP was the largest bucket in every program (2891 vs 2649 ALU in compiler.pas, 31-52% with MULIMM); the first implementation fired on 11 sites and left the benchmark byte-identical, because the -O2 cmp-immediate fold and the IR_JUMP_IF_FALSE branch fold had already consumed most of the population being counted. The gap was two arms wide and invisible until the pass was fired. This is a different failure from "the static sweep understates W1" further down — that one under-reports a real effect; this one over-reports a possible one. Count the population to choose the target; count the firings to claim a result.

3. Rebuild your baseline at HEAD, and check what rebased in under you. tools/sync.sh does a pull --rebase, so other lanes' compiler changes arrive in your tree between two of your own builds. Slice 6 was measured against a baseline that predated frankA's for-loop fix (8b35e88fa) landing in this checkout, and the result read as "slice 6 leaked outside its -O3 gate and made -O0 output 82 bytes bigger" — an alarming and completely false conclusion. The tell was the self-host printing converged after 2 round(s) where every previous build said 1; rebuilding the true baseline at HEAD gave 1 round and byte-identical -O0/-O1/-O2, with the same final binary sha. The binary had been right the whole time.

Two things made it cheap to catch, and both are worth keeping: per-procedure size extraction from the .map (only 3 procedures had changed, all RTL, none user code — a whole-binary cmp says only "everything differs"), and the fact that an -O3-gated pass changing -O0 output is impossible, so the contradiction pointed at the measurement rather than at the code. When a result says something that cannot be true, suspect the baseline before the change.

4. A deliberate break must be verified at the level the BUG lives — and the row it targets must be sensitive to it. Rule 1 says break the pass and confirm the test goes red. Slice 7 found the two ways that can silently not work, and both look like a passing control:

The general form, and it outlives this campaign: a test row proves the encoding only if its answer changes when the encoding names the wrong thing. Picking "distinct, memorable, far-apart values" — which is the instinct, and which the first cut of that test followed deliberately — produces rows that are maximally insensitive, because far-apart values compare the same way against almost anything. Adjacent values, not distinct ones, are what make an operand observable.

5. A PROMOTION moves a gate. Nothing moves the test rows with it. Rule 1 says every -O3 pass needs its own control test; the corollary nobody had paid for is that when the gate is re-spelled OptLevel < 3 -> < 2, that test is now asserting the wrong level. EmitStaticLitHandle/...A64 were promoted on 2026-08-30. The x86-64 side got a -O2 row. The aarch64 side's qemu loop still read for o in 0 3, so the only level aarch64 actually ships was the one level never asserted-O3 covered the promoted pass incidentally, as a superset, and a green there says nothing about the default. Found by running the arm by hand, not by any gate; tools/check_o3_backend_parity.py counts gate SITES per backend and cannot see that a site changed tier, and the tstate full run could not answer it either (the row carries no skip_holes field, so "no job went newly red" and "the cross arm ran" are different claims).

Fixed in 2649faaca, and proven live rather than assumed: a scratch emitter built with MSTR_STATIC_RC=0 makes aarch64 -O2 print b=Zbcdef while -O0 stays correct — sensitive in both directions. Build that broken emitter to a SCRATCH PATH with the good compiler; the same break run through make compiler/pascal26 hangs for minutes, because a compiler that frees its own .data literals cannot reach a fixedpoint.

So: when you promote a gate, grep for every test row and qemu loop naming that pass and move them with it, in the same commit. Both backends, and check the for o in ... lists specifically — an -O3-only list is correct for an -O3 slice and silently wrong for a promoted one.

Why — the measured opportunity

The compiler is a single-pass stack machine, so most emitted work is moving values, not computing them. Measured on a real build (O2 compiler self-compiling compiler.pas, 937k emitted instructions):

category share what it is
push/pop 22.6% per-statement operand staging (push rax; eval right; pop rcx)
rbp loads 9.4% reload local/param from frame
rbp stores 5.0% spill to frame
frame traffic total 37% memory shuffling, NOT compute
calls 12.3%

r14/r15 param residency ([[feature-callconv-register-args]]) already bought 1.34× self-compile from just 2 registers — direct proof the lever is real and far from exhausted. Killing the 37% is where the next multiplier lives.

Target scope — per-backend effort = x86-64 + aarch64 only

MEASURED 2026-08-30, and the scope was not being met. This section states aarch64 is in scope; nothing checked whether it was. Parsed OptLevel >= 3 gate sites: x86-64 15, aarch64 4, the other four backends 0 (correct — they are out of scope by this very section). aarch64 has the W2 residency keystone and two operand folds; it has none of the W1 slice 5-8 family. Filed as feature-opt-o3-w1-operand-folds-are-x86-64-only-aarch64-has-four-of-fifteen.

"aarch64 is in scope" and "aarch64 got 4 of 15" are consistent statements, which is why a stated scope needs a recurring count rather than a comment. From now on, each W1/W2 slice records its per-backend gate count here — one command, and the only thing that would have caught this.

CORRECTION, 2026-08-30 (slice 10): the count itself was undercounting, and its own recurrence is what caught it. The published method greps OptLevel >= 3, and roughly a fifth of the gates in this campaign are spelled if OptLevel < 3 then Exit; — an early return at the top of a predicate, which is the shape slices 7, 8 and 10 all use. Parsing BOTH spellings:

file >= 3 < 3 total
ir_codegen.inc (x86-64) 17 6 23
ir_codegen_aarch64.inc 5 2 7
the other four backends 0 0 0

And 23 : 7 was wrong too — by one on each row. See the correction below; the number is 22 : 6. The story never changed (aarch64 has the W2 keystone and none of the W1 slice 5-10 family); the count needed three tries. The sibling ticket's slug says "four-of-fifteen"; slugs are cited by resolved commits and are not renamed, so the corrected count is recorded inside it instead.

The lesson is the instrument's, not the scope's: a count is a grep, and a grep is a spelling. "Count arms by parsing, not by reading" (face 118) buys nothing if the parse matches one of the two ways the arm is written. Slice 10 added a gate and the >= 3 count did not move — 17 before, 17 after — which is the tell, and it is only visible because the count is taken every slice. SECOND CORRECTION, same day, same shape: 23 : 7 counted prose as code. Each of those two files carries exactly one CONTINUATION line inside a { ... } block that mentions a gate in passing — ir_codegen.inc:4434 and ir_codegen_aarch64.inc:1280 — and neither is caught by "does this line start with a comment marker?", because neither does. Properly comment-stripped, the counts are x86-64 22, aarch64 6. The aarch64 file's original figure even carried the footnote "(a 5th match is prose)", which is the tell: a number that needs an asterisk is a number nobody can re-derive.

Three counts, three wrong answers, and every one of them was the instrument rather than the thing measured. 15 : 4 missed a spelling; 23 : 7 counted comments; only the third, from stripped source, needs no footnote. The conclusion survived all three unchanged — which is the point, and also the danger: a finding whose supporting number keeps moving while its direction holds is one nobody re-checks.

So the count is no longer a command anyone has to remember to run correctly. It is tools/check_o3_backend_parity.py, wired as a step in gate.sh quick: it comment-strips, matches every spelling of an -O3 gate (>= 3, > 2, < 3, <= 2, = 3), derives the backend list from a glob so a seventh emitter cannot escape it, and freezes 22 : 6. It does not forbid a one-armed slice — most legitimately are. It forbids one nobody noticed was one-armed: widening the delta becomes an edit to that file, in the same commit, visible in the diff. --census prints every match with file and line so a reader can check the number instead of trusting it. Optimization splits by home (see optimization-architecture.md §3): shared-IR passes (§3a) help all six targets for free — one implementation, keep those target-agnostic. Per-backend work (§3b: emitter peepholes, the operand scheduler, the register allocator's emit side) targets x86-64 + aarch64 ONLY. Rationale:

Pipeline home — decided (do NOT post-rewrite bytes)

Register work lives before bytes, over the IR — a planning pass that annotates, then the emitter reads the annotation and emits once. NOT a post-emission byte peephole.

The data splits the work cleanly by home:

Workstreams (suggested order)

W1 — emit-time operand scheduler (do first; best effort:payoff)

Kill the binop push rax; eval; pop rcx dance without any liveness analysis. Peepholes 1–2 already direct-load leaf operands into rcx; generalize to a small per-statement register scheduler over expression trees (rax/rcx/rdx + caller-saved r8–r11 are free within a statement, clobbered only across calls). Local, low-risk, attacks the single biggest slice. De-risks the register model before the bigger scaffold. x86-64 emitter (§3b); a cross variant later.

Leaf functions first (the golden class). A function that calls nothing (ProcBodyMakesCall = false, already computed by the inline pass) may use all 9 caller-saved registers (rax,rcx,rdx,rsi,rdi,r8–r11) as scratch with zero save/restore — nothing can clobber them. Simplest, highest-value case: no prologue register save, whole scratch set free. Start the scheduler here, extend to non-leaf (where cross-call values need callee-saved or spill) after.

Standard ABI preserved at every call boundary. W1/W2 do internal allocation only — callers never see it, so nothing crystallizes and each body is an independent island. Custom register calling conventions (caller-side param passing, which DOES crystallize into the callee's ABI and only works when every call site is direct+visible — breaks on fn-pointers / virtual / exported / separate compilation) are explicitly deferred to a regcall-phase-3 follow-up ([[feature-callconv-register-args]]), not part of W1/W2.

W2 — register-liveness scaffold → linear-scan allocator (the keystone)

A pre-emit IR pass computing per-body live ranges, then assigning free callee-saved + caller-saved registers to the longest-lived / hottest values, annotating the IR/symtab (generalize the RcResident* mechanism the emitter already reads). Kills the 14% frame load/store. Unblocks two queued items that share exactly this scaffold:

Highest effort, highest ceiling. Estimate another ~1.3–1.5× on top of current, compounding with W1.

W3 — ride-alongs (cheap, after the scaffold exists)

Out of scope (captured so it is not lost)

Code-block reordering for locality ("code that runs together lives in the same/nearby page"). Genuinely useful, but needs profile / runtime hotness data to know which blocks co-run — the compiler has no PGO input, so this is out of scope until there is a profiling story. Note it here; do not attempt blind.

Gating & fallback (the whole point of -O3)

Acceptance (umbrella — each pass is its own landed unit)

Umbrella [[feature-optimization-levels]] · [[feature-opt-store-reload-elimination]] · [[feature-callconv-register-args]] · [[feature-inline-nonleaf-and-branch-locals]] · lesson [[project_regcall_residency_reemit_localinit_clobber]] · architecture devdocs/dev/optimization-architecture.md.

Log

2026-07-11 — W1 slice 1 LANDED behind -O3 (x86-64): binop mirror + r8/r9 scratch + leaf-index fold

2026-07-11 — W1 slice 2 LANDED behind -O3: callee-saved r12/r13 scratch across call-bearing right subtrees

2026-07-11 — W2 slice 1 LANDED behind -O3: loop-local register residency (r12/r13)

2026-07-11 — W2 slice 2 LANDED behind -O3: float loop residency in xmm8/xmm9

2026-07-11 — W1 slice 3 LANDED behind -O3: last-call-argument push/pop collapse

2026-07-11 — W1 slice 3b: last-arg collapse extended to virtual + indirect calls

Next steps (queued, in rough order)

  1. Record-aware inline (the raytracer unlock): Vec3-style record params/returns block both inline v1 (scalar-only) and float xmm residency (helper calls make bodies non-call-free). SROA-like splitting of small by-value records into scalars at inline sites — multi-session effort, file under [[feature-inline-routines]].
  2. -O2 promotion of the W1/W2 set after soak: the ticket's full gate (500-program -O0-vs differential, all four cross targets, -O2 flip + re-pin). Hold until T is back up or run the matrix locally.
  3. IR_INDEX callee-scratch for call-bearing index expressions (rare; cheap once measured worthwhile). Remaining stack-op census after slice 3b: pop rdi 9.4k (cdecl/variadic staging), pop rax 14.9k (call-y binop dances at depth>2 / InLValueWrite contexts), pop rcx 14.2k (complex index/base dances).

2026-07-11 — PROMOTED to -O2: mirror + leaf-index fold + last-arg collapse


Measurement, 2026-08-26 (agent-A-perf-9s) — the gap has a number now: ~4x FPC

Found while resolving bug-a-every-nilpy-compile-pays-a-fixed-nine-second-cost. That ticket's four fixes removed the algorithmic hotspots from the compiler's own hot path (8.62s -> 4.06s on a NilPy compile). What is left is not algorithmic, and this ticket is where it lives.

The end-to-end number. compiler.pas built by fpc -O2 and compiler.pas built by pxx -O2 are the same source and emit byte-identical output. Compiling empty.npy:

compiler binary wall
built by fpc -O2 1.06s
built by pxx -O2 (self-hosted, sha 66c9b8332) 4.06s

Same work, same output, 3.8x. Every self-host, every test binary, every compile every agent runs, carries that factor.

Isolated, so it is not an artifact of one workload. Three locals, no memory traffic, no arrays, no strings, no bounds checks in play:

program b_loop;
var i, s, t: Integer;
begin
  s := 0; t := 1;
  for i := 1 to 200000000 do begin s := s + i; t := t xor s; end;
  WriteLn(s, ' ', t);
end.
build wall
pxx -O2 0.78s
pxx -O3 0.77s
fpc -O2 0.19s

4.1x, and -O3 does not move it. Both print the identical result, so this is a codegen comparison and not a semantics difference. A second benchmark (1000-element array fill + sum, 20k iterations) gives 0.16s vs 0.04s, the same ratio; a string-append benchmark gives 0.42s vs 0.24s, i.e. the RTL is not the problem — scalar code is.

-S says exactly what is wrong. The whole emitted loop body, pxx -O2:

loop:
    movsxd rax, [0x0041a7f0]     ; load i
    mov    rcx, 0x0bebc200       ; rematerialise the loop bound
    cmp    rax, rcx
    jg     exit
    movsxd rax, [0x0041a7f4]     ; load s
    movsxd rcx, [0x0041a7f0]     ; load i  (2nd time)
    add    rax, rcx
    mov    [0x0041a7f4], eax     ; store s
    movsxd rax, [0x0041a7f8]     ; load t
    movsxd rcx, [0x0041a7f4]     ; load s  (just stored, one instruction ago)
    xor    rax, rcx
    mov    [0x0041a7f8], eax     ; store t
    movsxd rax, [0x0041a7f0]     ; load i  (3rd time)
    mov    rcx, 0x0bebc200       ; rematerialise the bound again
    cmp    rax, rcx
    je     exit
    movsxd rax, [0x0041a7f0]     ; load i  (4th time)
    add    rax, 0x00000001
    mov    [0x0041a7f0], eax
    jmp    loop

Twenty instructions and nine memory accesses for what is five instructions and zero memory accesses of real work. The induction variable is loaded four times per iteration, the loop bound is materialised into a register twice per iteration, and s is stored and immediately reloaded. This is the "single-pass stack machine moves values rather than computing them" cost this ticket already names, quantified: ~4x, on the simplest possible loop.

Why the prio should be revisited (a Track U / coordinator call, not mine)

Sitting at 35. The owner's loudest standing complaint is "we see testing overhead taking 95% of our development time", and this is now the largest single remaining lever on it — larger than any tiering or sharding proposal, and like the ticket it came from it costs no coverage at all: the same tests, faster. make compiler/pascal26 is 23.4s today and is mandatory in every agent's per-fix loop on every track; a 2x codegen improvement takes it to ~12s and takes every test binary with it.

Recorded here rather than acted on: the fix is a register allocator, which is this ticket's whole subject and a campaign, not a session. Reproduce the two benchmarks above before starting — they are 30 seconds each and they are the scoreboard.

Re-prioritised 35 -> 85 by the coordinator, 2026-08-26

The Track A worker that measured the 3.8x gap flagged this field as a decision rather than an engineering call and correctly left it alone. Raising it.

The number is what changed, not the opinion. The same source built by fpc -O2 compiles empty.npy in 1.06s against our own -O2 build's 4.06s, producing byte-identical output -- so this is not a semantics question, it is purely how well we allocate registers. Isolated to scalar codegen: a three-local loop runs 0.78s under pxx at both -O2 and -O3 versus 0.19s under fpc, and the -S dump above shows the induction variable loaded four times per iteration, the loop bound rematerialised twice, and s stored then reloaded one instruction later.

Why 85 and not higher: it sits below live segfaults and wrong-value bugs, which remain the owner's stated top rank ("compiler syntax, segfaults, etc, all prio"). It sits above essentially everything else because it is now the largest single lever on the owner's other standing complaint -- "we see testing overhead taking 95% of our development time" -- and unlike tiering, sharding or dropping jobs it costs no coverage at all. Every lane pays this tax on every make compiler/pascal26, which is mandatory in every agent's per-fix loop.

The hotspot work that produced this measurement already took empty.npy from 8.62s to 4.06s and make compiler/pascal26 from 32.08s to 23.36s. That was the easy half. This is the remaining 3.8x, and it is the reason the prio field no longer matched the value.


2026-08-26 (agent-O-regalloc) — four passes landed at -O3; 1.29x on the self-compile, and the ticket's own headline is now measured to be the SMALLER half

Claimed at prio 85 with the brief "the remaining 3.8x is register allocation". It is about half register allocation. The first thing done was to profile rather than to trust the ticket, and the profile named three misses the ticket does not mention, each larger than anything the register allocator would have bought in the same time.

The instrument (reusable — bench-o/, not committed)

perf is blocked on plexus (perf_event_paranoid = 4) and yama ptrace_scope = 1 forbids attaching to a non-descendant, so gdb cannot attach either. tools/pxxprof.c (60 lines, committed) solves both: it forks the target itself, PTRACE_SEIZEs its own child, and samples RIP with PTRACE_INTERRUPT on a timer. It profiles any binary including pxx's own custom-ELF output; tools/pxxprof_symbolize.py resolves addresses against a symbol map, which for a pxx binary comes from -g's DWARF DW_TAG_subprogram low_pcs (both headers carry the recipes). Two traps recorded so we do not re-learn them:

Wall-clock discipline: the box also runs Track T's watcher (load 4-15 during this session), so every timing below is %U user time, A/B alternating, reported as the minimum of N — the least noise-contaminated estimator. An A/B harness doing exactly that is three lines of shell; it was scratch, not committed.

What the profile actually said

Compiler compiling a one-line NilPy file, compiler built at -O2, sha e7f6312d9. 56% of the run was inside the first 5 KB of .text — the runtime blobs and the builtin heap — and the individual hot instructions were:

what share why
two idivs in PXXAlloc 8.7% ((size + 7) div 8) * 8 and Integer(size div 8) - 1divisor is the literal 8
a third idiv in PXXFree 3.5% same shape
the AnsiStrRelease blob's push/pop wall ~11% ten stack ops to guard a decrement
AsmText* + EmitAsmX64 ~12% the mini-assembler re-lexing fixed strings — filed separately

None of that is register allocation. x div 8 was emitting a real 25-40 cycle idiv, preceded by a zero-check on a constant that is provably non-zero. Integer(x) was emitting six instructions and 39 bytes (three of its constants do not fit a sign-extended imm32, so each became a 10-byte movabs) where movsxd rax, eax is three bytes.

Landed (all -O3-gated except where noted)

sha pass
e9317428d div/mod by a constant power of two -> shifts and masks. Signed forms bias with sar 63; shr 64-k first (Pascal's div truncates toward zero; a bare sar floors). A non-zero constant divisor also drops the pre-divide zero check — dead code at every such site, div 10 in every number formatter included.
029f79b26 AnsiStrRelease blob fast path (ALL opt levels — a runtime stub, not a pass): only rax is saved, and only across the free. Four of the five registers it used to push were being saved a SECOND time by EmitHeapFreeLocked on the only path that can clobber them. + -O3 cmp-immediate in the compare-into-branch fusion.
f9d9da4b5 narrowing ordinal cast -> one movsx/movzx. NarrowCastFold recognises the mask (movzx) and the mask/xor/sub triple (movsx) the IR lowering spells out, for 1/2/4-byte widths. Value-identical, so a hand-written x and $FF folds too.
6692d08b8 cmp-immediate for the VALUE-producing compare. The branch form left the bigger half on the table: a short-circuit and/or lowers each operand to a boolean VALUE, so if (c >= 'A') and (c <= 'Z') still paid mov rcx, imm32 twice per character — CaseEqual's inner loop, 3.2% on its own. CmpFusible moved above IREmitNode rather than being restated.
e7c0d1d2a residency refresh reads rax, not the slot it just wrote. This one IS the ticket's subject: the -O3 residency pass exists to break the loop-carried store-forward chain through a frame slot, and its own refresh was re-creating one — every store to a resident emitted mov [rbp+off], eax; movsxd rax, [rbp+off]; mov r12, rax. Re-extending rax's low bytes is the same value by construction. IR_ZERO_SYM and the IR_EXC_ENTER landing pad keep the reloading form on purpose: there the slot, not rax, is the authority.

Numbers

Same source built by each compiler; baseline binary self-hosted from e7f6312d9, final from e7c0d1d2a; plexus, watcher running, min of N.

workload base -O2 (today's default) base -O3 new -O3 fpc -O2
compile empty.npy 3.65 s 4.40 s 2.91 s 0.90 s
self-compile compiler.pas 18.03 s 13.96 s

Note the -O3 baseline is slower than the -O2 baseline (4.40 vs 3.65) — the register-residency passes were a net LOSS on this workload before e7c0d1d2a, which is what the refresh-reload bug was doing.

Correctness

Where output changes, byte-identity of the emitted code is not available, so:

The one decision left: -O2 promotion (COORDINATOR / Track U)

All four codegen passes gate OptLevel >= 3, so today's default -O2 gets 1.018x of the 1.29x. Promotion needs this ticket's stated bar (500-program -O0-vs differential, four cross targets, -O2 fixedpoint, make test + make test-opt) — breadth this lane may not run. Recommendation: promote all four, because they are in the mechanically-local class the July slice already promoted (no register-lifetime state, no reordering), except the residency refresh which touches only a mechanism that does not exist below -O3. Each is backed by a two-oracle differential in the tens of millions of cases. Handing the call to the coordinator with the sha; Track T's sweep of e7c0d1d2a is the evidence path.

Next slice, in priority order (measured, not guessed)

  1. The three-local loop is still 3x off fpc0.65 s vs 0.22 s at -O3 (was 0.76). What remains is exactly W2: only ONE of the three locals gets a register (the eligibility threshold is >3 loop accesses, which excludes the accumulator s at exactly 3), and the other two live in the frame across the whole loop. Two cheap experiments before building anything bigger: lower the threshold, and use all four free callee-saved registers rather than two. A for-loop induction variable should also be resident unconditionally.
  2. Globals are not residency candidates at all — the ticket's own headline -S dump uses program-level vars, which is why -O3 "bought nothing" on it. A global with no address taken and no call in the loop body is as register-able as a local.
  3. The boolean temp the short-circuit and/or lowering materialises: setae al; movzx rax, al; mov [rbp-31], al; movzx rax, [rbp-31]; test rax, rax — a frame round-trip per condition operand, four per CaseEqual iteration. The -O3 store->reload eliminator already handles the non-resident case; a resident destination is explicitly excluded by ReloadElimSym, and that exclusion's stated reason (the refresh clobbers rax via a reload) is no longer true after e7c0d1d2a. Revisit it.
  4. EmitAsmX64 re-lexes fixed strings — ~12% of a NilPy compile, filed as [[feature-opt-emitasmx64-reparses-fixed-strings]] (Track A+O, prio 60). Not codegen; a different file set; kept out of this ticket deliberately.
  5. Non-power-of-two constant divisors via magic-number reciprocals (div 10 is in every number formatter). Cheap once someone wants it.

One thing to keep in mind when touching IR_BINOP

Three of these passes add arms to IREmitNode's IR_BINOP guard chain, and IRFirstEvaluated / IRStmtFirstEvaluated MIRROR that chain to decide whether a reload is redundant — the file says "MUST MOVE TOGETHER" and it means it. Each new arm was checked against the mirror: all of them evaluate the same node first as the arm they preempt (const-right -> left first), so the mirrors did not need to change. The next arm might not be so lucky.

2026-08-27 (frankA) — next-slice item 1: both cheap experiments are NO-OPS, and the premise is wrong

Dispatched to work item 1 only ("the three-local loop is still 3x off fpc … what remains is exactly W2"), running the two experiments the slice says to run before building anything bigger. Both are no-ops, the third is near-moot, and the measurement does not support the premise: what remains on these loops is W1, not W2. Nothing was built beyond the instrument.

The instrument — this time it is COMMITTED

bench-o/ was scratch and had to be re-created, as the slice warned. The half that is worth keeping is now a probe rather than a directory:

PXXDBG=a.resid (UnifiedResidencyAssign, committed) prints one TALLY line per residency candidate — proc, sym, loop-access count, type, kind, whether it escapes, and how many GPRs were free — and one ASSIGN line per pick. It exists because the eligibility thresholds are invisible in the emitted bytes: a local that just missed the cut and one that was never a candidate emit identical frame traffic, so a disassembly cannot tell you which knob to turn. Reading the counts is what made both experiments below cheap, and items 2 and 3 of this slice will want it too.

The timing half stays scratch (three lines of shell): A/B alternating, %U user time, reported as min of 5 — the box was running Track T's watcher at load 13 throughout.

Every number below came from a self-hosted make compiler/pascal26 fixedpoint build at this commit, binary sha256 0b134438899d. Two benchmarks, both LongInt/Int64 (FPC's default mode makes Integer 16-bit — the first draft silently measured a range-check-folded no-op):

Both agree with fpc -O2 on output at every level tested.

(a) Lower the eligibility threshold — no effect

three.pas, min of 5, user seconds:

threshold j gets a register? pxx -O3 fpc -O2
> 3 (shipped) no 0.79 0.34
> 2 no 0.77
> 1 yes, r15 0.78
> 0 yes, r15 0.80

many.pas, where the pool actually runs out: > 3 → 0.16, > 1 → 0.17, fpc -O2 → 0.05.

The probe confirms the change lands — at > 1, j (count 2) is assigned r15 next to s (5, r12) and i (4, r13). It buys nothing, at either threshold, on either shape.

Why, and this is the part worth keeping: residency removes loads, not storesEmitStoreVar dual-writes the frame slot and the register, because the slot stays authoritative. A local with roughly one load and one store per iteration therefore trades one removed memory read for one added register move and nets ~zero. j is exactly that local. The existing comment's rationale ("plain L1 reloads measured as free — the regcall phase-2 rejection") is confirmed rather than overturned, so the threshold stays at > 3.

The corollary is a better ranking metric for whenever W2 does get built: rank by loads, not by loads+stores. A store-heavy resident is close to free; a load-heavy one is the whole payoff.

(b) Use all four free callee-saved registers — already landed; the slice is stale

UnifiedResidencyAssign already builds its pool as r12..r15 minus whatever regcall param residency claimed, and the probe reports freegpr=2..4 per body. The "rather than two" in item 1 describes the pre-unified pass and was overtaken by the unified-residency work itself. No change; the item is struck.

(c) A for counter should be resident unconditionally — near-moot

A for counter tallies 4 loop accesses on its own (entry test, bottom test, increment load, increment store), so it clears > 3 unaided in the common case — in three.pas it takes r13 with count 4. It loses only when hotter locals exhaust the pool: in many.pas, i (4) is beaten by a/b/c (6 each) and spends the loop in the frame. But forcing it in means evicting one of those, and (a) says the marginal register is worth ~0 there. Not implemented: it would be policy churn with no measurable payoff behind it.

What the gap actually is — read off the disassembly

Run's loop body in three.pas at -O3 is 21 instructions on the common path. The waste is not allocation:

pattern instances per iteration
mov %rN,%rax — operand staged through rax before every use 5
mov %rax,%r12 immediately followed by mov %r12,%rax 1 (pure round trip)
resident dual-write (mov %rax,-0x20(%rbp) + mov %rax,%r12) 2
j stored then reloaded from the same slot 1
for-limit temp reloaded from the frame 1

Every one of those is the single-accumulator operand model — that is W1's subject (the emit-time operand scheduler), not W2's. The register allocator cannot help a body that moves each value into rax before touching it.

Two things that look like levers and are not, both measured:

Verdict — do NOT start W2 on this evidence

Item 1 asserts "what remains is exactly W2". On the measurement it is not. Both cheap experiments the slice itself proposed as the gate on that assertion came back empty, and the third is moot. The remaining gap on both loop shapes is the operand model, so W1 (emit-time operand scheduler) should rank ahead of W2, and W2's own ranking metric should be loads rather than accesses when it is built.

Banked and handed back to the coordinator rather than starting W1 unilaterally — the dispatch was item 1 only. -O2 promotion untouched (coordinator's call); no pin taken.

Gate: make compiler/pascal26 fixedpoint (converged, 0b134438899d) + tools/gate.sh quick GREEN. The commit adds the probe only; no pass changed behaviour, so -O0/-O1/-O2/-O3 output is unchanged by construction.

2026-08-27 (frankA) — the -O2 promotion landed, and its payoff is 1.04x, not 1.29x

Coordinator's call, three passes, one commit each. All three landed green. But the number that justified doing this ahead of W1 does not survive being re-measured, so the record below leads with that.

What landed

commit pass guards moved
13d4bba0c div/mod by a constant power of two → shifts/masks the strength reduction + both zero-check elisions (tkDiv, tkMod)
e4fe576eb narrowing ordinal cast → one movsx/movzx one (NarrowCastFold's guard)
7767acc60 cmp-immediate, both the branch and value-producing forms two — they move together, because a short-circuit and/or lowers each operand to a value, so promoting only the branch form leaves the larger half behind

Gate constants only; no pass logic changed. Every other OptLevel >= 3 guard in ir_codegen.inc stays put — float-tree fusion (FloatTreeFits), the W1 scratch arms (ScratchSafeSubtree / CsScratchOn), and skipLastArg are not part of this set and were checked individually rather than swept by pattern.

e7c0d1d2a is deliberately NOT promoted, and this is the fourth pass a future reader will count and miss. It fixes the residency refresh so it reads rax rather than the slot it just wrote — and the residency mechanism it repairs is itself gated OptLevel >= 3 and does not exist at -O2. Promoting it would change no emitted byte at -O2: a no-op wearing the costume of a change. It stays at -O3 where it does work.

Correctness — per pass, differentials re-run after each promotion

Each pass got a checksummed two-oracle differential: -O0/-O1/-O2/-O3 must agree with each other and with fpc -O2, run once before the promotion as a control and again after, with the earlier passes' differentials re-run each time. Each also confirmed the -O2 binary actually changed — agreement from a pass that silently failed to fire is worth nothing, and that check is cheap.

Self-host fixedpoint after every commit, converging in 2 rounds rather than 1 — which is the expected signature here, because the compiler now compiles itself differently — and gate.sh quick GREEN after each.

One case is excluded and filed rather than papered over: QWord div by a LITERAL ≥ 2^63 returns a wrong value at -O0 too, so it is not ours — [[bug-p-qword-div-by-a-literal-above-2-63-is-signed]]. The sweep divides by the same value through a variable, which is correct.

The payoff, measured properly — 1.04x, and the 1.29x was never this

Method, because the answer depends on it: the baseline must be a compiler whose own code was emitted with the passes off. Built by seeding 552af4dcb's sources from the pinned binary and iterating to a fixedpoint — landing on 0b134438899d, byte-identical to the probe-only build measured earlier, which is what confirms the baseline is genuine. Then A/B alternating, %U, min of N.

workload pre-promotion -O2 post -O2 -O3-built
compile empty.npy (min of 15) 2.27 s 2.19 s 2.19 s (min of 9)
self-compile compiler.pas (min of 3) 18.31 s 17.95 s

The three promoted passes are worth ~1.04x, reproduced twice (2.35→2.26 and 2.27→2.19). The entire remaining -O3 set is worth about another 1.03x on top.

The 1.29x in the section above is not what this promotion could ever have delivered, and reading it as such is the mistake to avoid repeating:

  1. It compared base -O2 against new -O3, so it always included the -O3-only passes that are not in the promotable set.
  2. Its baseline no longer exists. That row measured 3.65 s for empty.npy; the same workload is 2.27 s today on the pre-promotion binary. Work that landed afterwards — 13e196cc8, emitting the variant clear/retain blobs once instead of at 10,707 call sites — had already captured most of that headroom. A banked speedup decays as the tree moves underneath it, and a figure quoted from a ticket months later is a claim about a binary nobody still has.

Not an argument against having promoted them: 1.04x on every compile on every track, for a change that alters no pass logic and is backed by tens of millions of differential cases, is a good trade. It is an argument against the priority reasoning that ranked it above W1 on the strength of a stale number.

Re-ranking: W1 before W2

On the evidence in the section above this one, the workstream order at the top of this ticket is wrong and is superseded:

A caution for whoever picks W1 up, from this session's own numbers: the static text-segment sweep put the operand-funnel family at ~1.2% of the binary while the hot loop shows 24% on its common path. Those are not in conflict — a static count weights cold code equally — but it does mean W1 must be justified on a dynamic profile, not a static one, and its win will land on hot loops rather than on overall code size.

2026-08-27 (frank-optimize) — W1 sized before building: it is worth 1.4%. The prize is the residency ADMISSION METRIC, and it is already shipped

A loop-carried accumulator must never sit in the frame while GPRs idle.

Dispatched to size W1 (the emit-time operand scheduler) before committing to it, after frankA's run above found the operand funnel — 5 of 21 instructions on the common path — and concluded "a register allocator cannot help a body that moves every value into rax before touching it". That is true. It is also true that removing those movs helps nothing, and this slice measures both halves.

Nothing was built. No pass changed. The whole result is one A/B harness.

Method — hand-written asm, calibrated at BOTH ends before it was used

Run's actual -O3 disassembly (17 instructions on the common path, i resident in r12, s/j in the frame) was transcribed into raw asm, then successively optimized into five variants, all six timed in one process. The next reader's first instinct will be to distrust a hand-written asm model, so it was calibrated against the two things it sits between:

model real binary
shipped -O3 body 1.147 s 1.14 s (pxx -O3)
ideal allocation 0.171 s 0.23 s (fpc -O2)

It reproduces the thing being modelled and the target being chased. A model calibrated at neither end is a story; this one is evidence.

Every variant returns the identical result (-200000002), which is also what pxx at -O0/-O1/-O2/-O3 and fpc -O2 print for the same source.

The decomposition

Binary sha256 591ae8160f69... (self-hosted fixedpoint at HEAD, confirmed different from pinned). Box at load 7-13 (Track T's watcher); %U user time, A/B alternating, min of 5. three.pas = j := i xor s; s := s + j over 200M iterations, three LongInt locals in a procedure.

variant s cyc/iter vs shipped
V0 — the shipped -O3 body (17 insns) 1.142 12.00 1.00x
V1 — W1 applied: all three mov %rN,%rax funnels + the dead round trip gone (11 insns) 1.133 11.89 1.01x
V4 — s also resident, dual-write kept 0.702 7.37 1.63x
V2 — W1 + store->reload elimination 0.630 6.61 1.81x
V5 — all three resident, dual-write kept 0.368 3.86 3.10x
V3 — true allocation, no frame traffic (fpc-like) 0.171 1.79 6.68x

W1 buys 1.4% on the exact loop that motivated it. Deleting 6 of 17 instructions moved nothing measurable: those instructions were never on the critical path. (Mechanism is almost certainly move elimination at rename on this Ivy Bridge Xeon, but the measurement does not depend on the explanation.) The 5-of-21 static share is real and irrelevant — do not build the operand scheduler for speed. Its residual value is code size (-35% on this body, ~1.2% image-wide by frankA's sweep), which is a different and much weaker case.

What the 12 cyc/iter actually is: two frame round-trips on the loop-carried dependency chain at ~5 cycles each (store-to-load forwarding), plus ~2 cycles of real work. Each variant above removes exactly one such round trip and pays back exactly one forwarding latency — V1->V2 removes j's store+reload (-5.3 cyc), V2->V3 removes s's (-4.8 cyc). The model is that simple.

Why the threshold experiment came back empty — this CONFIRMS frankA's run, it does not correct it

PXXDBG=a.resid (frankA's probe, and it is what made this cheap) on this body:

TALLY proc=Run sym=i count=4 ... freegpr=4     ASSIGN sym=i reg=r12
TALLY proc=Run sym=s count=3 ... freegpr=4
TALLY proc=Run sym=j count=2 ... freegpr=4

s misses the > 3 cut by one, with three registers sitting idle. Giving s a register is worth 1.63x on its own (V4); all three, 3.10x (V5) — keeping the dual-write exactly as designed.

frankA's three.pas already had s resident at count 5, so lowering the threshold there only ever admitted j — one load per iteration, the load-poor candidate, correctly worth ~0. Their conclusion "the threshold stays at > 3" is right. The measurement's actual content is that the METRIC is wrong, not the threshold — and their own corollary, rank by LOADS, not accesses, turns out to be the pass itself rather than a footnote for a future W2. Two runs from different angles converging on the same mechanism.

Revised ranking (adopted by the coordinator, 2026-08-27)

  1. Residency admission by LOADS, and never leave free GPRs idle — a small change inside the shipped UnifiedResidencyAssign. Up to 3.1x on tight scalar loops. Land behind -O3: unlike today's promotions this changes which programs get which registers, so it is not gate-constant — full -O0/-O1/-O2/-O3 + fpc -O2 differential, self-host fixedpoint per commit.
  2. Store->reload elimination for resident destinations — the ReloadElimSym exclusion whose stated reason stopped being true at e7c0d1d2a.
  3. Drop the dual-write inside a loop (register authoritative, frame re-synced at exits). V5->V3 is a further 2.15x. This is the real W2 and the big job.
  4. W1 — deprioritised to a code-size item.

Scope limit, stated up front

This bounds tight scalar user code (mandelbrot-class). It does not bound the self-compile, which this ticket already measures as memory-bound and which took only 1.05x from residency. When item 1 lands, make compiler/pascal26 gets measured and reported as its own number even if it is ~1.0x — a pass that is 3x on mandelbrot and 1.0x on the compiler is still worth having; a pass claimed at 3x that nobody separated is how a ticket ends up carrying a stale 1.29x.

Harness (six asm variants + driver) was scratch, not committed — it is ~120 lines of asm and the bodies are transcribed above and in frankA's disassembly table, which is what a re-run needs.

2026-08-28 (frank-optimize) — item 1 LANDED at -O3: residency admits and ranks on LOADS. 1.9-2.1x on tight scalar loops, and ~2-6% SLOWER on the self-compile. Both numbers are real

The slice the sizing above ranked first. UnifiedResidencyAssign now tallies loop loads separately from total accesses, ranks candidates on loads, and admits any candidate with at least one loop load while a register is free — the bound is the register pool, not a threshold.

Diff is 35 lines in ir_codegen.inc. Binary fc9d664a4e63.

Why loads, restated at the point of change

Residency removes reads. EmitStoreVar dual-writes — the frame slot stays authoritative — so a store costs one extra register move, which is free. The old test, Counts[k] <= 3 over loads AND stores, therefore ranked on a number whose store half it cannot cash: it let a store-heavy local outrank a load-bearing one, and on this ticket's own benchmark it excluded the accumulator s at exactly 3 accesses while three registers sat idle.

The numbers, and they disagree with each other

-O3-built compilers, both from the same source; %U user time, A/B alternating in a single run, min of N. Box under Track T's watcher at load 8-13 throughout, which matters — see the noise note below.

workload before after
three.pas (three-local loop) 1.15 s 0.61 s 1.89x (a second run: 1.33 -> 0.62, 2.15x)
mandelbrot 1.20 s 1.09 s within noise — its kernel is float, this is an int-side change
sieve 0.03 s 0.02 s too short to time
self-compile of compiler.pas at -O3 16.57 s 17.58 s ~6% SLOWER

The self-compile regression reproduced in all three runs that measured it (20.87 -> 21.22, 16.57 -> 17.58, 15.47 -> 16.05: +1.7%, +6.1%, +3.7%). The sign is consistent even though the magnitude is not, so it is real.

make compiler/pascal26 — the number every lane actually pays — is unaffected, by construction and by measurement: the pass gates OptLevel >= 3 and the build uses the default level. An 8-program corpus x 6 targets = 48 output hashes is byte-identical before and after.

Why the compiler loses while the loop wins. Residency's benefit scales with loop TRIP COUNT; its cost — prologue save, init load, epilogue restore — is paid per CALL. A body that is a loop pays the fixed cost once and wins big. A small, hot, call-per-item body with a short loop pays it constantly. The compiler is the second shape and three.pas is the first. That is the real finding here, and it is what the next slice has to price.

Tightening admission does NOT fix it — measured, so nobody retries it. loads >= 3 (roughly the old admission volume, ranked the new way) is worse on both workloads: self-compile 17.33 s (+12% vs before, worse than >= 1) and three.pas 1.17 s (giving up nearly the whole win). loads >= 2 was also tested and sits between. So the regression is not "too many residents", and the per-call cost story above is a hypothesis the next slice must test rather than a conclusion this one proved.

Correctness

Two process notes worth more than the patch

A 7% "regression" that was noise, and it nearly became a source comment. Mid-slice, mandelbrot measured 1.10 s before and 1.18-1.24 s after. I built a per-class rank split to fix it and wrote the cause into the code as fact. Re-measuring all three binaries in one interleaved run put them at 1.34 / 1.33 / 1.34 — the same "before" binary that had measured 1.09 now measured 1.34. The effect was the box, not the change. The split was reverted (the rebuilt binary is bit-identical to the pre-experiment one, which is how the revert was verified) and the comment now records that the two rankings are indistinguishable. On this box, min-of-N across separate invocations is not enough; only binaries timed inside the same interleaved run are comparable, and a causal comment must not be written from a delta measured across runs.

A corpus diff that was also an artifact. The -O2 byte-identity check initially flagged exc as changed on all six targets, which for an -O3-gated pass should be impossible. It was: exc is the only corpus program that uses SysUtils, and a git pull between the two hash runs (the v389 pin) had updated lib/rtl underneath. Re-running both sides back to back gave 48/48. Compare against a baseline you regenerate now, not one from earlier in the session.

2026-08-28 — CORRECTION to the entry above: the self-compile regression was not real either

The ~2-6% slower self-compile reported for item 1 does not survive a properly-powered measurement, and the number should not be used. It was produced the same way the mandelbrot artifact was — interleaved, but with only 3-4 repetitions of a 17-second workload on a box whose load moved between 8 and 16. "Reproduced in three runs" was three under-powered runs sharing a bias, not three confirmations.

Re-measured, all interleaved in single runs:

workload reps before after
self-compile of compiler.pas at -O3 min of 6 16.92 s 16.97 s +0.3%
compile hello.pas min of 15 0.20 s 0.16 s new is FASTER
compile the residency stress program min of 15 0.58 s 0.58 s identical
callheavy (trip count 3, 20M calls) min of 7 0.56 s (-O2), 0.56 s (old -O3) 0.51 s new is FASTER
looplong (same body, trip count 3000) min of 7 0.23 s 0.20 s new is FASTER

So the honest summary of item 1 is: 1.9-2.1x on tight scalar loops, and neutral-to-slightly-positive everywhere else measured. No workload measured here is slower.

The hypothesis the regression supported is also dead, and it was tested directly rather than abandoned. callheavy was written specifically to be the shape the per-call-cost story predicts a loss for — a small body, a three- iteration loop, twenty million calls — and residency makes it 9% faster, not slower. Residency's save/init/restore is evidently cheap relative to what even three iterations of removed loads buy. The "benefit scales with trip count, cost is per call" model is not wrong in principle, but the per-call cost is too small to have produced the regression that motivated it, because there was no regression.

What this does NOT change: the loads >= 3 result (worse on both workloads) stands — it was measured inside a single interleaved run against both comparators. And -O2 remains untouched and byte-identical, which was never a timing claim.

Method, now applied to itself. The entry above this one records the lesson that only binaries timed inside one interleaved run are comparable. That was right and insufficient: this measurement WAS interleaved and still wrong, because 3-4 reps cannot resolve a 5% effect on a 17-second workload on a contended box. The playbook entry has been extended accordingly. The generalisable form: interleaving fixes which runs you compare, repetition fixes how confidently, and a short workload with many reps beats a long workload with few. hello.pas at min-of-15 gave a cleaner answer in two minutes than compiler.pas at min-of-3 gave in ten.

2026-08-28 — item 2 (store->reload elimination for resident destinations): DISCONFIRMED, and item 1 is why

Claimed and measured. The exclusion should stay. No pass changed; the outcome is a corrected comment and this entry.

The ticket says ReloadElimSym excludes resident destinations for a reason that "is no longer true after e7c0d1d2a". That is right about the reason and wrong about the conclusion, because the exclusion had two reasons:

Dropping the exclusion and rebuilding, on bt.pas — a loop over if (c >= 'A') and (c <= 'Z'), the exact shape item 3 names — removes 6 instructions out of 13,483, every one a reg-reg move. That is the class the W1 sizing in this ticket already priced at ~0 (deleting 6 of 17 such moves from a hot loop body moved the clock 1.4%). Not worth timing; not worth landing.

Item 1 is what emptied it. Before item 1 the boolean temp lived in the frame and the sequence was a genuine memory round trip — mov %al,-0x1d(%rbp); movzbq %al,-0x1d(%rbp) — which is what made item 2 look valuable when it was filed. At -O3 today that temp is resident in r14, and the emitted body of Upcount contains exactly one memory READ, the string byte itself. There is no reload left to eliminate:

setae  %al
movzbq %al,%rax          <- extend
mov    %al,-0x1d(%rbp)   <- dual-write (a STORE; off the critical path)
movzbq %al,%rax          <- re-extend from al, not a reload
mov    %rax,%r14         <- refresh resident

Where the remaining value actually is, and it is item 3. What survives in that body is the dual-write storesmov %al,-0x1d(%rbp), mov %eax,-0x14(%rbp), mov %eax,-0x10(%rbp) per iteration — not reloads. Removing those means making the register authoritative inside the loop and re-syncing the frame at exits, which is the V5 -> V3 step the sizing measured at a further 2.15x. That is the real W2, and it is now the only unclaimed item in this ticket with measured value behind it.

So the ordering the sizing proposed survives contact: item 1 landed and was worth 1.9-2.1x; item 2 is empty because item 1 landed; item 3 is the job.

STANDING RULE for this umbrella: re-measure the PRIZE before starting an item, not just the mechanism

Every item in this ticket was sized against a compiler that has since changed. Twice in one session an item's stated prize turned out to have been consumed or created by a different item landing:

A stale prize is more expensive than a stale number, because it does not look like a claim to re-check — it looks like work waiting to be done. So: before starting an item, disassemble the current output and re-measure. It costs minutes.

2026-08-28 (frank-optimize) — item 3 sized before building: worth ~5%, and W1 is now worth ~1.6x. The order inverts

Dispatched to item 3 (make the register authoritative inside the loop, re-sync the frame at exits). Sized it first per the rule above. Do not build item 3 next; build W1.

Why the old 2.15x was wrong — it measured two changes at once

The V5 -> V3 step in the sizing entry above (0.368 -> 0.171 s) was read as the price of the dual-write stores. It is not. V5 and V3 differ by the stores and by all the operand staging through rax; V3 was an idealised 6-instruction body, not V5-minus-stores. The 2.15x is the two transforms together.

The isolated measurement

Run's current -O3 body, transcribed from today's disassembly (21 instructions, and note it now contains zero memory reads — item 1 removed them all; the only frame traffic left is three dual-write stores). Variants differ from A by deletion only. Interleaved in one process, min of 9-12, four independent runs agreeing.

variant s cyc/iter vs current
A — current -O3 (21 insns, calibrates: real binary 0.61 s) 0.616-0.654 6.5 1.00x
B — item 3 exactly: the three dual-write stores deleted, nothing else 0.593-0.618 6.3 1.04-1.06x
C — item 3 + W1: staging through rax also gone (11 insns) 0.365-0.385 3.9 ~1.65x
D — ideal, fpc-like (6 insns) 0.193-0.216 2.1 3.0x

Item 3 alone is ~5%. Stores retire into the store buffer and are off the loop-carried dependency chain, so deleting them buys close to nothing — the same reason the loads mattered so much is why the stores do not.

B -> C is ~1.6x, and that is W1 — the emit-time operand scheduler this ticket deprioritised this morning on my own measurement.

Why W1's value changed, which is the transferable part

W1 was sized at 1.4% against the pre-item-1 compiler and that number was correct. At the time the body was 12 cyc/iter dominated by two frame round-trips; every mov %rN,%rax sat in their shadow, and deleting six of them moved nothing. Item 1 removed the memory traffic. The body is now 6.5 cyc/iter and the dependency chain runs through the staging moves themselves — the value is threaded r13 -> rax -> r14 -> rcx -> rax between the xor and the add. The same instructions that were free when they overlapped a 5-cycle store-forward are the critical path once the store-forward is gone.

An optimisation's value is not a property of the transform. It is a property of the transform against a specific baseline — and this ticket's baseline moved this morning, by our own hand.

Recommendation

  1. W1 — emit-time operand scheduler. ~1.6x on this shape, now measured twice from opposite directions. Un-deprioritise it; it is the item that inherits item 1's win.
  2. Item 3 — register authoritative. ~5%, and it is the harder of the two (exit re-sync, exception landing pads, every path that reads the frame slot as authoritative). Worst effort-to-payoff ratio in the ticket right now.
  3. The far end (D) is another ~1.8x beyond C, and is a real allocator.

Nothing built, nothing changed. Handing the inversion back to the coordinator rather than switching items unilaterally — the dispatch was item 3.

2026-08-28 — W1 slice 4 LANDED at -O3: a resident right operand needs no mov rcx. 1.15x on the loop, neutral everywhere else

The first slice of the revived W1. Not the whole operand scheduler — one deletion, chosen because it is provably value-preserving.

What fires

When a BINOP's right operand is a register-resident sym, EmitLoadVarRcx used to emit mov rcx, r12..r15 purely to satisfy the "right operand is in rcx" contract, and the ALU op then read rcx. The ALU can read r12..r15 directly, so the move is deleted and the op encodes the resident register:

  mov %r12,%rax          mov %r12,%rax
  mov %r13,%rcx    ->    xor %r13,%rax
  xor %rcx,%rax

Run's -O3 loop body: 21 -> 19 instructions.

Why this is safe by construction, not by argument. It is a deleted MOVE, not a recomputed value: the resident register and the frame slot hold the same value by the residency contract — the same fact EmitLoadVarRcx's own resident arm already relies on. The only real question is whether the consuming arm reads rcx and nothing else, so W1AluRightEligible whitelists exactly the five plain-integer forms (+ - and or xor) and refuses everything else: float (xmm0/xmm1 + movq bridge), AnsiString + (pushes both operands, calls a helper), tyString/set (multi-register inline loops), comparisons (own fusion path), div/mod (rdx:rax) and shifts (need rcx by name). Anything unlisted keeps the rcx contract, so a new op or type is safe by default. {$Q+} forms are admitted deliberately: the overflow check emits AFTER the ALU op and reads flags and rax, never rcx.

State is a local in IREmitNode, which is recursive, so it cannot leak into a nested binop.

Numbers — the loop AND the neutral workloads, as required

Comparator is the item-1 build (c264c81a0d5a) vs this one (2cc445cbd5f4), interleaved in single runs. The loop figures use 3 runs per sample (~1.9 s, so 10 ms timer resolution is 0.5% rather than 2%) after the single-run form proved to be at the resolution floor.

workload before after
three.pas, 3 rounds of min-of-12 1.91 / 1.96 / 1.99 1.71 / 1.67 / 1.75 1.12x / 1.17x / 1.14x
bt.pas (boolean-heavy loop) 1.46 1.45 neutral
mandelbrot 1.05 1.04 neutral
compile the stress program 0.64 0.64 neutral
compile bt.pas 0.18 0.18 neutral

~1.15x, not the 1.65x the sizing model bounded — and that is expected, not a miss: the model deleted all eight staging moves from the body, this slice deletes two. The rest of W1 (a left operand and a destination that are not forced through rax) is the remaining ~1.4x and is a much larger change to IREmitNode's register contract.

Caveat, unchanged and still attached: this is one loop shape. three.pas is the same benchmark that gave item 1 its 1.9x. It bounds tight scalar loops and says nothing about the corpus.

A resolution note that cost two wrong readings. At single-run granularity bt measured 0.49 vs 0.51 and then 0.55 vs 0.56 — "2-4% slower", twice, which is exactly the shape of a real small regression. Both were one 10 ms tick on a ~0.5 s workload. Amplifying to three runs per sample resolved it to 1.46 vs 1.45, neutral. Same for hello.pas (0.18 vs 0.19 -> 0.64 vs 0.64 on a longer compile). Below ~2% of the workload, /usr/bin/time is quantisation, not measurement — amplify the sample before believing a small delta, in either direction.

Gates

-O3 self-host fixedpoint byte-identical (2cc445cbd5f4); -O0/-O1/-O2/-O3 agree with each other and with fpc -O2 across ten programs including the residency stress, the boolean loop and both call-heavy shapes (only divergence is the pre-existing float print, identical on the pre-change compiler); -O2 and all four cross targets byte-identical, 48/48; gate.sh quick GREEN.


PARKED 2026-08-28 (frank-optimize) — state of the umbrella, and what the next agent should do first

Nothing is half-applied. Every slice below landed green with an -O3 self-host fixedpoint, and -O2 is byte-identical to where the day started (48/48 corpus hashes across all six targets, re-verified after each landing). The tree is a safe place to stop.

What landed today

sha slice measured
562965e1c item 1 — residency admits and ranks on LOADS, not accesses 1.9-2.1x tight scalar loops; neutral elsewhere
46c8cf47e W1 slice 4 — a resident BINOP right operand needs no mov rcx 1.12-1.17x on the loop; neutral elsewhere
cb5e2f564 item 2 closed as disconfirmed (comment corrected, no pass change) 6 insns of 13,483

What is left, ranked, with the numbers behind each

  1. W1's larger half — a left operand and a destination not forced through rax. The sizing model bounds the whole of W1 at ~1.65x on the loop shape and slice 4 collected ~1.15x of it, so roughly 1.4x remains. This is a change to IREmitNode's register contract (every arm assumes rax = value), not a deletion — a multi-session project, which is why it was not started here. Model variant C in the 2026-08-28 sizing entry is the target shape.
  2. Item 3 — register authoritative inside the loop (~5%, and the harder build: exit re-sync, exception landing pads, every path treating the frame slot as authoritative). Worst effort-to-payoff in the ticket. Re-sized today from a bad 2.15x that had compared two changes at once.
  3. The promotion experiment (coordinator's call, deliberately not started): a -O2 build with the passes promoted, the optimisation-agreement tier, and a corpus-wide timing story — proposed as ONE experiment, not a request to Track T. Blocked on nothing but Track T's queue depth.
  4. Parked, not scoped: beyond model variant C is another ~1.8x and that is a real register allocator.

Read these two things before touching anything

Standing condition on any future slice here

Land the neutral workload in the same commit as the flattering one — a speedup reported without it is half a measurement. Every entry above from 562965e1c onward follows that.


2026-08-28 (resumed) — W2: in-place ALU on a resident destination. LANDED.

Re-measured the prize first, per this ticket's own standing rule, and the ranking above inverted. Item 1 above says W1's larger half is a multi-session register-contract project worth ~1.4x. That is still true, but it decomposes, and one bounded slice collects most of it.

Sizing — hand-written asm, A/B by deletion only, at ba79fbeb2b0c

The -O3 body of three.pas's loop was byte-for-byte what was recorded at parking (19 instructions), so the model's A is the real compiler's output, not an idealisation. Variants differ from A by exactly one deletion each.

variant insns cyc/iter vs A
A — current -O3 output 19 6.13-6.50
B — 5a: the loop-bottom cmp reads the resident directly 18 6.10-6.25 1.02-1.04x
C — 5b: in-place ALU, ONE of the two sites 17 5.32-5.52 1.15x
G — 5b, BOTH sites 15 4.88-5.07 1.25-1.31x
H — G + 5a 14 4.91-5.36 1.20-1.29x
E — the full register-contract change 13 4.43-4.61 1.37-1.41x

Three readings that changed the plan:

The rule that came out of it, and it generalises past this ticket

Instruction count is not the signal; which dependency chain the instruction lands on is. Every reg-reg move this campaign has deleted or declined splits cleanly on that one test, and nothing else predicts them:

move on the loop-carried chain? measured
W1 slice 4's operand funnel (mov rcx, r13) no ~0 at the time; 1.12-1.17x only after item 1
5a's mov rax, r12 feeding cmp no 1.02-1.04x — dropped
W2's mov rax, R / mov R, rax bracket yes 1.25-1.31x

The bracket IS the recurrence: next iteration's mov rax, R waits on this iteration's mov R, rax. Chain length 4 -> 2.

What landed

x := x <op> y with x register-resident now does the ALU on the resident register itself, for + - and or xor, with an imm32 or another resident as the right operand:

mov rax,R / <op> rax,y / mov [x],eax / movsxd rax,eax / mov R,rax   (5, four on the chain)
<op> R,y  / mov [x],R32 / movsxd R,R32                              (3, two on the chain)

W2InPlaceEligible + EmitW2InPlace in symtab.inc; one gated call in IR_STORE_SYM. The frame slot stays authoritative — same dual-write, same order, and R is re-extended by the identical size/sign rule RegcallRefreshResidentFromRax applies to rax.

Two things worth keeping from the build:

Numbers — binary 3e26c10fbad9, baseline ba79fbeb2b0c (same tree, W2 the only difference)

All interleaved, amplified, min-of-N, both binaries built at HEAD.

workload base W2
three.pas — tight scalar accumulator loop 1.86/1.81/1.84 1.48/1.45/1.53 1.20-1.26x
looplong 1.21/1.19 1.15/1.12 1.05-1.06x
bt (binary trees, alloc-heavy) 4.40/4.28 4.17/4.19 1.02-1.05x
fib, mandelbrot, nbody, raytracer neutral (float/call-bound; W2 excludes float)
neutral: compiling hello.pas, x40/sample, min-of-7 x3 11.45/11.35/10.78 11.56/11.60/10.91 +1-2%

The neutral number is a real +1-2%, consistent in direction across three runs, and it is not work done: and short-circuits (verified — a side-effecting right operand is not called), so at the default -O the OptLevel >= 3 gate means W2InPlaceEligible is never reached. Attributable to code layout. Stated rather than rounded away.

bt was read as a 10% REGRESSION on single runs (0.79 -> 0.87) and is actually a small speedup. Fourth false reading from this box, same cause as the other three, caught by the amplification rule this ticket already carries.

Correctness

What is left, re-ranked

  1. The rest of the register contract (E minus G): ~1.10x, and it is the multi-session IREmitNode change. Still the largest single item, now correctly priced — it was being credited with G's share.
  2. Item 3 — register authoritative inside the loop, ~5%, hardest build.
  3. The promotion experiment — coordinator's call, unchanged.
  4. 5a (cmp reads the resident) — measured and rejected, 1.02-1.04x. Do not rebuild it; the entry exists so it is not re-proposed.

2026-08-28 — re-priced 85 -> 55, and the promotion step is now blocked

prio 85 -> 55. 85 was set against a prize that has since been collected. The umbrella was ranked when the remaining register-contract change looked like ~1.4x; W2 took ~70% of that, so what is left is ~1.10x on one loop shape, plus item 3 at ~5%. The number follows the measurement. Re-raise it if a future sizing finds more, but do not re-raise it on the strength of the older text.

Item 1 (the rest of the register contract) is deliberately NOT open, on the coordinator's call and for a reason worth keeping: a multi-session project should be opened at its correct price, at the start of a session — not at the tail of one, and not at a price its own author has just superseded.

The promotion experiment is blocked on chore-t-nothing-in-the-matrix-runs-o3-so-no-failures-is-unfalsifiable (Track T, filed today from this lane). -O3 is a free tier because nothing gates OptLevel>=3 — and nothing gating it also means nothing exercising it, so "no -O3 failures" and "nobody ran -O3" are currently the same evidence. Every pass in this campaign has been validated only by its author's own bench. Promoting one to -O2 before the matrix sweeps -O3 would be a first exposure wearing a promotion's clothes.

The lesson from W2 that generalises past this ticket

For an optimisation, the characteristic failure mode is invisible to every correctness check in the repo. W2's first version guarded on IR node types, so a for loop's own increment (tyUnknown) never matched: it fired on s := s + j and not on i := i + 1. Half the win, and:

A missing optimisation is not a wrong answer, so no correctness test can see it. The suite proves you did not break anything; nothing in it proves you did anything at all. Only disassembling the output caught this.

So the check that belongs in this lane's loop, next to the gate: disassemble the shape the pass was written for and confirm the pass actually fired. It is seconds, and it is the only signal that exists.

The fix generalises too: guard on the property the operation depends on, not on the syntax that usually carries it. The store narrows by Syms[].TypeKind and the refresh re-extends by it, so that is what the predicate must ask about; the node's type kind merely correlates, and stops correlating exactly where the compiler generates the code itself.


2026-08-28 — item 3: the resident's frame slot stops being written. LANDED.

Re-sized first, and the ticket's own number was wrong by 2-3x in our favour: item 3 was carried at ~5% and measured 1.10-1.17x against the post-W2 shape.

variant insns cyc/iter vs current
A — current -O3, post-W2 15 4.96-5.19
B — item 3: the three dual-write stores gone 12 4.43-4.51 1.10-1.17x
C — probe: re-extensions dropped instead (illegal, control only) 12 4.05-4.27 1.18-1.28x
D — floor: neither 9 2.99-3.06 1.65-1.73x

This is where the chain rule stops being the whole story, and the correction matters more than the slice. Those three stores feed nothing and are entirely off the recurrence, so the rule that killed 5a predicts they are ~free. They are not. A loop this short is throughput-bound as well as latency-bound: three stores per iteration against one store port. The rule needs both halves:

A move or a store costs if it is on the recurrence (latency) or if the loop is short enough that issue and port pressure bind (throughput). 5a was neither. W2's bracket was the first. These stores are the second.

Predicting ~0 here and measuring 1.10-1.17x is the second time in two slices that the model was wrong in the direction of not building the thing.

The blocker was found by measurement, not by audit

The dual-write exists to keep the frame slot authoritative. Stopping it is only safe if nothing reads the slot — and the readers that can be found by grep are all fine (EmitLoadVar/EmitLoadVarRcx consult ResidentRegOf; the epilogue's result load goes through EmitLoadVar; captures are lambda-lifted into extra params so there is no uplevel frame access at all; address-taken and absolute locals are refused by SymSlotEscapes; IR_ZERO_SYM writes the slot itself and refreshes from it). The residual risk was a direct [rbp+off] emit somewhere in 10k lines, and enumerating those by hand is an audit with no completion criterion.

So it was turned into one experiment. PXXDBG=a.poisonslot (new, kept) fills a resident's slot with $5EEDADAD right after each dual-write, so a surviving reader returns garbage rather than a plausible value — the -dPXX_HEAP_DEBUG $DD trick one level up. A stale slot and a correct slot are indistinguishable; a poisoned one is not.

Ungated, 2 of 19 programs changed behaviour — both the ones with try/except in a loop. One hung; the other printed 1592634797 back, which is $5EEDADAD. That is the exception landing pad, which re-syncs every resident from its slot and is the one reader residency cannot see through. Gated on RcProcHasExc, all 16 agree, including a new case built specifically to attack the gate: a body with residents and no handler of its own, unwound THROUGH by a raising callee.

PoisonResidentSlot calls the same predicate the optimisation uses (ResidentSlotIsDead), deliberately — the experiment is only evidence if it poisons exactly the set item 3 stops writing.

GPR residents only. Float residents (xmm8/9) keep their dual-write: the experiment never poisoned them, so nothing is known about them.

Numbers — pascal26.I3 vs pascal26.W2 (same tree, item 3 the only difference)

workload W2 item 3
three.pas 1.47/1.55/1.56 1.35/1.39/1.36 1.09-1.15x
bt (alloc-heavy) 4.05/4.17 3.67/3.61 1.10-1.15x
looplong 1.14/1.10 1.06/1.05 1.05-1.08x
fib, mandelbrot, nbody, raytracer neutral
neutral: compiling hello.pas x40, min-of-7 x3 2.00/2.08/2.02 1.96/2.02/2.01 flat

fib first read as 4.7% SLOWER and is neutral on amplification — fifth false reading from this box today. Note also that the neutral batch took 2.0s here and 11s during the W2 measurements: the box load dropped sharply mid-session, so absolute numbers are not comparable across entries in this ticket. Only the interleaved pairs are.

Correctness

What is left

  1. The rest of the register contract (E minus G): ~1.10x, multi-session, deliberately unopened — open it at the START of a session.

  2. The promotion experiment — UNBLOCKED 2026-08-28. My premise was false. I filed chore-t-nothing-in-the-matrix-runs-o3-so-no-failures-is-unfalsifiable arguing that nothing gates OptLevel>=3 and therefore nothing exercises it. Track T resolved it by refutation (c8ec8a1b3): tools/optdiff.sh has compiled and run every standalone test program at -O0/-O2/-O3, comparing stdout+stderr and exit code, since its creation commit 597e4ab05 (2026-07-11) — one day after this umbrella opened. Verified here from the file's HISTORY rather than its current contents: git show 597e4ab05:tools/optdiff.sh already carries the -O0 / -O2 / -O3 header and the for L in 2 3 run loop. 701 runs, 30 non-GREEN, four -O3 findings in done/ — one of which, bug-o-o3-diverges-on-cmath-sign-bits-and-pascal-hijack, was filed against this very track.

    By this ticket's own standard — a control is not a control until it has failed once — -O3 differential coverage is the best-evidenced control in the campaign. "First exposure wearing a promotion's clothes" does not survive that. What replaces the gate is a citation: a promotion cites the opt-tier run that swept a sha containing the pass. Real coverage is ~30% of shas (opt runs only as idle watcher work), so a pass that landed after the last sweep genuinely has zero exposure — ordinary, not exotic.

    The boundary, which matters here more than anywhere: optdiff is a CORRECTNESS instrument. It proves -O3 is not WRONG. It cannot prove a pass FIRES. A pass promoted to -O2 that silently stops firing passes optdiff perfectly — which is this ticket's own central lesson wearing the other hat. Read promotion evidence as "not wrong", never as "works". The benches are the only thing that says a pass still does anything, and the disassembly is the only thing that says it fired.

  3. Item 3's exception-frame gate is a real restriction, not a formality: any body with try/except keeps every dual-write. Lifting it means teaching the landing pad to restore residents from somewhere other than the frame slot, which is its own ticket and was not attempted.

  4. Float residents were never covered and are a KNOWN-UNKNOWN, not a non-issue. Item 3 stops writing the frame slot for GPR residents only. Float residents (xmm8/xmm9, FloatResidentXmmOf) keep their full dual-write because PXXDBG=a.poisonslot never poisoned them — so the 16/16 green result says nothing whatever about whether their slots are read. Extending item 3 to floats is a real slice, and its first step is extending the probe, not extending the optimisation: not covered is not the same as fine, and a null result is worth exactly what the probe's reach is worth. (Float work is also Track F, i.e. low prio by definition — so this is likely to sit.)

  5. 5a — measured and rejected at 1.02-1.04x. Do not rebuild.

Known-unknowns, collected — the things a null result did NOT cover

Every green number in this ticket has a reach, and these fall outside it:

not covered by what what would have to happen first
float residents' frame slots the poison probe fills GPR residents only extend PoisonResidentSlot to FloatResidentXmmOf, re-run the 19
bodies with try/except RcProcHasExc refuses them outright teach the landing pad to restore residents from somewhere other than the frame slot
-O3 at large nothing in the test matrix compiles at -O3 the Track T job — chore-t-nothing-in-the-matrix-runs-o3-so-no-failures-is-unfalsifiable
aarch64 every slice here is x86-64 port item 1 first, then W2 / item 3 — see below

The last row is bigger than "aarch64 lacks W2", and it was checked rather than assumed. UnifiedResidencyAssignA64 contains none of W2, item 3 or the poison probe — and ir_codegen_aarch64.inc:757 still reads:

if Counts[k] <= 3 then Continue;      { int threshold: > 3 loop accesses }

That is the pre-item-1 rule: admission by TOTAL accesses with a > 3 threshold, ranked on Counts rather than LCounts. So aarch64 is not at the pre-W2 shape, it is at the pre-item-1 shape — it still excludes the loop-carried accumulator that item 1 was written to admit, which on x86-64 was worth 1.9-2.1x, far more than everything landed since.

So the ranked next move for aarch64 is item 1, not W2. It is also the cheapest of the three to port (a threshold and a ranking key, no new encodings), and Track O's per-backend rule explicitly covers aarch64 — this is in scope, not a stretch. Nobody should port W2 there first just because it is the most recent thing in this ticket.

Re-priced 55 -> 70 by the coordinator, 2026-08-28

Track O re-priced this 85 -> 55 earlier tonight, correctly: the remaining x86-64 work had been cut from ~1.4x to ~1.10x by its own decomposition, and the umbrella should not carry a prize a bounded slice had already collected.

Then, while writing the known-unknowns table on its way to parking, it found the aarch64 row is not what anyone assumed — and that supersedes the premise the 55 rested on, which is the one condition that re-opens a rank.

Verified independently before acting on it, in the source rather than from the report, and from the other side as well:

So aarch64 is still on the pre-item-1 rule and excludes the loop-carried accumulator that item 1 exists to admit — worth 1.9-2.1x on x86-64, more than W2 and item 3 combined. It is also the cheapest of the three to port: a threshold and a ranking key, no new encodings and no new predicate. Track O's per-backend rule names aarch64 explicitly, so it is in scope rather than a stretch.

Ranked next move for this umbrella is the aarch64 port, ahead of item 1 on x86-64's remaining ~1.10x. p70 reflects a large prize at low cost; it is not p85 because nothing is blocked on it and it is a port of a proven change rather than novel work. Track O may revise this when it resumes — it has the benches and I do not.

A note on how this was nearly missed, which is worth more than the ranking. Its filer said it wrote the finding into the ticket "so nobody ports the most recent thing in it first just because that is what the last section talks about."

A ticket's most recent section reads as its next action. Document order becomes work order, and the newest writing is the loudest regardless of what it ranks.

Same family as a diagnosis in the shape of the previous fix is confirmed by resemblance — recency and resemblance both supply confidence that the content has not earned.

The promotion block is LIFTED — its premise was false (coordinator, 2026-08-28)

chore-t-nothing-in-the-matrix-runs-o3-so-no-failures-is-unfalsifiable is resolved (c8ec8a1b3), and it resolved by refutation: Track T checked the premise against the archive before building anything, and every clause of it is contradicted. The block recorded above at "The promotion experiment is blocked" therefore does not survive, and I am lifting it rather than letting it sit as a precondition nobody can meet because it was already met.

What is actually true, verified here from a path Track T did not choose — the file's own history rather than its current contents:

So the instrument has failed thirty times and produced four real bugs. By this ticket's own standard — a control is not a control until it has failed once-O3 differential coverage is the best-evidenced control in the campaign, and "first exposure wearing a promotion's clothes" does not survive contact with it.

How I got it wrong, which is the part that generalises. I surveyed the gate tiers — quick / native / limited / full — found no -O3 job in any of them, and concluded nothing runs -O3. Each observation was true. The survey was structurally blind to exactly one tier, opt, which is disjoint from all four and runs only as idle watcher work. That is this repo's own rule, applied to me: an existence claim survives one grep; a non-existence claim does not — and I never asked what my survey could not have seen. It is also the generator signature in its plainest form: "no -O3 failures in the reports I read" and "no -O3 runs" produced the same reading, and nothing in the reports named their own scope.

What was real, and what replaces the block. The narrow finding underneath was worth its p60: opt is disjoint from every gate tier, so of 2296 shas carrying a gate verdict only 690 — 30% — had ever been swept at -O3, and no report said which 30%. That is attribution, not absence. Track T has now made it citable: reports state whether opt covered this sha, and when it did not, how stale the newest sweep is and which sha it ran at (takes effect at the next watcher restart). -O1, which genuinely was unswept anywhere, is now swept too.

So the hard precondition becomes a citation requirement, which is cheap and is the thing that was actually missing:

Each per-pass -O3-O2 promotion cites the opt-tier run that swept a sha containing that pass. Aggregate exposure of the campaign is not exposure of a pass that landed after the last sweep, and at 30% coverage that gap is ordinary rather than exotic.

One thing the refutation does not buy, and must not be read as buying. optdiff is a correctness instrument: it proves -O3 does not produce a wrong answer. It cannot prove a pass fires, and a pass promoted to -O2 that silently stops firing passes it perfectly — which is this ticket's own central lesson, recorded two sections above under "the characteristic failure mode is invisible to every correctness test you own." The promotion evidence is "not wrong", never "works". Benches remain Track O's, and Track O has them.

The 55 -> 70 re-price below stands unchanged — it rests on the aarch64 divergence verified in source from both sides, which this correction does not touch.


2026-08-28 — item 1 ported to aarch64. LANDED.

ir_codegen_aarch64.inc was on the pre-item-1 admission rule. Ported the rule and, because it turned out to be necessary rather than nice, the probe.

What the probe showed, which is the whole argument

aarch64 had no a.resid probe at all, and this box has no aarch64 disassembler (objdump here has no aarch64 support; no llvm-objdump, no cross-binutils). So before this commit there was no way whatsoever to see what the aarch64 residency pass decided — not a cheaper way, none. Ported TALLY and added ASSIGN lines the x86-64 side has and aarch64 never did.

For three.pas's Run, on the pre-port rule:

TALLY sym=i count=4 loads=3      <- admitted:  4 > 3
TALLY sym=s count=3 loads=2      <- EXCLUDED:  3 <= 3   (the accumulator)
TALLY sym=j count=2 loads=1      <- EXCLUDED:  2 <= 3

and after:

ASSIGN sym=i reg=x19 loads=3
ASSIGN sym=s reg=x20 loads=2
ASSIGN sym=j reg=x21 loads=1

One resident becomes three, and the one it had been refusing is the loop-carried accumulator — the exact local item 1 exists to admit, refused while five of six registers sat idle. That is arithmetic on printed data, not inference.

Measured — and the first reading was wrong

qemu says 1.07-1.13x across four interleaved three-way runs, monotonic every time:

build run 1 run 2 run 3 run 4
-O2 (residency OFF — it is -O3 gated) 11.96 12.63 13.23 12.75
-O3 pre-port (1 resident) 11.26 12.24 12.86 12.27
-O3 post-port (3 residents) 10.54 10.88 11.87 10.84

The first measurement said the port was 7% SLOWER (0.93x) and it was a false reading — the sixth from this box today. It was a two-way interleaved min-of-5; the same binary measured 7.42 there and 11.26 minutes later, a 1.5x load swing, which is fast enough drift to defeat interleaving itself. What caught it was a control, not a repeat: build at -O2, where residency is off entirely, and ask whether qemu prefers that. It does not — the ordering is -O2 slowest, pre-port middle, post-port fastest, every run. A structural ordering across three variants is evidence a two-point comparison cannot give you.

Read the number as directional only, and as a LOWER bound. qemu user-mode times translated instruction throughput; it does not model store-to-load forwarding, which is the precise stall residency exists to remove. The same change was 1.9-2.1x on x86-64 hardware. So 1.07-1.13x confirms the pass fires and helps; it is not a prediction for real aarch64 silicon, and nothing here should be quoted as one.

Correctness

Note on the promotion question, which moved twice today

The -O3 sweep block was lifted (my premise was false — see above), but "lifted" means the per-pass question is askable, not answered. Track T overclaimed and corrected within the hour, measured per pass:

pass swept?
562965e1c item 1 GREEN at 0fbcbdebccd3, 2026-08-28T09:41:46Z
46c8cf47e W1 slice 4 same run
c93292fe4 W2 NOT SWEPT — landed nine hours after it

trackt optcov <commit> answers it (e4c004a5e). Two things it gets right that matter when reading a NOT SWEPT: uncheckable runs are counted separately from misses (a sha you have not fetched reported as "not swept" would be a wrong answer wearing a careful one's clothes), and it reprints the boundary — a GREEN opt run proves -O3 is not WRONG on that tree and cannot prove a pass FIRES. This ticket's own lesson, now printed at the point of use.

Nothing was promoted here. This port is new work, not a promotion. Item 3 and this aarch64 port have never been swept either, being hours old.

Lock released by the coordinator, 2026-08-29 — and the file boundary that goes with it

I moved this into working/ on frank-optimize-b4's behalf yesterday while it was mid-build (f2d59f45d). Releasing it, because the work that lock covered has LANDED — the aarch64 register-pressure port is cf70cb5be — and the next unit (aarch64 ports of W2 and item 3) was offered and never confirmed. No commits touch this campaign's files since. A lock over completed work reads as "someone is on it" and is the more expensive of the two lock failures, so it does not stay on my say-so alone.

This is my own lock being undone, not a peer's claim being overridden. If frank-optimize-b4 is in fact mid-work, say so and I will restore it immediately — nothing here is a judgement about the lane.

The boundary that matters, and the reason this was not a formality. This umbrella's file-ownership is Track A and it edits symtab.inc specifically — W2InPlaceEligible / EmitW2InPlace live there (see :1399). Track P has just filed bug-a-nodemetaclassci-does-not-know-a-virtual-class-method-call [A p65], whose fix is also in symtab.inc. That is a genuine collision, not a nominal one, and it is exactly what the letters exist to prevent.

Allocation while both are live:

file held by
compiler/symtab.inc frankA, for the NodeMetaclassCi fix, until it parks
compiler/ir_codegen_aarch64.inc and the other backends this campaign

An aarch64 port of W2 that stays inside ir_codegen_aarch64.inc may proceed concurrently. One that needs symtab.inc must wait or coordinate — do not take it on the strength of holding the O umbrella, because the umbrella's letter is not what allocates the file.


2026-08-29 — item 3 on aarch64, plus the prerequisite nobody had ranked. LANDED.

21d2c4234. Two changes, and the first is a prerequisite, not a companion.

The dependency the source-level ranking could not see

aarch64's dual-write refreshed a resident by reloading the frame slot written one instruction earlier — an immediate materialisation, an add x9, x29, x9 and a load, on the critical path of every store to a resident. x86-64 removed exactly this in RegcallRefreshResidentFromRax; aarch64 never got it.

Item 3 could not be ported without it. Item 3 makes the slot stale; a refresh that reads the slot then loads garbage. So the port that was ranked as "item 3 on aarch64" was really two changes, and the order between them is forced. This is the second time this port has turned out to be bigger than a source reading suggested — the first was the missing probe.

Item 3 itself is worth more here than on x86-64: skipping the dual-write removes three instructions (immediate + add + store) rather than one, and with the refresh change about six per store.

Gated on the same shared predicate as x86-64 — ResidentSlotIsDead, which is target-agnostic because ResidentRegOf is — so both targets refuse an exception-frame body for the same reason and one poison experiment validates both. RcProcHasExc is now recomputed in the aarch64 pass: redundant today, but otherwise aarch64's correctness would depend on the x86-64 pass having run first, a coupling nothing states and a reordering would silently break.

Measured — and one earlier number in this ticket is corrected

qemu, interleaved, 9 reps, three runs each:

before after
item 1 alone (cf70cb5be) 11.76 / 10.67 / 8.53 10.56 / 9.66 / 7.63 1.105-1.118x
item 3 + refresh-from-x0 10.87 / 8.28 / 8.00 5.32 / 4.23 / 4.08 1.855-1.961x
control: -O2, no residency at all 11.10 5.01 (full stack) 2.216x

A FOUR-WAY interleaved run read item 1 as NEUTRAL (~1.00x), and it was wrong — the seventh false reading from this box. It was min-of-4 across four variants, one running at half the others' wall time, so the interleave was uneven and the rep count too low. That is the repetition trap, occurring in a run that was correctly applying the interleaving and control disciplines — the traps do not compose for free:

Adding a variant does not come free; it costs reps. A control earns its place by making the ordering structural, but it also divides your sampling budget, and an under-powered four-way beats a well-powered pair only if the reps come with it.

Re-measured pairwise at 9 reps it is 1.105-1.118x over three runs, matching the original 1.07-1.13x. The number in the cf70cb5be entry stands; the four-way run that appeared to contradict it is the one that was wrong.

All of these stay directional and lower bounds: qemu times translated instruction throughput and does not model store-to-load forwarding, the precise stall residency removes. Nothing here predicts real aarch64 silicon.

Correctness

File boundary observed

Stayed entirely inside ir_codegen_aarch64.inc. symtab.inc is frankA's while bug-a-nodemetaclassci-does-not-know-a-virtual-class-method-call is open, and this needed nothing from it beyond the predicate already there. The umbrella's track letter does not allocate the file — worth stating in the ticket, because holding an O ticket that already edits symtab.inc is exactly the reasoning that would have taken it.


2026-08-29 — W2 on aarch64. LANDED (0b4805f8e).

The last of the four ports. x86-64's W2 (x := x <op> k on a resident becomes one ALU op on the register, no load, no store) now has an aarch64 half, entirely inside ir_codegen_aarch64.inc.

The shared predicate does not port unchanged, and that is the whole entry

W2InPlaceEligible in symtab.inc admits all five ops — + - and or xor — with a constant right operand. That is correct for x86-64, where every one of them encodes as 81 /digit imm32: one form, one width, all five ops. It is a statement about the x86 encoding wearing the clothes of a statement about the optimisation.

aarch64 has no such uniform form. Plain 12-bit immediates exist only for add/sub; and/orr/eor take the bitmask-immediate encoding, which this pass does not compute. So W2InPlaceEligibleA64 wraps the shared predicate and narrows it: constants only for add/sub, |iv| <= 4095. Register right operands keep all five.

I had written the restriction into a comment in the emitter and then not enforced it. The result was a := a and $00FFFFFF emitting add xR,xR,#(low 12 bits) — a wrong answer, silently, in the arm of the pass least likely to be exercised by anything except a test written for it.

A comment describing a precondition is not a precondition. The emitter knew the rule; the admission check did not; only the admission check runs.

Caught by test_o3_resident_inplace at -O3 — the program written for exactly this, which is the argument for having written it. The generic corpus would not have caught it: it needs a narrowing mask on a resident local inside a loop, which is a shape you write on purpose or never see.

The probe shipped with the port, not after it

PXXDBG=a.w2 prints a FIRE line per site (proc, sym, op, reg, slotdead). It is in the same commit as the pass because a pass that stops firing is invisible to every correctness check — the program stays right and merely gets slower, and there is no aarch64 disassembler on this box to notice. That exact failure hid a tyUnknown miss on i := i + 1 on the x86-64 side for half a day. Same reason the a.resid port was a precondition for item 1 rather than a courtesy.

Verification

Timing — qemu proxy, directional only

three.pas, min of 9 interleaved reps, three independent runs, +item3 vs +item3+W2:

run +item3 +W2 ratio
1 4.02 2.67 1.506x
2 5.93 3.93 1.509x
3 5.81 4.13 1.407x

Larger than any other single step in this campaign, and the reason to distrust the magnitude rather than the sign: qemu times translated instruction throughput, so removing a load and a store removes translated work proportionally, while on real silicon most of what W2 removes is a store-to-load-forwarding stall qemu does not model at all. The direction is solid and consistent across three runs; the size is an artifact of the proxy. Real aarch64 hardware would settle it and this box has none.

Sweep status — unchanged, and now four items deep

W2 (x86-64), item 3, the aarch64 item-1 port and this are all unswept by the opt tier (trackt optcov <commit>). None is a promotion candidate. -O3 remains the right home for the whole set until the tier reaches them.

File boundary observed

Entirely inside ir_codegen_aarch64.inc. symtab.inc is frankA's while bug-a-nodemetaclassci-does-not-know-a-virtual-class-method-call is open — and this is the port that most wanted to reach into it, since the honest fix for the encoding mismatch is arguably to split the shared predicate into an op-set-by-target form rather than wrap it downstream. Wrapping was the right call anyway: the narrowing is genuinely a property of the aarch64 encoding, not a shared concept, so it belongs in the backend even when symtab.inc is free. That the file was locked made the question visible; it did not change the answer.


PROVENANCE — frank-coordinator, 2026-08-29. Everything below was written by frank-optimize-b4 at 18:14 and never reached this ticket. The append was addressed to unfinished/<slug>.md; the ticket had moved to working/ at 17:57 when I claimed it, so >> recreated the old path as an orphan with no frontmatter. Second instance of this in 24 hours, and this one was caused by a MOVE — which means every claim, park and resolve opens the same window. Found by the NO-FRONTMATTER check added to progress check the same evening, on its first run. Reunited here, unedited.

2026-08-29 — CORRECTION: every "48/48 corpus hashes identical" in this ticket was a vacuous diff

Found while checking a routine claim, not by anything failing. The scratchpad corpus harness did not export PXX_HOME. A compiler binary copied into the scratchpad cannot find its RTL from there, so every row came back FAIL — and FAIL compares equal to FAIL. Both sides compiled nothing, the diff was empty, and the harness printed what reads as total agreement.

A diff of two totally-failed runs is a passing diff. The comparison has no floor: it reports agreement most confidently when it has measured least.

Same shape as the make compiler/pascal26 no-op in a seeded tree already written up in CLAUDE.md — a success message in the wrong dialect, with nothing downstream to notice. Both are the eighteen-face signature: the failure is indistinguishable from the success by its output alone.

The harness is fixed (exports PXX_HOME, skips the 3 corpus files that are units and can never compile standalone, and exits 2 with a loud line if no row produced a hash). The real row count is 25, not 48: 8 corpus programs x 6 targets = 48, minus 8 xtensa rows (that target has no dynamic-symbol support and the corpus pulls in calloc) minus 3 units x 5 targets.

Every claim was re-run against the actual binaries, and every one holds:

step claimed actually
W1 -> W2 (x86-64 W2) 48/48 identical identical, 25 rows
W2 -> item 3 (x86-64) 48/48 identical identical, 25 rows
aarch64 item-1 port -> item 3 48/48 identical identical, 25 rows
item 3 -> W2 (aarch64) 48/48 identical identical, 25 rows

So the conclusions stand and nothing landed on bad evidence — but they stood on nothing until this re-run, and the commit messages for 0b4805f8e and its siblings overstate the check as "48/48". Those messages are history and stay as written; this table is the correction. The number to quote from here on is 25 rows, and the harness now refuses to answer at all rather than answer emptily.

2026-08-29 — item 3's exception gate: CHARACTERISED, PRICED, and NOT LIFTED

The reader, and how to tell it from the alternatives

The gate is if RcProcHasExc then Exit in ResidentSlotIsDead. It came from a poison run that found a reader and never characterised it, so nobody could say whether proc-wide was necessary or merely sufficient. Three hypotheses were live: the landing-pad refresh, the unwinder reading frame slots, or setjmp/longjmp restore semantics.

PXXDBG=a.noexcrefresh (new, both targets, 9d46bff96) suppresses the IR_EXC_ENTER landing-pad refresh, which makes the three produce three different values in the handler instead of one correct one:

handler sees mechanism consequence
the latest value registers survived the raise refresh is dead code, gate deletable outright
the try-entry value ExcLongJmp rolled the register back out of the jmp_buf refresh is load-bearing
$5EEDADAD (with a.poisonslot) a reader other than this loop the gate is not about the landing pad at all

Measured, x86-64 -O3: IN seen 9453 -> 9316, which is exactly sum 1..136, the try-entry value at the raising iteration. MIX a 633 -> 630, NEST y 1077 -> 76, FIN n 90 -> 89. Try-entry values, every one.

So: the reader is the landing-pad refresh, its cause is the longjmp rollback, and there is no second reader. Consistent with the stubs — ExcSetJmp saves r12-r15 (aarch64 x19-x28), ExcLongJmp restores them, and the raise path touches only BSS_EXC_* and the jmp_buf. The discriminator is recorded because a correctness suite cannot distinguish "the slot is never read" from "the slot is read and happens to hold the right value", which is the whole reason item 3 needed poison rather than tests.

The partition falls out of the same run, and it is per-symbol

Only residents written inside a protected region moved. OUT, THR and PAR — written outside — did not. MIX has both classes in one body: ins moved, outv did not. So the exposure is per-symbol, not per-proc, on measurement rather than on argument, and the proc-wide gate is strictly coarser than the truth.

SymWrittenInProtectedSpan (e967f9038, report-only) computes it, and agrees with the measured partition 15/15.

The prize — and it is why this stops here

population int residents recoverable (exc=1 excwr=0) must keep dual-write
compiler.pas (self-host) 3049 0 0
chess 388 3 0
mandelbrot 378 0 4
jsondemo 1124 3 0
NilPy: xml.etree 1061 3 0
NilPy: collections.abc 1056 6 0
NilPy: codecs 1256 3 0

Zero in the compiler — it contains 9 try statements in total and not one of them is in a proc that also gets a resident, so the self-host benchmark cannot move by a single instruction. Under 1% everywhere else, and the qualifying sites are Repl, RunTUI, pyiter_hasdriver loops, not hot ones. The NilPy guess (Python leans on exceptions, so its population should be larger) was checked and is wrong: 0.3-0.6%, same as Pascal.

Verdict: do not lift the gate. The change is correct, designed, and cheap to write — and it would trade a wrong value on the unwind path, the highest-consequence and least-exercised path in the compiler, for under 1% of residents in driver loops and exactly nothing in the self-host. The coarse gate costs almost nothing because exception-bearing procs in this codebase are not the hot ones. That is a property of the code, measured across four independent populations, and it is the answer.

What survives and is worth having: the discriminator, the test, the analysis, and a stated re-open condition — if a workload appears whose hot loop sits inside a try, re-run PXXDBG=a.resid and read excwr=; the design below is ready. Expose iff written-inside; the landing pad must then skip the refresh for exactly the symbols whose slot is dead, which is the same predicate on both sides rather than two rules that must be kept in agreement.

Per-symbol is the correct granularity, not per-site. A symbol with any in-span store must dual-write at every store, because the last store before a raise may have been an out-of-span one and the landing pad refreshes from the slot regardless. Recording this because per-site looks like a free improvement and is silently wrong.

<!-- Recovered 2026-08-29: the 176 lines below were appended to unfinished/ while the ticket itself was in working/, leaving a headless fragment the ranker could not read. Concatenated here verbatim, nothing dropped. Third instance of the move-then-append race; found by progress check. -->


2026-08-29 — item 3 extended to FLOAT residents. LANDED (da88ba9d7).

A tyDouble local resident in xmm8..13 dual-writes its frame slot on every store. At -O3, in a body with no exception frame, nothing reads that slot, so the store is dead. This deletes it. Entirely inside EmitStoreVar's float skLocal/skParam arm in symtab.inc; ir_codegen.inc untouched.

Priced before it was built, and it priced differently from the last item

population int residents float residents
compiler.pas 3049 21 (0.7%)
chess 388 19 (4.7%)
mandelbrot 378 49 (11.5%)
jsondemo 1124 57 (4.8%)
NilPy xml.etree / collections.abc / codecs 3 / 6 / 3

The count alone would not have justified it — the exception gate died at a comparable share. What justified it is where: mandelbrot's EscapeCountLimit is the Mandelbrot iteration itself, it holds six float residents (zre zim zr2 zi2 cre tmp), and nine of that loop's fourteen IR_STORE_SYMs target them. The driver-loop pattern that killed the exception gate was looked for and is not here.

The raw 146 is deflated on purpose. A large share sits in the RTL float formatting path — FloatToStrSig, FloatToStrF, FloatToExpStr, PXXWriteFloatNat, PXXWriteFloatFixed, ParseFloatCore — which links into every program that prints a real. That is Track F subject matter and low prio by the standing ruling: the subject is float formatting, regardless of the fact that the mechanism here is a store elision. The case rests on the non-formatting residents.

First confirmed the item existed at all

ResidentSlotIsDead reaches float residents never: it goes through ResidentRegOf, which scans RcResidentSym (r12-r15). Float residents live in FrResidentSym behind FloatResidentXmmOf. So all 146 kept the dual-write unconditionally. Measured, not inherited from this ticket's own known-unknown list.

The probe was defective and its first result was worthless

Whole-program poison, three programs, all clean, control fires. That result was nearly reported. It was wrong.

The control moved WithExcFrame — a body the RcProcHasExc gate excludes and which therefore has zero poison sites. A row that cannot move, moved.

Cause: the RTL float formatting path is itself full of float residents, and every Writeln of a real goes through it. A whole-program poison run corrupts the printer, so every line of output moves whether or not that line's own residents were read.

The instrument was measuring its own printer — and it reads exactly like a working control.

Fixed with PXXDBG=a.poisonslot:<Proc>, restricting poisoning to one body, for both poison probes. Only then does a per-case reading mean anything.

The catch came from a case whose expected direction was already known. Not from suspecting the probe. That is the argument for always including a case that must not move: it is the only row whose failure is unambiguous.

Result, per proc — poison alone vs poison + a.poisonctl

proc poison control sites
Recurrence clean visible 11
ValueParam clean visible 3
AcrossInternal clean visible 4
AcrossIndirect clean visible 4
AcrossMath clean visible 4
NarrowTypes clean visible 2
ViaGlobal clean visible 2
mandelbrot EscapeCountLimit clean visible
WithExcFrame clean BLIND 0RcProcHasExc, correct
RefCaller clean BLIND 0 — by-ref, never resident, correct

The two blind rows print their zero site count rather than leaving it inferred, which is the same remedy as the harness fix earlier today: a clean row with zero comparands and a clean row with eleven are otherwise indistinguishable.

Negative results worth keeping

Same gate as the int half, different mechanism — do not narrow it by analogy

Int residents are rolled back because ExcLongJmp restores r12-r15 from the jmp_buf. xmm8..13 are not in the jmp_buf at all — the setjmp stub saves no XMM. The float pool is lost instead because a raise longjmp skips the unwound frames' save-iff-used epilogue restores; the landing pad then restores the pool from the try-entry snapshot and calls FloatResidencyRefreshAll, which reloads each resident from its own frame slot. Different route, same conclusion, and anyone narrowing one gate on the strength of the other's mechanism will get it wrong.

The measured effect, and a count I could not reconcile

mandelbrot -O3: 296 fewer bytes of code (98337 -> 98041) and 37 fewer movsd [rbp+disp32],xmm0, both measured on the artifact.

The probe counts 76 skip sites at codegen. Those two should agree and do not. Ruled out: dead-proc elimination (procs=788 at both -O2 and -O3) and double emission (Recurrence fires exactly 3 times for its 3 source stores to zr; a minimal proc with 4 stores fires 4). The model that fits both this and a minimal repro is that ~10 fires per program land in code emitted and then rewound — unverified, so every number quoted elsewhere is the binary one.

And the reason that discrepancy was chased at all: earlier in the same session, 261 poison sites were reported by dividing a 2876-byte size delta by 11. The real count was 76. The probe could have been asked directly and was not.

A count inferred from a byte delta is not a count. It is the empty-diff defect wearing better clothes — a derived number standing in for a measured one, and it reads as more rigorous rather than less.

Not timed

The removed store is off the recurrence — loads already come from the xmm register — so unlike int item 3 there is no store-to-load-forwarding latency to recover and only the throughput arm is in play. Prediction on record before measuring: small, plausibly under 1.02x, possibly indistinguishable from noise. Timing needs a quiet box and the coordinator's go-ahead; the code-size result stands on its own either way.

Timing: NOT MEASURED, deliberately — the prediction stands as the artefact

Predicted, on record before any run: under 1.02x, possibly indistinguishable from noise. The removed store is off the recurrence (loads already come from the xmm register), so unlike int item 3 there is no store-to-load-forwarding latency to recover — only the throughput arm, and mandelbrot's inner loop may not be store-port-bound at all.

Not run, for a reason worth stating rather than deferring indefinitely: the box carries five active sessions, and a null confirmed under contention is worth less than no measurement, because a flat result there is indistinguishable from a flat result caused by the contention. If the prediction is right the number changes nothing; if it is wrong, a loud box is the worst place to find out.

No contention-immune alternative exists here: there is no valgrind on this box and this qemu-x86_64 has no TCG plugin support, so deterministic dynamic-instruction counting is not available. perf is blocked (playbook).

A stated unmeasured prediction is a better artefact than a number taken under contention, and this is the entry that records it as one. If someone later wants the figure: interleaved, 9 reps, three independent runs, pre-emit vs post-emit compilers, with an -O2 control to make the ordering structural.

What the pass is justified on instead, and it is exact

Measured on the artefact, contention-immune, deterministic:

A structural result of that shape is a stronger justification than a marginal timing figure would have been, because it is exact and it does not decay with the machine it was taken on.

<!-- Recovered 2026-08-29 (FOURTH instance): appended to unfinished/ at 8e6144c5c while the ticket lives in working/. Concatenated verbatim, nothing dropped. -->


2026-08-29 — the -O1 leaf-operand arm ported to aarch64. LANDED (1185b3489).

Fourth and last of the four. The largest single result in this campaign, and it came from measuring a gap nobody had priced rather than from a clever transform.

The gap

Every integer binop on aarch64 emitted the same four-instruction dance, regardless of operand shape:

    eval left -> x0
    str x0, [sp, #-16]!     <- push
    eval right -> x0
    mov x1, x0
    ldr x0, [sp], #16       <- pop

x86-64 has collapsed this since -O1 when the right operand is a constant or a leaf sym. aarch64 had no such arm at all — not even for a constant — so it paid two stack memory ops and a register shuffle on every binop in every program.

Priced before it was written

PXXDBG=a.a64binop (report-only, added in the same commit) classifies the right operand of every integer binop reaching that path:

program integer binops CONST LEAFSYM collapsible
compiler.pas 54056 80.9% 12.6% 93.4%
mandelbrot 4953 77.7% 16.3% 93.9%
chess 4917 77.7% 16.0% 93.6%
jsondemo 8016 77.9% 16.0% 93.9%

Four populations, all within half a point of each other. That consistency is itself informative: it is a property of how Pascal is written, not of any one program.

What landed, and the contract that made it safe

The CONST half. A constant has no side effects and cannot observe the left operand, so it is materialised after the left is in x0, straight into x1 where the op arms already expect it.

The downstream register contract is IDENTICAL — x0 = left, x1 = right. So every op arm, {$Q+} checked forms included, works unchanged. This is a port rather than a rewrite precisely because it changes how x1 is populated and nothing else.

EmitLoadImmA64 writes only its destination register, and IR_CONST_INT is exactly EmitLoadImmA64(0, IRIVal[node]) with no truncation step to lose — both checked rather than assumed.

mandelbrot, aarch64 -O3: 725196 -> 683112 bytes. 42084 bytes and 10521 instructions removed, 5.8%.

What was deliberately NOT done

The LEAFSYM half — another 12.6-16.3% of every integer binop. It needs the right operand in x1 while the left sits in x0, and EmitLoadVarA64 hardcodes x0 behind residency, float-residency, dyn-array-handle and sign-extension arms.

A load-to-x1 twin would duplicate all of that, and the dyn-array comment inside that very function records its arms having already drifted apart once, with a segfault as the result. The cheap alternative — mov x1, x0, load right into x0, read the operands reversed — is valid only for commutative ops, so it is the same second path in a different costume.

Filed as feature-opt-a64-loadvar-destination-register (A, p55) with the population, the reason the cheap alternative fails, and the specific risk: the extension arms encode their destination in the opcode constants, so a register parameter has to reach into each encoding rather than be OR-ed in uniformly.

Gating choice, stated because it diverges from the sibling

Behind -O3, though the x86-64 arm is -O1. Mirroring at -O1 would have bought wider differential coverage; -O2 being the proven default and this being a hot path on a target verifiable only through an emulator decided it the other way.

Verification

Not timed, and here the reason is sharper than before

qemu does not model store-to-load forwarding, and removing two stack ops per binop is mostly about exactly that. A qemu figure would understate this by an unknown factor, which is worse than no figure. Code size and instruction count are exact and are what this stands on.

chess does not build for aarch64 at all (stackful generator, x86-64 only) — pre-existing and unrelated, but it means the aarch64 corpus is thinner than the x86-64 one.


RELEASED FROM working/ 2026-08-29 — frank-optimize-b4 parked, four of four landed

Moved to backlog/ rather than unfinished/: nothing is half-applied. b4 confirmed the release in the form that actually answers the question, which the repo cannot:

HEAD = 2103881fe = origin/master   (level)
unpushed: 0    uncommitted: 0
git diff origin/master -- symtab.inc / defs.inc / ir_codegen_aarch64.inc / ir_codegen.inc:  0 lines

Two conditions, not one: no working-tree copy differing from what the next holder checks out, and nothing in a commit they cannot see. working/ and ListAgents can answer neither.

Also added the missing track: A field. This umbrella had no track: line at all, which matters more than it looks: an unset track parks a ticket in Track T's queue regardless of what the body says. It escaped notice only because working/ is deliberately never ranked — so a p70 umbrella was invisible to every queue and mis-tracked, and the second fault was masked by the first.

Two defects, one of which hides the other, is not twice the work — it is a defect you cannot find by fixing the first. Releasing the lock is what would have exposed the mis-track; had anyone released it earlier, the ticket would have surfaced under the wrong letter.

Remaining on the umbrella, both genuinely unclaimed:

Landed, four of four: operand scheduler, callee-saved scratch, float-resident dead-store elimination, and the aarch64 -O1 leaf-operand port (42084 bytes and 10521 instructions off mandelbrot at -O3, 5.8%).


2026-08-29 — the aarch64 leaf-operand remainder is CLOSED, by census

The aarch64 leaf-operand collapse is complete: CONST landed in 1185b3489, LEAFSYM in the EmitLoadVarA64 two-step. Together they take 93-94% of integer binops on this target. The ~6% remainder is not worth a pass, and this is the measurement rather than the intuition.

PXXDBG=a.a64binop now names the IR kind of an OTHER right operand, because "6% are OTHER" is a number with no next step. What they are:

right operand kind compiler.pas mandelbrot lispdemo collapsible?
binop 1914 193 183 no — a nested expression evaluates through x0/x1 itself
call 745 48 51 no — side effects, and it owns x0/x1 for args and result
neg 724 22 15 only if ITS operand is a leaf — a nested case, not this one
load_mem 525 29 36 maybe, for simple addressing; needs its own analysis
not / lea / index 8 8 8 negligible

The two dominant kinds are structurally uncollapsible. The whole trick is that a constant or a leaf-symbol right operand has no side effects and cannot observe the left, so it can be materialised after the left is in x0. A nested binop and a call are exactly the operands for which that is false — they need x0 as their own working register, and the stack round trip is what makes them safe, not what makes them slow.

What is left after removing those: neg-of-a-leaf and simple load_mem, which on mandelbrot is 51 sites — 1.0% of all binops, at 3 instructions each, and neg only for the subset whose own operand is a leaf. That is a smaller slice of a smaller slice, needing an addressing analysis that does not exist yet.

So: not filed as a ticket, deliberately. A ticket justified by the fact that a number remains is a placeholder that sits at low prio forever — the exact backlog leak CLAUDE.md names. The remainder is priced and closed; if someone wants it later, this table is the starting point and the answer it gives today is no.


2026-08-29 — W1 slice 1 measured DYNAMICALLY: 1 instruction per iteration, 4.54%

The umbrella asks W1 to be justified on a dynamic profile, not a static sweep. Here is the dynamic number for slice 1 (the constant shift count), and the method matters as much as the figure.

perf is unavailable on this boxperf_event_paranoid = 4 denies unprivileged access to every event, hardware and software alike, and lifting it is a root sysctl change on the owner's workstation. Not done, not worked around.

qemu-x86_64 -one-insn-per-tb -d exec was used instead, and it is the better instrument here. It emits one log line per instruction executed, so the count is exact and deterministic rather than sampled — no multiplexing, no skid, and completely load-invariant, which on a box that sat between 9.5 and 16.4 all evening is the property that actually decides whether a number is worth writing down. The cost is a ~100x slowdown, paid for by shrinking n: the loop is straight-line with a single back-edge, so the per-iteration count is constant and the measurement scales exactly.

n BASE retired HEAD retired delta delta/n saved
2 000 44 211 42 211 2 000 1.000 4.52%
20 000 440 227 420 227 20 000 1.000 4.543%
50 000 1 100 235 1 050 235 50 000 1.000 4.544%

delta is exactly n at all three sizes — one instruction removed per iteration, with no residue. Per-iteration cost 22.0047 → 21.0047 instructions; the fractional tail is the fixed prologue amortising away, and it converges as it should.

The loop body, counted from the execution trace rather than from the listing: 18 addresses hit exactly n times, 3 hit n-1 times (the increment tail, skipped on the final iteration) = 22 instructions per iteration in BASE, 21 in HEAD. Note this corrects the earlier "1-of-18" static claim — 18 was the straight-line body, and it omitted the loop-control tail that also retires every iteration. The honest denominator is 22, so the saving is 4.55% of the loop's dynamic instruction stream, not 5.6%.

Provenance, proven by content rather than by filename — binaries 85655efad0ff (HEAD, has the slice) and 272e95c5ec9c (BASE, pre-slice), compiling the same three.pas at -O3. The loop body differs in exactly one instruction and nothing else:

BASE:  mov rax,r12 / mov rcx,0x1 / cdqe / shr rax,cl     48 c7 c1 01 00 00 00 + 48 d3 e8  = 10 B
HEAD:  mov rax,r12 /              cdqe / shr rax,0x1                        48 c1 e8 01  =  4 B

6 bytes per site, matching the static sweep's figure, and code=16683Bcode=16659B = 24 B over 4 sites. A filename is not evidence of what a binary contains; a diff of the emitted loop is.

Load average is recorded because a perf number without it has the same unstated-as-of problem as a benchmark without its baseline sha — 9.51 at the start of the run, 16.44 shortly before. It does not matter here, which is exactly the point of choosing an instrument whose output cannot depend on it.

Wall clock is still owed and is deliberately not taken: the box carried seven concurrent pascal26 processes, a stage_2a fixedpoint and a pinned at 106% over 12 cores. Best-of-five alternation is a sound design for a mildly noisy box and is not one at 1.2x oversubscription with self-host builds landing at random. It goes in when the fleet quiets, stamped with its own load.


2026-08-29 — W1 slice 5 LANDED behind -O3: resident left operand into a compare

Slice 4 killed mov rcx, rN on the RIGHT. The left half of the funnel is harder and mostly cannot be killed: add/sub/and/or/xor write their result to rax, so a resident left operand has to be moved there. A compare has no destination, so its mov rax, rN is deletable outright.

Census before writing the arm, not afterPXXDBG=a.w1left (new, report-only) classifies every binop whose left operand is a resident leaf symbol by what it feeds. "The funnel is 1.21% of the binary" does not say which arms could drop the move; this does. Static emit sites at -O3:

program CMP ALU MULIMM SHIFT OTHER deletable
compiler.pas 2891 2649 422 285 110 52%
jsondemo 794 806 91 105 113 46%
mandelbrot 234 462 31 59 55 32%
lispdemo 237 451 27 59 52 32%
sieve 226 451 27 59 58 31%

CMP is the largest bucket in every program and the half needing no encoding trick. MULIMM is the other deletable bucketimul rax, rN, imm is a three-operand form — and is deliberately not in this slice: different encoding, different proof, own measurement.

The census also over-promised, and the first cut proved it. Guarding only the two -O1 leaf-operand arms fired on 11-12 sites and left three.pas byte-identical at -O3 — because most comparisons with a constant right are folded earlier by the -O2 cmp-immediate arm, and the for-loop's own compare is emitted by the branch fold in IR_JUMP_IF_FALSE, not by the binop path at all. Extending to those two folds is what made the slice real: −119 B on three.pas, −2772 B on jsondemo. A population count is not a firing count, and the gap between them was two arms wide.

Four sites, two new emitters (EmitCmpLeftRcx, EmitCmpLeftImm32), not four hand-written encodings — the same normalisation as EmitAluRaxRight. Worth naming: the immediate form is a different opcode, not the rax one with a register swapped. rax has a short accumulator encoding (48 3D id) that r12..r15 do not, so the resident form is 81 /7 id — 2 bytes longer on the cmp itself, still net −1 byte and −1 instruction once the mov is gone.

Soundness. EmitLoadVar's resident arm emits the move and nothing else; its own comment states the register already holds a correctly size/sign-extended value, so cmp rN, rcx is equivalent instruction-for-instruction. For the register form the eligibility test is a whitelist of operand types, deliberately not a copy of the conditions guarding the string/char arms below it: every one of those is reached through tyAnsiString, tyString or tyChar, so excluding exactly those three is sound today and stops being sound, silently, the day a fourth is added — and the caller skips the left load on this predicate's word, so a string arm reached with rax unloaded compares a stale register. Naming what is allowed fails closed. The two cmp-immediate arms need no whitelist: CmpFusible already excludes float/string/variant and those arms have no path below them.

Verification

check result
-O0/-O1/-O2 vs base compiler byte-identical, all five programs
-O3 size smaller on all five: −119 / −360 / −413 / −455 / −2772 B
values at -O0..-O3 vs base identical, and equal to FPC 3.2.2
self-host fixedpoint converged after 1 round, bf5d6afc8e37

Dynamic, on the three-local loop (qemu exact count, method in the entry above): 22 → 20 retired instructions per iteration, delta exactly 2n at n=2000/20000/50000 — 9.09% of the loop's instruction stream, of which slice 1 was the first instruction and this slice the second. The mov rax,r12 before cmp r12,rcx is gone from the emitted loop.

No whole-program dynamic number. qemu's exact count costs ~100x, and sieve and lispdemo did not finish inside a bounded run on a loaded box. The honest state is: exact dynamic evidence on the microbenchmark, static size evidence on the five programs, and nothing in between. Not padded with an estimate.

The test is a control pair, and it was proven non-vacuous. It runs at both -O0 and -O3 against one expectation — the pass is -O3-gated, so -O0 provably cannot use either new encoding, which makes a wrong encoding show up as two optimisation levels disagreeing rather than as a number with no oracle. Every variable holds a distinct value and none is 0 or 1, because the failure being hunted is comparing the wrong register; negatives and values astride the 32-bit boundary cover the sign-extension half.

Breaking the ModRM field on purpose made -O3 print acc=0 while -O0 stayed correct. And the compiler still self-hosted byte-identically while broken, because it builds at the default -O level — a concrete instance of the scope limit CLAUDE.md records: the fixedpoint gate cannot see an -O3-only defect. That is the reason this test exists rather than leaning on the gate.

Landed 81d2ec232. -O2 promotion not taken — coordinator's call, after soak.


2026-08-29 — W1 slice 6 LANDED behind -O3: resident left operand × constant → three-operand imul

The second deletable bucket from slice 5's census (MULIMM, 422 sites in compiler.pas). imul is the only form in the -O1 immediate-fold arm with a three-operand encoding, so a resident left operand can be its source while rax stays its destination and the mov rax, rN in front disappears. The five arms beside it — add/sub/and/or/xor — are short accumulator encodings that read and write rax, so a resident left has to be moved there and this does not help them.

No type screen, and that is a decision rather than an omission. The equivalence is at the register level, not the type level: EmitLoadVar's resident arm emits the move and nothing else, and the consumer here is the very next instruction emitted — there is no path below that could reach a different arm expecting rax loaded. That last clause is exactly what slice 5's register- form compare does not have, which is why that one needs a whitelist and this one does not. Same campaign, two arms, opposite answers, for a reason that is written down at both sites.

Verification (against a baseline rebuilt at HEAD — see standing rule 3):

check result
-O0/-O1/-O2 vs baseline byte-identical, all five programs
-O3 size −18 / −27 / −15 / −15 / −159 B
values -O0..-O3 identical, and equal to FPC 3.2.2
self-host fixedpoint converged after 1 round, 76c14ec57dcc
dynamic, three-local loop delta exactly n at n=20000 and n=50000

The emitted loop now opens 49 69 c4 03 00 00 00imul rax, r12, 0x3.

Cumulative W1 on that loop: 22 → 19 retired instructions per iteration = 13.6%, one instruction each from slices 1, 5 and 6, every one of them measured as an exact delta rather than estimated.

The test puts the changed arm and the five unchanged ones in one program, so a mistake in imul cannot hide behind the five that still work. Non-vacuous by construction check: swapping the reg/rm fields — the specific mistake this encoding invites, because the operand roles are the opposite way round from this campaign's compare emitters — made -O3 produce no output at all while -O0 stayed correct.

Landed 7f01306c8. -O2 promotion not taken — coordinator's call, after soak.

What is left in W1, and what it is worth

The census bucket that remains is ALU (2649 sites in compiler.pas), and it is not a third easy slice: add/sub/and/or/xor write their result to rax, so a resident left operand cannot stay in place. lea rax,[rN+rM] could serve the commutative subset but does not set flags, which needs a liveness answer this campaign does not have. Priced and parked, not filed — per the backlog-leak rule, a ticket justified by a number remaining is a placeholder.

Still genuinely open, and larger than any of the three landed slices: the mov r8,rax / mov rax,r8 park around a right subtree, and the for-limit temp reloaded from the frame every iteration. Note the ticket's own earlier warning before touching the latter — making the limit a constant measured slower (0.87 vs 0.79), so it is not the obvious win it looks like.


2026-08-29 — the AN_FOR init temp is elided at -O3 when both bounds are re-emittable

Follow-up to 8b35e88fa, which fixed a real correctness bug (both for-loop bounds must be evaluated before the control variable is assigned) and paid for it by capturing the initial bound into a hidden temp. That capture is what this removes, for the subset where it is provably unnecessary.

Priced before building, per standing rule 2. The cost is exactly 2 instructions per loop ENTRY — not per iteration — measured by exact instruction count on a shape built to maximise it (inner loop entered 20 000 times, running twice): 500 198 → 540 198, i.e. 8% of that program. Statically, per procedure, pre- vs post-8b35e88fa: mandelbrot +301 B over 23 procs, jsondemo +863 B over 60, and every changed procedure grew, none shrank — the signature of a fixed per-entry cost rather than anything data-dependent.

The condition. The capture exists because initValNode is a value node the backend re-emits at the store, so leaving it uncaptured moves the initial expression's side effects after the limit's. It is unnecessary when re-emitting the initial bound later cannot observe anything different — true for a literal, a plain scalar variable, or pure arithmetic over those. Both bounds must pass: the initial one so there is nothing to reorder, the limit so it cannot write what the initial one reads.

ForBoundReEmittable is a closed allow-list defaulting to false, which is the opposite default from CASTLValueHasSideEffect a few thousand lines up. That is deliberate and worth stating: a wrong "no side effect" there merely misses an optimisation, while a wrong one here silently restores a wrong iteration count — the bug this lowering was fixed for two hours earlier. AN_INDEX and AN_FIELD are excluded as a judgement call, not an oversight: they are reads that a pure limit cannot disturb, but they can fault, and fault ordering is not something to reason about casually in a lowering this recently broken.

The census over-promised AGAIN — same shape as slice 5, second instance

PXXDBG=a.forinit (new) reports the AST kinds of both bounds for every loop that pays for the temp. compiler.pas, top shapes of ~250:

init limit count elided?
ident binop 87 only if the binop is call-free
binop literal 55 only if the binop is call-free
field binop 28 no
binop binop 25 only if both are call-free
index binop 18 no
ident literal 13 yes
ident ident 11 yes

Reading that table, widening from "plain ident or literal" to "pure arithmetic" should have reached most of the population. It recovers 14 B on mandelbrot and 28 B on jsondemo. The blocker is invisible in the table: those binops mostly contain callsLength(s) - 1 is an AN_BINOP whose child is an AN_CALL. The census classified by the ROOT node kind, and the disqualifying node was a child. A census is only as good as the granularity it classifies at, which is a sharper version of the same rule slice 5 produced.

What reaching the big bucket would require, and why it is not attempted. A call-bearing limit is fine if the initial bound is a local that no call can write. That is an aliasing question, and the tree has an address-taken flag for params only (ProcBodyAddrTakenParam) — nothing for locals. Building that analysis inside a lowering fixed two hours ago, to recover 2 instructions per loop entry, is the wrong trade tonight. Banked, not filed: the probe and this table are the starting point.

So the honest result: a real win for tight loops with simple bounds — exactly the shape this campaign targets, and the worst-case inner loop returns to its exact pre-8b35e88fa instruction count — and close to nothing for the RTL's string-processing loops, whose bounds are calls.

Verification: -O0/-O1/-O2 byte-identical on four programs; -O3 smaller; 8b35e88fa's own test_for_bounds_before_control_var passes all 13 rows at all four levels; self-host fixedpoint b3434e287096, 1 round.

The new test's control was VACUOUS, and only the vacuity check found it

Worth recording as a method failure rather than a footnote. The test carries a call-bearing row precisely so that an accidental widening to calls would be caught. Breaking the elision on purpose — eliding unconditionally, ignoring purity — left that row passing. Eliding a call-bearing bound swaps the two calls, and both orders make the same number of calls and produce the same iteration count, so the call counter and the count are both blind to it. 8b35e88fa's test failed one row; mine failed none.

The row now logs call order (iL correct, Li broken) and the deliberate break moves it. A counter cannot assert an ordering, and "I added a control for this" is not the same claim as "I watched this control fail" — which is standing rule 1's last clause, met here by the rule catching its own author.

Landed a2711f2ea. -O2 promotion not taken — coordinator's call, after soak.

2026-08-29 — W1 slice 7 LANDED behind -O3: fused compare reads its RIGHT operand in place

A fused compare staged its right operand through rcx unconditionally: mov rcx, <right> then cmp rax|rN, rcx. When the right operand is already a value cmp can address, that mov is dead weight. Two cases now fold:

W1CmpRightInPlace returns W1CMP_REG / W1CMP_MEM / W1CMP_NONE, screened by the existing LeafSymRcxLoadable (which already excludes arrays, floats, strings and by-ref params — a by-ref slot holds a pointer, so folding it would compare the wrong thing entirely). Wired into both the value form and the IR_JUMP_IF_FALSE branch form.

Only 8-byte operands fold, deliberately. A 4-byte local is loaded with movsxd; folding it would mean a 32-bit compare plus the assumption that the left register holds a properly sign-extended value. That invariant's failure mode is a silently wrong comparison — a wrong loop bound, not a crash — so the 4-byte rows in the test are controls that must stay on the rcx path.

Result, and the honest half first: three.pas's hot loop is unchanged, and its dynamic delta is 0. Its limit temp is a 4-byte movsxd, so the 8-byte-only fold correctly declines — the slice does not fix the case that motivated it. What it does do is shrink every program measured: three.pas -63 B, mandelbrot -213 B, lispdemo -159 B, sieve -156 B, jsondemo -978 B.

Verification. Re-run at the LANDED HEAD after the push, because tools/sync.sh rebased a sibling compiler change (4576ad4d1) in underneath — standing rule 3, and the reason the pre-push binary sha 36f9c3c11b99 is not reachable from master. Controlled A/B at HEAD, baseline = HEAD with only this slice's ir_codegen.inc hunk reverted:

program -O0 -O1 -O2 -O3 base -> new
perf/three.pas (scratch) same same same 18677 -> 18614 (-63)
bench/portable/mandelbrot.pas same same same 25260 -> 25158 (-102)
examples/mandelbrot/mandelbrot.pas same same same 122855 -> 122642 (-213)
examples/lisp/lispdemo.pas same same same 105836 -> 105677 (-159)
examples/json/jsondemo.pas same same same 447995 -> 447017 (-978)
examples/primes/sieve.pas same same same 86753 -> 86597 (-156)

New binary 13d2803df79f, baseline 261e6cd2b58f, both self-host in 1 round. Values identical at all four levels; FPC 3.2.2 agrees with the test's expectation. Correction to the commit message of e9ec79125: it quotes mandelbrot at -213 alongside the other four, which reads as one program — those are two different mandelbrots, and both numbers above are real.

Non-vacuity — this is where the slice cost its time, and it produced standing rule 4 at the top of this ticket. Six deliberate breaks now move the test: mem reg field, mem displacement (+1 slot), both reg/rm forms of the reg-reg encoding, and each REX bit. Getting there took three false passes: one break was a no-op encoding despite the patch applying cleanly, and two genuine breaks passed because the rows were insensitive to which register was named. The band rows (blo = a-1, bhi = a+1, straddled from both sides) are the fix and the transferable part. Read rule 4 before writing the next slice's test.

Banked, not filed: the ALU bucket (2649 sites) needs flags-liveness before lea is safe; widening the fold to 4-byte operands needs the sign-extension invariant proved rather than assumed.

2026-08-30 — W1 slice 8 LANDED behind -O3: the 4-byte compare, folded as a 32-BIT compare

Slice 7's honest failure was that three.pas's loop — the shape this campaign exists for — came out unchanged, because its for-limit temp is 4-byte and the fold was 8-byte-only. This is the completion.

The argument is better than the one banked, and that is why it was worth taking now. It was banked as "needs the sign-extension invariant proved rather than assumed", which was a correct reason not to act on a hunch. But the invariant is not needed at all if the compare is 32-bit: cmp r12d, DWORD PTR [rbp+off] (opcode 3B, no REX.W) reads exactly four bytes of the slot and exactly the low half of the register, so what is in the upper half is irrelevant by construction rather than by proof. "Do not depend on an invariant" beats "prove an invariant" — the second decays the moment someone changes how residents are normalised; the first does not.

Equivalence, stated once: sign- and zero-extension from 32 to 64 bits are both strictly monotonic maps under signed and unsigned order alike (sign-extension sends everything >= 2^31 above everything < 2^31, which preserves unsigned order). Both operands are gated to the same TypeKind, so both are extended by the same map — therefore the narrow compare agrees with the wide compare of the extended values under every predicate. The fold cannot introduce a disagreement with the existing semantics, whatever those semantics are.

Result on the motivating loop: 19 -> 18 instructions, the movsxd rcx,[rbp-0x24] replaced by a fused cmp r12d,DWORD PTR [rbp-0x24]. Cumulative for the campaign on that loop: 22 -> 18.

Controlled A/B at HEAD, baseline = HEAD with only this hunk reverted (new bf1c35821b23, baseline e6a8e8d87798, both 1 round):

program -O0 -O1 -O2 -O3 base -> new
perf/three.pas same same same 18614 -> 18590 (-24)
bench/portable/mandelbrot.pas same same same 25158 -> 25137 (-21)
examples/mandelbrot/mandelbrot.pas same same same 122642 -> 122534 (-108)
examples/lisp/lispdemo.pas same same same 105677 -> 105587 (-90)
examples/json/jsondemo.pas same same same 447017 -> 446761 (-256)
examples/primes/sieve.pas same same same 86597 -> 86511 (-86)

The probe is the transferable part, not the encoding

Slice 8 was written, self-hosted clean at f0c3e2b8bf39, and changed its target loop by zero bytes. There was no way to tell a pass that never ran from a pass that ran and declined — the exact shape the campaign had already been warned about. So the first thing built after that was PXXDBG=a.w1cmp32, which prints on every exit including FIRE32, not only on success.

It answered immediately, and the answer was not on the list of things worth guessing: the guard IRTk[left] <> IRTk[right] was rejecting because the left node's IR type is frequently 0 — unset, not different. A guard that looked prudent was rejecting on missing metadata, and it rejected precisely the for-loop limit compare the arm exists for. The guard was also not load-bearing (the monotonicity argument above is the real one), so it went.

Two things follow. A pass that says why it did not fire is the difference between an optimisation you can reason about and one you have to disassemble — and a disassembly is what slice 7 needed instead. And a guard that has never been observed rejecting is not known to be selective: this one was silently rejecting everything interesting, and passing every test, because declining is always semantically safe. Correctness tests cannot see an over-strict guard; only a decline log can. The probe now also proves the mixed-width control in the test is a control — it shows exactly one typekind decline, the 8-vs-4-byte row.

Non-vacuity: four deliberate breaks — REX.R dropped, REX.W restored (a 64-bit read of a 4-byte slot), displacement +4 (the adjacent slot), and a wrong reg field — each move -O3 while -O0 stays correct. Band rows from the start this time, per standing rule 4, rather than retrofitted.

(B) and (C) are now SPLIT OUT as their own ranked tickets, with the disassembly carried into each rather than left in this log:

Together they take this loop 18 -> 15. Both carry forward the two controls this campaign paid for: an ordering change is invisible to a counter (log the order), and a guard needs a decline log to be known selective.

W1 slice 10 — the resident read and the widen are one instruction

feature-opt-o3-fuse-resident-read-and-widen-into-movsxd, landed 2026-08-30 as 2365dafa2. Baseline compiler 1bca19929e04, new compiler 46ff97f32ed7both built at the LANDED HEAD, the baseline being HEAD with only this hunk reverted, so the delta is immune to a sibling's commit rebasing in.

Those are the SECOND pair of shas. The first — 1055347eb44a / c8303ca1f5b2 — were measured before tools/sync.sh rebased, and de276c8f5 (xtensa) and d2a61a524 (lib/rtl/math.pas, bignum.pas) landed underneath. compiler/ was untouched by both, and the binary still moved, because the compiler links the RTL: lib/** is a build input. The pre-rebase shas do not reproduce at HEAD and are no longer quotable.

This is the second time this campaign has quoted a sha the rebase then invalidated, so it is worth stating as a rule rather than an incident: a sha measured before the push names a tree that may not survive it. The re-measurement produced byte-for-byte the same deltas (-2 / -10 / -6, all lower levels identical), which is exactly the property the "HEAD-minus-only-this-hunk" baseline was chosen for — the DELTA survived a changed tree, the absolute shas did not. Same shape as three.pas below: deltas travel, absolutes do not. Re-measure after the push, not before.

A shift's LEADING sign-extend on a 4-byte signed operand was mov rax, rN + cdqe; it is now movsxd rax, rNd. Five bytes and two instructions become three and one. In three.pas's loop, verbatim:

  base:  4c 89 e0        mov    %r12,%rax
         48 98           cltq
         48 c1 e8 01     shr    $0x1,%rax
  new:   49 63 c4        movslq %r12d,%rax
         48 c1 e8 01     shr    $0x1,%rax

Measured, -O0/-O1/-O2 byte-identical on every program tested and -O3 strictly smaller, at exactly -2 bytes per firing: three.pas -2 (1), jsondemo -10 (5), the new test -6 (3); lispdemo unchanged (no firing). -O3 runtime output identical on all of them. The loop body goes 22 -> 21 instructions.

That 22 is NOT this campaign's running 18 -> 17, and the chain is not extended here. three.pas was a scratch file, never committed, and is gone; the file measured above is a RECREATION from the description in this log, and it is a bigger program (j := i xor s needs a narrowing cast that the original evidently did not have, which is four instructions of difference before this slice does anything). Two numbers named three.pas are two programs. What carries across is the delta — one firing, one instruction, two bytes, on a baseline that is HEAD with only this hunk reverted — and that is the number to quote. A benchmark that lives only in /tmp cannot be re-measured, and a cumulative chain built on one silently becomes a chain of different programs. The recreation is therefore committed, as bench/w1_three_locals.pas, with that history in its own header — so the next slice can re-measure the same file instead of re-deriving it from this prose. It is a measuring stick, not a test: nothing in the Makefile runs it.

Why not the obvious version. The cdqe is a provable no-op today: every write to a 4-byte resident re-normalises the register. Deleting it would work and would be one instruction cheaper still — and it is exactly the invariant-dependent elision slice 8 refused, resting on code in another file, failing silently, and decaying the moment residency normalisation changes. movsxd sign-extends the low 32 bits by construction and costs the same.

One condition, two readers. The pre-decision that SKIPS the left load and the emit site that must honour the skip both call W1LeadingCdqe. They cannot drift apart, and the reason they must not is asymmetric: a widen site that failed to honour a skip would leave rax never loaded, holding whatever the previous statement left there. That is deliberate break 3 below, and it is a wrong number rather than a crash.

Non-vacuity: three deliberate breaks — a wrong ModRM rm field (names the adjacent resident), a dropped REX.B (reads rsp's encoding instead of r12's), and the widen site ignoring the deferred load — each move -O3 while -O0 stays correct.

A second oracle, free. In this dialect LongInt shr 1 promotes to native width, which is why the leading cdqe exists; FPC keeps 32 bits and answers differently. --strict-fpc reproduces FPC 3.2.2 exactly on every row — and is simultaneously a CONTROL, because strict mode tags the result 4 bytes, so W1LeadingCdqe is false and the fold cannot fire. The test therefore carries two expectations, both asserted at -O0 and -O3.

Per-backend gate count: x86-64 22, aarch64 6 (comment-stripped, all spellings — see both corrections above; the 23 : 7 first published with this slice was itself one-per-row too high). This slice is one-armed — x86-64 only — so it widens the delta by one, as every one-armed slice does. That delta is now asserted by tools/check_o3_backend_parity.py rather than recounted by hand.

2026-08-30 — END-TO-END PRICE OF THE LANDED CAMPAIGN: ~5-7%, by this ticket's own rule 2

Every W slice so far was measured individually, at the point it landed. Nobody has measured what the campaign has delivered collectively, and that is a different number. Measured now, by frank-optimize, at HEAD 440c822e6a80, compiling compiler.pas:

The instruction side — a mnemonic-frequency diff of two 1.95M-instruction builds, no timing, so this half has no variance:

-O2 -O3 delta
push + pop 215,525 (11.0%) 161,595 (8.3%) -53,930
mov family 898,158 (45.9%) 937,991 (48.2%) +39,833
total instructions 1,956,618 1,945,077 -11,541 (-0.6%)

24.1% of the -O2 push population is gone — 26,965 matched push/pop pairs converted to register moves. The campaign's lever is real and it fired.

The time side — 9 paired interleaved runs, box load 6.3 → 11.1: -O3 is ~5-7% faster (min-of-9 6.6%; 7 of 9 pairs favour -O3). Reported as a sign test rather than a mean because at this effect size on a contended box a mean is substantially a measurement of the other agents.

And that ~5-7% is an UPPER bound on this campaign's share, because it is the whole remaining -O3-vs--O2 gap and other passes are inside it.

What this means, stated plainly

This is rule 2 of this ticket, at campaign scale. "A population count is not a firing count… count the population to choose the target; count the firings to claim a result." The population was 22.6% push/pop and the stated goal was "killing the 37%". The firings are real — a quarter of all pushes eliminated — and the delivered time is single digits.

The gap is not a failure of the slices; each did what it said. It is that eliminated stack traffic converts to time at a much worse rate than its instruction share suggests — 53,930 fewer stack ops and 39,833 more register moves nets 0.6% fewer instructions, and a modern core was already hiding most of that push/pop behind store-forwarding.

Context that changes the campaign's priority, not its correctness: -O3 was 28% faster than -O2 this morning. EmitStaticLitHandle — one string-literal pass, nothing to do with register pressure — accounted for ~78-82% of that and has been promoted to -O2 (440c822e6a80). What is left for everything else in the tier, this campaign included, is the 5-7% above. See [[decide-the-o3-tier-is-34-percent-faster-and-nothing-gates-it]].

Recommendation: do not start W2's linear-scan allocator on the current justification. The "Why — the measured opportunity" section above prices the opportunity from an instruction census, and this measurement is the first time the campaign's output has been priced the same way it prices its input. A keystone-sized piece of work should be re-justified against delivered time first — ideally on a quiet box, since 5-7% is at the edge of what load 6-11 resolves. The cheap slices already landed; what remains is the expensive one.

Not claiming this ticket — this is a measurement filed into it, not a resumption of the campaign.

2026-08-31 — W2's linear-scan allocator: DECLINED. The justification inverted, measured on an idle box

Dispatched to decide, not to start. Box at load ~4.5, the first quiet window since this campaign parked. Result: the headroom argument this item rests on is void, and it cannot be repaired by re-measuring.

The measurement

Three compilers, all emitting byte-identical output (checked, both ways), so the work is provably identical and only the code generator differs. Min-of-N, three-way interleaved — the non-interleaved sanity run disagreed with the interleaved one by 7%, which is the size of the effect, so interleaving is not a stylistic preference here.

compiler binary self-compile
FPC 3.2.2 -O2-built 15268 ms ours -O3 14.4% faster, 7/7
FPC 3.2.2 -O3-built 15509 ms ours -O3 15.0% faster, 6/6
ours -O2-built 14215 ms ours -O3 8.05% faster, 7/7
ours -O3-built 13071 ms

FPC -O3 is marginally slower than its own -O2, so "you only beat it at -O2" is closed rather than left as an objection.

Why that decides it

This ticket prices its remaining opportunity two ways and both are now retired:

  1. The FPC gap"the residual is mostly whole-body register allocation." There is no residual. We are ahead at both of FPC's levels. A headroom argument whose comparator has since fallen behind is not off by a factor, it is inverted.
  2. The instruction census — 37% frame traffic, "killing the 37%". Retired by this ticket's own end-to-end re-pricing above, under its own rule 2: the campaign eliminated 24.1% of the entire push population and delivered ~7-8%. Population is not firings and firings are not time.

Under the owner's ruling, promise is delivered value, measured. Nothing here shows promise for the expensive item, and the two things that used to stand in for promise are gone.

What is NOT being claimed

The revival condition, so this is a decision and not a veto

Anyone may take the linear-scan allocator up again. What it needs is a benchmark on a workload where register pressure actually binds — tight scalar loops, not compiler.pas — plus the promise+proof gates. What will not do is the FPC gap (gone) or an instruction census (retired by rule 2).

The campaign closes here. Its delivered value is real and now precisely measured on a quiet box: -O3 is 8.05% faster than -O2 on the self-compile, 7 of 7, on top of everything the -O2 promotions already banked. The cheap slices landed; the expensive one is declined on evidence.