← board

Register-based internal calling convention (args in registers, not stack slots)

Motivation

PXX's internal convention on x86-64 today: every argument is pushed to the stack, popped into rdi/rsi/... just before the call, and the callee's prologue immediately SPILLS every register argument back into frame slots. Each argument round-trips memory twice; the callee then reloads from the frame on every use. FPC's register convention + register allocator is a big chunk of the measured 2.04x generated-code gap (benchmark-compiler-runtime, 2026-07-03: FPC-built pascal26 compiles the compiler in 5.1s vs self-built 10.4s, identical source).

Shape

Prerequisites

Acceptance

Self-compile wall time drops measurably with -O2 (record in benchmark-compiler-runtime); full make test green under a -O2-built compiler; -O0 self-host byte-identical unchanged.

Design worked out 2026-07-03 (arc scoped, not yet implemented)

Current model (x86-64, mapped): confirmed 2 memory round-trips + per-use reload:

Chosen approach — callee-side register RESIDENCY via callee-saved regs. Park each non-pinned scalar param in a callee-saved register for the whole body instead of a frame slot: kills the spill (round-trip 2) AND every per-use reload. Caller stays UNCHANGED (still passes in rdi/rsi via push/pop) — half the theoretical win, but far lower risk and self-contained. Callee-saved regs survive calls by SysV ABI, so a resident param needs NO cross-call spill (the key simplifier).

Register budget (audited this session):

The invariant that makes it safe: every routine (and helper) that USES a callee-saved reg must push it in prologue / pop in epilogue. Then: our helpers preserve them (verified push/pop), external SysV callees preserve them (ABI), and regcall routines preserve them (new prologue/epilogue). So a param in r14 survives any call. -O0/-O1 keep the current spill model untouched (gate OptLevel>=2).

Pinned (must stay in frame, never register-resident): IsRef (var/out/const by-ref), IsArray, float/record/set/variant types, any param whose address is taken (an IR_LEA / IR_SLOTADDR in the body references its SymIdx), and params captured by a nested/lifted routine (parser.inc 12505-12595). Everything else (plain int/ptr/char/bool/enum scalar) is register-eligible.

Phasing (each lands + self-host-gates independently, all behind -O2)

  1. Addr-taken analysis (prereq, no codegen change). Per body at CompileAST: for each param of CurProc, eligible = scalar int/ptr, not IsRef/IsArray, and no IR_LEA/IR_SLOTADDR references its SymIdx. Verify by instrumenting counts over a self-compile = MEASURE the opportunity (how many params are eligible) before committing. (Also unblocks [[feature-opt-store-reload-elimination]].) NOTE: a first instrumentation attempt this session printed 0 — CurProc validity/timing at the probe site needs checking; the analysis itself is sound, the probe placement was off. Fix before trusting the number.
  2. r14/r15 residency (x86-64, -O2). Assign up to 2 eligible params to r14/r15. Prologue: push r14/r15 + mov r14, rdi (from the SysV incoming reg) instead of the frame spill. EmitLoadVar/EmitStoreVar skParam: emit mov rax, r14 when resident. Epilogue: pop r15/r14. Gate every site OptLevel>=2 + a per-Sym "resident register" field (or a side table).
  3. Expand to rbx/r12/r13 after auditing the helpers save them (5 resident params total).
  4. Caller-side direct-eval (optional, harder) — eval args straight into arg regs without the push/pop, reclaiming round-trip 1. Needs care (nested arg eval clobbers). Lower priority; measure whether phase 1-2 already closes most of the gap first.
  5. Cross targets (aarch64 first) reuse the same eligibility analysis later — only if the host win justifies it (per user: x86-64 is the priority; cross optional).

Risk / why it's a careful multi-session arc

The stack-machine codegen funnels everything through rax; residency works ONLY because callee-saved regs are never used as scratch in normal codegen (verified for r14/r15; helper-audited for rbx/r12/r13). Any emitted sequence that clobbers a resident reg without saving = silent param corruption. So phase 1 must land minimally (r14/r15 only), full make test under a -O2-built compiler, -O2 self-host fixedpoint, before expanding. -O0 byte-identity stays the anchor.

Phase 0 MEASURED (2026-07-04) — opportunity confirmed large

--measure-regcall flag added (compiler.pas + RegcallMeasureBody in ir_codegen.inc, called from CompileAST after IRLowerAST; flag-gated, zero codegen effect). Eligibility = scalar int/ptr (RegcallScalarType: tyInteger, tyBoolean, tyChar, tyClass, tyInt8..tyNativeUInt, tyPointer), not IsRef, not IsArray, and no IR_LEA/IR_SLOTADDR in-body references its SymIdx.

Measured on the compiler self-compile (--measure-regcall compiler/compiler.pas):

Metric Value % of params
bodies with params 1262
total params 2625
eligible 2084 79%
capture @ 2 regs (r14/r15, phase 1) 1595 61%
capture @ 5 regs (+rbx/r12/r13, phase 2) 2053 78%
eligible param loads+stores (reload traffic removed) 7234
addr-taken rejects 0

Phase 1 DONE (2026-07-04) — r14/r15 residency behind -O2, x86-64

Implemented as a self-contained codegen change (NO parser reorder). Key realization: the early prologue spill already writes each param's frame slot, so a deferred mov r14,[rbp+off]-style reload — emitted in CompileAST after the body IR is lowered (residency known), before IREmitMachineCode — reads a correct value regardless of arg-register liveness. And nested routines reject capturing enclosing params (parser.inc ~12525), so a param frame slot is never touched by a nested routine → no capture hazard.

