Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
Compact native raw-recursion continuations
Date: 2026-07-13
Exact base: 0401fb314a71311a476cd140f3930a242e6268bf
Experiment branch: codex/perf-compact-raw-continuation
Result
The existing exact Fibonacci raw-int ABI still carried a complete nine-slot tagged Pit frame through every private recursive activation and used the general direct-call enter/leave helpers. This experiment keeps the public tagged frame and fallback unchanged, but represents private raw activations as ordinary three-slot Pit frames and gives their proven one-int convention exact enter/leave helpers.
On native fib(30), the median of three interleaved fresh-process medians fell
from 23.820 ms to 13.947 ms: 41.4% less time, or 1.708x throughput. A
compact-frame-only attribution arm reached 21.804 ms (-8.5%). Specialized prep
with the general leave reached 17.814 ms (-25.2%). The complete enter and
leave convention is therefore the important result, not frame size alone.
The exchange is small and favorable for memory-constrained targets:
- every private logical activation shrinks from 104 bytes to 56 bytes of Pit frame-stack memory (-48 bytes, -46.2%);
- no actor field, heap object, side table, widened header, or mcode operation is added;
- runtime text grows by 588 bytes and unwind data by 8 bytes;
- the selected Fibonacci dylib loses 64 bytes of generated text but gains 48 bytes of import stubs, for 16 fewer bytes of linked target content;
- runtime plus one selected target is therefore about 580 bytes more static content, while live recursion uses less memory.
This is a QBE/native optimization. Mach code and timing are unchanged.
Representation
The public entry remains the full tagged ABI and owns the original nine-slot frame because any rejected value or invalidated self binding must execute the untouched tagged body. After the wrapper proves the raw region, private activations use the fixed slot convention:
| Slot | Meaning |
|---|---|
| 0 | tagged copy of raw argument n |
| 1 | first recursive result across the second call |
| 2 | second recursive result at a suspended return boundary |
The raw arithmetic value is still carried in a QBE integer parameter/result.
Slots contain canonical PitValues because they are the state retained when the
native host stack unwinds. The frame itself is still OBJ_FRAME; its ordinary
header records capacity 3, and function, caller, address, and all slots
remain precisely visible to the copying collector.
This is not a second continuation stack. It deliberately reuses the runtime’s one GC-walkable, relocatable, stack-limit-accounted Pit frame chain. That keeps the representation valid when actors freeze, another actor runs, and the original actor later resumes on a reconstructed host stack.
Enter and leave convention
pit_rt_self_prep_raw1(ctx, caller_fp, raw_arg) is selected only by the exact
nan64 raw-Fibonacci proof. It:
- enforces the ordinary actor stack limit;
- allocates a three-slot normal stack frame;
- links the exact caller and inherited function;
- stores one canonical tagged int plus two null continuation slots;
- activates the ordinary AOT GC root and both logical depths;
- charges the same native call reduction and can suspend before child entry.
On the no-GC bump path, the incoming caller slot pointer is current and avoids
an AOT-root lookup. On the slow path, allocation may move the entire frame
stack, so the stale pointer is discarded and the caller is recovered from its
GC-updated AOT root. raw_arg is not a PitValue, and Pit_NewInt32 does not
allocate, so no unrooted PitValue crosses an allocation.
pit_rt_self_leave_raw1(ctx) is the normal-return mate. The emitter proof
guarantees that the top frame is the exact three-slot child and that its direct
caller remains below it. The helper reads the GC-updated child->caller,
clears the child’s AOT root, decrements both depths, restores frame_free by the
exact 56-byte frame size, and returns the relocated caller slots in one C
transition. Suspension and disruption do not use this helper; they retain or
unwind through the existing general control path.
The shared AOT-root index helpers now cast their already-proven nonnegative depth to unsigned. This exposes shift/mask indexing to C rather than signed division/remainder correction. The audit found every lookup either uses the current nonnegative depth after capacity establishment or is guarded by a positive-depth test.
Frozen actor and resume proof
The raw wrapper’s two resume segments are exact equalities, as before. A raw resume now jumps directly back into the already-admitted raw region rather than rechecking the captured self binding.
That is required for compact child frames: they intentionally do not contain the tagged body’s unrelated scratch slots. It is also semantically sound. When native code suspends, the actor’s complete Pit frame chain is retained and its Pit code is not running. No other actor can mutate this actor’s captured self binding. Resume therefore preserves the proof established on fresh entry. Public fresh entry still checks the mutable binding before entering the raw region, and every tagged fallback remains available there.
Performance attribution
Each row below is the median of 11 native executions after three warmups. The table reports the median of three interleaved fresh-process rows on a quiet machine.
| Arm | Native fib(30) | Change from base |
|---|---|---|
| exact raw-self base | 23.820 ms | — |
| compact frame, general prep/leave | 21.804 ms | -8.5% |
| compact specialized prep, general leave | 17.814 ms | -25.2% |
| compact specialized prep and leave | 13.947 ms | -41.4% |
The complete arm is 36.0% faster than frame compaction alone. The specialized leave removes a further 21.7% from the prep-only arm. The previous general helpers were therefore doing meaningful metadata/root/frame reconstruction on both sides of every recursive edge.
The three-run complete data were:
| Round | Base | Compact frame | Complete |
|---|---|---|---|
| 1 | 23.888 | 21.898 | 13.996 |
| 2 | 23.820 | 21.804 | 13.917 |
| 3 | 23.801 | 21.572 | 13.947 |
An initial timing attempt was rejected because an unrelated cold bootstrap occupied a full core. After independently localizing and seeding all three worktrees, a second quiet bracket reproduced the result: base 23.646/23.440 ms, compact 21.880/20.647 ms, and complete 13.828/13.786 ms. Generated artifact hashes and IL/assembly sizes matched the first quiet gate exactly.
Two non-selected controls emit byte-identical benchmark IL and the same dylib hash on base and candidate:
| Benchmark | Base median of medians | Candidate | Interpretation |
|---|---|---|---|
call_hot | 6.994 ms | 7.038 ms | +0.6%, ordinary run noise |
rec_alloc | 4.702 ms | 4.580 ms | -2.6%, GC-heavy run noise |
There is no evidence of a language-wide regression outside the exact selector.
Generated and runtime size
Freshly seeded Apple arm64 artifacts:
| Measure | Base | Compact frame | Complete | Complete delta |
|---|---|---|---|---|
| QBE IL | 67,411 B | 67,401 B | 67,231 B | -180 B |
| generated assembly | 52,006 B | 51,997 B | 51,805 B | -201 B |
target __text | 13,988 B | 13,988 B | 13,924 B | -64 B |
| target stubs + helper | 864 B | 864 B | 912 B | +48 B |
| target text content total | 14,996 B | 14,996 B | 14,980 B | -16 B |
The host dylib file is 144 bytes larger despite less target content because the two new imported symbols enlarge link metadata. Static console builds should use the section-content figures, not host Mach-O packaging size.
The linked public wrapper remains 308 bytes. The private raw body falls from
640 to 576 bytes: 16 fewer arm64 instructions. Disassembly confirms the hot
edges now pass %fp and one raw integer to a single exact prep call, and normal
returns use one exact leave call; the general seven-argument preparation and
general frame reconstruction sequences are absent. The original tagged body
is unchanged.
| Runtime measure | Base | Complete | Delta |
|---|---|---|---|
__text | 1,039,128 B | 1,039,716 B | +588 B |
| unwind info | 6,456 B | 6,464 B | +8 B |
| C strings | 57,536 B | 57,536 B | 0 B |
mapped __TEXT segment | 1,212,416 B | 1,212,416 B | 0 B |
The current nan64 PitFrame header is 32 bytes and each slot is 8 bytes. The
original selected function has nine slots, so private activations were 104
bytes. Three slots are 56 bytes. At 29 private activations this saves about
1,392 live bytes; the exact amount scales linearly with depth. A future 32-bit
PitValue representation would reduce slot bytes further, while the same compact
slot count remains useful.
Scope and representation gates
The existing structural selector is unchanged: this experiment cannot broaden which programs receive the raw ABI. No mcode instruction, public language ABI, object layout, actor state, or fallback behavior changed. The two new C helper symbols are internal QBE runtime ABI only.
The compact raw-int convention remains nan64-only. Compiler coverage clones
the QBE macro record as f32 and fixed and proves neither emits _rawi or the
compact helpers. A future nan32 port must make a separate signed-22/float32
overflow proof; it must not inherit nan64’s int32 assumptions silently.
Executable and section-size validation in this experiment is Darwin arm64. The helper uses only the existing C runtime/frame interfaces and static console links should resolve it normally, but no N64, GBA, Playdate, or Windows target binary was produced here. Those targets need their own ABI and stack-budget gate before this moves from host proof to a platform guarantee.
Correctness evidence
The final code has passed:
- local C/runtime build;
- freshly seeded compiler suite: 106 passed, 0 failed;
- boundary/fallback oracle: Mach/native result
78; - budget-1 forced suspension with moving GC: result
78, 205 suspensions/collections, maximum retained depth 11; - stack-limit-8 exact overflow/disruption: 14 suspensions/collections, maximum retained depth 8;
- deep
fib(34), budget 1003, moving GC: result5702887, 18,399 suspensions/collections, maximum retained depth 34; - emitted-IL assertions for exact prep, exact leave, exact resume segments, and nan64-only selection;
- full local suite: 1,918 passed, 0 failed;
- deterministic fuzz: 3,733 checks passed, 0 failed across the contiguous
250-program ranges beginning at seeds
20260713and20260963.
Source/artifact isolation proof
The three timing worktrees each resolved .pit/core to their own exact path and
were independently rebuilt and seeded. All share committed base 0401fb31
but have distinct compiler source hashes:
| Arm | qbe_emit.cm SHA-256 | Fibonacci dylib |
|---|---|---|
| base | 22f4865c…bbbf4 | 8c373d9c…7a87a0 |
| compact frame | d3f08782…ccca4 | f0976b47…54446 |
| complete | ce07b974…c5ef | 643c26ed…d3351 |
The complete runtime-helper source hash is 2f4ce27d…0611. nm confirms the
fresh runtime exports both compact helpers, and pit build qbe from that exact
shop prints calls to both helpers, slots 1/2 as continuation destinations, and
a resume jump directly to @w_raw.
Rejected and revised arms
Frame compaction without a specialized convention was safe and reduced live memory, but its 8.5% speedup left most of the call cost. Keeping only that arm would have mistaken the allocation size for the dominant cost.
A prep-only arm showed that fixed arity/slot setup, caller reuse on the no-GC path, and direct root activation recover about one quarter of total time. Its general leave still rebuilt metadata and frame state on every return. The exact leave helper was retained only after its own attribution gate recovered another 21.7% from that arm.
The first prep draft looked up the caller through the chunked AOT root even when
the allocator proved no collection could occur. Passing %fp and using it
only on that no-GC path is safe and removes the lookup. The slow allocation
path explicitly discards %fp and recovers the moved caller from its root.
The first resume draft repeated the self-binding check after every suspension. That both cost time and was incompatible with intentionally compact frames. The final direct raw resume follows the actor-freeze invariant described above; fresh public entry remains guarded.
Tooling friction
This experiment found serious worktree/rebuild ambiguity:
- inherited boot debug paths named the root
bc8cworktree even while.pit/corecorrectly resolved to the isolated/private/tmpworktree; those paths are provenance strings, but they made a stale-boot crash look like cross-worktree source loading; - running a changed boot compiler before
make seedproduced a crash inmcode_lower_func_node; after exact local seed the compiler suite passed 106/106; - the first candidate
make seedspent over a minute compiling the correct modules and then failed withreply timeout; stopping only that worktree’s daemon and repeating the now-warm seed succeeded; make seedrewrites and replaces many tracked content-addressed boot objects, creating a large unrelated working-tree diff that must be restored after validation;pit build qbe FILEis useful for provenance, but prints the entire backend and helper IL (thousands of lines). A function filter or output-file option would make emitted-code audits much faster and less error-prone;- the one-shot 500-program fuzz reached 425 programs and 3,178 passing checks, then the terminal request timed out before its reply. The exact contiguous seed range passed when split into 250+250 programs (1,891 + 1,842 checks);
- a concurrent cold bootstrap consumed a full core during the first timing window. Performance gates need an explicit shared lock or a harness-level CPU-quiescence check; those samples were rejected and rerun.
These are tooling defects, not benchmark qualifications. The final result was reproduced only after exact source links, complete local seeds, emitted-symbol proof, and quiet sequential timing.
Concrete fixes proposed from this run:
- embed the seed-input hash and resolved core path in the packed runtime, and make local commands fail actionably when boot-loaded sources no longer match;
- make seed progress extend or classify its request deadline, and write boot outputs to a temporary directory before one atomic replacement so a timeout cannot leave a half-rewritten tracked tree;
- add a focused
build qbe FILE --function NAME(and optionally--output) path for generated-code inspection; - give the benchmark harness a cross-worktree machine lock and record a short CPU/process census beside every accepted timing gate.
Recommendation
Keep this optimization. It is a strong speed and memory win for its selected program, preserves the actor suspension/GC/stack contract, reduces generated target content, and costs only about 596 bytes in the shared runtime.
The larger lesson is to make private native calling conventions exact to the compiler’s proof. A general version should derive the minimum retained tagged slot set at each suspendable call boundary, assign a compact ordinary Pit frame, and generate exact enter/leave helpers or inline sequences for proven arity and result shape. The frame must remain the GC-walkable actor continuation; a second opaque native stack would violate the language runtime contract.
The current exact recognizer remains a benchmark anchor. Generalization should be gated one shape at a time against native executable speed, retained frame bytes, runtime text, forced suspension, moving GC, and non-selected code identity.
The next narrow native call experiments, in order, should be:
- inline the 88-byte exact leave sequence into the selected raw body and keep the current helper arm as an attribution control; accept only if the extra per-function text recovers measurable time;
- split exact prep into a small emitted no-GC bump/root fast path plus one shared cold allocating helper, again measuring target-text multiplication;
- replace the Fibonacci-only slot assignment with boundary liveness for any proven direct one-int self recursion, while continuing to store tagged values only at suspendable boundaries;
- then extend the private convention to proven non-self direct calls. Stable compiler origin IDs may later carry profile data, but no PGO is needed to establish these semantic proofs.
call_hot did not improve, so this result should not be presented as a general
call-speed solution yet. It proves that exact continuation representation and
exact runtime entry/exit are high-value levers when the compiler can prove the
call region.
Source: plans/archive/perf-2026-07/perf-native-compact-raw-continuation.md