Repository navigation
Stack-place non-escaping Boxy closures inside ARC's existing solve/emit, no separate pass #12678
Description
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
dynamiclayout 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/decrefa borrowed callee performs internally is then a no-op, so backends keep dumbly following the explicitincref/decrefstatements 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 closureop 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
mapshape, 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