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_call twin for tail positions) — one instruction per call site instead of frame + setarg×argc + invoke (2 + argc). The only variable-length mcode op; see compiler-contract §4b for the pass contract.
  • VM: MACH_CALL + MACH_CALLARGS payload 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). go statements keep goframe/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):

fileraw Δ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 invoke is a resume boundary, but the frame/setarg prefix means arg transport is spread over 1 + argc separate mcode instructions, each lowered as its own QBE statements with slot state materialized between them. With call, 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 memcpy when 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_call is preserved in mcode, so a future backend can emit a real jump-with-frame-reuse without recompiling the world; the VM currently treats it as call (as it treated tail_invoke as invoke).

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 fn from 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-validated func_val, it kept using the stale fn for fn->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