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

calling sequence v2 — measured cost attribution (before build)

Base: b1dbf1e6 (branch work/calling-v2), make check ALL GREEN. Machine matches the honest chart in perf-next.md. All numbers median-of-7, fresh daemon per fixture, PIT_ALLOW_NATIVE_LOAD=1, rows from observe.jsonl. Reproduce: aot_bench/mt.ce (mach-only median) + aot_bench/attr/*.ce (inlining-defeating call sweep), or add aot_bench/attr/* to vs.ce BENCHES.

Baseline (the four task rows)

benchmach msnative msreal callswhat it actually measures
fib75.7627.892,692,539recursion; native uses the DIRECT self-call path
closure74.0269.272,000,000dynamic call; native uses the TRAMPOLINE
call_hot16.067.952callee is INLINED away — not a call bench
record_field172.9962.060generic record field RMW — a §3 records bench

Census confirms the “real calls” column (aot_bench/census.ce):

  • call_hot: call 2 — the streamline inliner collapses the known single target; add/mul/remainder are 2M each (the inlined body). call_hot measures arithmetic, not call overhead, in BOTH lanes. The plan’s “known-callee native calls already good (call_hot 7.5ms)” is because the call is gone, not because the call sequence is fast.
  • record_field: 0 calls, 126M ops dominated by is_record/is_stone/is_num guards + load/store. Out of scope for calling-v2; pure no-regression gate.
  • fib: 2.69M call+return+getup; closure: 2.00M call+return+setup. These two are the real call benches.

Attribution sweep (2M iters; dynamic dispatch via LCG+array defeats inlining)

floor = loop + LCG + dynamic array read + add, NO call. Each callN swaps the array read for a dynamic call to one of 8 closures taking N args. bigframe = call1 whose callee declares 24 extra locals (large nr_slots).

fixturemach msnative msmach−floornative−floor
floor28.1912.91
call0 (0 args)64.6667.5036.5 (18.2 ns/call)54.6 (27.3 ns/call)
call1 (1 arg)77.5495.0649.482.2
call2 (2 args)91.97104.1363.891.2
call4 (4 args)124.55104.9296.492.0
bigframe (24 locals)112.7484.4184.671.5

(mach-only mt.ce agrees: floor 27.45, call0 61.7, call1 79.37, call2 91.95, call4 121.72, bigframe 114.09.)

What the numbers say (design against THIS, not assumptions)

  1. The native trampoline is the single biggest structural cost. A 0-arg dynamic call+return through the trampoline is 27.3 ns — SLOWER than the same call on mach (18.2 ns). That is exactly why closure-native (69.3) ≈ closure-mach (74.0). The trampoline = pit_rt_signal_call + ret PIT_NULL to pit_native_dispatch + loop re-entry + frame re-derivation + re-validate fn/arity + re-enter. fib-native avoids all of it (direct self-call path) and is 2.7× faster than fib-mach. Killing the trampoline for dynamic native callees is the highest-value change (element 5).

  2. The mach dynamic call+return floor is ~18 ns, ~4 ns of which is the trivial callee body (getup+return); ~14 ns is pure machinery: frame alloc + size negotiation + is_func + arity + dispatch + return plumbing. The overlapping-frames/precomputed-size/redundant-work reductions target this ~14 ns.

  3. Frame zero-init is LOW value. bigframe adds 24 slots but census shows it also adds ~14 real ops/call (the 24 assignments+guards); netting those out, per-slot frame init is < ~0.2 ns/slot — a few ns/call even for a big frame. The watermark-init element (4) is not where mach time goes, and it couples to the GC scanner (risk). Deprioritize / treat as optional.

  4. Arg copy is LOW value on its own. The per-arg deltas (call0→call4) are dominated by callee-side arg use (an add + is_num guard per arg), not the copy. Overlapping frames’ payoff is contiguous frame reuse + removing the separate copy loop’s bookkeeping, not the loads/stores.

  5. call_hot is not a call benchmark (inlined). Do not use it to judge call-sequence changes; use fib (mach) and closure (both lanes). Keep it as a no-regression row only.

Priority order chosen (measured value × safety)

  • P1 native: replace the dynamic-call trampoline with an inline call (no signal_call/resume round-trip). Target: closure 69 → ≤25 ms. Constraint: C-stack depth stays bounded by ctx->stack_limit exactly as the existing direct-safe/hosted path already is.
  • P2 mach: remove redundant work in the hot MACH_CALL register path (dead pre-alloc of the packed frame-info int that the register path overwrites; the double fn/is_function derivation) + precomputed frame size. Target: fib 76 → ≤40 ms. Safe, enumerable, no GC-invariant change.
  • Deprioritized: watermark frame-init (low value, GC-coupled risk), arg-copy elimination in isolation. Kept as open questions.

OUTCOME (built, gated)

P1 landed: non-direct-safe dynamic native calls lower to an inline call (pit_rt_call_dynamic); a callee proven stack-safe at compile time (creates no closure, never signals the dispatcher) gets a LIFO frame-stack frame and a direct call instead of a heap frame + dispatcher. See the calling-v2 + reseed commits.

bench (native)beforeafternote
closure69.2733.072.1×; frame alloc 152 MB → ~0; match=Y
fib27.8928.2unchanged — already on the direct-safe path
call_hot7.957.0unchanged — inlined
record_field62.0662.5unchanged — no calls

mach lane unchanged (native-emitter-only). Gates: make check ALL GREEN (vm suite / language suite / fuzzer); full vs.ce matrix 32/32 match=Y (native_call excluded — its math/radians C module is unbuilt in this fresh shop, a mach-lane env gap orthogonal to calling-v2); frame-GC regression fixtures under natural heavy GC all match=Y (aot_bench/stress/, via aot_bench/nstress.ce).

Not reached, and why: closure ≤25 ms needs closure-body inlining (a separate compiler arc; the calling-sequence win alone is the 33 ms above). fib-mach ≤40 ms needs the mach overlapping-frames restructure — deferred (criteria #3: mach lowering is a later pass; the measured mach cost is fixed per-call machinery, and a safe incremental tweak cannot reach a 46% cut).

Source: plans/archive/perf-2026-07/calling-v2-measurements.md