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_FUNCSPit_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_CALLmach_call_common → the PIT_FUNC_KIND_C branch:

  1. ctx->reg_current_frame / current_register_pc / vm_call_depth++
  2. mach_root_call_argsmemset the 104-byte MachArgScratch, decode the MACH_CALLARGS payload, copy the named caller slots, push a CCallRoot
  3. Pit_PushGCRef(&fn_root) — an out-of-line call (runtime.c, no LTO)
  4. pit_call_c_functioncopies the arguments a second time into its own arg_inline[4], pushes a second CCallRoot, checks the call hook, calls actor_profile_native_enter (out-of-line), dispatches on cproto, calls actor_profile_native_return (out-of-line), checks the ret hook
  5. mach_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.

lanens/iteroverhead/call
base int loop12.6
pit → pit call, 1 arg16.23.6 ns
pit → pit call, 2 args16.23.6 ns
native fit.not, 1 arg45.232.6 ns
native fit.and, 2 args46.033.4 ns
native math.sqrt47.834.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 componentwhat it removesmeasured value
symbol index replaces registry lookupnothing — the lookup is per module per actor (§1a/1b)0
direct member referencenothing — already a slot read (§1c)0
static callee type/arity knownmist_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 owns arg_copy and the CCallRoot that covers it; the core pads to the declared arity in place (bounded by arg_cap), widens root->argc to 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 in pit_internal.h.
  • pit_call_c_function(...) — unchanged signature and semantics, now a thin wrapper that builds arg_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:

lanebeforeafterΔΔ% of call
base int loop12.5912.42
pit → pit call, 1 arg16.1815.69(noise)
native fit.not (1 arg)44.9940.85−4.14 ns−9.2%
native fit.and (2 args)45.9642.07−3.89 ns−8.5%
native math.sqrt47.8044.07−3.73 ns−7.8%

As overhead above the empty loop (the number the optimisation actually moves):

lanebeforeafterΔ%
native fit.not32.6 ns28.6 ns−12.3%
native fit.and33.4 ns29.8 ns−10.8%
native math.sqrt34.4 ns30.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_root GC ref entirely. It is arguably redundant: the callee is reachable through the rooted caller frame, and pit_call_c_dispatch reads f->u.cfunc before 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_slot inlining. 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_CALLNATIVE opcode. 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).

#gateresult
1cold rm -rf .pit build cold-build pit && make at the branch baseexit 0
1bcold rm -rf .pit build cold-build pit && make after the change, on the committed treeexit 0; cold-built binary reproduces the §3 numbers (40.0 / 40.8 / 43.7 ns) and hot ps = 0.026 s
2make after the changeexit 0, no new warnings
3make seed fixpointforge: two clean boot presses are byte-identical + forge: promoted clean binary + boot fixpoint, exit 0; forge C tests 18 passed / 5 skipped / 0 failed
4sh scripts/gate.sh "vm suite" ./pit test run tests/vm_suite.cecheck: vm suite OK
5./pit test suite475 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
7force-gc arm — ARMS="force-gc" sh scripts/check_arms.shALL GREEN; force-gc tests 18 passed / 5 skipped / 0 failed
8argument-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
9boot sanity — daemon booted from the freshly pressed section, hot ps0.029 s
10native-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