Skip to content

Stack-place non-escaping Boxy closures inside ARC's existing solve/emit, no separate pass #12678

Open

Description

@rtfeldman

Under --specialize=no every function value is an erased callable: a heap allocation holding a function pointer, a drop callback, a refcount, and the capture bytes, with an incref of each refcounted captured value at construction. The most common shape in Roc code is a lambda that captures a local and is used immediately by a higher-order function, for example items.map(|x| x + blah) where blah is a local rather than a top-level constant. Today that costs a malloc and a free every time the expression runs, plus retain/release traffic on every refcounted capture, which in a loop means one allocation per iteration. This task is to place such closures on the stack (or in the dynamic frame region when the capture layout is dynamic) whenever ARC can show the closure never escapes the proc that built it.

Under .lss this is already free and nothing here applies: a finite lambda set lowers to a generated tag union whose payload is the capture record, so the closure is an inline value in a local and never reaches the heap. The only heap callables under .lss are erased ones, which exist precisely because they cross a boundary that forces escape (host ABI, Box.box, forced-dynamic iterators). The work is .boxy-only.

The escape facts already exist

No new analysis is needed. The ARC solver already classifies every occurrence of every local as owned or borrowed, using callee RcSigs, the consume_args / retain_args masks of low-level ops, and the pinned all-owned erased-call rule. A closure local may be stack-placed exactly when its binding is a fresh closure / dyn closure construction and every occurrence of it is borrowed:

  • passed to a borrowed parameter position (a map-style callee whose body only calls its callable parameter infers that parameter borrowed; Boxy lowering emits no owned erased calls, so calling the closure directly is also a borrowed occurrence)
  • called directly through dyn call_erased / assign_call_erased

Any owned occurrence disqualifies it: returned, captured by another closure, stored by a retain_args op, passed to an owned parameter position, passed to the host, or boxed. That is the whole non-escape condition, and the solver already has every input to it.

Do not add a separate pass

This must not be implemented as a new LIR pass or a new walk over proc bodies. It lives entirely inside ARC's existing solve-then-emit structure:

Solver. Model a closure construction the way a borrowing low-level op is modeled: the closure's resource starts out borrowing its captures, and each captured local is a borrowed occurrence whose lender must be live across every use of the closure (the same constraint the solver already imposes for result_borrows_args). Borrow inference starts optimistic and demotes to owned, so an owned occurrence of the closure anywhere in the proc demotes the construction to owning, which is today's heap form with owned captures. A closure that is never demoted finishes the fixpoint with borrowed captures and no ownership unit of its own. This is one more node kind in the lattice the solver already iterates, not a pre-pass or a post-pass. Whole-program mode inference is a fixpoint, so the result is available by the time emission runs; nothing has to be decided before the callee modes are known.

Emission. The emitter already visits the construction statement and the end-of-life point of every local. At the construction it reads the solved placement and emits either the heap form (unchanged) or the stack form. At the end of life of a stack-placed closure it emits nothing: there is no ownership unit to release, and the captured locals' own lifetimes cover the closure's uses because the solver constrained them to. The capture increfs disappear with it.

The stack form

  • Statically sized captures become an ordinary stack local. Captures with a dynamic layout go in the prologue-allocated dynamic frame region (frame_alloc) that the layout-polymorphic design already provides.
  • The object has the same erased-callable layout as the heap form (function pointer, drop callback, refcount, capture bytes) with the refcount set to the immortal sentinel that static data uses. Any incref / decref a borrowed callee performs internally is then a no-op, so backends keep dumbly following the explicit incref / decref statements in the LIR and nothing in a callee has to know how its argument was placed. Control flow at scope exit does not matter because nothing has to run there.
  • The closure / dyn closure op gains a placement that backends, the interpreter, and LirImage honor mechanically. The lowerer does not choose it: it cannot, because callee ownership signatures are not known until ARC runs.
  • A borrowed callee receives a stack-placed callable the same way it receives a heap one. It only ever passes a null reuse pointer through the erased ABI, since it holds a borrow, so the reuse channel is never pointed at stack memory.

Interaction with per-module caching (#12674)

This does not invalidate the planned cache. Placement for a closure local depends only on the uses inside its own proc and on the ownership signatures of the callees it is passed to, which are in the same module or upstream. Upstream signatures are already an input the caching plan must make stable (#12672 / #12674: a module's exported ownership signatures come from the module and its imports, never from its callers). Once that holds, placement is a pure function of the module's content plus its imports' checked keys, which is exactly the cache key. Nothing downstream can affect it, because downstream code can only call this module's procs and cannot observe how a local inside one was placed. The containing proc's own signature is unchanged because placement is internal to the body, and the callee sees the same layout either way. If an upstream edit flips a callee parameter from borrowed to owned, that module's key changes, every importer's key changes with it, the caller is recompiled, and the closure demotes to the heap form.

Ordering: this needs the fixed-upstream, per-module inference model from #12672 to exist first, and it builds on the dyn closure op and the dynamic frame from the Boxy rewrite, so it lands after both.

Scope

  • ARC solver: closure constructions as borrowing nodes in the existing fixpoint; captures as borrowed occurrences with liveness constraints on their lenders; demotion on any owned occurrence.
  • ARC emission: read the placement at the construction site; omit the release and the capture increfs for stack-placed closures.
  • LIR: placement on closure / dyn closure; certifier checks that a stack-placed closure has no owned occurrence and that every captured lender is live across every use.
  • Backends, interpreter, LirImage: emit the stack form with the immortal refcount; no other change.
  • design.md: document the placement rule in the ARC section next to the existing erased-callable reuse rules, and the borrowed-captures node in the solver's equations.
  • Tests: LIR harness cases pinning the stack form for the map shape, demotion for each kind of owned occurrence (return, capture by another closure, store, owned parameter, host boundary, Box.box), the dynamic-capture frame case, and a differential run of the Boxy CLI suite.

Activity

  1. added
    boxyThe --specialize=no backend, aka boxy
    on Oct 28, 2026
  2. added theissue type on Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    boxyThe --specialize=no backend, aka boxy

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions