Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
P8 — native bake-in and direct C-call finalization
Branch p/native-bake, from af0126485 (the press switch: ship/boot presses run
through pit.mcode.program@1 in pit-shop/boot_cart.cm pool_executable).
The charter, from plans/one-binary.md:
P8 — Native bake-in. Direct symbol-index call finalization at lowering, stamped with binary identity (pools already carry profile/ABI/toolchain hashes; the manifest already aggregates
natives).
John’s framing: “new linking opportunities open up with P8, since we can actually point C calls directly to the binary in the cart that is embedded.”
The result in one line: the pointing is already direct — and it was never what a native call costs. The registry hop the charter proposes to remove does not exist per call, and the member load a symbol index would replace was already hoisted out of the loop by the compiler in 2026. What a native call actually costs is the call sequence itself, and that is where this landing went: −4 ns, −8 to −9% of total native-call time, −11% of the overhead above the loop, reproducible 3/3 A/B rounds, with no artifact-format change, no identity stamp, and no ABI move.
1. The current C-call path, end to end
1a. Where a native module is resolved
pit-shop/shop_build.cm:add_native_provider decides, at realize/link time,
whether a use() names a compiled-in module:
sym_name = c_symbol_for_file(c_pkg, file_path) // pit_<pkg>_<file>_use
if (os.internal_exists(sym_name)) {
state.modules[canonical] = {type: "static", symbol: sym_name, ...}
log.native("using static native module " + canonical) // the boot lines
}
Those using static native module … lines are this branch, once per module per
executable link — not a runtime table lookup.
The table itself is pit_static_extensions[] in the generated
build/obj/static_extensions.c (49 rows on this host, in a separate
20-row endowment table), written by scripts/render_static_extensions.sh from
the package manifests, and installed by pit_set_static_extensions
(source/registry.c:201).
1b. Where the symbol is turned into values
engine/boot_walk.cm, ENTRY_NATIVE:
if (!os_mod.internal_exists(row.symbol)) refuse(...)
return os_load_internal_fn(row.symbol)
pit_lookup_extension (source/registry.c:315) is a linear strcmp scan
over those 49 rows. It runs once per native module per actor, at start-plan
walk time. Calling the returned pit_<pkg>_<file>_use builds one Pit record
whose members are PIT_FUNC_KIND_C function values
(PIT_USE_FUNCS → Pit_SetPropertyFunctionList).
There is no per-call registry hop. The linear scan is bounded by
modules × actors, not by call count.
1c. Where the member is fetched
Already hoisted. pit-compiler/mcode.cm:prepare_static_export_defs walks the
unit’s use() imports and their used_members, and for every
module.member on a static binding it reserves a hidden main-scope def
$linked_export_N, filled once at unit init. static_export_ref
(mcode.cm:2909) then resolves the call site to that slot, not to a
property read. The behaviour is pinned by an existing test:
tests/compile.cm:test_static_use_lowers_to_context_load —
“expected one initialization load and no repeated export property load”.
So the shape of wota.encode(x) inside a compiled unit is already
slot read + call. A baked symbol index would replace one indexed load with
one indexed load.
1d. What the call itself does
source/mach_vm.c, MACH_CALL → mach_call_common → the
PIT_FUNC_KIND_C branch:
ctx->reg_current_frame/current_register_pc/vm_call_depth++mach_root_call_args—memsetthe 104-byteMachArgScratch, decode theMACH_CALLARGSpayload, copy the named caller slots, push aCCallRootPit_PushGCRef(&fn_root)— an out-of-line call (runtime.c, no LTO)pit_call_c_function— copies the arguments a second time into its ownarg_inline[4], pushes a secondCCallRoot, checks the call hook, callsactor_profile_native_enter(out-of-line), dispatches oncproto, callsactor_profile_native_return(out-of-line), checks the ret hookmach_arg_scratch_dispose,Pit_PopGCRef(out-of-line),VM_RELOAD_FRAME(mach_reload_frame_ref, out-of-line),mach_release_prepared_frame_slot(out-of-line), pause-flag atomic load
1e. What that costs — measured
scratch/nativebench2.ce, min-of-9 per lane, 3,000,000 iterations, host
darwin/nan64. Overhead is over the matching empty loop.
| lane | ns/iter | overhead/call |
|---|---|---|
| base int loop | 12.6 | — |
| pit → pit call, 1 arg | 16.2 | 3.6 ns |
| pit → pit call, 2 args | 16.2 | 3.6 ns |
native fit.not, 1 arg | 45.2 | 32.6 ns |
native fit.and, 2 args | 46.0 | 33.4 ns |
native math.sqrt | 47.8 | 34.4 ns |
A native call costs about 9× what a Pit call costs, and the C body is
noise: a sample(1) profile of a 400M-iteration fit.not loop attributes
101 of ~5,900 in-pit samples (1.7%) to pit_fit_not itself. The rest is
the sequence in §1d — a chain of small cross-translation-unit calls, two
argument copies, two root pushes, and two profile-hook calls that both begin
by returning.
That is the real P8 finding, and it is not the finding the charter predicted.
2. The charter’s bake-in, evaluated
The proposed artifact was: at press lowering, finalize each native call site to a direct index into the pressing binary’s static native module table, stamp the pool with that binary’s identity, and have a foreign binary refuse the baked form and fall back to registry resolution.
Against §1 that buys, per call:
| charter component | what it removes | measured value |
|---|---|---|
| symbol index replaces registry lookup | nothing — the lookup is per module per actor (§1a/1b) | 0 |
| direct member reference | nothing — already a slot read (§1c) | 0 |
| static callee type/arity known | mist_is_function + mach_check_call_arity | ~1–2 ns of 33 |
The one real component is the third, and it is the one that genuinely needs the identity stamp — a call site may only assume “this is a C function of arity K” if the binary that supplies the module is the binary the site was baked against. Everything else the charter would have bought is already free, and the guard elimination alone does not justify a pool-format extension, an identity plumbing lane, and an ABI crossing.
So P8’s artifact half is not warranted on this corpus. Recorded, with the numbers, so it is not retrod. The contract that would govern it, if a later measurement changes the answer, is written out in §5.
3. What landed
Three files, no artifact format touched, no gate deleted, no ABI move.
3a. One argument copy and one root, not two
source/runtime.c. pit_call_c_function is split:
pit_call_c_dispatch(ctx, func_obj, argc, arg_copy, arg_cap, root)— the dispatch core. The caller ownsarg_copyand theCCallRootthat covers it; the core pads to the declared arity in place (bounded byarg_cap), widensroot->argcto the padded count before any C code runs, and does not push or pop a root.pit_call_c_function_rooted(...)— the pre-rooted entry, declared inpit_internal.h.pit_call_c_function(...)— unchanged signature and semantics, now a thin wrapper that buildsarg_inline[4]+ its root and delegates. One dispatch switch still exists; no duplication.
source/mach_vm.c uses the pre-rooted entry at all three PIT_FUNC_KIND_C
sites (MACH_CALL, MACH_INVOKE, the tail-invoke). The VM has already
copied the arguments into MachArgScratch and rooted them; the second copy and
second root push were pure duplication. mach_arg_scratch_cap() reports the
writable capacity (8 inline, or exactly argc when heap-allocated — and in the
heap case argc > 8 > 4 ≥ declared, so no padding is ever needed there).
The GC contract is unchanged: the argument array is covered by a live
CCallRoot for the whole call, and the padded tail is written before the root’s
count is widened to include it.
3b. The GC-ref push/pop is open-coded at the VM’s call sites
Pit_PushGCRef/Pit_PopGCRef are two pointer stores each, live in runtime.c,
and the build has no LTO — so at the VM’s per-call sites they were real
calls. The profile put them at 452 of ~5,900 in-pit samples (7.6%).
mach_gcref_push/mach_gcref_pop (static inline, mach_vm.c) do exactly the
same two stores, and fall back to the out-of-line originals on the misuse cases
(duplicate top, mismatched pop) so those keep owning their diagnostics.
3c. The profile-hook early-out is hoisted to the call site
actor_profile_native_enter/_return both begin with the same two relaxed
atomic loads and return when profiling is off — which is always, on the hot
path. The test is now at the call site in pit_call_c_dispatch, so the common
case is two loads instead of two cross-TU calls. Semantics identical; the
functions are unchanged and still re-check.
3d. The scratch memset is gone
mach_arg_scratch_alloc zeroed all 104 bytes of MachArgScratch including the
8-slot inline array, then every caller overwrote the slots it uses. It now
initialises only values/count (and does so on the error path too, which is
what dispose and the callers read).
Numbers
Same bench, A/B on the same machine, alternating binaries, 3 rounds, daemon restarted between each. Medians:
| lane | before | after | Δ | Δ% of call |
|---|---|---|---|---|
| base int loop | 12.59 | 12.42 | — | — |
| pit → pit call, 1 arg | 16.18 | 15.69 | (noise) | — |
native fit.not (1 arg) | 44.99 | 40.85 | −4.14 ns | −9.2% |
native fit.and (2 args) | 45.96 | 42.07 | −3.89 ns | −8.5% |
native math.sqrt | 47.80 | 44.07 | −3.73 ns | −7.8% |
As overhead above the empty loop (the number the optimisation actually moves):
| lane | before | after | Δ% |
|---|---|---|---|
native fit.not | 32.6 ns | 28.6 ns | −12.3% |
native fit.and | 33.4 ns | 29.8 ns | −10.8% |
native math.sqrt | 34.4 ns | 30.7 ns | −10.8% |
Every round agreed on direction and magnitude. The Pit→Pit and empty-loop lanes did not move, which is the control: this is native-call-specific.
Raw rounds (ns/iter, fit.not / fit.and / math.sqrt):
before: 44.32/45.55/47.62 45.20/45.98/47.80 44.99/45.96/48.41
after: 39.90/40.74/44.00 41.17/42.07/45.06 40.85/42.22/44.07
The first A/B attempt on this branch was run while the machine was carrying other night agents and showed no difference — the load swamped a 4 ns effect. The table above is the re-run with the daemon restarted between binaries and the machine quiet. Treat any single-run native-call number on a loaded machine as unusable.
4. What did not land, and why
- The pool annotation + binary-identity stamp. Not warranted (§2). Writing dead data that buys nothing is not a landing.
- Dropping the
fn_rootGC ref entirely. It is arguably redundant: the callee is reachable through the rooted caller frame, andpit_call_c_dispatchreadsf->u.cfuncbefore anything can allocate. Worth ~1 ns. Not taken — removing a GC root from the hot VM path on the strength of an argument rather than a proof is exactly the class of change CLAUDE.md warns about, and the force-gc arm is not a substitute for thinking it through in daylight. mach_reload_frame_ref/mach_release_prepared_frame_slotinlining. Both showed in the profile (156 and 159 samples). Both are more than two stores and touch frame lifetime; left for a measured follow-up.- A dedicated
MACH_CALLNATIVEopcode. The remaining ~29 ns is spread across the sequence, not concentrated anywhere a new opcode would collapse. A fused opcode would still have to spill the interpreter’s register state to leave the dispatch loop, which is likely the irreducible floor here. Wants a measurement of that floor first — that measurement does not exist yet.
5. The identity/fallback contract, if the artifact half is ever revived
Recorded so the design is not re-derived. Under R1/R3 the boot section and its
binary are co-produced by one forge invocation, so a boot cart can never
disagree with its binary — the baked form is sound there by construction and
needs no runtime check beyond what the pool mapper already does. A side/app
cart (pit cement --app, a mounted game bundle) is produced at a different
time than the binary that mounts it, so it is the only case that needs the
stamp:
- The producer writes an additive, optional pool row: call site → (native module index, member ordinal, arity) + the pressing binary’s identity hash. Additive-optional is the ABI safety property: a predecessor binary that does not know the row ignores it and resolves through the record as it does today, so the new lowering is runnable by the old binary and the R3 fixpoint gate (two clean presses byte-identical) is what proves the crossing — never an ABI change and a lowering-breaking change in one commit.
- The consumer compares the stamp at mount, not per call. Equal → the baked indices are live. Unequal → the rows are ignored and the record path runs. Fallback, not refusal: the semantics of the two forms are identical by construction, so a mismatch is a performance fact, not a trust fact. (The trust decision is the mount, per R5.)
- Nothing about this composes with the provider-registry dissolution any differently than today, because the registry is already off the call path.
6. Gates
Run on p/native-bake, on a machine carrying four other night agents
(a cold build, a Docker cross-CI lane, and two forge presses).
| # | gate | result |
|---|---|---|
| 1 | cold rm -rf .pit build cold-build pit && make at the branch base | exit 0 |
| 1b | cold rm -rf .pit build cold-build pit && make after the change, on the committed tree | exit 0; cold-built binary reproduces the §3 numbers (40.0 / 40.8 / 43.7 ns) and hot ps = 0.026 s |
| 2 | make after the change | exit 0, no new warnings |
| 3 | make seed fixpoint | forge: two clean boot presses are byte-identical + forge: promoted clean binary + boot fixpoint, exit 0; forge C tests 18 passed / 5 skipped / 0 failed |
| 4 | sh scripts/gate.sh "vm suite" ./pit test run tests/vm_suite.ce | check: vm suite OK |
| 5 | ./pit test suite | 475 passed / 0 failed / 475 |
| 6 | ./pit fuzz 100 (seed 733315) | 2214 passed / 0 failed; optimized_unoptimized 736/0, linked_unlinked 742/0 with shapes 6/0, join_types 736/0 |
| 7 | force-gc arm — ARMS="force-gc" sh scripts/check_arms.sh | ALL GREEN; force-gc tests 18 passed / 5 skipped / 0 failed |
| 8 | argument-padding sanity (benchmarks/…-adjacent probe: 1-arg call of a 2-arg C function, 0-arg, 1-arg, 2-arg natives) | 7 ok / 0 failed |
| 9 | boot sanity — daemon booted from the freshly pressed section, hot ps | 0.029 s |
| 10 | native-call A/B, 3 rounds | §3 numbers |
Gate 3 carries an extra fact worth stating: git status shows
boot/root.cart and boot.qop UNMODIFIED after the reseed. The re-press of
the whole fleet by the changed binary reproduced the committed artifacts
byte-for-byte. That is the strongest available statement that this landing is
artifact-neutral: it changes how a native call is made, and nothing about what
is emitted.
Gate 7 is the one this change specifically owed. The landing moves argument
rooting (one CCallRoot instead of two, padding written in place before the
root’s count is widened) and open-codes a GC-ref push/pop; FORCE_GC_AT_MALLOC
is what tests that the rooting is still honest.
7. Reproducing
./pit down && ./pit benchmarks/native_call_dispatch.ce
A/B against another binary: build both, then alternate
./pit down; rm -f pit; cp <binary> pit; ./pit benchmarks/native_call_dispatch.ce
with a warm-up run per binary. Do not trust a single round.
Source: plans/archive/night-2026-08-04/p8-native.md