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:
| Build | fib native ms | closure native ms |
|---|---|---|
| suspension implementation before local push | 32.143 | 32.532 |
| local push, first arm | 30.819 | 31.327 |
| local push, repeat | 29.874 | 30.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:
| Benchmark | unified candidate ms | local-push ms | change |
|---|---|---|---|
call_hot | 7.053 | 7.028 | -0.4% |
mandelbrot | 28.236 | 28.189 | -0.2% |
fannkuch | 203.812 | 203.283 | -0.3% |
spectralnorm | 173.656 | 173.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:
| Path | Budget | Result | Suspensions | Max depth | Forced GCs |
|---|---|---|---|---|---|
direct recursive fib(10) | 4 | 55 | 44 | 10 | 44 |
recursive apply, depth 80 | 8 | 80 | 10 | 81 | 12 |
| 2M stack-safe dynamic closure calls | 4,096 | 245,453 | 488 | 2 | 488 |
| recursive stack limit 5 | 2 | original stack-overflow disruption | 2 | 5 | 2 |
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