← board

Optimization levels (-O0/-O1/-O2/-O3/-Os) + pass framework

Target scope (Track O policy)

Per-backend optimization effort = x86-64 + aarch64 only. Shared-IR passes (§3a) help all six targets for free and stay target-agnostic; per-backend work (emitter peepholes, register allocator/scheduler) is built only for the two targets where compiled-code throughput matters. 32-bit (i386/arm32/rv32) is perf-irrelevant (legacy/control/bring-up, not throughput); ESP32/xtensa is a special case whose hot paths are hardware peripherals (DMA/ADC/SPI, already supported), not compiled loops. Don't port per-backend passes to those.

Motivation

PXX is single-pass and emits straightforward code: full prologue/epilogue per call, naive register use, no cross-statement reasoning. That is correct and keeps self-host byte-identical tractable, but leaves easy cycles on the floor — especially in hot loops and on the cycle-starved ESP targets. We already do partial constant folding at -O0 (kept, it is local and invisible). The goal of this ticket is a deliberate, level-gated optimizer: a small set of cheap, safe, deterministic passes, each independently landed, tested, and self-host verified, organised behind the conventional -O flag.

No standard, but a conventional shape

There is no ISO/standard mandate for what -O1/2/3 must do — GCC/Clang/MSVC only loosely agree. We adopt the convention and assign passes per-feature by the safe vs. some-risk axis, not by a rulebook:

Level Contract Notes
-O0 none beyond existing partial const-fold; 1:1 source↔asm, debuggable dev default; protect this contract
-O1 cheap, safe everywhere, no code-size blowup, deterministic candidate to become the default once proven
-O2 full speed; code size may grow; some heuristics release default
-O3 aggressive; may not always pay (icache, codegen risk); benchmark-gated opt-in
-Os size-first; O2 minus anything that grows code; inline only if net-smaller matters for ESP/xtensa/riscv

Level assignment is not written in stone — per pass we pick the level by whether it is proven safe with issue detection (then it can sit at O1) vs. correct but carrying some risk (O2). Example: inline; (see feature-inline-routines) graduates from O2 to O1 once it reliably detects ineligible bodies and degrades to a call.

Architecture decisions

Hard gates (ticket-level, non-negotiable)

  1. Determinism. No heuristic may depend on pointer values, allocation addresses, or hash/map iteration order. Cost models count IR nodes (stable), nothing address-derived. Non-determinism breaks self-host fixedpoint.
  2. Self-host byte-identical at every shipped -O level. The compiler must self-compile byte-identical at each level it offers. This is an N×M matrix (levels × targets) — start O1-only to keep it small, grow deliberately.
  3. Cross-level output-equality oracle. The cheapest strong test: the same program compiled -O0 vs -O1 vs -O2 must produce identical runtime output. Any behaviour change = optimizer bug, caught immediately. Wire into make test per pass.
  4. -O0 stays 1:1 debuggable. Do not sneak in folds/motions at O0 that disturb source↔asm line mapping. Existing local const-fold is fine.
  5. Volatile Support before Optimizing. To prevent incorrect elision of memory accesses (especially on MMIO / hardware boundaries), volatile semantics MUST be fully parsed, mapped, and enforced in the AST/IR before any optimization passes (such as dead store elimination, redundant load elimination, or loop-invariant code motion) are enabled.

Candidate passes (assign levels as proven)

O1 (cheap, safe, no growth):

O2 (speed, size may grow):

O3 (aggressive, gated):

-Os (ESP/embedded):

Build-out order (split into sub-tickets only when work starts)

Keep this as a single umbrella for now — do not flood the board. When work begins, split per pass so each lands + reseeds (make bootstrap) + self-host verifies independently, matching the fine-grained-commit norm. Suggested first four (each independently testable, low risk):

  1. Pass framework + -O flag plumbing + {$O±} scope + cross-level oracle harness.
  2. Constant-folding completion.
  3. Dead-code after exit / if false.
  4. Tiny-leaf provably-not-larger auto-inline (ties into feature-inline-routines).

Acceptance

Log

Measured baseline + concrete low-hanging fruit (2026-07-03, v162)

