Skip to content

fix(compiler): box wide continuation and closure captures - #1788

Open
sebastian-griego wants to merge 4 commits into
bendlang:mainfrom
sebastian-griego:fix/box-wide-captures
Open

sebastian-griego wants to merge 4 commits into
bendlang:mainfrom
sebastian-griego:fix/box-wide-captures

Conversation

@sebastian-griego

@sebastian-griego sebastian-griego commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Large continuation and closure captures can fail native compilation with an arity over 247. This change boxes captures before saving them.

Use one helper for function parameters and captures. Box the widest values until their width fits. Cache a flag for each parameter boxed by that helper, and preserve only those incoming boxes. Closure arguments and match fields use their normal layouts. Update capture bindings in place while retaining owners of borrowed arguments.

The regression tests cover calls, forks, closures, shared fields, and parent reads. Native layout assertions detect boxing every capture and unboxing a retained parameter. The original compiler comments are restored.

Related to #1069. Hundreds of separate one-word values and oversized combined call results can still exceed the limit.

Validation:

  • 53 repository test programs pass native execution at 1, 4, and 8 threads, plus JS.
  • Scalar closure and match probes pass. Generated C no longer keeps or sinks a raw scalar closure argument.
  • Native layout assertions fail on the earlier implementation.
  • The TypeScript check reports the same 11 existing diagnostics, with none in comp.ts. git diff --check passes.
  • Full cluster and GPU gates were not run.

The token gate is pending: restoring the comments puts comp.ts at 66,464 of 66,000 tokens. The requested upstream reduction, #1829, was closed without merging. This branch still needs that reduction before it can pass the gate.

@Lorenzobattistela Lorenzobattistela self-assigned this Oct 22, 2026

@Lorenzobattistela Lorenzobattistela left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I merged this onto main 96c068f and ran the gates. test, ping, perf and safe all match main. I found no miscompile: both new tests pass, and so did two extra probes (a two-call fork holding several 67-word two-arm values shared after the call, and a closure capturing borrowed wide parameters the caller reads again). They match the interpreter at 1, 4 and 8 threads and in JS.

