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.

artifact24c1af69cleanupchange
QBE IL184,086 B183,252 B-834 B (-0.45%)
ARM64 assembly121,998 B121,527 B-471 B (-0.39%)
hot outer-frame lookups6,400,0000-100%
main frame slots1918-1 slot

The same exact delta survives composition with the structured loop-phi root dc34dbf6:

artifactcomposed rootcleanupchange
QBE IL177,333 B176,499 B-834 B (-0.47%)
ARM64 assembly118,266 B117,795 B-471 B (-0.40%)
optimized get operations64-2
main frame slots1918-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:

armMach msnative msresult
root C1203.14379.4771,623,647,009
cleanup T1182.01277.0321,623,647,009
root C2202.18378.8561,623,647,009
cleanup T2177.65078.8811,623,647,009
cleanup T3179.09575.9821,623,647,009
root C3200.55876.8851,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:

  1. The inlined sn_a division is materialized at its generated return/move/ continuation labels.
  2. 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