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

Native suspension and freeze-safe calls

Status: required architecture and experiment gate; no implementation accepted.

This note records the correctness constraint that must be included in final AOT performance numbers. General Pit execution may not rely on an unfreezable C call stack. An actor must be able to yield during long native loops and nested Pit calls; C-module internals are the only explicit exception.

What exists now

  • PIT_SUSPENDED is already a distinct internal PitValue sentinel.
  • The scheduler already retains and requeues a suspended turn, but resumes it only through Pit_ResumeRegisterVM.
  • Mach polls pause_flag on taken backedges and stores a GC-rooted frame/PC on suspension. It refuses to suspend while vm_call_depth > 0 because a C call is still live.
  • Native frames are GC-rooted and linked through frame->caller. The native dispatcher already runs stack-flat for old invoke points and resumes a function from the segment encoded in frame->address.
  • The newer fast native call paths bypass that dispatcher. Proven self calls recurse on the C stack; dynamic stack-safe calls invoke a native function from pit_rt_call_dynamic. The emitted code checks only PIT_EXCEPTION, and both paths pop the callee before any possible suspension result is handled.
  • emit_backedge_branch currently emits only an unconditional QBE jump despite its comment claiming a pause poll.
  • On a single-thread scheduler, wall-clock pause timer events are explicitly discarded because no timer thread can change a running actor’s flag. A real reduction counter is therefore required; pause-flag polling alone cannot satisfy console fairness.

The current native benchmark numbers consequently omit a mandatory cost and must not be presented as the final achievable performance contract.

Proposed fast-path shape

Use the existing PIT_SUSPENDED sentinel and Pit frames; add no semantic mcode operation.

  1. Assign target-internal resume segments to native backedges and call-return sites. These are lowering metadata, not serialized mcode facts.
  2. Keep a small reduction allowance in a native register. Taken backedges and Pit-call boundaries decrement it. Only the exhausted slow path consults the per-context budget/pause state, so ordinary cycles do not call C.
  3. At a yielding backedge, spill every promoted/raw value needed after resume, encode the resume segment in the current frame, and return PIT_SUSPENDED.
  4. A direct native callee that returns PIT_SUSPENDED remains the top rooted frame. The caller must not run pit_rt_direct_leave. It records its own return segment only on this cold branch and propagates the sentinel up the C stack. Thus normal direct calls need a sentinel comparison, but not an unconditional continuation store.
  5. The outer native dispatcher returns PIT_SUSPENDED to the scheduler without popping the retained frame chain. It must not clear the suspension cause.
  6. A generic Pit_ResumeVM selects register or native resume state. Native resume invokes the function belonging to the retained top frame at its saved segment. When that frame eventually returns, the existing dispatcher logic pops it, writes the result into the caller’s saved destination, and resumes the caller segment. The chain stays GC-walkable between turns.
  7. Interrupt/kill state remains distinct: it raises and unwinds rather than being reported as cooperative suspension.

The inline dynamic helper needs a slow suspension return that can recover the caller through callee->caller and save the passed constant segment/destination without charging the normal path an unconditional frame store. Cross-kind native/Mach calls need an explicit continuation bridge; merely suppressing Mach suspension with vm_call_depth would let a long Mach callee hog the actor and is not an acceptable final design.

Reduction policy

The fast timer remains useful on threaded hosts, but reductions are the portable invariant. A candidate policy is:

  • one unit per taken backedge and Pit call;
  • a register-local tranche (for example 256 units) to amortize shared-state access;
  • a per-context remaining budget reset at turn/resume entry;
  • exhaustion requeues cooperatively and does not count as a slow strike;
  • the slow timer still detects C modules or runtime helpers that cannot yield.

The exact tranche is a benchmarked policy, not a semantic constant. Every cycle must retain at least one accounting edge after loop rotation/fusion.

Memory and code constraints

  • Reuse frame->address; do not allocate one continuation object per call.
  • Per-context reduction/suspension state should remain a handful of words.
  • No per-call heap allocation is allowed on the normal stack-safe path.
  • Spill slots count as existing frame slots unless a new hidden slot is proven necessary and measured.
  • Record added branch/code bytes per backedge and call site. Outline the cold suspension tail if repeated inline state grows shootout code materially.
  • The current per-depth native root table is a candidate for replacement by one root of the linked top-frame chain, but that is a separate memory/call experiment. Nested dispatch must first link its entry frame to the existing top frame; otherwise one-root reachability is unsound.

Acceptance tests

Correctness:

  • suspend and resume a long native leaf loop multiple times;
  • suspend at depths above 32 during direct recursion, then complete exactly;
  • suspend a callee while the caller retains pointer and raw numeric locals;
  • GC between suspension and resume, including moved frames and captured cells;
  • disruption and stack overflow after at least one resume;
  • native to Mach, Mach to native, and native to C boundaries;
  • timer/kill state while suspended;
  • single-thread deterministic reduction exhaustion;
  • profiler/crash stack reconstruction from every retained segment.

Performance:

  • call_hot, closure, fib, and tco_self against current native and BEAM rows;
  • arith_int, loop_nested, mandelbrot, spectralnorm, and fannkuch with the real backedge cost enabled;
  • direct-call normal-path instruction/assembly inspection;
  • generated text growth and per-context/frame memory deltas;
  • latency/fairness is validated for correctness but is not credited as a language throughput benchmark.

An experiment that improves unsuspendable call timings but cannot pass these gates is rejected regardless of its microbenchmark result.

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