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

Native suspension hot-path follow-up

Status: completed isolated experiment, based on 836a3151.

The unified native-suspension implementation made AOT execution obey the actor freeze rule, but its original measurements showed closure at +3.6% and fib at +12.3% versus the previously unfreezable native path. This follow-up profiled those two outliers and keeps one runtime-only improvement.

Finding

The production call charge is real but was not the main avoidable cost. A correctness-disabled attribution build removed the reduction decrement from direct recursion and moved fib from 32.125 ms to 31.652 ms (about 1.5%). That arm cannot freeze call-only recursion and was deleted.

The larger avoidable cost was duplicated activation bookkeeping. Both fast call paths already had a NativeRTState *, had checked the stack limit, and had allocated and initialized the callee frame. They then called the general pit_rt_push_existing_frame, which rediscovered native state, repeated the stack-limit check, and entered another out-of-line helper before activating the known root slot. This occurred in:

  • pit_rt_self_prep, on every proven direct self-recursive call; and
  • the stack-safe arm of pit_rt_call_dynamic, on every eligible dynamic native closure call.

The accepted change performs the already-proven push locally: ensure the root chunk has capacity, increment ctx->stack_depth, activate the stable root, write the frame, and advance aot_depth. Slow root-chunk growth, stack-limit failure, allocation failure, reduction charging, suspension, disruption, and moving-GC behavior are unchanged. The shortcut adds no unchecked invariant: each caller still performs the same stack-limit test immediately before frame allocation, and no intervening operation changes stack depth.

Performance

Focused native measurements use the existing 3-warmup/11-sample median harness. The clean toggle sequence after caches were warm was:

Buildfib native msclosure native ms
suspension implementation before local push32.14332.532
local push, first arm30.81931.327
local push, repeat29.87430.927

The two accepted fib medians bracket normal machine drift but are both below the immediately adjacent original build; their median is 30.346 ms, about 5.6% faster than 32.143 ms. The two closure medians average 31.127 ms, about 4.3% faster than 32.532 ms. This recovers the unified experiment’s measured closure tax (its earlier pre-suspension baseline was 31.154 ms) and takes recursive fib below the 30.962 ms pre-suspension median reproduced during this audit. After the final OOM guard and clean runtime restart, confirmation medians were 29.813 ms for fib and 30.608 ms for closure.

The change is entirely in the shared runtime helper. Benchmark QBE IL and assembly are byte-for-byte unchanged: fib remains 61,010/47,654 bytes and closure remains 53,994/43,526 bytes. Shared host __text grows from 1,033,776 to 1,034,136 bytes, exactly 360 bytes (+0.035%); the mapped __TEXT segment is unchanged at 1,212,416 bytes. No per-program code, frame word, continuation, heap object, actor state, or allocation was added. The 360 shared bytes are a good trade for removing a general helper transition from millions of calls.

The non-target macro rows remained neutral against the unified experiment’s candidate medians:

Benchmarkunified candidate mslocal-push mschange
call_hot7.0537.028-0.4%
mandelbrot28.23628.189-0.2%
fannkuch203.812203.283-0.3%
spectralnorm173.656173.608-0.03%

These small differences are within ordinary run noise; the claim is targeted call-path recovery with no detected macro regression, not a macro speedup.

The original +12.3% fib number was not reproducible as a stable current tax. On this checkout the first same-machine reproduction was 30.962 ms before suspension and 32.126 ms with it (+3.8%). Thermal/run-order movement was visible afterward. The optimization remains justified by its own repeated A/B/A result, not by claiming the old 12.3% as a current baseline.

Rejected arms

  • Disable call charging: about 1.5% faster fib, but call-only recursion could hog an actor forever. Deleted.
  • Pass the resume segment as a third generated-function argument: reduced fib assembly by 92 bytes, but an interleaved 31-pair comparison was 33.360 ms for the original and 33.335 ms for the candidate (no measurable speedup). It also changed the internal compiled-function ABI. Deleted.
  • Tag fresh frame pointers to bypass resume decode: grew fib assembly from 47,654 to 48,162 bytes and did not improve timing. Deleted.
  • Move cold control blocks to the end of the QBE function: QBE produced the same assembly byte count and no win. Deleted.

Correctness and memory gates

The accepted build passed these focused forced-suspension checks:

PathBudgetResultSuspensionsMax depthForced GCs
direct recursive fib(10)455441044
recursive apply, depth 80880108112
2M stack-safe dynamic closure calls4,096245,4534882488
recursive stack limit 52original stack-overflow disruption252

The depth-80 case crosses the 32-entry root-chunk boundary. Forced collection after every suspension verifies that activated roots continue to be rewritten by the copying collector. The stack-limit case verifies that removing the second redundant check did not relax the effective limit.

Final gates on the accepted source/runtime pair:

  • CCACHE_DISABLE=1 make: passed;
  • tests/compile.cm: 73/73;
  • isolated realized vm_suite: 1,086/1,086;
  • warm full suite with the checked-in timeout: 1,918/1,918; and
  • ./pit fuzz 500 --seed 20260713: 3,733/3,733.

Tooling friction

ccache cannot create files in the user cache from the isolated sandbox, so repeatable C rebuilds require CCACHE_DISABLE=1 make. Also, the focused runner re-realizes compiler packages after each .cm emitter edit even when the edit is later rejected; runtime-only toggles avoid that delay. A small first-class two-dylib interleaving mode in perf_one.ce would make future generated-code A/B tests less error-prone, but the temporary local harness used here was deleted rather than added to this candidate.

The test runner also charges cold realization against its 30-second per-test deadline. The first full run had only vm_suite time out while its one large module was still compiling, and immediate isolated attempts could repeat that until realization completed. A validation-only 120-second timeout allowed the same fixture to realize and pass 1,086/1,086; that edit was reverted, after which the unchanged 30-second warm full suite passed 1,918/1,918. Setup/build time should be separated from test execution rather than requiring this manual warm-up.

Source: plans/archive/perf-2026-07/perf-native-suspension-tax.md