Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.

Native fannkuch loop-pressure experiment

Date: 2026-07-13

Baseline: 7fca24a4 (the exact parent performance-campaign tip when this work started). The branch also contains the independently validated typed CLI reply deadline change as 70c4e0b1; it does not affect generated language code.

Result

Keep one narrow native lowering improvement. When QBE’s existing structured loop proof finds at most six loop-carried values, behavior is unchanged. When a function has more than six candidates, it no longer discards every opportunity: it keeps at most six values that are both:

  • exact int according to the final point-specific compiler facts, and
  • proven _int_bounded, so the value remains an int32 lane.

Raw doubles and tagged booleans are deliberately not retained in this overflow case. There is no slot-number, benchmark-name, source-name, target-name, or PGO special case. No semantic mcode, runtime ABI, frame layout, GC layout, or native suspension convention changed.

On fannkuch, the final optimized function has eight candidate physical slots:

SlotLane
5bounded raw int
8, 9, 10, 11, 13, 18raw double
19tagged bool

The accepted rule selects only slot 5. Across nine fresh-process, median-of-11 native measurements, the control aggregate median was 107.330 ms and the candidate aggregate median was 106.135 ms: a 1.11% improvement. The candidate won 8 of 9 like-for-like directional pairs. The best paired observation was 105.972 -> 103.096 ms (-2.71%), but the defensible claim is about 1%, not 2.7%. Every run returned the exact expected value 3000008629.

Generated code also shrank:

ArtifactControlCandidateChange
QBE IL bytes167,625167,229-396
assembly bytes114,287114,043-244

Why the broader approaches were rejected

The opt-in diagnostic constructs final-index natural loops and reports candidate types, loop nesting, loop-membership overlap, and an actual backwards CFG liveness fixed point. Fannkuch’s eight-candidate union has a peak CFG liveness of only six, which initially looked as though all eight candidates might fit. That inference was wrong at the backend boundary.

Selecting all eight reduced QBE IL but substantially regressed native code:

PolicyNative medianVersus 105.972 ms controlIL bytesAssembly bytes
Reject all eight (control)105.972 ms-167,625114,287
Select all eight127.451 ms+20.3%161,653121,943
Select bounded raw int only103.096 ms-2.7% in this pair167,229114,043

The all-eight arm made IL 3.6% smaller but assembly 6.7% larger. Thus physical slot liveness is not enough to predict QBE/host register pressure, conversion traffic, and spill cost.

Every one-candidate extension on top of the integer was also slower and was deleted:

Added carried valueNative median
tagged bool slot 19106.124 ms
raw double slot 11107.316 ms
raw double slot 13128.533 ms
raw double slot 18110.473 ms
raw double slot 8110.605 ms
raw double slot 9110.182 ms
raw double slot 10115.656 ms

This is why the final rule uses a semantic lane class rather than a pressure heuristic or a particular physical slot. All experiment selectors were removed. The expensive liveness fixed point and detailed records are strictly gated by _loop_phi_diagnostics; normal compilation does not execute them and does not use them to select code.

Benchmark-corpus audit

aot_bench/dumpil.ce <file> loopphis was run over every source in aot_bench/shootout, aot_bench/suite, and aot_bench/attr (43 sources). Fannkuch was the only source whose candidate union exceeded six, so it is the only existing benchmark whose generated code changes.

Notable unchanged selections:

  • spectralnorm: two candidates in each hot helper and two in main;
  • binarytrees: one candidate in main;
  • array_read, closure, and the call-attribute fixtures: two candidates;
  • record_field / d_field: three candidates;
  • mandelbrot, nbody, call_hot, loop_nested, fib, and the integer-overflow suite fixtures: no eligible structured-loop candidates in this lowering mode.

Exact candidate/control artifact comparisons were byte-identical for unrelated performance gates:

SourceQBE IL bytesAssembly bytes
spectralnorm176,497117,795
loop_nested27,24625,574
mandelbrot63,61943,227
call_hot39,32433,247

Timing differences on those byte-identical artifacts are noise and are not credited to this change.

Suspension, GC, and correctness

native_suspend_loop_phi_overflow.ce is a real-source oracle whose final IR has nine carried candidates: eight raw doubles and one bounded raw integer. The overflow rule selects only the integer. Both Mach and native return 2125000. The native suspension runner, with a reduction budget of 64 and PIT_SUSP_TEST_GC=1, returned the same value after 48 suspensions and 48 moving collections. This exercises the required canonical tagged frame spill across pause/resume; the actor stack remains freezable.

Validation commands and results:

./pit test tests/compile.cm
  115 passed, 0 failed

./pit test
  1918 passed, 0 failed

./pit fuzz 250 --seed 20260713
  1891 differential checks, 0 failed

./pit fuzz 250 --seed 20260963
  1842 differential checks, 0 failed

total fuzz: 3733 checks, 0 failed

PIT_INPROCESS=1 PERF_LOC=aot_bench/regress/native_suspend_loop_phi_overflow.ce \
  ./pit aot_bench/perf_one.ce run
  Mach result 2125000; native result 2125000; match=true

PIT_SUSP_TEST_GC=1 native_suspension_test <dylib> 2125000 64
  value=2125000; exception=0; suspensions=48; max_depth=1; gc=48

The fuzz campaign was split only because the current terminal-owned one-shot target is auto-reaped at 60 seconds. One 500-program command reached 475/500 and 3,545 passing checks at monotonic 58.961 seconds before losing its terminal target. The two 250-program commands use the same deterministic contiguous campaign convention already used by the performance suite, finish in about 31 seconds each, and together cover the required 3,733 checks.

Memory and code-size tradeoff

Target/runtime memory cost is zero:

  • no additional Pit frame slots;
  • no persistent side tables;
  • no runtime heap objects;
  • no code metadata;
  • no altered GC roots or suspension records.

The accepted fannkuch output is 244 assembly bytes smaller. The diagnostic has compiler-only temporary arrays/records and a backwards-liveness fixed point, but it exists only when a caller explicitly sets _loop_phi_diagnostics. Normal target generation pays only the small bounded-integer filtering loop when the already-computed candidate union exceeds six.

Tooling changes and friction found

aot_bench/dumpil.ce was converted from environment-variable/bare-script use to the signed-off typed request/reply CLI:

./pit aot_bench/dumpil.ce FILE raw|mcode|il|asm|loopphis

It now has an explicit 600-second reply deadline, returns success/failure, and can print the opt-in loop diagnostic. aot_bench/perf_one.ce now advertises the same 600-second reply deadline. These changes do not affect compiled programs.

The lifecycle investigation found a separate tooling defect: terminal-owned one-shot targets still receive the default 60-second auto-reap timer even when their typed help declares a longer reply timeout. Cold compiler realization can therefore continue in the shop/builder after the test or fuzz target and its terminal have been stopped. Warm reruns pass. The parent campaign separately captured the exact 60.0239-second reap and is fixing the terminal start contract; that lifecycle fix is intentionally not part of this compiler experiment.

Recommendation

Keep the bounded-raw-int overflow fallback. It is a small, target-independent compiler lowering improvement with no runtime memory cost, smaller code, exact suspension/GC coverage, and a repeatable ~1% fannkuch-native gain. Do not keep the all-candidate, raw-double, bool, or CFG-pressure selectors: all were measured and rejected.

Source: plans/archive/perf-2026-07/perf-native-fannkuch-loop-pressure.md