← board

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

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

Optimization splits by home (see optimization-architecture.md §3): shared-IR passes (§3a) help all six targets for free — one implementation, keep those target-agnostic. Per-backend work (§3b: emitter peepholes, the operand scheduler, the register allocator's emit side) targets x86-64 + aarch64 ONLY. Rationale:

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

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

The data splits the work cleanly by home:

Workstreams (suggested order)

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

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

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

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

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

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

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

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

Out of scope (captured so it is not lost)

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

Gating & fallback (the whole point of -O3)

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

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

Log

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

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

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

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

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

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

Next steps (queued, in rough order)

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

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