Skip to content

Design: constant-space tail calls on the explicit-frame VM (closures, multi-arity, apply) #620

Description

@nnunley

Summary

#645 changes the starting point for #620.

The compiler already emits OP_TAIL_CALL for ordinary calls in tail position. Before #645, a tail call to a *Closure or *MultiArityFn fell through ExecContext.Invoke into a nested Frame.Run, so deep recursion grew the Go stack. #645 makes those direct bytecode spans Go-stack-safe by descending through an explicit parent-linked frame chain.

That is not yet general tail-call elimination. A closure or multi-arity tail call still allocates and retains one child Frame per call. The remaining #620 goal is therefore:

Reuse the current VM frame for every resolvable bytecode tail call, including closures and multi-arity functions, and make apply* capable of handing its target back to the VM tail loop without exposing a continuation as a let-go Value.

The implementation should be stacked on #645. It must not introduce a second callee resolver or a second frame-acquisition path.

State after #645

Call path #645 behavior Remaining #620 work
Non-tail direct bytecode call Descend to a child heap frame; flat Go stack None for Go-stack safety; non-tail depth remains linear heap growth
Tail *Func Existing same-frame reuse Move onto the shared resolver/transition path
Tail *Closure Descend to a child frame Reuse the current frame while installing captures
Tail *MultiArityFn Resolve the variant, then descend Reuse the current frame after variant selection
Tail metadata-wrapped bytecode fn Unwrap, then descend Reuse through the same target preparation path
Tail native leaf Invoke and return its value Preserve terminal behavior and error attribution
Tail apply* Native calls public ExecContext.Invoke, hiding the target behind a new invocation segment Return a private pending-call outcome to the tail loop
Tail ProtocolFn Protocol dispatch calls public ExecContext.Invoke Resolve the implementation into the shared raw path, if included in v1
Tail MultiFn Dispatch function and selected method are nested invocations Requires a resumable dispatcher; explicitly include or defer

Goals and non-goals

Goals for v1

  • Constant Go and VM-frame space for tail calls to *Func, *Closure, and *MultiArityFn, including metadata wrappers and closures over multi-arity functions.
  • Tail-transparent apply* without allowing an internal continuation to escape through Value, variables, collections, or public host APIs.
  • One shared path for callee unwrapping, multi-arity selection, closure captures, fixed/variadic arity validation, argument preparation, and frame transition.
  • Preserve current OP_TAIL_CALL semantics for protected regions, dynamic bindings, errors, tracing, profiling, and host entry points.

Non-goals for v1

Architecture

1. Separate call preparation from frame allocation

#645's childFrameFor currently combines two responsibilities:

  1. resolve MetaFn / MultiArityFn / Closure to a concrete bytecode *Func, preserving captures and validating/packing arguments;
  2. allocate and initialize a child Frame.

Extract the first responsibility into a behavior-preserving resolver. The exact names are not prescribed, but the shape is:

type bytecodeCallTarget struct {
    fn          *Func
    args        []Value
    closedOvers []Value
}

func resolveBytecodeCall(fn Fn, args []Value) (bytecodeCallTarget, bool, error)

bool == false means the callable is not a directly executable bytecode target and must proceed through raw/public invocation. The resolver owns:

  • metadata unwrapping;
  • multi-arity fixed/rest selection;
  • closure capture preservation, including closure-over-multi-arity;
  • exact and variadic arity errors;
  • rest-list packing semantics.

It does not allocate a frame or decide whether the call is tail-positioned.

2. Give one prepared target two transitions

The caller chooses the transition, not the resolver:

The tail replacement must install, at minimum:

  • code, consts, and constsc;
  • closedOvers (clear for plain functions, install for closures);
  • owned arguments and argc;
  • ip = 0 and sp = 0;
  • sufficient operand-stack capacity.

The existing separate zero/nonzero-arity OP_TAIL_CALL branches should converge on this path.

3. Make argument ownership explicit

Arguments read by OP_TAIL_CALL are slices into the current operand stack. They cannot remain borrowed while that stack is reset and reused.

The transition must copy/reposition them into storage owned by the reused frame before overwriting the source. Variadic packing must also be fresh or frame-owned; the current append(sargs, restlist) tail path can reuse and mutate an input backing array.

A reusable frame-owned argument buffer is preferable to allocating a new slice on every tail hop. Correctness comes first; allocation behavior is measured and optimized after the ownership invariant is explicit.

4. Split raw invocation from public invocation

Fn.Invoke, NativeFn.proxy, and public ExecContext.Invoke return (Value, error). A pending call must not implement Value merely to fit those interfaces.

Introduce a private/raw outcome with two states:

  • final Value;
  • pending (Fn, []Value).

Then enforce these boundaries:

  • raw invocation performs one dispatch step and may return a pending call;
  • public ExecContext.Invoke iteratively drains pending calls and returns only a final Value;
  • non-tail OP_INVOKE uses the draining/public behavior when a callable cannot become a direct bytecode child;
  • OP_TAIL_CALL consumes the raw outcome, so a pending bytecode target can replace the current frame;
  • direct Fn.Invoke entry points remain continuation-proof.

