← board

Managed temps for an untaken branch are still init'd and finalized

The isolated repro

Two functions, same semantics, same never-taken branch. The only difference is whether the record type is named in the hot body or behind a call.

type TBig = record neg: Boolean; limbs: array of Int64; end;

function SlowSplit(x, y: Int64): Int64;
begin SlowSplit := BTo(BAdd(BFromInt(x), BFromInt(y))); end;

function AddSplit(x, y: Int64): Int64;            { hot body names no TBig }
var r: Int64;
begin
  r := x + y;
  if ((x >= 0) = (y >= 0)) and ((r >= 0) <> (x >= 0)) then
    AddSplit := SlowSplit(x, y)
  else AddSplit := r;
end;

function AddInline(x, y: Int64): Int64;           { same, TBig named inline }
var r: Int64;
begin
  r := x + y;
  if ((x >= 0) = (y >= 0)) and ((r >= 0) <> (x >= 0)) then
    AddInline := BTo(BAdd(BFromInt(x), BFromInt(y)))
  else AddInline := r;
end;

20M calls each, -O2, x86-64, sha 0604b414089f, box load 1.58 → 1.84 across both halves:

form time per call
split — hot body free of TBig 0.294 s 14.7 ns
inline — TBig named in the body 13.060 s 653.0 ns

44x. The branch is never taken in either.

Re-measured at HEAD 3bc3b47ce326, binary faf762981c3c, box load 4.20 -> 4.19

form time per call
split 0.28 s 14.0 ns
inline 13.14 / 13.27 s ~658 ns

47x — still there, three shas and one pin later. Both halves back to back on the same binary; the binary's sha256 was checked against the build's fixedpoint line before the run, because the first attempt at this re-measurement was taken on a throwaway experimental compiler still sitting in the tree and would have been reported as a HEAD number.

The repro, in full, so it cannot drift

It was previously cited as $SCRATCH/uf/tbig.pas — a session-local path that outlives nothing. It needs no RTL beyond a dynamic array. Build with pascal26 -O2 tbig.pas tbig, then run tbig split and tbig inline; each does 20M calls. It takes the arm as ParamStr(1) rather than timing both halves in-process because there is no GetTickCount64 here — time it with time, and run the two halves back to back.

program tbig;
{ Isolated repro of the mechanism: does merely MENTIONING a dynamic-array record
  in a never-taken branch cost the caller on every call? }
type
  TBig = record
    neg:   Boolean;
    limbs: array of Int64;
  end;
var
  i, n: Int64;
  acc: Int64;

function BFromInt(v: Int64): TBig;
begin
  SetLength(Result.limbs, 1);
  Result.limbs[0] := v;
  Result.neg := v < 0;
end;

function BAdd(const a, b: TBig): TBig;
begin
  SetLength(Result.limbs, 1);
  Result.limbs[0] := a.limbs[0] + b.limbs[0];
end;

function BTo(const a: TBig): Int64;
begin
  BTo := a.limbs[0];
end;

{ split form: the hot routine never names TBig }
function SlowSplit(x, y: Int64): Int64;
begin
  SlowSplit := BTo(BAdd(BFromInt(x), BFromInt(y)));
end;

function AddSplit(x, y: Int64): Int64;
var r: Int64;
begin
  r := x + y;
  if ((x >= 0) = (y >= 0)) and ((r >= 0) <> (x >= 0)) then
    AddSplit := SlowSplit(x, y)
  else
    AddSplit := r;
end;

{ inline form: identical semantics, but TBig is mentioned in this body }
function AddInline(x, y: Int64): Int64;
var r: Int64;
begin
  r := x + y;
  if ((x >= 0) = (y >= 0)) and ((r >= 0) <> (x >= 0)) then
    AddInline := BTo(BAdd(BFromInt(x), BFromInt(y)))
  else
    AddInline := r;
end;

begin
  n := 20000000;

  if ParamStr(1) = 'split' then begin
  acc := 0; i := 0;
  while i < n do begin acc := AddSplit(acc, 1); i := i + 1; end;

  writeln('split  -> ', acc); end else begin


  acc := 0; i := 0;
  while i < n do begin acc := AddInline(acc, 1); i := i + 1; end;

  writeln('inline -> ', acc); end;
end.

What the emitted code does

PXXPromoAdd's prologue before 0c3ad8a10, from objdump of a -g build:

sub    $0x160,%rsp                 <- 352-byte frame for a 3-pointer routine
lea    -0x48(%rbp),%rdi ; xor %rax,%rax ; mov $0x10,%rcx ; rep stos %al,(%rdi)
lea    -0x58(%rbp),%rdi ; ...                                    (x21)

Two problems, and they are separable:

  1. 21 managed temps are zeroed unconditionally, for branches that the inline-tier fast path exits before reaching. The epilogue finalizes them again — 30 calls in the first 400 instructions.
  2. A 16-byte clear is emitted as rep stosb (rcx=0x10, stos %al), i.e. byte at a time with microcode startup, where two mov %rax would do. This is worth fixing on its own and is much the smaller change.

Why it matters beyond one file

promocore.pas:796 has carried the diagnosis since the feature landed:

A function that so much as mentions a TBig pays managed prologue/epilogue on EVERY call [...] Measured: with the slow path inline, one PXXPromoAddInt cost ~344 ns; split out, the fast path is a handful of instructions. Keep every hot routine free of TBig.

That is a correct diagnosis and a workaround: it was applied to PXXPromoAddInt/SubInt/MulInt and to nothing else, so eleven sibling entry points kept paying it for the entire life of the feature — PXXPromoCmp, the one every Python < goes through, at 303 ns per call. 0c3ad8a10 hand-split four more. Mod/And/Or/Xor/Shl/Shr/ToStr/ToVariant still have it.

This is the normalise-dont-special-case shape exactly: a rule that every future author must remember, enforced by nothing, silently costing 20-40x when forgotten. The codegen fix removes the rule.

Suggested work

Sink managed-temp init and finalization into the branch that uses them, or equivalently do not materialise a temp in a block the flow does not enter. Fallback if that is too large: emit 16-byte clears as two stores rather than rep stosb, which is independent and helps every managed frame.

Then delete the hand-splits in promocore.pas and re-measure — if the codegen fix is real, AddInline and AddSplit converge and the *Slow routines become unnecessary rather than mandatory.

A second, independent witness for item 2 (2026-08-30, frank-optimize)

Item 2 — the 16-byte clear emitted as rep stosb — turns up again on the NilPy side, reached from the opposite direction (profiling a Variant call, not a managed record). Every NilPy Variant local is zeroed three or four times in the same prologue, by three passes that do not know about each other:

# pass emits, per Variant local
1 PyInitVariantLocals (pyparser.inc:2074) 2x movq $0 — sufficient on its own
2 EmitManagedLocalsZeroInit -> EmitZeroFrameSlot (symtab.inc:11205) 16-byte rep stosb
3 the NilPy watermarked pass, ir_codegen.inc:11239, seeded at ScopeBase 16-byte rep stosb again

plus a fourth on the result slot from IR_ZERO_SYM. Measured on a 1-arg, 3-local def: 8 movq + 9 rep stosb in one prologue, and the counts are identical at -O0, -O1, -O2 and -O3 — no pass removes any of them.

Pass 3 is redundant for every local that existed at prologue time, because ManagedLocalZeroBytes — the table pass 2 uses — already covers tyClass and NilPyUserCode, tyAnsiString and tyVariant. Its watermark starts at Procs[CurProc].ScopeBase when it should start at the SymCount the prologue's zero-init reached; the pass exists for locals minted after the prologue, and that is the only set it should cover. Disabling it entirely (measurement only, reverted) removes exactly one rep stosb per Variant local and nothing else.

Priced, and small: ~4% of the call, under the noise floor of a 3-run A/B at box load 6.5. Recorded here rather than filed as its own ticket because the single change that pays for all of it is this ticket's item 2 plus one watermark seed, not a separate campaign. Whoever fixes item 2 should fix the seed in the same pass; whoever does not, should not bother with the seed alone.

Gate

make compiler/pascal26 (the fixedpoint) plus gate.sh quick. This changes prologue emission for every managed frame in every program, so it is a change that wants Track T's full sweep against its sha before anyone leans on it, and it should not be pinned the same day it lands.

Not to be confused with

0c3ad8a10 fixed the instances in promocore.pas. This ticket is the mechanism, and closing it is what makes the next instance impossible rather than merely absent.

Raised p55 -> p65 (coordinator, 2026-08-30)

On frank-optimize's evidence, and on the count rather than the size of any one win. promocore.pas:796 — "keep every hot routine free of the managed type" — was violated three separate times in one day, and frank-optimize fixed two of them:

frank-optimize's summary is the argument: "that rule is enforced by nothing and is forgotten silently at 20-40x." An isolated repro on this one measured 44x — 20M calls, 0.294s against 13.060s, identical semantics, same never-taken branch.

Three independent violations in a day of a rule nothing enforces makes this a root cause rather than a perf item. It is the ticket that retires the rule by making it unnecessary, which is worth more than any single site it fixes — the tickets-closed-per-change measure root-cause-over-microfix asks for.


RESOLVED — two causes, each about half the cost

Interleaved on one box (load 5.4-6.0), three rounds, inline arm, 20M calls, every binary named because the tree moved between builds:

binary change round 1 2 3
494fd9c21ad3 HEAD, neither fix 10.52 s 9.77 9.93
bda1155557b2 + epilogue all-zero guard 5.10 s 5.15 5.23
416c3640e2f0 + per-arm flush 0.43 s 0.45 0.44

split is 0.27 s on the same binary, so the 35-47x gap closes to ~1.6x.

Independently confirmed, and the two closure figures differ only by load — recorded here so the gap is not later mistaken for a regression. frank-optimize measured the same repro at HEAD d9ceac6a6e75, binary 3383432c4697: split 0.33 s, inline 0.60 s, 1.8x, at box load 10.6 against the 5.4-6.0 above. Same fix, same repro, two evenings' worth of contention apart. A bare "1.6x" and a bare "1.8x" on different days with no load beside them is precisely how a phantom regression gets filed later, so: both are the same result.

The mechanism was also checked by an instrument contention cannot touch. Un-splitting PXXPromoCmp — inlining CmpSlow back in, the shape promocore.pas:796 forbade — now costs 2 rep stosb and 282 instructions across the entire binary, against a prologue that used to zero 21 managed temps and finalize them in 30 calls within its first 400 instructions. That is an absent penalty, not a shrunken one, and it needed no stopwatch. The rule is retired in promocore.pas as of that measurement; the eight *Slow routines are kept as harmless rather than deleted. Both arms still print 20000000.

Cause 1 — the finalize was emitted after the merge, not in the arm

IRFlushPostCallIntf finalizes by-value record argument temps, and AN_SEQ called it once per statement. An if is one statement, so a temp created inside a branch had its finalize emitted into the merge block and ran on every call — including the calls that took the other arm. PXXDBG=a.ir shows it plainly: three default_mem + copy_rec_managed pairs sitting in BB6, after both arms have joined.

AN_IF now takes its own base after the condition is lowered and flushes at the end of each arm. The condition's temps stay with the outer boundary, where they belong — they are live across the branch. An if yields no value, so nothing outside an arm can reference what the arm created.

It cannot leak, and this is the property that makes the change safe rather than merely faster: a path that skips the sunk flush — exit from inside the arm, a goto out — still meets EmitManagedLocalCleanup in the epilogue, which visits every local unconditionally. The epilogue is the backstop.

Cause 2 — an all-zero record still bought a heap lock

EmitManagedLocalCleanup's record arm is an interface walk plus EmitAcquireHeapLock / release / EmitReleaseHeapLock. Every managed field kind releases to a no-op when its slot is zero, so the lock round trip is the only remaining effect for an untouched temp. The repro has nine such temps and paid nine heap-lock round trips per call on a path that allocated nothing — which is the sentence that justifies the change, more than the multiplier does.

Guarded by a run-time all-zero test (or the record's qwords, jz past the walk), bounded to <= 8 qwords so the test cannot exceed the block it guards.

The two halves of item 2 are jointly effective, and a future reader must not delete one. The test is sound on its own — it reads the slot at run time, so a record that owns something is never skipped — but what makes the fast path actually hit is the zero-init contract, whose cheap word stores landed in 991fa5c15. Removing the prologue zeroing would silently disarm the epilogue guard: still correct, no longer fast, and nothing would say so.

Deliberately NOT done: the NilPy watermark seed

frank-optimize asked that whoever fixed item 2 also seed PyZeroedUpTo (ir_codegen.inc:11292) at the prologue's SymCount rather than Procs[CurProc].ScopeBase. Not done, and not forgotten. It needs a new recorded high-water mark (nothing stores the prologue's SymCount today), it lands in a pass whose own comments record three segfaults from getting zero-init coverage wrong, and frank-optimize priced it at ~4% — under the noise floor of its own measurement. Landing an unmeasurable risky change behind a 23x result is how a later regression becomes unattributable. Filed as its own item rather than folded in here.

The rule this retires

promocore.pas:796"keep every hot routine free of TBig" — was a correct diagnosis and a workaround enforced by nothing, forgotten three times in one day. The hand-splits in promocore.pas can now be measured against the unsplit form; if they converge, the *Slow routines are unnecessary rather than mandatory. That is a separate change and a separate measurement.

Log