Inline expansion — remaining slices (branch-with-locals + non-leaf)
- Type: feature (codegen — optimization) — Track O (Optimization lane; file-ownership Track A)
- Status: backlog — parked 2026-07-19 sweep; landed slices green, remaining scope below
- Opened: 2026-07-04 (follow-up split from [[feature-inline-routines]])
- Umbrella: the
-O2/-O3tier of [[feature-optimization-levels]]; the earlier inline slices (v1 pure-expr, 2a if-then-else Result, 2b straight-line multi-statement) all shipped at -O2 — this ticket carries the deferred rest.
What shipped already (context — do not redo)
feature-inline-routines delivered, all at -O2, all gated OptLevel>=2,
-O0/-O1 byte-identical, validated O0-vs-O2 across ~700 programs + 4 cross targets:
- v1 — pure-expression leaf
Result := E. - 2a —
if C then Result:=A else Result:=B(retained asAN_TERNARY). - 3 — arbitrary arg expressions via eval-order-safe temps.
- 2b — straight-line multi-statement bodies with scalar ordinal locals + a
single Result (
t:=a+b; Result:=t*t). Retained as the wholeAN_SEQchain withAN_INLINE_PARAM/AN_INLINE_LOCAL/AN_INLINE_RESULTplaceholders.
The retention/splice machinery (TryRetainInlineBody /
TryRetainInlineStmtBody in parser.inc; IRInlineExpand / IRCloneInlineBody
in ir.inc; the reserved AST region + placeholders) is the reusable base for
everything below.
Slice 2c — if-then-else with locals / multiple statements per branch
2b is straight-line only (rejects any AN_IF in a multi-statement body). The
common next shape is a body that branches AND uses locals, e.g.
function Clamp(v, lo, hi: Integer): Integer; inline;
begin
Result := v;
if Result < lo then Result := lo;
if Result > hi then Result := hi;
end;
Blockers to solve:
- Branch-aware assigned-before-read. 2b's read-before-write guard
(
InlineWrittenLocal) is a simple linear scan. With branches, a local/Result assigned only inside one arm is NOT guaranteed assigned after theif— the guard must become a conservative dataflow (a var is "assigned after S" only if assigned on ALL paths through S). Reject anything it can't prove. - Result read is currently forbidden (v1..2b) to dodge the uninitialised- Result hazard. Clamp above READS Result. Options: (a) allow Result-read once a prior unconditional assignment is proven (the assigned-before-read analysis covers this), or (b) zero-init the Result temp when the body reads it before an unconditional write — but that can diverge from -O0's stack-garbage semantics, so (a) is preferred.
- Statement cloning already handles
AN_IF(genericLeft/Rightrecursion inCloneToInlineRegion/IRCloneInlineBody) — the work is the analysis, not the splice.
Slice 4 — non-leaf inlining (under a depth budget)
Drop the leaf-only rule: inline bodies that themselves call. The ~97% of call sites 2b/2c can't reach. Needs (see the design notes in [[feature-inline-routines]] "slice 4" section):
- Depth budget + recursion guard by proc identity (a stack of procs being
inlined; skip a proc already on the stack → normal call). Extend
InliningActivefrom a boolean-ish counter to a real depth + identity stack. - A cost model (body-node count × call-site count under a budget) so non-leaf bodies don't blow up code size / I-cache. Static heuristic only — no profile.
- Early
Exit→ merge label for bodies with a mid-flow return. - This is the real FPC-parity lever and the highest-risk slice. -O3 (opt-in) until a cost model proves it doesn't regress.
Acceptance (per slice)
-O0/-O1byte-identical (gateOptLevel >= tier); the shipped slice's self-fixedpoint byte-identical;make test-optdifferential green; O0-vs broad sweep + cross targets clean; new oracle cases intest_inline_expand.- Slice 4 additionally: a code-size regression check (record -O2/-O3 sizes) so the cost model is honest.
Related
- [[feature-inline-routines]] — the shipped base (v1/2a/2b/3).
- [[feature-optimization-levels]] — umbrella.
- [[feature-callconv-register-args]] — the other -O2 codegen win.
Log
-
2026-07-18 night (fable-O) Slice 2c LANDED (-O3): branch-with-locals.
TryRetainInlineStmtBodynow acceptsif C then <assigns> [else <assigns>]statements (arms = single assign or straight-line assign chain) under a DEFINITE-assignment dataflow: entry state saved per if; each arm validated with reads gated on definite-at-entry ∪ written-earlier-in-arm; merge = entry ∪ (then ∩ else); no-else keeps entry. Result-read now allowed (InlineExprSimplegainsallowResRead;InlineResultDefglobal) but only when definitely written — -O0 stack-garbage Result is never observable (ticket option (a)). Result must be definite at body end. Splice side untouched (generic clone + IRLowerAST handles AN_IF). 2b straight-line stays -O2; branch acceptance gated OptLevel>=3. Nested ifs inside arms decline (v1). Known non-firing shape: bare-funcname-as-VALUE bodies (F := v; if F < lo …) — funcname in expr position is not a Result-read in this dialect; call fallback keeps them correct. Testtest_inline_branch_locals.pas(optdiff-swept): guard-if local, if/else Result, 3-statement clamp-via-local, mixed entry-definite reads — all inline (IR call-site diff O2→O3 = the three 2c shapes; the if/else Result one already fires at -O2 via 2a) and outputs identical -O0/-O2/-O3- aarch64 O0-vs-O3 under qemu. Gates: test-opt, quick, make bootstrap (FPC seed), C 220/220, nilpy, mandelbrot/nbody checksums, O3-built compiler byte-identical. Clamp-style microbench -O3 vs -O2: 1.32x. REMAINING (this ticket): non-leaf inlining (callee makes calls); nested ifs in arms; while/for bodies.
-
2026-07-18 night (fable-O) 2c increment: NESTED ifs in arms. The if save/validate/merge dataflow extracted to
InlineIfValidate;InlineArmValidaterecurses through it (mutual recursion, forward decl in parser.inc — FPC-seed verified viamake bootstrap). Definite-assignment composes: an inner if/else that writes a local on both paths makes it definite for the rest of the outer arm. Test extends withGrade(nested if/else in a then-arm) — 4 call sites now vanish at -O3, outputs identical -O0/-O2/-O3 + aarch64 differential. Gates: test-opt, quick, bootstrap, O3-built byte-identical, mandelbrot checksum. REMAINING: non-leaf (callee makes calls); while/for bodies. -
2026-07-18 night (fable-O) NON-LEAF slice 1 LANDED (-O3).
InlineExprSimpleaccepts a directAN_CALLto a plain internal scalar function (non-extern/cdecl/variadic/generator, arg count checked, args recursively simple, plain-call shape only) as an expression element — so wrapper bodies (Wrap := Leaf(a) + Leaf(b)*2, incl. inside 2b/2c chains and arms) now retain. The spliced body's inner calls stay REAL calls (InliningActive already blocks re-inlining) — the win is the removed outer frame; measured 1.13x on a 3-wrapper loop despite inner leaf-calls materializing. Correctness key:InlineBodyHasCall[proc](set viaInlineRetentionSawCallduring validation) forces the splice to temp-capture EVERY argument — a direct-substituted pure arg's placeholder can sit after the inner call's side effects. Side-effect-exact testtest_inline_nonleaf.pas(g= counts the callee's global increments) — identical -O0/-O2/-O3 + aarch64 differential. Gates: test-opt, quick, bootstrap (FPC), C 220/220, nilpy, checksums, O3-built byte-identical. FUTURE: depth-1 re-inline of inner calls inside splices (lift the InliningActive=0 gate to a depth budget) — would recover the leaf-in-wrapper fusion the -O2 wrapper call keeps today. REMAINING: while/for bodies. -
2026-07-18 late night (fable-O) depth-1 re-inline REVERTED (revert of a3f6e70a). A 10-minute pasmith O-level self-differential fuzz run (new harness: random programs, -O0 vs -O2 vs -O3 output diff) produced 21 SILENT -O3 divergences; commit-bisect pinned a3f6e70a (2c/non-leaf commits GREEN), and reverting it clears all 21. The InlineResultSym-local fix was necessary but not sufficient — some further nesting-state interaction diverges values. All curated gates (test-opt corpus, benches, the inline tests) had passed — ONLY the random-program fuzz caught it. Repro corpus kept in the session scratchpad; a reduced case is being minimized for the re-land. LESSON: fuzz before pushing aggressive splice-machinery changes; the optdiff corpus is too tame for inliner state bugs. Non-leaf slice 1 (inner calls stay real) remains landed and fuzz-clean.
-
2026-07-18 late night: post-revert master fuzz-clean — 1724 pasmith programs, 25 min, zero O0/O2/O3 divergences (tools/optfuzz.sh). The full remaining -O3 stack (ABI/residency both arches, regcall p3 both slices, inline 2c + nested-ifs + non-leaf slice 1) holds under random programs. Depth-1 re-land blocked on the reduced repro's root cause.
-
2026-07-19 early (fable-O) depth-1 re-inline RE-LANDED, root-caused. The pinned-anchored reducer (oracle: pinned-O3 + bad-O0 + bad-O2 must all agree, only bad-O3 diverging counts — REQUIRED, or reduction drifts into uninitialized-Result UB) shrank the repro to 88 lines and exposed the true bug:
IRInlineExpandbound arguments into the SHAREDInlineArgAST[]while argument LOWERING can re-enter the expander (a nested call in a splice's argument list at depth<2) — the inner activation rebinds the outer's argument ASTs mid-loop. Fix = the REENTRANCY CONTRACT: bind into a localboundAST[], publish the Inline* globals only inside the clone window (IRClone* never lowers → no reentry), hold the Result temp in a local across the reentrant body lowering. Bonus: arg capture now runs OUTSIDE the depth bump, so argument calls inline at current depth. New regressiontest_inline_depth_reentry.pas— PROVEN to catch the reverted binary (diverges on it) and identical on the fix at -O0/-O2/-O3 + aarch64. Pre-land fuzz: 1216 programs, 0 diffs; full battery green (test-opt, quick, bootstrap, benches, O3-built byte-identical). Side-find from the UB-drifting reducer already filed: [[bug-pascal-undefined-field-on-empty-record-compiles]]. -
2026-07-19 (sweep) PARKED to backlog. Shipped this campaign: 2c branch-with-locals (8f144dfe) + nested-ifs-in-arms (17a2f1e5), non-leaf slice 1 (7711208e), depth-1 re-inline reverted (819cb25a, 21 fuzz divergences) then root-caused + re-landed with the IRInlineExpand reentrancy contract (d4c19919); post-land fuzz-clean. REMAINING scope for the next session: while/for loop bodies; deeper non-leaf (depth budget >1).