Because apply* is defined in pkg/rt, the opt-in API may need a small exported constructor or opaque outcome type. It must remain impossible to store the pending-call object as a let-go value.

5. Make apply* the first raw continuation producer

apply* already normalizes its final sequence into (fn, args). Instead of recursively calling public ec.Invoke, its raw form returns that pending call. Public/non-tail callers drain it immediately; a tail opcode can reuse its frame for the target.

This does not imply that every callback-taking native becomes resumable in v1.

6. Define dispatcher scope explicitly

ProtocolFn can resolve its implementation without first invoking another function, so it is a reasonable v1 candidate for returning the implementation as a pending call.

MultiFn is different: it must invoke the dispatch function, await its value, select a method, and then invoke the method. Constant-space recursion through a multimethod therefore needs resumable dispatcher state, not only a (fn, args) redirect. Either implement that state machine deliberately or mark MultiFn out of scope for v1. Do not call it bounded while it still nests one dispatch continuation per recursive call.

Correctness invariants

  • Public APIs never return a pending-call outcome.
  • A prepared call has exactly one owner for argument storage before frame replacement begins.
  • Closure captures survive metadata and multi-arity unwrapping.
  • Same-frame replacement is legal only when no handler or cleanup continuation must return to the current body. The compiler already clears tail position inside try/catch/finally; retain runtime assertions or a safe fallback and add regressions.
  • binding and with-redefs cleanup remains intact.
  • Eliminated tail frames may disappear from traces, but errors from arity resolution, native leaves, apply*, and dispatcher selection receive a defined nearest-call-site source.
  • Preserve the current tracing/profile/allocation-attribution semantics of the existing *Func tail-reuse path unless a separate change intentionally revises them.
  • OP_RECUR and OP_RECUR_FN remain in-frame recursion mechanisms; do not route them through the general resolver merely for symmetry.

Execution plan

Phase 1 — resolver extraction, no behavior change

Exit gate: bytecode and observable behavior are unchanged; no tail-reuse behavior is added.

Phase 2 — constant-space bytecode tail replacement

  • Route all OP_TAIL_CALL arities through the prepared target.
  • Replace the current frame for Func, Closure, and MultiArityFn targets.
  • Add frame-owned argument storage or an equivalent ownership-safe transition.
  • Preserve captures, arity errors, variadic semantics, source attribution, and protected-region behavior.

Exit gate: deep capturing-closure and multi-arity tail recursion use one live VM frame rather than a linear parent chain.

Phase 3 — raw invocation and apply*

  • Introduce raw outcome and public draining layers.
  • Make apply* return a pending call through the raw-only API.
  • Ensure tail apply* can replace the current frame; non-tail and host callers still receive ordinary values.
  • Prove that no outcome escapes through direct Invoke, Vars, collections, macros, callbacks, or host APIs.

Exit gate: deep recursion through tail-position apply* is constant-space, and non-tail apply* is semantically unchanged.

Phase 4 — dispatchers

  • Route ProtocolFn resolution through the raw path if included in v1.
  • Decide whether MultiFn gets a resumable dispatcher in this issue or a dedicated follow-up.
  • Narrow the issue's “arbitrary callee” wording to exactly the implemented/tested set.

Phase 5 — validation and performance disposition

  • Full tests and race tests.
  • Generated-bundle checks and supported target builds.
  • Deep tests under a live-frame counter/budget so vm: make direct bytecode calls non-recursive with an explicit frame chain #645's heap-linear descent cannot accidentally satisfy a tail-space assertion.
  • Interleaved benchmarks through both Frame.Run and Func.Invoke.
  • Controls for ordinary direct calls, closures, collections, and loop/recur.
  • Allocation measurements for fixed, variadic, closure, multi-arity, and apply* tail loops.

Required tests

  • Multi-million top-level mutual tail recursion.
  • Multi-million mutual recursion through closures with a real capture.
  • Multi-arity fixed and rest variants, including a closure over the multi-arity function.
  • Metadata-wrapped functions and closures.
  • Tail-position arity mismatch parity with ordinary invocation.
  • Variadic argument immutability for vector-backed apply*.
  • Tail and non-tail apply*.
  • Direct/public Invoke containment: only final Value escapes.
  • try/catch/finally, binding, and with-redefs cleanup.
  • Error source attribution across eliminated tail calls.
  • Protocol recursion if ProtocolFn is in v1.
  • Multimethod recursion only if MultiFn is claimed in scope.

Relationships

Open decisions before Phase 3

  1. Is ProtocolFn required for v1? Recommendation: yes; its lookup can redirect without a resumable intermediate computation.
  2. Is MultiFn required for v1? Recommendation: no; track the resumable dispatch state separately unless implementation work shows it is small and reviewable.
  3. What raw continuation API should pkg/rt receive? It must be opaque to let-go values and impossible to leak through public Invoke.
  4. Should tail replacement preserve today's trace/profile behavior exactly, or should logical callee transitions become visible? Recommendation: preserve current behavior in Design: constant-space tail calls on the explicit-frame VM (closures, multi-arity, apply) #620 and make observability changes separately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions