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
backlogwhile the file was being edited, here it would sayworkingwhile 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.incunder [[bug-o-uforth-blocktest-runs-slower-under-pxx-than-under-cpython]], which is inworking/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
backlogand was telling the truth about the last deliberate act, while eight commits landed inir_codegen.incover five hours.ready --track Atherefore 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
claimin 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 openir_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 saidbacklog. frankA rannext --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.incfor 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 break was an identity. Changing
$85 or ((lreg - 8) shl 3)to$85 or (lreg - 8)looks like a wrong ModRM reg field. For the register that actually occurred (r13) it emits the same byte —$85already has bit 2 set, soor 5changes nothing. The edit script asserted the source matched exactly once, which proves the edit applied and says nothing about the encoding changing. Verify a break by disassembling the emitted bytes, not by asserting the patch applied. - The row was insensitive. With a genuine break (
r13->r14), the test still passed: the row wasif a > bwithb = -5000000001, which is true for essentially any junk value a wrong register could hold. Three separate real breaks passed for this reason. The fix is to straddle: withblo = a-1andbhi = a+1, the paira > bloanda < bhiis true only for a register holding exactlya, and only for those two slots — so one shape catches a wrong reg field, a wrong rm field and a wrong displacement at once. Mirror it (blo < a,bhi > a) to cover the operand roles the other way round.
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.
- Type: feature (codegen — optimization) — Track O (Optimization lane;
file-ownership Track A — edits the shared
ir_codegen.inc/symtab.inc/ backends, so it obeys A's no-concurrent-edit rule + self-host gate) — umbrella for the next optimization campaign. - Status: backlog (folder is the lock; line corrected by the coordinator 2026-08-30)
umbrella sat in
backlog/between slices) section at the bottom for what landed, what is left and what to read first). Nothing is half-applied. Worked from a dedicated optimization checkout (~/frank-optimize), because Track O is implicitly Track A and two agents inir_codegen.incat once is the hazard the track letters exist to prevent. Nothing is half-applied; every commit passed the self-host fixedpoint andgate.sh quick. Read the two 2026-08-27 sections at the bottom before the older ones — they supersede two claims the older text still makes:- The
-O2promotion is DONE (13d4bba0c,e4fe576eb,7767acc60), and it is worth 1.04x, not the 1.29x quoted below. That figure compared base-O2against new-O3and its baseline no longer exists. A banked speedup decays as the tree moves underneath it — do not re-quote a number from this ticket without rebuilding its baseline. - W1 now ranks ahead of W2. Both of W2's cheap experiments came back empty;
the gap on the hot loops is the operand model, not allocation.
Next slice = W1 (emit-time operand scheduler), and it must be justified on
a dynamic profile — the static sweep understates it badly.
New passes still land behind
-O3;-O2stays the proven default.
- The
- Opened: 2026-07-10 (post -O2-default flip, [[feature-optimization-levels]]).
- Owner: frank-optimize-b4
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 >= 3gate 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 asfeature-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 spelledif 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< 3total ir_codegen.inc(x86-64)17 6 23 ir_codegen_aarch64.inc5 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
>= 3count 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:4434andir_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 ingate.sh quick: it comment-strips, matches every spelling of an-O3gate (>= 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.--censusprints every match with file and line so a reader can check the number instead of trusting it. Optimization splits by home (seeoptimization-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:
- 32-bit (i386 / arm32 / riscv32): perf-irrelevant. Legacy / control-plane / bring-up correctness, never throughput. Not worth per-backend allocator effort.
- ESP32 / xtensa: special case, also skip. Its perf-critical paths are the hardware peripherals (DMA / ADC / SPI capture), already supported and offloaded to silicon — not compiled-code throughput. Tight compiled loops there are rare and not the bottleneck. So: build W1/W2 for x86-64 first, aarch64 second; do NOT port the register allocator to the 32-bit or xtensa backends. Shared-IR passes (DCE, any future IR-level transform) still land once and benefit all — no reason to gate those.
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.
- Hard rule: no pass rewrites emitted bytes — branch/label fixups store
absolute
CodeLenoffsets. (A post-byte pass is not impossible — fixups are already tracked lists and could be re-based when bytes move — but x86 variable-length encoding + 6 backends make it strictly more machinery than planning up front, for no extra reach.) - Allocation is fundamentally global: "keep
xin r13" needsx's whole live range and every contender for r13. Can't be a local/post decision. - Plan over the IR with live-range data, never reserve registers blind. Reserving before you know contention is exactly how the residency re-emit bug happened ([[project_regcall_residency_reemit_localinit_clobber]]).
The data splits the work cleanly by home:
- push/pop (22.6%) → emit-time operand scheduler (local, no scaffold).
- rbp load/store (14%) → planning-phase register allocator (global, needs liveness).
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:
- [[feature-opt-store-reload-elimination]] (blocked on it today)
- [[feature-callconv-register-args]] phase 2 (rbx/r12/r13) + hot locals, not just params.
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)
- regcall phase 2/3 on the new liveness data.
- store-reload elimination.
- relocate compare-fusion (peephole 4) to an IR tag → cross targets get it.
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)
- All in-flight work gates
OptLevel >= 3.-O3currently just aliases-O2(nothing gates>=3), so it is a free experimental tier: passes fire only at-O3, and the-O2default stays byte-for-byte what it is today = the stable fallback every track builds on. - Per pass, promote
-O3 → -O2only after the full gate, the same bar slice 2b cleared: 500-program-O0-vs differential byte-clean, all four cross targets (i386/aarch64/arm32/riscv32),-O2self-host fixedpoint byte-identical,make test+make test-optgreen. Land only green; never a long-lived branch. -O0remains the byte-identity reference and is never touched (passes gateOptLevel >= tier).
Acceptance (umbrella — each pass is its own landed unit)
- W1 shipped at
-O3, measurable push/pop reduction, promotable to-O2under the gate above. - W2 scaffold exists and at least one consumer (allocator OR store-reload-elim)
ships at
-O3. - Net self-compile speedup measured and recorded; the 37% frame-traffic figure meaningfully reduced.
Links
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
- What fires (all gate
OptLevel >= 3, x86-64 emitter only,not InLValueWrite):- Mirror: leaf LEFT binop operand (const / plain scalar sym) loads AFTER the complex right evaluates — kills push/eval/mov/pop from the left side. Const left reorders across anything (incl. calls); sym left requires a proven side-effect-free right.
- Scratch: complex-complex binop parks the left value in r8 (nested: r9;
deeper: push/pop fallback) across the right's evaluation when
ScratchSafeSubtreeproves the right subtree call-free and r8/r9-clean. Whitelist predicate in symtab.inc next to LeafSymRcxLoadable; notable exclusions documented there (tkIn uses r8; string concat/cmp call helpers or inline through r8/r10/r11). - Both also applied to the compare-into-branch fusion operand dance.
- Leaf-index fold (IR_INDEX): const index → single
add rax, disp(nothing for elem 0); leaf-sym index → load/scale in rcx directly. Kills the push-base/eval-index/pop-rcx dance on every simple array access.
- Measured (self-compile of compiler.pas, -S instruction mix O2 → O3):
total instructions 940,900 → 873,386 (−7.2%);
pop rcx34,374 → 14,175;pop rax20,690 → 16,749;push rax99,877 → 75,757; emitted code 4,160,828 → 4,031,287 B (−3.1%). Wall-clock: 3.406s → 3.356s (~1.5%, hyperfine ±0.02 — OoO/stack-engine hides most of the stack-op win, consistent with the regcall-phase-2 lesson). - Gates run: -O2 self-host fixedpoint byte-identical (untouched, pass inert below -O3); -O3 self-host fixedpoint byte-identical; -O3-built compiler's -O2 output byte-identical to the -O2-built compiler's; test-opt extended with an -O3 differential column + -O3 fixedpoint (green).
- Remaining W1 targets (instruction-mix census at -O3):
pop rdi~31k +pop rsi~10k = runtime-helper ARG staging (hand-coded per call site);pop rax16.7k = binop dances whose right subtree contains calls — needs a callee-saved scratch (r12/r13 + prologue/epilogue save) = the W2 boundary.
2026-07-11 — W1 slice 2 LANDED behind -O3: callee-saved r12/r13 scratch across call-bearing right subtrees
- The remaining generic binop dances (right subtree contains calls, so r8-r11
die) now park the left value in r12/r13, which survive calls. Per main
body,
CalleeScratchAssign(called from CompileAST after IROptimize, under the same suppression discipline as regcall residency) pre-scans the final IR; if any BINOP/fused-compare right operand would take the push/pop path, it reserves two frame slots, saves caller r12/r13 once at entry, andEmitProcEpilogrestores them on every return (mirrors the r14/r15 restore). Bodies with inline asm bail entirely (user asm may use r12/r13 across statements); value subtrees cannot contain IR_ASM (asm is statement-only), and every runtime path (pxx bodies, helper blobs, CoSwitch context save, the setjmp buf) preserves r12/r13 — audited before landing. - Measured runtime of compiled programs, -O2 vs -O3 (identical outputs): mandelbrot --bench 1.14× (86.7→76.3 ms), raytracer 1.04×, sieve 1.01× (memory-bound), compiler self-compile 1.01× (memory-bound — IPC rises but the frontend is cache-limited, consistent with the label-clear findings). W1's payoff is compute-bound user code, not the compiler itself.
- Fire:
mov r12, rax×1802 in the self-compile image; binop pop-dances 20.7k (O2) → 14.9k (O3 incl. slice 1). - Gates: -O2 fixedpoint untouched + byte-identical; -O3 self-host fixedpoint byte-identical; test-opt (incl. -O3 column + fixedpoint) green; make test green; testmgr quick GREEN.
2026-07-11 — W2 slice 1 LANDED behind -O3: loop-local register residency (r12/r13)
LoopResidencyAssign(CompileAST, after IROptimize): tallies LOAD/STORE_SYM accesses inside backward-jump loop ranges (nested ranges credit twice — natural depth weighting), picks up to two eligible scalar locals/params (>3 loop accesses, no addr-taken, RegcallScalarType) and keeps them resident in r12/r13 — the r14/r15 regcall mechanism generalized (choke-point encodings now computed from the register number; residency arrays widened to 4). Body entry saves the caller's r12/r13 to frame slots; every store dual-writes + refreshes; the epilogue and the IR_EXC_ENTER exception-landing refresh cover all exits (the longjmp-rollback landmine fixed earlier today). Mid-body IR_ZERO_SYM on a resident local now refreshes too. Mutually exclusive per body with the W1 r12/r13 callee scratch (residency wins; scratch bails).- Why this works when regcall phase-2 (more param residency) was rejected: the target is the LOOP-CARRIED store-forward chain through the frame slot (i := i + 1), which OoO cannot hide — not plain L1 reloads, which it can.
- Measured (-O2 vs -O3, outputs identical): compiler self-compile 3.427 → 3.268 s = 1.05× (was 1.01× with W1 alone); sieve 1.03×; mandelbrot stays 1.13× (its loop kernel is float — excluded from residency, wins came from W1). Cumulative -O3 story: compute-bound 1.13×, self-compile 1.05×.
- Gates: -O2/-O3 self-host fixedpoints byte-identical; test-opt (incl. -O3 differential + fixedpoint) green; make test green; quick GREEN.
2026-07-11 — W2 slice 2 LANDED behind -O3: float loop residency in xmm8/xmm9
- Up to two loop-hot tyDouble locals/params stay resident in xmm8/xmm9 for
the whole body. xmm8-15 are caller-saved, so there is NO save/restore and no
exception/longjmp interplay — the trade is that residency is legal only in
bodies whose entire IR emission is provably call-free
(
FloatResidencyBodySafe: node-kind whitelist + no managed-string / dyn-array store traffic; div/mod allowed since the div-zero call never returns). tySingle is excluded on purpose: a register cache would hold the unrounded double and diverge from the frame's narrow-then-widen roundtrip. - Reads:
movaps xmm0, xmm8/9in EmitLoadVar's float path; stores dual-write (movaps xmm8/9, xmm0after the frame write); IR_ZERO_SYM refreshes. - Measured (-O2 vs -O3, outputs identical): mandelbrot --bench 1.21× (83.7 → 69.4 ms; was 1.13× before this slice), raytracer 1.04× (its kernels call Vec helpers → bodies not call-free; inline-expansion follow-up would unlock it). Cumulative -O3: mandelbrot 1.21×, self-compile 1.05×.
- Gates: -O2/-O3 fixedpoints byte-identical, test-opt green, make test green, quick GREEN.
2026-07-11 — W1 slice 3 LANDED behind -O3: last-call-argument push/pop collapse
- Internal (non-variadic, <=6-param) calls push every arg then pop them into
the SysV registers; the LAST argument's push/pop pair is back-to-back — it
now stays in rax and takes a single
mov <its reg>, rax(nothing between the last eval and the pop loop touches rax; the hidden aggregate-dest eval runs after the pops). Variadic and >6-param stack-convention calls unchanged. - Measured: self-compile image
pop rdi31.1k → 9.4k,push rax75.8k → 44.9k, total instructions 890.8k → 860.5k (−8.5% vs -O2 cumulative). Runtime: raytracer 1.04× → 1.09× (call-heavy code), mandelbrot holds 1.20×, self-compile 1.04-1.05×. - Gates: -O2/-O3 fixedpoints byte-identical, test-opt green, make test green, quick GREEN.
2026-07-11 — W1 slice 3b: last-arg collapse extended to virtual + indirect calls
- Same transform as the direct-call collapse, applied to IR_VIRTUAL_CALL (register-convention dispatch, <=6 params) and IR_CALL_IND's internal path. cdecl indirect calls and the >6-param stack conventions unchanged. Matters for OO/method-pointer-heavy user code rather than the compiler itself.
- Gates: -O2/-O3 fixedpoints byte-identical, test-opt green, make test green, quick GREEN.
Next steps (queued, in rough order)
- 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]].
- -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.
- 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
- The three mechanically-local W1 passes (no register-lifetime state) moved from -O3 to the -O2 default after the promotion gate: 564-program corpus -O0-vs-O2 differential clean (4 flagged = harness noise: gtk pointer output nondeterministic at -O0 too; lib_sockets port TIME_WAIT flake, hangs identically at both levels), -O2/-O3 self-host fixedpoints byte-identical, test-opt, make test, quick GREEN. Compiler reseeded (2-step build can't converge across a codegen change).
- Promoted-set effect at the new default: raytracer 1.07×, self-compile ~1.03×; mandelbrot ~1.01× (its wins live in the register passes).
- Still -O3-only (register-lifetime schemes, need soak): r8/r9 scratch, r12/r13 callee scratch, loop-local residency, float xmm residency — worth another promotion pass after a few days of -O3 soak + T back up.
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:
- FPC's
-pg+ gprof gives usable call counts and useless times here (three samples for 1.03 s of user time). Read the counts, not the percentages — which is exactly what the debugging playbook already says. - The profiler's own samples land in the vDSO at a rate that swings 8% -> 38%
between runs of the same binary. Exclude out-of-
.textsamples; do not read them as time.
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) - 1 — divisor 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 | — |
-O3codegen alone: 1.25x onempty.npy(base-O3-> new-O3).- 1.29x on the self-compile —
make compiler/pascal26's own workload, the one every agent pays on every fix, on every track. - The gap to fpc went 4.06x -> 3.24x; 27% of it is closed.
- Compiler binary at
-O3: 9,321,568 -> 9,161,656 bytes (-1.7%). - At today's
-O2default only the release-blob change is live: 1.018x. Everything else is waiting on the promotion below.
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:
- differentials against two oracles —
-O0/-O1/-O2/-O3must agree with each other AND withfpc -O2on the same source. ~153M div/mod evaluations (k = 0..31, div and mod, Int64/Integer/UInt64/Cardinal, dense -300k..300k plus a sparse wide sweep and the type extremes); ~134M comparisons (140k values x 4 types x 20 constants including the imm32 sign-extension boundaries x 6 operators x branch and value forms); every narrowing ordinal cast plus the hand-written idioms and four deliberate near-misses that must NOT fold; a residency stress whose every store overflows its declared type (so a missing re-extension shows at once) including a try/except that stores to residents inside the protected block; a string-churn program under--threadsafewith max RSS as a leak canary. The generators were scratch (bench-o/, not committed) — they are a page of Python each and the shapes are described above, which is what a re-run needs. -O3self-host fixedpoint byte-identical after every commit.- The
-O3-built compiler's-O2output byte-identical to the-O2-built compiler's. - All four compilers emit byte-identical output for
empty.npy. tools/gate.sh quickGREEN after every commit (5 runs).
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)
- The three-local loop is still 3x off fpc —
0.65 svs0.22 sat-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 accumulatorsat 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. Afor-loop induction variable should also be resident unconditionally. - Globals are not residency candidates at all — the ticket's own headline
-Sdump uses program-levelvars, 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. - The boolean temp the short-circuit
and/orlowering 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-O3store->reload eliminator already handles the non-resident case; a resident destination is explicitly excluded byReloadElimSym, and that exclusion's stated reason (the refresh clobbers rax via a reload) is no longer true aftere7c0d1d2a. Revisit it. EmitAsmX64re-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.- Non-power-of-two constant divisors via magic-number reciprocals (
div 10is 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):
three.pas— three locals:i(for counter),j(temp),s(accumulator).many.pas— six candidates, three free registers: genuine pressure.
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
stores — EmitStoreVar 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:
- Making the limit a constant (no limit temp at all) made
three.passlower, 0.87 vs 0.79. Do not chase the limit temp on its own. - Across the whole
-O3-built compiler these patterns are individually small. Approximate linear decode of the 9.1 MB text segment, 1.93M instructions (approximate because a pxx binary carries no section headers, so the sweep decodes data as code in places — read the magnitudes, not the digits): dead rax round trip 0.29%, store-then-reload-same-slot 0.16%, resident dual-write 0.03%, operand funnelmov %rN,%rax1.21%.
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.
- div/mod: every power of two through 2^62 and non-powers (10, 3, 7, 100) for the zero-check arm, over Int64/LongInt/Cardinal/QWord, dense −300k..300k across zero where the sign bias lives, a 40k-point sparse sweep of the full Int64 range, and the type extremes.
- narrowing: every width and signedness, dense −70k..70k plus a 60k-point
sparse sweep, every width boundary by hand, the hand-written idioms the fold is
value-identical to, and four near-misses that must not fold (partial mask
$FE, 9-bit$1FF, mask+xor with no subtract, subtract off by one). - cmp-imm: the imm32 sign-extension boundaries are the whole point — 0, ±1, 127, −128, imm32 max/min, one past each, 4294967295, 4294967296, the Int64 extremes — against Int64/LongInt/QWord/Cardinal, six operators, both the branch and the value form, plus two short-circuit shapes that exist only in the value form.
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:
- It compared base
-O2against new-O3, so it always included the-O3-only passes that are not in the promotable set. - 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:
- W1 (emit-time operand scheduler) is now the keystone. Both of W2's cheap experiments came back empty, and the disassembly puts 5 of 21 instructions on the hot loop's common path in the rax funnel alone. W1 attacks that directly.
- W2 (linear-scan allocator) drops behind it, and when it is built its
ranking metric should be loads, not loads+stores —
EmitStoreVardual-writes, so residency removes reads and not writes, and a store-heavy resident is close to free while a load-heavy one is the entire payoff.
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)
- 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 -O2differential, self-host fixedpoint per commit. - Store->reload elimination for resident destinations — the
ReloadElimSymexclusion whose stated reason stopped being true ate7c0d1d2a. - 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.
- 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
-O0/-O1/-O2/-O3agree with each other AND withfpc -O2on a purpose-written residency stress (every store overflows its declared type, so a missing re-extension shows at once: Byte/Word/ShortInt/SmallInt wrap; seven live candidates against four registers; residents written inside atryblock that raises mid-loop, exercising theIR_EXC_ENTERlanding-pad refresh; nested procedure writing an enclosing local; recursion; a float loop) plus arrays/records/strings/exceptions/hello. The only divergence anywhere is the last two digits of one float print, which is identical on the pre-change compiler — pre-existing, Track F, not this slice's.-O3self-host fixedpoint byte-identical (fc9d664a4e63) after every commit.- The
-O3-built compilers from before and after this change produce byte-identical output compilingcompiler.pas. -O2and all four cross targets unmoved: 48/48 corpus hashes identical.tools/gate.sh quickGREEN.
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:
- (a) stale, as the ticket says. "The resident dual-write refreshes through
EmitLoadVarand so clobbers rax" — the refresh now re-extends from rax itself, so rax does still hold the stored value. Dead. - (b) still true, and now measured. A resident load is ALREADY a reg-reg
move (
mov %r13,%rax). Eliminating it removes a register move, not a memory access.
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 stores — mov %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:
- item 2 was emptied by item 1. Filed when the boolean temp lived in the frame and its store/reload was a real memory round trip; by the time it was claimed the temp was resident and there was no reload left to remove.
- W1 was REVIVED by item 1 (below). Correctly disconfirmed at 1.4% against the compiler as it stood; the same transform is now worth ~1.6x, because item 1 removed the memory traffic that had been hiding it.
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
- 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.
- 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.
- 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
- 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. - 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.
- The promotion experiment (coordinator's call, deliberately not started):
a
-O2build 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. - 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
- The standing rule at the top of this ticket: re-measure the PRIZE, not just
the mechanism. It fired twice in one session — item 2 was emptied by item 1
landing, and W1 was revived by it (correctly disconfirmed at 1.4% in the
morning, measured at ~1.6x in the evening, because item 1 removed the memory
traffic that had been hiding the staging moves). Every remaining number above
describes the compiler as of
46c8cf47eand has a shelf life. - The three measurement traps, now in
devdocs/dev/debugging-playbook.md: interleaving fixes WHICH runs you may compare, repetition fixes HOW CONFIDENTLY, amplification fixes WHETHER THE TIMER CAN SEE IT AT ALL. This box produced three false readings in one night and two of them were briefly believed. Every number in this ticket dated 2026-08-28 was taken under all three; numbers dated earlier were not.
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:
- 5a is worth nothing and was dropped. H is not better than G — twice it was
worse. Folding a resident into a
cmpdeletes a real instruction and buys ~0, exactly as W1's operand funnel did. - G gets 70% of E's win for a fraction of the work. The multi-session contract change was the wrong next move; ~1.10x of it is left, not 1.4x.
- The first run said
D=4.17(1.47x) and it was noise. Three runs of 21 put it at 4.96-5.19 and made the ordering monotonic. Reported here because believing run 1 would have oversold the slice by a third.
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:
- rax is left dead after the store, and that doubles the win. Keeping rax
live costs half of it (1.17x vs 1.27x, measured — it is not a free move
either). Safe because an
IR_STORE_SYMis always a statement root:ir.inc'sIRDropManagedStrResultnote says a store consumed by a parent would be emitted twice, andPXXDBG=a.iron C'sx = (y = 3)confirms it — that lowers tostore_sym ythen a separateload_sym y, and the outer store consumes the load. No store is ever a value operand. - The first version silently refused the hottest shape in the language. It
guarded on IR node types, and a for loop's own increment carries
tyUnknown(i := i + 1lowers to binop/store withIRTk = 0, while the same expression in the loop BODY carries the real type). It fired ons := s + jand not oni := i + 1— half the modelled win, and the disassembly is the only reason that was caught rather than shipped. The predicate now guards onSyms[].TypeKind, which is what the store and the refresh actually narrow and extend by.
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
- self-host fixedpoint verified,
converged after 1 round(s),3e26c10fbad9 w2stress.paswritten for this pass — all five ops in-place, every integer width driven past its wrap point, signed and unsigned narrowing,x := x + xself-reference, non-commutativex := x - y,{$Q+}, in-place stores insidetry/except,varand value params, pointer arithmetic-O0/-O1/-O2/-O3all agree, and all 8 outputs (4 levels x 2 binaries) are byte-identical to the baseline compiler's- cross-target corpus: 48/48 (corpus x target) hashes identical to baseline
- one FPC divergence, pre-existing and not W2's: under
{$Q+}pxx detects a LongInt overflow thatfpc -O2misses. Baseline says the same. pxx is the stricter side; error-reporting parity, not this lane.
What is left, re-ranked
- The rest of the register contract (E minus G): ~1.10x, and it is the
multi-session
IREmitNodechange. Still the largest single item, now correctly priced — it was being credited with G's share. - Item 3 — register authoritative inside the loop, ~5%, hardest build.
- The promotion experiment — coordinator's call, unchanged.
- 5a (
cmpreads 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:
- every output still byte-identical to baseline
- all four
-Olevels still in agreement - 48/48 cross-target hashes still identical
- self-host fixedpoint still green
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
- self-host fixedpoint verified,
eee7e675e5e0 - poison experiment green 16/16 both gated AND with item 3 on (the slot is then written only by the poison, which is the strongest form of the check)
- 13 programs x 4
-Olevels: every output identical to the pre-item-3 compiler - cross-target corpus 48/48 hashes identical
test/test_o3_resident_inplace.pas+.expectedadded to the repo — the stress written for W2, extended with the unwind-through cases. All four-Olevels match the recorded expectation.
What is left
-
The rest of the register contract (E minus G): ~1.10x, multi-session, deliberately unopened — open it at the START of a session.
-
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-unfalsifiablearguing that nothing gatesOptLevel>=3and therefore nothing exercises it. Track T resolved it by refutation (c8ec8a1b3):tools/optdiff.shhas compiled and run every standalone test program at-O0/-O2/-O3, comparing stdout+stderr and exit code, since its creation commit597e4ab05(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.shalready carries the-O0 / -O2 / -O3header and thefor L in 2 3run loop. 701 runs, 30 non-GREEN, four-O3findings indone/— 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 —
-O3differential 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
-O3is not WRONG. It cannot prove a pass FIRES. A pass promoted to-O2that 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. -
Item 3's exception-frame gate is a real restriction, not a formality: any body with
try/exceptkeeps 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. -
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 becausePXXDBG=a.poisonslotnever 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.) -
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:
compiler/ir_codegen_aarch64.inc:757—if Counts[k] <= 3 then Continue;, admission by total accesses.compiler/ir_codegen.incranks onLCounts(loads) at:9755, and the comment at:9771says in its own words: "The old test wasCounts[k] <= 3over loads."
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:
tools/optdiff.shcompiles and runs every standalone test program at-O2and-O3against an-O0baseline, comparing stdout + stderr + exit code. It has done so since its creation commit597e4ab05, 2026-07-11 — one day after this ticket was opened, and seven weeks before I called-O3unexercised.- That tier has run 701 times, 30 of them non-GREEN.
- Four
-O3findings indone/came from it, and one names its finder in its own first line:bug-o3-inline-breaks-frame-walk-intrinsicsopens "Track T NEW-RED (optdiff#shard0/6, sha 69f7bda93ac4)" — a silent miscompile at-O3, caught by the sweep I said did not exist.bug-o-o3-diverges-on-cmath-sign-bits-and-pascal-hijackis a second, filed against this track.
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→-O2promotion 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
- self-host fixedpoint verified,
1bdc93d1b061 - 13 programs x
-O0/-O2/-O3under qemu: every output identical to the pre-port compiler - all 6 targets at default
-O: 48/48 hashes identical — the port is-O3-gated, so default output must not move, and it does not - x86-64 untouched by construction (the change edits only
ir_codegen_aarch64.inc) and confirmed by the same 48/48
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
- self-host fixedpoint verified,
a07c5ee5f972, and again at603e1fa38a2cafter rebasing onto ~18h of drift - 13 programs x
-O0/-O2/-O3under qemu: identical to the pre-item-3 compiler PXXDBG=a.poisonslotgreen 12/12 on aarch64, includingtest_o3_resident_inplacewith itstry/exceptand unwind-through cases- all 6 targets at default
-O: 48/48 hashes identical
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
- differential, 13 programs x
-O0/-O2/-O3vs the pre-W2 aarch64 compiler: fail=0 PXXDBG=a.poisonslot: fail=0- the
-O3stress test fires 381 times (this is the number that would have gone to 0 unnoticed) - all 6 targets at default
-O: 48/48 corpus hashes byte-identical - self-host fixedpoint: converged,
914994960b99
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 toworking/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 everyclaim,parkandresolveopens the same window. Found by theNO-FRONTMATTERcheck added toprogress checkthe 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_has — driver 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 | 0 — RcProcHasExc, 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
FloatPoolSave/FloatPoolRestoreare NOT slot readers. They round-trip the whole xmm8..13 pool through the separateFxSaveBasereserved area and never touch a variable's own frame slot. This was the reader most likely to exist and it does not.FloatResidencyRefreshAllhas exactly one caller, theIR_EXC_ENTERlanding pad.defs.inc's comment said extern/indirect calls also refreshed through it; that was wrong when written, not made wrong by this change, and is now fixed. Second stale-comment-as-durable-wrong-answer of the evening.- Address-taken locals cannot be residents:
SymSlotEscapesgates both the int and the float candidate loops, so the escape case needs no separate guard.
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:
- 9 of the 14
IR_STORE_SYMs in mandelbrot'sEscapeCountLimitno longer write the frame slot —zre2,zim2,zr22,zi22,tmp1. That is the Mandelbrot iteration itself, and it matches the 9-of-14 figure this item was priced on before it was built. - 37 fewer
movsd [rbp+disp32],xmm0and 296 fewer bytes of code in the program overall (98337 -> 98041).
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
- self-host fixedpoint
9a671a37afbe - aarch64 differential vs the pre-port compiler, 10 programs x
-O0/-O2/-O3under qemu — 30 pairs compared, 0 differ: div/mod mixed signedness, integer cast truncation, int64-of-nativeint, sized names,{$Q+}narrowing overflow, div-by-zero raises, and the three-O3residency stress programs. The harness reports its comparison count and exits 2 if it compared nothing. - default
-Ocorpus identical on all six targets, 25 rows, isolated against a build of HEAD with only this change reverted — necessary because the raw comparison against this morning's binary showed anexcx86-64 row moving that turned out to belong to another lane.
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:
- Item 1's x86-64 residual, behind b4's own fresh-session rule.
feature-opt-emitloadvara64-needs-a-destination-register-parameter[A p55] — filed separately, and now a two-step job: makeEmitLoadVarA64's scratch usage uniform (skGlobal/tySinglefrom x1 onto x9, which local/param already use) before adding a destination parameter, because the helper's scratch is inconsistent across its own arms and self-clobbers at exactlyrd=1.
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 box — perf_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=16683B →
code=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 after — PXXDBG=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 bucket — imul 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 00 — imul 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 calls — Length(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:
- register-resident right —
cmp rN, rM(4C/4D 3B /r) - 8-byte frame local or param —
cmp rN, [rbp+off](ModRM mod=10 rm=101)
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:
feature-opt-o3-fuse-resident-read-and-widen-into-movsxd(-1) — and the split changed its design. Banked here as "thecdqeis a no-op, delete it", which is correct today and is exactly the invariant-dependent elision slice 8 refused. Writing it up properly produced the version that needs no invariant: emitmovsxd rax, r12d(3 bytes, one instruction) instead ofmov rax,r12+cdqe(5 bytes, two). Same net effect, strictly better encoding, correct whatever the upper half holds. Splitting a banked item out is not clerical — it is the first time anyone writes the argument down, and that is when the better design shows up.feature-opt-o3-operand-order-for-non-commutative-binops(-2) — the emit-time operand scheduler this umbrella names in its own charter. Distinct from the-O2mirror already in the file: the mirror swaps operands and so needs commutativity, while this swaps evaluation order and keeps the operand roles, which is what covers-,shr,div,mod.
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 46ff97f32ed7 —
both 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 beforetools/sync.shrebased, andde276c8f5(xtensa) andd2a61a524(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.pasbelow: 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:
- 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.
- 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
- Not "there is no headroom." A comparator falling behind bounds nothing about the absolute optimum. What died is the justification, not the possibility.
- Not a codegen-only comparison. This is whole toolchains — codegen and RTL, 4.1 MB of FPC runtime against 10.0 MB of ours. It does not isolate register allocation.
- Not a claim about all workloads. One program, one shape. This ticket's own record shows W1 slices worth 1.9-2.1x on tight scalar loops while costing 2-6% on this very self-compile. The self-compile cannot see a workload where register pressure binds.
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.