Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
Native suspension experiment
Status: research-only; do not merge.
Superseded by perf-native-suspension-unified.md. This file records the rejected
scalar-state prototype and its measurements; do not use its boundary claims as
the state of the current experiment.
Baseline: 1ea3d92a.
This experiment asks the narrow question left open by the AOT call work: can
native Pit code stop at a fair scheduling boundary, discard the host C stack,
survive a moving collection, and later continue without abandoning the current
fast native frame representation? The answer is yes for an all-native frame
chain and for a Mach caller waiting on native success, but not yet for an
arbitrarily nested Mach/native chain. The scalar suspended-Mach state in
PitContext is the architectural blocker.
The implementation is deliberately not proposed for integration. It proves the vertical slice, identifies its steady-state and code-memory costs, and specifies the continuation stack needed by a follow-up.
What the prototype does
- The native lowering assigns target-internal resume segments to backward branches and call sites. Canonical mcode is unchanged and there is no new semantic opcode.
- A taken backedge decrements a QBE-local tranche counter. Once per 256 taken backedges it charges a per-context reduction budget (default 65,536).
- Native calls charge one reduction while already inside their frame-preparation helper. This matters because recursive computation may have no backedge.
- On exhaustion or pause, generated code spills register-promoted locals to its
GC-walkable Pit frame, writes the resume segment to
frame->address, and returnsPIT_SUSPENDEDup the host stack. - Direct native callers retain the callee frame, stamp their own continuation, and propagate the sentinel. The scheduler requeues cooperative exhaustion without counting a slow strike.
- Resume starts from the retained top native frame and returns through the native chain stack-flatly. Consumed frame-address stamps are cleared before the continuation executes, so the same activation can suspend again later.
- A Mach caller can retain its frame while native code suspends, receive the eventual successful result, and continue through the existing scheduler path.
No Pit frame field or semantic slot was added. NativeRTState grew by 40 bytes
on the host nan64 ABI. Suspended calls retain the frames that were already live;
the prototype adds no per-frame bytes. Its significant memory cost is generated
code, not actor heap.
Correctness bugs found while building it
- A deep native callee unwinds through every C caller. Stamping only the top frame’s immediate caller loses outer continuations. Propagation must walk outward and stamp the first unstamped native caller.
- A resumed frame must clear the continuation stamp it consumed. Otherwise a later suspension sees the caller as already stamped and skips it.
- Nested dispatcher-created native frames were present in the AOT GC-root
array but their Pit
callerfield was null. Normal execution hid this; spectralnorm suspension crashed while trying to spill the unreachable outer caller. Explicitly linking the frame fixed repeated suspension, including GC after every yield. - Spilling promoted locals before every call was correct but expensive. Spilling after the callee returns the suspension sentinel is safe only after re-deriving the exact caller from the GC-updated frame chain. Moving this work to the cold edge recovered roughly half of the initial call-path regression.
- Returning a tagged prepared-frame pointer on the rare “defer entry” case added normal-path masking and branching. Returning the existing null/cold result and querying suspension only on that branch is cheaper.
Proven vertical slice
The focused runner loads a compiled dylib into a raw context, applies a small budget, optionally forces GC between every resume, and checks result, suspension count, and retained native depth.
| Case | Budget | Forced GC | Result |
|---|---|---|---|
| 2M integer loop | 64 | every resume | 144; 11,718 suspensions; 11,718 GCs |
fib(30) direct recursion | 65,536 | every resume | 832040; 41 suspensions; max depth 28; 41 GCs |
| non-tail depth 80 | 8 | every resume | 80; 10 suspensions; max depth 81 |
| depth 80 with stack limit 32 | 8 | every resume | stack-overflow disruption; max retained depth 25 |
| 1M loop then invalid call | 64 | every resume | original disruption after 3,906 suspensions |
| spectralnorm | 65,536 | every resume | 62 suspensions; max native depth 4; exact normal run matches Mach |
This proves repeated loop suspension, direct recursive suspension, nested dispatcher native suspension, moving-GC pointer updates, stack overflow after prior resumes, and native disruption after prior resumes.
Boundary that is not solved
Native to Mach to native nesting is not correct with the current context shape.
There is one ctx->suspended_frame_ref, suspended_pc, and frame mark. In this
sequence:
- outer Mach calls native;
- native calls inner Mach;
- inner Mach suspends and writes the scalar state;
- native unwinds;
- outer Mach then suspends and overwrites the same scalar state.
The inner continuation is gone. No ordering trick can recover it after the
overwrite. A similar boundary exists when native eventually disrupts while a
suspended Mach caller is waiting. A direct check in the Mach resume entry makes
that case correct, but A/B measurement showed that the added edge perturbs
Clang’s optimization of the enormous register-VM function: arith_int Mach
moved from about 20.7 ms to 28.7 ms and compiler realization began missing the
VM-suite deadline. That arm was deleted. Successful Mach-to-native suspension
is retained; disruption delivered through that bridge is not claimed.
C modules remain a suspension barrier, as required: native code does not try to serialize an arbitrary C activation.
Controlled performance
Both sides were built in detached worktrees from the same 1ea3d92a baseline.
Numbers are 11-sample medians from aot_bench/perf_one.ce. They measure the
cost of the current fairness mechanism, not an executable-speed win.
| Benchmark | baseline native ms | prototype native ms | change |
|---|---|---|---|
call_hot (inlined 2M loop) | 6.815 | 7.616 | +11.7% |
arith_int | 9.997 | 11.044 | +10.5% |
fib | 29.172 | 33.198 | +13.8% |
closure | 33.095 | 36.375 | +9.9% |
loop_nested | 33.996 | 37.831 | +11.3% |
spectralnorm | 175.204 | 176.588 | +0.8% |
mandelbrot | 27.509 | 31.791 | +15.6% |
fannkuch | 205.583 | 206.285 | +0.3% |
The mandatory part of the tax is a bounded poll: calls must observe pause and a long loop must check often enough that an actor cannot monopolize its worker. The current exact implementation is not mandatory. In particular, all of these are prototype choices and remain optimization work:
- decrement/compare/branch at every taken backedge;
- a context reduction update on every native call;
- per-site resume blocks in the same QBE function, increasing register pressure even on the normal path;
- a linear resume-segment dispatcher;
- separate native and Mach suspended-state mechanisms.
Call optimization and fairness must be evaluated separately. The baseline fast frame prep/pop remains useful; fairness added about 14% to recursive fib here. The initial eager-spill arm was about 25% slower, so cold-edge serialization is already a material improvement, but this is not a satisfactory final call ABI.
Code-memory tradeoff
IL and assembly byte counts come from the same focused builds.
| Benchmark | IL bytes base -> prototype | asm bytes base -> prototype |
|---|---|---|
call_hot | 36,529 -> 39,103 (+7.0%) | 31,244 -> 33,548 (+7.4%) |
arith_int | 25,212 -> 27,256 (+8.1%) | 23,870 -> 26,026 (+9.0%) |
fib | 57,823 -> 63,753 (+10.3%) | 45,798 -> 49,537 (+8.2%) |
closure | 50,209 -> 55,098 (+9.7%) | 40,502 -> 44,095 (+8.9%) |
loop_nested | 30,079 -> 35,024 (+16.4%) | 26,239 -> 31,354 (+19.5%) |
spectralnorm | 390,232 -> 427,342 (+9.5%) | 256,300 -> 278,185 (+8.5%) |
mandelbrot | 52,133 -> 90,982 (+74.5%) | 33,968 -> 68,590 (+101.9%) |
fannkuch | 176,438 -> 185,836 (+5.3%) | 120,825 -> 127,819 (+5.8%) |
The mandelbrot result alone rejects this resume-block layout for small-memory targets. The common 5-10% growth is also material for N64/GBA-class budgets. No amount of compile-speed indifference makes a 2x executable acceptable.
Smallest correct unified continuation stack
The follow-up should replace the scalar Mach suspension fields and the bridge booleans with one per-context LIFO of execution states. A compact custom-scanned entry is:
typedef struct {
PitValue frame; /* GC-updated owner/leaf frame */
size_t frame_mark; /* Mach frame-stack restoration mark; zero for native */
uint32_t resume; /* Mach PC or native target-internal segment */
uint16_t dest; /* result slot, 0xffff for none */
uint8_t kind; /* MACH or NATIVE */
uint8_t flags; /* LEAF/AWAITING, TAIL, DISRUPTED */
} PitContinuation;
This is 24 bytes on nan64/64-bit hosts and 16 bytes in a nan32/32-bit profile.
Embedding a PitGCRef instead of using one context scanner makes it 32 bytes on
the host, so the custom scan is worth having. Allocate stable chunks of four
entries lazily: roughly 112 bytes including a host chunk header, or about 72
bytes on a 32-bit target. An actor that never crosses or suspends allocates no
chunk.
Ownership and invariants:
- The owning
PitContextand its scheduler worker are the only mutators. - Push the caller’s AWAITING entry before crossing Mach/native kinds. This establishes the right order before a child can suspend.
- A yielding leaf pushes/updates the top LEAF entry. After it completes, pop it, deliver its value to the preceding AWAITING entry, then resume that entry. Repeat stack-flatly until a new yield or the outer result.
- Mach-to-Mach and native-to-native calls keep their existing intra-VM frame chains; entries are required at cross-kind boundaries and for the suspended leaf, not for every language call.
- GC scans every active entry’s
frameand overwrites it with the forwarded value. Inactive chunk cells must be null. Native frames remain in the existing AOT root stack during the pilot; duplicate roots are safe and can be removed only after a separate audit. - The stack bound is
ctx->stack_limitwith a hard representational ceiling of 65,535. A push that would exceed it raises stack overflow before entering the child. Allocation failure raises OOM before crossing. Never drop an entry, overwrite the oldest state, or fall back to an unfreezable Pit call. - Pop only after the result or disruption has been installed in its owner. Disruption uses the same LIFO delivery, so handlers run in the correct VM.
- C calls are explicit barriers and do not create serializable entries.
This stack is prerequisite infrastructure, not an executable optimization. Once it is correct, optimize the hot poll independently: measure time-based pause-only calls versus cooperative call tranches, hoist loop polls to selected latches, and split cold resume code so it does not extend normal-path liveness. Keep the requirements: bounded pause latency, no host-stack retention, and no new per-frame memory.
Verification and tooling notes
CCACHE_DISABLE=1 make: passed. The sandbox cannot write the host ccache directory; plainmakefails there even though the project is healthy../pit test tests/compile.cm: 73/73 passed.- Focused native suspension fixtures above: passed, including forced GC.
- Full sandboxed suite: 815 passed / 7 failed; six are denied bind/socket operations and one is the known cold VM-suite realization timeout.
- Full unrestricted suite: 832 passed / 1 failed (
vm_suiterealization timeout). The isolated run also hit the deadline while rebuilding. No test assertion failed, but this is not a green full-suite result and is another reason not to integrate the branch. meson compileupdatesbuild/libpit_runtime.dylibbut focused./pitcommands load the worktree-root dylib. Runningmakeis required after helper edits; otherwise the generated dylib and runtime silently disagree. A dedicatedmake native-dev-synctarget or having the focused runner load the build-tree dylib would remove this trap.
Disposition
Keep this commit as a research artifact only. It proves that Pit native frames can be frozen and resumed without changing semantic mcode or adding per-frame memory, and it found the caller-link invariants needed for GC. It does not meet the language’s complete cross-kind call contract, its hot tax is 0-16% on the measured corpus, and its code growth reaches 102%. The next implementation experiment should start with the unified continuation stack, then reapply only the smallest leaf/backedge slice and use the performance/code-size gates above.
Source: plans/archive/perf-2026-07/perf-native-suspension-experiment.md