Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
Spectralnorm inline-callee cleanup and generated-code audit
Status: accepted on the experiment branch after composed artifacts, strict A/B, the full suite, and deterministic fuzz verification.
Question and corrected hypothesis
The initial question was whether sn_a(i, j) still paid ordinary call/activation
overhead in the 400-by-400 spectralnorm inner loops. It does not. On baseline
24c1af69, both sn_row_av and sn_row_atv contain sn_a’s scalar body after
the normal inline pass. The benchmark therefore executes no dynamic sn_a
calls. It still makes 16,000 row calls, but the rejected A1 experiment had
already shown that flattening those calls was neutral for native while growing
IL 16.6%.
Inlining did leave one piece of the consumed call behind. A sibling function is
loaded through a level-one get before its call. The splice removed the call but
not this reaching definition, so every inner iteration still walked the outer
frame and loaded the sn_a closure into a slot which the inlined body overwrote
without reading. There are 6.4 million such iterations:
10 iterations * 2 AtAv calls * 2 row transforms * 400 rows * 400 columns
This is the same dead-value class as M17’s cleanup after self-tail-call elimination, but it occurs after either inlining round.
Implemented compiler cleanup
callresolve.drop_dead_gets computes the slots read by the final instruction
array and replaces a get with a compacted _nop_ marker only when all of the
following hold:
- its destination has no read anywhere in the function;
- the destination is not read or written by a nested closure;
- the containing function actually received an inline splice.
The use test is deliberately whole-function rather than local. Physical slot
reuse can therefore keep an unrelated dead get, but cannot make the cleanup
delete a value which is read on another block. get is a pure lexical-frame
lookup; the transform removes no allocation, disruption, call, or actor charge
point.
Cleanup runs only after both inline rounds. Running it after round one made a row helper appear upvalue-free and allowed round two to flatten more of the call tree, accidentally recreating the rejected A1 code-growth policy. A regression requires the second round to remain unchanged. Further regressions retain a normal locally used lexical read and a value whose only use is in a nested closure.
Artifact evidence
Before composing with loop phis, optimized spectralnorm mcode lost exactly the
two row-loop get instructions. This in turn made the top-level sn_a binding
dead, so ordinary cleanup also removed its closure construction and compressed
the main frame from 19 to 18 slots. The unused sn_a function body is still
emitted; whole-function reachability is a separate possible static-size pass.
| artifact | 24c1af69 | cleanup | change |
|---|---|---|---|
| QBE IL | 184,086 B | 183,252 B | -834 B (-0.45%) |
| ARM64 assembly | 121,998 B | 121,527 B | -471 B (-0.39%) |
| hot outer-frame lookups | 6,400,000 | 0 | -100% |
| main frame slots | 19 | 18 | -1 slot |
The same exact delta survives composition with the structured loop-phi root
dc34dbf6:
| artifact | composed root | cleanup | change |
|---|---|---|---|
| QBE IL | 177,333 B | 176,499 B | -834 B (-0.47%) |
| ARM64 assembly | 118,266 B | 117,795 B | -471 B (-0.40%) |
optimized get operations | 6 | 4 | -2 |
| main frame slots | 19 | 18 | -1 slot |
Both composed artifacts retain the same raw-double accumulator and raw-integer index lanes selected by the loop-phi proof.
The target-memory tradeoff is strictly favorable: one fewer main-frame
PitValue and no sn_a closure object. There is no side table or runtime text.
Compiler-only analysis creates a short-lived used-slot map; compile time and
compiler memory are not performance goals in this campaign.
Runtime evidence
Every focused run used three warmups and eleven measured samples. Alternating the exact composed root and cleanup worktrees produced these medians:
| arm | Mach ms | native ms | result |
|---|---|---|---|
| root C1 | 203.143 | 79.477 | 1,623,647,009 |
| cleanup T1 | 182.012 | 77.032 | 1,623,647,009 |
| root C2 | 202.183 | 78.856 | 1,623,647,009 |
| cleanup T2 | 177.650 | 78.881 | 1,623,647,009 |
| cleanup T3 | 179.095 | 75.982 | 1,623,647,009 |
| root C3 | 200.558 | 76.885 | 1,623,647,009 |
The median of run medians is 202.183 -> 179.095 ms Mach (-11.4%) and 78.856 -> 77.032 ms native (-2.3%). Every nearest bracket decisively favors Mach. Native ranges from neutral to about 3% faster as the machine changes thermal state; even the neutral reading accompanies strictly smaller code and target state.
call_hot is a nonselected inline/call control. Root and cleanup both emitted
39,326 B IL, 33,247 B assembly, and the identical native dylib hash
8320874c9cfc8f9c885ce2acfe8ec46de5279a69a6f6db2755da24a540e0ba7c.
One timing run per arm was sufficient because the executable is byte-identical.
Validation:
- focused compiler tests: 113/113;
- default suite: 1,918/1,918;
- deterministic fuzz: 3,733/3,733 checks over 500 programs, seed 20260713;
- spectralnorm Mach/native checksum agreement in all A/B runs.
The child-capture negative is a direct helper-level fixture. A real function
which creates a nested closure is currently not an inline host, so trying to
force that source shape tested an inliner eligibility rule rather than this
cleanup. The final fixture independently retains a locally used get, a
destination visible to a child closure, and deletes only a third wholly dead
get.
Remaining generated numeric cost after loop phis
The structured loop-phi change at 9324addc keeps the row accumulator as a raw
double and the loop index as a raw integer. It removes four of the previous six
hot qbe_new_float64 calls per inner iteration. Two remain, or 12.8 million
calls over the benchmark:
- The inlined
sn_adivision is materialized at its generated return/move/ continuation labels. - Multiplication by the raw array element is materialized before its generated success join and the raw accumulator add.
These are not heap allocations: nan64 encodes a short float or immediate int. They are nevertheless repeated call/branch/tag work in the hottest path.
The next independent experiment should carry one transient raw double chain through compiler-private single-predecessor labels:
raw divide -> inline return move -> raw multiply -> raw accumulator add
The proof must reject handlers, captured slots, externally entered labels,
multi-predecessor joins, calls/invokes/suspension seams, and any non-double-safe
use. Each arithmetic result must still pass the existing canonical_double
boundary so NaN, infinity, signed zero, and nan64 range behavior remain exact.
The value is a QBE temporary only: it adds no Pit frame slot, heap object,
metadata, or suspended actor state. It should be implemented and measured as a
separate arm so its result is not attributed to the dead-callee cleanup.
Tooling finding
aot_bench/dumpil.ce now accepts DUMP_MODE=asm, using the same QBE backend as
native compilation. This made exact IL/assembly audit possible without editing
the linker. The mode is a separate commit.
Two concurrent PIT_INPROCESS=1 dump commands still collided through the same
worktree shop and left the daemon socket temporarily unavailable. Sequential
in-process dumps after pit down worked. Until the CLI provides a truly private
in-process shop, compiler artifact commands in one worktree must be serialized.
Source: plans/archive/perf-2026-07/perf-spectral-inline-cleanup.md