make benchmark-compiler-runtime: identical compiler source, FPC-built binary 5.1s vs pxx-built 10.4s for the same self-compile — generated code is 2.04x slower and 1.97x larger (4.06MB vs 2.06MB). That gap is the -O budget. Sibling axes: [[perf-compiler-hotspots-algorithmic]] (compiler's own algorithms: FindProc linear scans = 13% of self-compile), [[feature-callconv-register-args]] (the -O2 ABI flag-day), [[feature-inline-routines]] (-O1/-O2 inlining).

Cheapest first, all -O1 candidates (deterministic, local, peephole over the emitted stream or one-node IR context):

  1. push/pop pair elision — the stack-machine emits push rax … pop rcx around nearly every binary op even when nothing intervenes; a small emitter-level window (track last-emitted push, cancel matching pop into mov rcx, rax or direct register use) removes two memory ops per operand pair. Biggest single win, everywhere.
  2. redundant load eliminationmov [slot], rax immediately followed by mov rax, [slot] (store-then-reload of the same slot) drops the reload. Very common at statement seams.
  3. constant peepholesmov rax, 0 -> xor eax, eax (7 bytes -> 2); add rax, 1/sub rax, 1 -> inc/dec; compare-with-0 after arithmetic that already set flags. Mostly size, some speed.
  4. IR_CONST_INT into BINOP immediatesmov rcx, imm; add rax, rcx -> add rax, imm when the constant fits imm32. Kills a register shuffle per constant operand.
  5. branch-over-branchjcc +2; jmp target -> j!cc target where the pattern appears from the comparison lowering.
  6. dead store to hidden temps — lowering-time temps written once and read once immediately after can bypass the frame slot entirely (subset of 2).

Suggested pass placement: 1–5 as an emitter-side peephole ring buffer (no IR change, applies to every backend that opts in — x86-64 first); 6 wants the liveness scaffold shared with [[feature-callconv-register-args]].

Gate discipline per the table above: -O0 stays byte-identical (the self-host gate is UNCHANGED); each -O1 pass lands with a codegen-diff test (compile a corpus at -O0/-O1, run both, identical output) + make test under an -O1-built compiler + benchmark delta recorded here.

Progress — -O plumbing + pass 1 LANDED (2026-07-03)

OPEN DECISION: pins stay -O0-built for now (byte-identity continuity for B/C). Flipping the pinned binary to -O1-built is free performance for every track once we trust the pass battery — revisit after 2-3 more passes.

Progress — pass 2 LANDED (2026-07-03)

Progress — pass 3 LANDED (2026-07-03)

Store-reload elimination (queued pass 2 in handover) — DEFERRED -> [[feature-opt-store-reload-elimination]]

Investigated the IR structure (flat post-order array; IR_BLOCK is a no-op range marker; a driver loop emits statement roots and recurses for operands). The redundant reload (mov [slot],rax then mov rax,[slot]) lives DEEP in the NEXT statement's expression tree, not as an adjacent statement root — so an IR-stream peephole over roots can't see it. Catching it needs a "value-in-register" tracker whose invalidation must fire on EVERY rax write, but rax is written by scattered raw EmitB calls throughout ir_codegen.inc with NO single choke point — airtight invalidation would require auditing hundreds of sites, and one miss = silent miscompile. Byte-level detection (matching emitted Code[]) is the forbidden path (fixups reference CodeLen). Correct implementation wants the liveness scaffold flagged for [[feature-callconv-register-args]] / ticket item 6. Deferred to that scaffold rather than land a risky tracker (correctness-first). Did the safe queued peepholes (pass 3 above) instead; branch-over-branch next.

if-false DCE + strength reduction — MEASURED, REJECTED (2026-07-03)

Measured BEFORE building (const-fold lesson applied):

Neither shipped. Tripwire extended (IROptWarnMissedFold) to also warn on a constant-condition IR_JUMP_IF_FALSE, so if a frontend ever stops folding, we learn and revive if-false DCE. Validated: still silent on real code; the const-fold arm still fires on injection; -Werror promotes.

IR const-fold + algebraic identities — MEASURED, REJECTED (2026-07-03)

Implemented both as IR passes, instrumented, measured: ZERO fires on the whole compiler self-compile AND on synthetic foldable tests. PXX eliminates these upstream — Pascal folds source constants in ConstEval/AST; C + Nil-Python share the same AN_BINOP lowering; that lowering guards pointer/index stride if elemSize > 1 (ir.inc ~3345/3359) so index*1 is never emitted. No const-const IR_BINOP nor identity operand ever reaches the IR for any frontend. Correct but pure dead weight -> NOT shipped (measured-not-speculative, same call as the rejected allocator bins). A { NOTE ... } in ir.inc records the finding and the revive condition (a future pass that PRODUCES const-const binops). Framework (DCE + redundant-jump) unchanged; still v170.

Progress — shared-IR pass framework + DCE LANDED (2026-07-03)

Architectural pivot (Rene-endorsed): optimization now has TWO homes — emitter-side peepholes (x86-64 only; passes 1-4) and a shared-IR pass pipeline run before codegen, seen by ALL 6 backends and ALL 4 frontends at once ("optimize prior, not post"). Full study doc: devdocs/dev/optimization-architecture.md.

Progress — pass 4 LANDED (2026-07-03): compare-into-branch fusion

Pin-flip to -O1-built — DONE, v168 (2026-07-03, Rene OK'd)

Pinned binary is now -O1-built (make PXXFLAGS=-O1 stabilize + make pin).

(prior) Pin-flip readiness note — kept for history

The queued "decide flipping pins to -O1-built" is de-risked and ready:

Branch pass — reassessed (2026-07-03)

Literal "branch-over-branch (jcc +2; jmp X -> j!cc X)" has NO pervasive machine-generated source here: integer/float comparisons lower to setcc al; movzx eax,al (already good), not jcc-over-jmp (only one hand-written spot in the string-compare sequence, ir_codegen.inc ~1340). The REAL high-frequency branch win is compare-into-branch fusion: IR_JUMP_IF_FALSE whose condition is a comparison BINOP currently emits cmp; setcc al; movzx eax,al; test rax,rax; jz label (~8 wasted bytes + latency every if/loop) — fuse to cmp; j!cc label (inverted condition). Bigger, control-flow / fixup-touching change; gate to integer signed/unsigned scalar comparisons, fall back to the generic path for float/other. Its own careful pass (not rushed at a session tail — a half-applied Track A codegen change trips the self-host gate).

Next passes queued: compare-into-branch fusion (branch pass, above); inc/dec + imm-fold into BINOP operand; store-reload once the liveness scaffold ([[feature-callconv-register-args]]) lands. Pin-flip awaiting Rene's OK.

Progress — compiler-throughput wins (2026-07-10, profiling session)

Shifted from codegen-quality (emitted code) to compiler-throughput (pin time), profiling the self-compile with an FPC-symboled binary (fpc -g -o... compiler.pas — real per-function attribution; the pxx --proc-map route is unreliable). See [[project_per_body_full_array_clear_waste]].

Pin note: the label-clear win is byte-identical-transparent, so re-pinning would hand B/C/D a 1.30x-faster compiler for free — but pin-flip stays a user-approved action; the commits are on master and fold into the next pin.