Design: frame slot stays authoritative (early spill + store dual-write via RegcallRefreshResident, which reloads through EmitLoadVar to reuse the canonical size/sign extension). The register is a pure read cache → any non-EmitLoadVar reader and the excluded addr-taken cases stay correct; callee-saved regs survive calls by ABI so there is no cross-call spill. r14/r15 caller values saved to a reserved frame slot at body entry (FrameSize bump), restored in EmitProcEpilog (every return path, early Exit included).

Files: RegcallAssignResidency + CompileAST hook (ir_codegen.inc); ResidentRegOf/RegcallRefreshResident + EmitLoadVar/EmitLoadVarRcx/EmitStoreVar resident hooks + epilogue restore (symtab.inc); RcResident* globals (defs.inc); --measure-regcall (phase 0). Gated OptLevel>=2 + x86-64; excludes generator/stackless routines and bodies with inline asm.

Gates: -O0 self-host byte-identical (unchanged); -O2 self-compile fixedpoint byte-identical; -O2 differential corpus green; make test green + green under an -O2-built compiler. make test-opt extended to gate -O2 differential + -O2 self-fixedpoint permanently.

Measured: self-compile 1.34x faster (6.53s→4.87s, hyperfine, identical output) and compiler code 12.2% smaller (4.08MB→3.58MB) — from just 2 params/body. Closes a real chunk of FPC's ~2x lead.

Pin policy — FLIPPED to -O2 (2026-07-04, user-approved). Pins are now -O2-built (regcall + inline). -O2 output is byte-identical-transparent (an -O2-built compiler emits the same -O0 output as before), so B/C/D see identical compiled output and just get a faster compiler. Pinned via make PXXFLAGS=-O2 stabilize && make pin.

Status: phase 0 + phase 1 DONE. Phase 2 MEASURED + REJECTED (2026-07-10). Phase 3/4 remain open.

Phase 2 (r12/r13 residency, cap 2→4) — MEASURED, REJECTED (2026-07-10)

Prototyped the full phase-2 extension and measured it before shipping (per the "re-benchmark first" note). Net result: no measurable runtime win; reverted.

Phase 3 slice 1 LANDED (2026-07-18, -O3) — all-simple-args direct load

Caller-side: when EVERY argument of an internal <=6-param non-variadic call is a side-effect-free rax/xmm0-only leaf (CallArgDirectLoadable: IR_CONST_INT/ STR/DATA, IR_PROCADDR, IR_LOAD_SYM of scalar/pointer/class/float/ansistring/ frozen-string/dynarray-handle syms — incl. by-ref-param derefs and resident reads), the push/pop round-trip disappears: eval left-to-right, mov <argreg>, rax each. Leaves are order-invisible, so left-to-right holds Pascal semantics; ANY non-leaf arg falls the whole call back to the proven push/pop path (a call-bearing sibling could write a variable a leaf reads). tyAnsiString conversion args (helper calls) excluded. Gated OptLevel>=3; -O2 keeps only the last-arg collapse; -O0/-O2 byte-identical (fixedpoints + parity vs pinned verified).

Measured: call-heavy int loop (3-arg helper per iteration) -O3 vs -O2 1.11x; float helper-per-iteration 1.32x (combined with residency); self-compile parity (memory-bound, expected). test-opt + quick + C 220/220 + nilpy + .bas green; O3-built compiler output byte-identical.

Regression found+fixed by test-opt while landing: slice-1 residency's FloatPoolBoundaryAssign reserved pool slots in the MAIN program body, whose statements compile as separate CompileAST calls with no per-body frame patch — the IR_CALL_IND wrap then wrote wild below rbp (test_frozen_string_reentrant -O3 SIGSEGV, indirect frozen-string call in main). Fix: CurProc < 0 exits the pass (both x86-64 + aarch64 mirror) — main is the call-stack root, nothing above it holds residents and main never gets residents, so the wrap protects nothing there. LESSON: run make test-opt per -O3 slice, not just quick+ self-host (the -O3 corpus is where -O3-only emission breaks surface).

Remaining: phase 3 slice 2 (mixed simple/complex args — defer simple loads after complex pushes when no later arg has side effects); phase 4 cross.

Phase 3 slice 2 LANDED (2026-07-18, -O3) — mixed simple/complex args

Generalizes slice 1: DEFERRABLE args (immutable leaves + loads of ADDR-CLEAN locals/by-value params — no IR_LEA/IR_SLOTADDR on the sym in this body, so no call a sibling makes can write them; per-body verdict cache PaScan*) skip the push/pop from ANY position. Non-deferrable ("ordered") args — complex subtrees, globals, by-ref derefs, addr-taken locals, tyAnsiString-conversion cases — evaluate in source order onto the stack (Pascal left-to-right effects hold), last ordered arg collapses into rax→reg, rest pop in reverse, then the deferred leaves load directly. All-deferrable degenerates to slice 1; none-deferrable emits exactly the old sequence. Gated -O3.

New permanent test test_regcall_arg_order.pas (optdiff-swept): global read BEFORE a mutating sibling call, addr-taken (var-param) local staying ordered, deferred leaves in every register position around a middle complex arg, strict L-to-R among two side-effecting args — identical -O0/-O2/-O3.

Gates: test-opt green, quick GREEN, self-host byte-identical, C 220/220, nilpy, .bas, mandelbrot/nbody checksums unchanged, O3-built compiler output byte-identical, residency+frozen tests identical all tiers. Mixed-args loop (complex first arg + 2 deferrable per iteration) -O3 vs -O2 1.17x.

Remaining: phase 4 cross-target caller-side (optional per charter).

Log