Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.
Direct call op: call dest, fn, argc, args... replaces frame/setarg/invoke
Date: 2026-07-08. Baseline: e07750e6 (frame-stack harvest). Gate: make check
ALL GREEN + make budget OK, re-baselined.
What changed
- New mcode instruction
["call", dest, fn, argc, arg0...](+tail_calltwin for tail positions) — one instruction per call site instead offrame+setarg×argc +invoke(2 + argc). The only variable-length mcode op; see compiler-contract §4b for the pass contract. - VM:
MACH_CALL+MACH_CALLARGSpayload words (3 arg slots per word). Register→register calls validate once and activate the callee frame directly; C/native callees reuse the INVOKE path. First cut jumped into INVOKE’s entry for everything and re-validated what CALL had just established — that made closure_calls wall +12% despite −5% instructions; direct activation removed the redundancy. - Emitter: every synchronous call site routes through
emit_call(method dispatch, proxy dispatch, log/panic helpers, intrinsic arity ladders).gostatements keepgoframe/goinvoke. - Streamline: call-aware defs/uses/refs, copy-propagation, stone insertion, intrinsic result typing, check-mode arity/null diagnostics, and the inliner now matches the one-instruction call form.
Numbers (vs e07750e6 baseline)
Static, compile corpus (raw = emitted, final = after streamline):
| file | raw Δ | final Δ |
|---|---|---|
| tokenize.cm | −7.2% | −9.1% |
| fold.cm | −9.6% | −12.0% |
| parse.cm | −9.2% | −12.3% |
| streamline.cm | −2.0% | −3.7% |
| mcode.cm | −12.3% | −15.2% |
| corpus total | −7.0% | −9.3% |
setarg (9.1% of final output) and frame are gone from the synchronous
path. Slots/guards/moves ~flat.
Dynamic (dispatched instructions, median of 3):
- text_search −12.5% instr (calls in the hot loop)
- closure_calls −5.3% instr, wall 5.6ms (= baseline wall, ns/i 1.96→2.06: one heavier dispatch replaces three light ones)
- other benches ~flat (not call-bound)
Compile speed: unit builds −3% to −8% on 4 of 5 corpus files (fewer instructions to emit and stream through passes); streamline.cm itself +2.6% unit (its source gained the call-handling code). Wash-to-slight-win.
Why QBE/asm gets dramatically simpler (the AOT payoff)
This op exists for the native backend, not the VM:
- Resume segments collapse. Today each
invokeis a resume boundary, but theframe/setargprefix means arg transport is spread over 1 + argc separate mcode instructions, each lowered as its own QBE statements with slot state materialized between them. Withcall, a call site is ONE IL unit: the emitter sees callee + full arg list at once and can emit alloc-frame → block-copy args → call, with no intermediate materialization points. - The prepared-frame protocol disappears from codegen. frame/setarg/invoke
forces native code to model a half-built frame object across instruction
boundaries (what FRAME allocated, SETARG’s ABI remap per arg, INVOKE’s
re-validation). Direct call needs none of that state machine: arity check,
frame alloc, memcpy of a contiguous slot range (args are whole slots —
a candidate for a real
memcpywhen arg slots are allocated contiguously), jump. This is the same shape a C compiler emits for a call. - One validation point. Callee function-ness and arity are checked once per call site instead of split across FRAME (validate) and INVOKE (re-validate) — in native code that’s one branch instead of two, and the is-function probe can read the object header directly (per the little-C-as-possible goal).
tail_callis preserved in mcode, so a future backend can emit a real jump-with-frame-reuse without recompiling the world; the VM currently treats it ascall(as it treatedtail_invokeasinvoke).
Notes
- Reserved slots 0/1/2 (misty spec hardwires 0/1/null): DEFERRED. The direct call op is additive — no shared-ABI break — and the reserved-slot win is mostly interpreter constant-loads that AOT folds anyway. Revisit only if a deliberate ABI break lands for other reasons.
- Pre-existing bug found while gating (reproduces with the legacy emission on this branch): deep recursion past the stack limit segfaulted in ctx_gc while Pit_GetStack built the overflow trace. Fixed in a follow-up commit (VM entry overflow path leaked entry_frame_ref in the persistent GC root chain plus the entry frame itself).
- POST-LANDING FIX (same follow-up commit): MACH_CALL derived
fnfrom the callee slot BEFORE mach_alloc_stack_frame; the alloc can GC and move the function object, and while the code re-read and re-validatedfunc_val, it kept using the stalefnforfn->kind/fn->u.pit.code— a GC- timing-dependent SIGSEGV in the shop/builder actors that surfaced as “silent” compile deaths. The re-derive after VM_RELOAD_FRAME is exactly the CLAUDE.md rule about values lifted before an allocating call.
Source: plans/archive/perf-2026-07/2026-07-08-direct-call-op.md