None of the points below are correctness issues. They are about boxing more than needed, and about code shape. comp.ts is right at its token cap, so take the ones that are cheap in tokens. If a fix costs too many tokens, skip it and say so.

  1. emit_fork counts values the call consumes (bind_caps call in emit_fork). The width is computed over every binding in sc.uses, including ones the calls take as arguments. Because of this, defs that already fit get boxed. In tests/compile/record_wide_node.bend, sum4(a, b, c, d) holds 256 words. After a and b go to the calls, only 128 words plus 2 results are saved. Main builds it with at most 193 params per segment. With this PR all four R1s are boxed: 4 extra heap_alloc per call, then 8 unboxes to pass a and b right back. Counting only the bindings still used after the call (the continuation's live set) would leave these defs alone.

  2. Values that arrive boxed get boxed again. In wide_capture_closure.bend, check(+a, +b, +c, +d) receives its four 67-word parameters boxed, through the wide-parameter rule in fun_of. They are unboxed on entry (4× spare_free), then bind_caps allocates four fresh 66-word nodes for the closure. sum in wide_capture_calls.bend takes the same unbox → box → unbox path. If the original box could be kept when the scope is already wide, both copies would go away.

  3. Every multiword binding gets boxed. Once the total passes WIDE, all multiword captures are boxed, even small ones. Take one 240-word record plus ten 2-word pairs: boxing only the record fits, but this boxes all eleven. Boxing widest-first until the total fits would allocate less.

  4. emit_fork copies the scope on every call. new Map(bind_caps(sc, [...sc.uses], results)) builds a new array, a new Map and a new Scope for every fork or non-flat call, on every pass of the ownership fixpoint, even when nothing is boxed. Updating the entries in place (sc.uses.set(p, ...)) would avoid the copies, would drop the let live reassignment in emit_clo, and would save tokens.

  5. The rule now lives in two places. fun_of (const wide = ... > WIDE; ... wide && l.ks.length > 1 ? BOX : l) and bind_caps keep the same rule in two shapes (Lay[] vs Bind). One helper over layouts plus an extra width could serve both. That would also make 3 a single change.

@sebastian-griego

Copy link
Copy Markdown
Contributor Author

Updated in c5b3b07, based on current main b8664b0.

  1. emit_fork now excludes single-use, unowned bindings consumed by the first call. It keeps later-call arguments, repeated uses, and owners. This is still conservative for owned or repeated values, so borrowed arguments keep their owners. record_wide_node now has no added record allocation sites and reaches at most 193 parameters per segment.
  2. Parameter binding keeps incoming boxes when the nominal parameter width exceeds WIDE. The generated check segment now allocates only its closure environment. The call and fork paths also retain their incoming box.
  3. The shared helper boxes the widest value first and stops when the width fits. The new mixed-width test builds a 240-word record beside ten two-word pairs. Its call, fork, and closure capture paths allocate no pair boxes.
  4. emit_fork updates the existing scope map. It no longer copies the map and scope for capture boxing. emit_clo keeps one live array.
  5. fun_of and capture boxing now use the same layout helper.

I shortened compiler comments to keep the file at 65,987 of 66,000 tokens.

Validation: 53 programs pass at 1, 4, and 8 CPU threads and in JS. All three capture tests pass ASAN and UBSAN at those thread counts. The repository gate passes 54 / 54. The TypeScript check still reports 11 existing diagnostics, with none in comp.ts. Full cluster and GPU gates were not run.

@Lorenzobattistela Lorenzobattistela left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround! I merged c5b3b07 onto main b8664b0 and ran the gates. repo (54 / 54, comp.ts 65,987), test, ping, perf and safe all match main. I found no miscompile: the three new tests and four extra probes give the same output in the interpreter, JS and C, and main rejects every probe with "an arity over 247". The narrowed count in emit_fork also checks out. It leaves out exactly the bindings emit_args deletes, borrowed arguments keep their owners, and I couldn't build a frame that outgrows the check.

Please change these three before merge:

  1. The kept-box check applies to the whole def, not to each parameter (Lam case). The test asks whether the def is wide, not whether lay_limit boxed this particular parameter. So inside a wide def, every argument whose layout is BOX skips the unbox. That includes closure arguments, which always arrive as BOX. For example, in a wide go(+a..+d: Bundle, +p: Duo), the closure +x => U32.add(x, x) emits term_keep(e, _x_0, 1) on a raw U32, and x => 5 emits term_sink on one. The same closures in a non-wide def emit neither. This is harmless today only because U32/Nat words carry tag 0, and ownership and hot facts still flow from a box that doesn't exist. Please make the test per parameter: fun_of(sc.def).lays[i] is BOX while lay_of(A) is not. Storing those flags on Fun in fun_of, where lay_limit already knows them, also stops the Lam case from recomputing the def's total width for every binder.

  2. Please restore the unrelated comments. About 25 comments outside this fix were rewritten to fit the cap, including the file-header NOTE, and some of them lost information:

    • "A spin of SPIN_FAR characters is a call on the device" → "SPIN_FAR selects device calls" (the threshold is gone)
    • "An argument nested past TPL_DEEP brackets goes to a local" → "TPL_DEEP limits argument nesting" (the action is gone)
    • "A frame slot holding a shared word takes the wrapped one" → "Frame slots store the shared word" (now wrong: the slot holds the term_keep-wrapped word)
    • the WL_LOAD ladder, and the brwl/own/hot names in the File comment, are gone

    #1829 brings comp.ts down to about 65,414, so once it lands this PR fits without the rewrites. Please rebase on it then and keep only the comments this change actually needs.

  3. wide_capture_mixed.bend doesn't test the new commit. It passes on 6b5e45b (before this commit) with the same output, so a regression to boxing every capture, or to unboxing incoming boxes, would go unnoticed. It needs a case that only fits with widest-first, for example a record plus enough Duos that the total stays over WIDE unless the record is boxed first. It could also use a case that fails without the kept box.

Optional, same as before: take these only if they're cheap in tokens.

  1. Owners used once are still counted. An owned value with n=1, passed only to an owning parameter of the first call, still counts toward the limit even though emit_args pops it. If it tips the total over WIDE, it gets boxed and then unboxed right away at that call. Leaving out owners whose only use is a non-borrowed argument of x.v[0] would avoid that.

  2. Different defs box different parameters. lay_limit picks boxes per def and breaks ties by index. So two defs with the same parameter types but different counts box different parameters. For example, once(a, b, c, d, e, x) boxes a and b, sum(a, b, c, d, x) boxes only a, and every call from once to sum unboxes b. Widest-first moves this conversion cost between defs rather than removing it. A stable choice (by type, not index) might help, but it's not required here.

  3. The bind_caps contract is implicit. It now returns void and updates its caller's array in place, and its doc comment is gone. A future caller that passes a fresh .filter() result would silently lose the boxing. A one-line comment, or setting sc.uses inside bind_caps, would make that explicit.

@sebastian-griego

Copy link
Copy Markdown
Contributor Author

Updated the parameter check, restored the comments, and added layout regression checks. Local tests pass. #1829 was closed without merging, so the rebase is pending and the token gate remains over its cap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants