The variant hidden-dest clear is a full proc call where IR_VAR_STORE uses an inline blob
IRBuildHiddenDest and IRAppendCall both clear the variant scratch slot
before a hidden-dest call by emitting IR_CALL to PXXVarClear with an
IR_ARG holding an IR_LEA — a real call with argument setup.
IR_VAR_STORE and IR_VAR_BOX clear their destination with
IREmitNode(IRA[node]); EmitVariantClear; — the address in rax and the
VariantClearBlobAddr blob, which preserves rax (defs.inc). No call
frame, no argument node.
The two do the same thing by different means, and the expensive one is on the hot path: every NilPy method call returns a Variant, so every method call pays it.
MEASURED COST, 2026-09-15, interleaved min-of-5 against pascal26_BOTHFIX
| program | old | new | |
|---|---|---|---|
| 6M bare method calls, no other work | 1.96s | 2.23s | +14% |
| method-heavy with allocation per iteration | 0.84s | 0.91s | +8% |
The clear itself is correct and is NOT the thing to remove — it fixes an
unbounded leak (bug-n-a-method-result-that-rides-the-variant-carrier-leaks-a-reference-per-call,
closed 2026-09-15). This ticket is only about how it is spelled.
The worst case in the table is the honest one to quote for a dispatch-bound program, and the 8% row is closer to what real code sees.
WHAT IT WOULD TAKE, AND WHY IT IS NOT A ONE-LINER
There is no IR kind for "release the variant payload at this address". The
store arms reach EmitVariantClear from inside their own codegen arm, where
the address is already in the register. A shared spelling needs either a new
IR kind — and IRCallDest is consumed per backend, so every backend needs the
arm — or the clear folded into the existing hidden-dest emission each backend
already does.
Do not start this by changing one backend. gate.sh quick's backend-parity
row exists to catch exactly that, and the leak fix it would be optimising was
written in the IR precisely so all six inherit it.
WHAT WOULD RETIRE THIS TICKET WITHOUT WORK
A measurement showing the clear is cheap relative to the dispatch it rides on
in a REAL program rather than a microbenchmark. Both rows above are synthetic,
and neither says what a demo pays. lekkerzeilen's own leak instrument reports
CPU percentages per leg and would answer it as a side effect.
2026-09-22 (frankh-c0) — THE PREMISE, RE-READ AT HEAD. The title is wrong in the direction that inflates the prize.
Checked before planning a fix, because the cost rows above are from 2026-09-15
against a binary (pascal26_BOTHFIX) that no longer exists.
"the store arm uses an inline blob" IS NOT WHAT THE CODE DOES. The blob is
out of line and reached by a call, and it exists precisely because the
inline spelling was too expensive: EmitVariantClear used to splice its
~96-byte body at every site, which on a ZERO-BYTE .npy is 9,859 sites,
~946 KB, ~42% of the whole 2.23 MB output — and 138,026 of the compiler's
139,657 AsmTextLine calls, ~99% of the text assembler's traffic
(EmitVariantBlobs header, ir_codegen.inc).
So this ticket is not call-versus-inline. Both paths call. The real difference is narrower:
| path | what it emits |
|---|---|
IRBuildHiddenDest / IRAppendCall |
IR_CALL(PXXVarClear, IR_ARG(IR_LEA(scratch))) — the portable Pascal proc, with an argument node and a frame |
IR_VAR_STORE / IR_VAR_BOX |
address already in rax, call VariantClearBlobAddr — no arg node, no frame, and the blob preserves rax |
THE SHARPER STATEMENT, WHICH IS ALSO A SMALLER FIX THAN THIS TICKET CLAIMS:
on x86-64 the hidden-dest path takes the portable route while the store path
takes the target's own fast one. builtinheap.pas says it outright —
"This is the PORTABLE half of a pair: x86-64 emits the same test inline
(EmitVariantClear) and every other target calls here."
Census of every backend at HEAD, VariantClearBlobAddr / 'PXXVarClear' /
EmitVariantClear:
ir_codegen.inc (x86-64) 2 5 13
ir_codegen_aarch64.inc 0 0 7
ir_codegen386.inc 0 1 0
ir_codegen_arm32.inc 0 1 0
ir_codegen_riscv32.inc 0 1 0
ir_codegen_xtensa.inc 0 1 0
ir_codegen_wasm32.inc 0 3 0
Only x86-64 has a divergent fast spelling. Four backends already call the portable proc from BOTH paths and are uniform; aarch64 has its own helper. So "every backend needs the arm" is true of the new-IR-kind approach and not of the asymmetry this ticket actually names — that one is x86-64-local, where two spellings already coexist, and the semantics are identical either way.
The saving is therefore argument marshalling plus a Pascal frame per hidden-dest variant call, not a whole call. Whatever the retirement measurement comes back as, the prize is smaller than the title implies, and the title should be fixed whether or not the ticket survives.
Status: the timing half is DEFERRED, deliberately
Load average was 14.35 when this was written. lekkerzeilen-7a measured the same binary, same scene, same pin at 530 ms on a quiet box and 624 ms while peers were merely COMPILING — no second demo, no GPU contention — and the CPython arm moved 66% where pxx moved 18%, so the ratio went 14.0x to 19.7x. Contention is differential, so a ratio is unbounded until both arms are measured in one interleaved session on a quiet box. No timing row is worth taking here until that holds.
Asked 7a for one row instead (dispatch as a share of a roofs frame). Its
asymmetry argument is why that one run can settle this: the flag
PyModuleHasComputedGetattr is TRUE today, so every method pays boxing — if
dispatch is small in the EXPENSIVE regime it is small in the cheap one too, so
the measurement can retire this ticket but cannot confirm it.
2026-09-22 (frankh-c0) — the body's NilPy claim, RE-DERIVED at HEAD, and the denominator it hides
Line 23 says "every NilPy method call returns a Variant, so every method call pays it." My re-read section above corrected the blob claim and the six-arm claim and never touched this one, so it was still inherited when frankz-e5 relayed it to frankb-8e as a re-measured fact of mine. Measured now.
PXXDBG=a.ir:driver on a NilPy class with two k.m(t) call sites, at HEAD:
16: lea a=557 [sym=] <- unnamed compiler-minted carrier
17: arg a=16
18: call a=64 b=17 <- the clear
19: lea a=557 [sym=]
20: virtual_call ival=4 <- hidden dest
21: var_store a=4 b=20
34: lea a=558 [sym=] <- a DIFFERENT unnamed carrier
36: call a=64 b=35
38: virtual_call ival=4
The claim is TRUE and is now re-derived rather than inherited. Each method
call site mints an unnamed carrier and emits a clear call on it before the
hidden-dest call; the lea/arg/call then lea/virtual_call shape is what
ir_codegen.inc documents for this path, and the carriers are [sym=].
THE CARRIER IS PER CALL SITE, NOT PER CALL — and that is two different denominators
557 and 558 are distinct symbols for two static sites. A loop calling one method a million times reuses one slot. So:
- the dynamic cost (what this ticket is about) is per CALL;
- the swept population (what a release-sweep analysis counts) is per SITE.
Anything that sizes a prize by multiplying a per-slot win by call frequency is mixing them, which is the umbrella's own do-not-multiply warning reached through this subsystem rather than its own. Recorded here because the two numbers are both about "variant carrier slots" and read as interchangeable.
What is measured here and what is NOT
Measured: where the carrier is minted, that it is unnamed, that it is cleared
by a call before the hidden-dest call, that it is distinct per site, and that
it is var_stored into the user local afterwards.
NOT measured: whether the carrier still owns a reference after that store —
whether var_store of a variant retains or moves. That is the fact that
decides whether a release on these slots can be SKIPPED rather than merely made
cheaper, it is perf-a's question and not this ticket's, and nothing here may
be read as answering it.
2026-09-22 (frankh-c0) — THE TWO PRIZES OVERLAP RATHER THAN ADD, and neither ticket could notice alone
perf-a-every-return-releases-every-managed-local and this ticket are two ends
of the same slots, and their savings are not additive.
- This ticket changes how the clear is SPELLED.
IRBuildHiddenDestcalls the portable Pascal proc with an argument node and a frame;IR_VAR_STOREcalls the target's own blob with neither. Same semantics, same releases, same number of them. The saving is marshalling plus a frame, x86-64 only. - perf-a asks whether a release must happen AT ALL.
So if perf-a's half ever lands, the slots it skips stop paying this ticket's clear too. Whoever measures second must subtract rather than add. Neither ticket can see this from inside itself, which is why it is written down: each is correct about its own mechanism and the interaction lives only in the pair.
And this ticket does NOT depend on perf-a's open question. frankb-8e has
parked that half behind an ownership INVARIANT in the IR rather than a fifth
IRNodeOwns... predicate (0fe34c1e3; the retraction at
ir_codegen.inc:5577 records four wrong attempts, one of which segfaults a
ten-line program). That is an ownership question. Nothing here asks it — a
spelling change needs to know nothing about whether a slot owns its referent —
so "no fifth predicate" must not be read as blocking this.
The fixture note that outlives both tickets
The three-fixture gate on this family is closed at both ends and each member
has a verified must-fail case in the Makefile, not an assumed one:
RECVLIVE asserts VALUES and was verified to SEGFAULT on two broken builds;
VARCARRY and GETTERLIVE read BYTES and were verified to FAIL at
cfee5d6255237332. The byte pair cannot see a premature free and says so; that
is recvlive's job. Any future attempt in this area passes all three or it is
not measured.
GETTERLIVE keeps a k-sweep PAIR (ksweep_k1/ksweep_k8) on purpose: the leak
is k-1 per call site per scope, so a fixture calling once per scope cannot see
it at all and k1 alone is a guard that cannot fail. Do not trim it for
speed. That is the interesting-element-position rule with the axis being calls
per scope rather than ordering.
THE ROOFS ROW LANDED PARTIAL, 2026-09-22 — and it answers the retirement condition in the NEGATIVE while keeping the ticket alive
lekkerzeilen-7a, one arm, one round, before the owner paused GUI testing.
Page 894144c in the lekkerzeilen repo,
devdocs/perf/ROOFS-DECOMPOSITION-2026-09-22-PARTIAL.md; raw at
/data/lz-perf/roofs-decomp-2026-09-22/. Do not act on this row yet — that is
7a's own instruction and the reasons below are good ones.
The number, with the denominator named, because there are three
10 of 89 main-thread samples (11.2%) fall in 0x400560-0x4005f5, an unnamed
block with no symbol in the .map — six of them on one instruction,
movq $0x0,0x8(%rax), the payload clear. No symbol-level profile could ever
have named this block, which is why the ticket had only synthetic evidence for
seven days.
I re-bucketed the raw independently rather than taking the table, got 10 and 6 exactly, and got a different SHARE:
| denominator | share | what it is |
|---|---|---|
| 89 | 11.2% | main thread only — the right one |
| 256 | 3.9% | mine: every PC below 0x1000000 |
| 1246 | 0.8% | every sample |
Mine was wrong and the way it was wrong is this ticket's own house error.
The run has 15 threads; the 14 non-main ones sit at FIVE fixed addresses for
every sample — parked for the whole run — and two of those addresses are in
the demo's own text (0x5cf482, 0x5cf369). So "PC is inside the binary" is
an honest filter that enumerates the wrong population: it is a denominator
padded with sleeping threads, and the padding factor is however many threads SDL
happened to spawn. All 10 clear-block samples and all 6 payload-clear samples
are thread 1; none are on a parked thread.
Dispatch is an UPPER BOUND, not a measurement — 7a's correction, and it matters
I was going to write "dispatch is cold". That overstates what 89 draws can say.
PyHostCall is 1 of 89; PyFindMethCI, PyFieldGet/Set and the closure arm are
zero of 89 — and by the rule of three, zero in 89 is consistent with a true
share up to ~3.4%, which is several times the row sitting beside it. So the
retirement condition is answered "not supported by this arm", not "refuted".
pcdispatch.py now prints that ceiling instead of a bare 0.00%.
Why it stays parked rather than retired or acted on
- The pre-registered repeatability check has NO DATA.
v416/prof-r2.rawis 988 lines containing zeroPCrecords — the stop landed after the round began and before sampling started. 7a declared a two-rounds-per-arm falsifier in advance; it has not been run, on either arm. That is pre-registration working: the absence is visible instead of skipped. - A profile share is not a lever until an A/B says it is. 7a's own last miss on this exact question was 12.3x per call against 3.3% in situ.
- Skid is NOT the reason to withhold it — 7a withdrew that and it was right
to. Its precedent (16.5% -> 4.9% at a lock
xchg) is PMU sampling, where skid is a hardware artefact. This is gdb SIGINT sampling with an exact RIP. Signal-delivery bias toward landing after a store retires is plausible and unmeasured, and it is a different mechanism. The reason to withhold 11.2% is the population, not skid. - Cross-ARM is available where cross-ROUND is not: 13/433 on v416 against 10/256 on v418 under my (wrong) denominator, same order of magnitude. That varies the wrong thing to be a repeatability check.
What would now retire this ticket
An in-situ A/B of the flip, not a microbenchmark, once GUI testing reopens — which only the owner can lift. At 11.2% of main-thread time it is worth one.
RETRACTION, 2026-09-22 — "41% of interrupts discarded under load" is WRONG, and it weakens 11.2% further rather than less
I put that sentence in commit 8b7b556dc's message and in a peer message.
Neither can be edited, so this is the forward correction. lekkerzeilen-7a
gave it to me as measured, retracted it within the hour, and I verified the
retraction against the raw rather than taking that on trust either:
v418/prof-r1.raw:11467 [Inferior 1 (process 604839) exited normally]
blocks 1- 89 : every one produced PC records
blocks 90-150 : none, and NO live block after the first dead one
v416/prof-r1.raw:18648 [Inferior 1 ... killed] <- still alive at 150
A hard cliff with zero interleaving. Contention degrades; this stopped. Nothing was discarded and nothing was under load — the demo exited and gdb kept answering.
The real harness bug is different and still real: gdbsample2.sh tests
liveness with kill -0 $GDBPID — gdb's pid, not the inferior's. gdb stayed
up, so the loop ran to 150, interrupting a dead process and writing a
=== SAMPLE header each time. The harness reported 150 and had 89.
AND THE CONSEQUENCE FOR THIS TICKET'S NUMBER IS THE OPPOSITE OF REASSURING. The 89 samples are a contiguous PREFIX covering the first ~59% of the intended window, not a random subsample. A share computed off it is safe only if the workload is stationary across the run, which is untested. So 11.2% is weaker than the population correction alone made it — and the earlier attempt-count-denominator worry is more warranted, not less: any run whose inferior died early reported its full requested count, deflating every share and inflating none.
Why the v418 demo exited cleanly at ~213 s while v416 ran past 307 s is UNKNOWN. One run each. Not named as a pin effect, a scripted duration, or anything else.
And the methodological half is mine, not 7a's. I re-bucketed 7a's raw when it was a TABLE and took its harness claim on trust because it arrived as a mechanism with numbers attached — three minutes after I had praised 7a for naming exactly this failure ("I reached for a stored lesson because it fit the shape, not because it fit the mechanism"). A causal story with a count in it reads as already-measured in a way a bare table does not, which is precisely backwards: the table is the part I can check cheaply, and the mechanism is the part I cannot. The discriminator here was one pass over a file I already had open.