Skip to content

Build constructors before the calls that fill them (constructor tails and forks) - #1810

Open
Lorenzobattistela wants to merge 7 commits into
mainfrom
ctor-tail-dest
Open

Lorenzobattistela wants to merge 7 commits into
mainfrom
ctor-tail-dest

Conversation

@Lorenzobattistela

@Lorenzobattistela Lorenzobattistela commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

A clean rewrite of the idea in #1793 (tail modulo cons), sequential path only.

What

A def whose body ends in a constructor that boxes a call's result once now builds that constructor before the call, and the call fills the field.

  • One call (t = f(p); Link{t}): list, string and chain builders (List.map, append, a Link chain, a String builder) run as loops with constant stack, and reuse the cell they consume: List.map over a list allocates and frees nothing.
  • A fork whose constructor is made only of its calls' results (l r = f(a) g(b); Node{l, r}, each once, in box fields): the node is built before the first call, reusing the consumed node, and every call fills its own field. A child that is itself a constructor tail writes its root straight into the parent's field.

How

bend2/comp.ts:

  • ctr_hole finds a call's field: a non-hot, non-Array Ctr that holds the result once, in a BOX field, with the segment and the callee returning one BOX.
  • One call (emit_fork's sequential arm): emit_dest emits the constructor with an empty placeholder (node_fill skips the store), hands over the consumed node's spare, then WL_DEST(root, field) and the existing tail jump.
  • Fork ("tree" in emit_fork): emit_dest builds the node before the first call and publishes the root through WL_DEST to the last call's field. Each earlier call's frame holds its field (the first frame also holds the node, which later continuations read at its fixed stack offset), and its continuation stores the result there. The last call pops the fork's words and tail-jumps. Values the fork drops are freed there, where the join would have freed them.
  • Runtime: a Dst~put frame [root, field, FID] stores the returned value into the field and returns the root. A fork's continuations are dst segments with the same layout (field just under the fid): they take the fids right after Dst~put, so WL_DEST recognizes any of them with one unsigned range check, writes the root into that field and points the frame at its own field, or else pushes a Dst~put.
  • Unchanged: the !seq heap continuation, the fork's parallel arm and its join (whose parameters the tree rebuilds in the same order), WL_RETN. No new tag, no bits packed into continuation words.

Two comp.ts: shed tokens with no output change commits make room under the 66k cap (type aliases, an op() helper, a variadic file_push, inlined single-use locals, redundant checks dropped, tighter comments). Over every test, bench and demo, the first leaves JS byte-identical and changes C only in runtime comments; the second leaves C and JS byte-identical.

Measured

Gates on main 5ce1e07: repo 54/54 (comp.ts 65,989 / 66,000), test 1699/1699, ping 73/73, safe 0 false rejects. Perf fails only histogram:PAR in both runs, which main fails in both runs too.

Perf against main, best of two cluster runs each:

bench SEQ PAR GPU
tree-radix 3.688 → 3.114 (−16%) 0.652 → 0.532 (−18%) 1.118 → 0.820 (−27%)
kmeans 3.744 → 3.711 0.771 → 0.771 0.638 → 0.557 (−13%)
lexer 1.940 → 1.953 0.353 → 0.342 2.598 → 0.747 (−71%)
tree-bitonic 5.459 → 5.387 1.305 → 1.285 1.699 → 1.660
tree-matmul 1.926 → 1.880 (−2%) 0.385 → 0.379 (−2%) 0.858 → 0.931 (+9%)

Everything else is within noise. tree-matmul GPU is the one cost: +5% to +9% across runs (median of six alternating runs on one mini: 0.877 → 0.922). Building each Qd before its four add kids, rather than after, is the difference there. The gate's tree-radix compile cell (0.515 → 0.721 s) is placement: the same build on one mini, alternating with each tree's compiler, takes 0.72–0.84 s on main and 0.71–0.76 s here.

Tests: tests/compile/ctor_tail_dest.bend (a box between word fields, a mutual even/odd chain, List.map in place, a three-way fork filled in turn, and a shared result that must keep the old order) and ctor_tail_dest_bang.bend (a chain, a left fork whose left kids nest, and ! kids meeting at a join, on each lane). Twelve probe programs also match main at 1 and 8 threads and on Metal, including one that drops a field at every fork node (same output and peak RSS as main).

🤖 Generated with Claude Code

Lorenzobattistela and others added 5 commits October 21, 2026 10:13
Type aliases (Ty, Probe), an op() helper for OPERATIONS, a variadic
file_push, FL.tlds/FL.ctrs, shared include and C-array helpers, a few
if-to-ternary rewrites and tighter comments. The emitted JS is
byte-identical over every test, bench and demo; the C differs only in the
tightened runtime comments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A def whose body ends in a constructor boxing its last call's result once
now builds that constructor first, with a placeholder in the field, and
tail-jumps into the call. The call's continuation is a Dst~put frame
[root, field] that stores the result and returns the root; a constructor
tail that finds one on top of the stack writes its own root there and
moves the frame to its field instead. So list, string and chain builders
(List.map, append, ...) run as loops with constant stack, and reuse the
cell they consume. Sequential mode only: the !seq arm and the fork join
are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A sequential fork whose body is a constructor made only of its calls'
results (each once, in a box field) now builds that node before its first
call, reusing the consumed node, and publishes the root through WL_DEST
to the last call's field. Each earlier call's frame holds the field (the
first also holds the node), and its continuation stores the result there:
a dst segment, so a child constructor tail fills it directly like a
Dst~put. Dst segments take the fids after Dst~put, so WL_DEST knows them
by range. The last call pops the fork's words and tail-jumps; the parallel
arm and its join are unchanged. Placeholder fields are not stored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Small equivalent rewrites: single-use locals and wrappers inlined,
redundant checks dropped (c_ids' !k, `=== ""` as `!k`), bind_set where
its body was spelled out, lay_c defaulting to a Term, optional Seg flags,
and direct op() entries for the array ops. Every test, bench and demo
emits byte-identical C and JS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Lorenzobattistela Lorenzobattistela changed the title Build constructor tails before their last call, which fills the field Build constructors before the calls that fill them (constructor tails and forks) Oct 21, 2026

@nicolas-abril nicolas-abril 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.

Building the constructor before the call looks right to me, and the two cleanup commits look behavior-preserving. But the new constructor-tail paths introduce a use-after-free.

Root cause. emit_fork still computes call arguments with emit_args(..., jump=false). In that mode an owned value is lent to a callee that borrows the parameter, on the assumption that the caller's frame keeps it alive and frees it after the call returns. The single-call path (emit_dest) and the last call of a tree fork drop that frame and become tail jumps, but keep the lending. The lent value is then freed (by bind_dead) or consumed (by another constructor field) before WL_JMP, and the callee reads freed memory.

Repro. Three programs below, built from e75b98f and from main (5ce1e07) with -DBEND_POISON:

probe path PR main
p1.bend single call, bind_dead in emit_dest bend: memory fault 2084625045
p2.bend tree fork, last call (needs +RTS --threads 1) bend: memory fault 378632985
p3.bend single call, sibling field consumes the borrowed value bend: memory fault 507450896

Without poisoning they happen to print the right numbers, which is why the test suite stays green. In all three, copy(xs) borrows xs because lend lends it elsewhere (c = copy(xs); ... mix(xs, 0)). For p1, wrap emits:

term_sink(e, _v_0); WL_DEST(...); r0 = _v_0; WL_JMP(FID_copy);

Suggested fix. Compute the arguments of these frame-less calls as owned, the way emit_body's ordinary tail call does (emit_args(sc, ck, true, ...)), or keep the lent values held until the callee returns. Patching the individual bind_dead calls isn't enough, as p3 shows.

p1.bend
import Base

type L is Data:
  LN{}
  LC{h: U32, t: L}

type Chain is Data:
  End{}
  Link{v: U32, t: Chain}

def gen(n: Nat, +k: U32, acc: L) -> L:
  match n:
    case 0n:
      acc
    case 1n+p:
      gen(p, (k + 1 : U32), LC{k, acc})

def mix(xs: L, +s: U32) -> U32:
  match xs:
    case LN{}:
      s
    case LC{h, t}:
      mix(t, ((s * 31 : U32) + h : U32))

def copy(xs: L) -> Chain:
  match xs:
    case LN{}:
      End{}
    case LC{h, t}:
      r = copy(t)
      Link{(h + 1 : U32), r}

def csum(c: Chain, +s: U32) -> U32:
  match c:
    case End{}:
      s
    case Link{v, t}:
      csum(t, ((s * 31 : U32) + v : U32))

def lend(n: Nat) -> U32:
  +xs = gen(n, 1, LN{})
  c = copy(xs)
  (csum(c, 0) + mix(xs, 0) : U32)

def wrap(n: Nat) -> Chain:
  xs = gen(n, 7, LN{})
  t = copy(xs)
  Link{9, t}

def main() -> IO(Unit):
  do IO<Unit>:
    Unit <- IO.print(U32.show(lend(1000n)))
    IO.print(U32.show(csum(wrap(1000n), 0)))
p2.bend
import Base

type L is Data:
  LN{}
  LC{h: U32, t: L}

type Chain is Data:
  End{}
  Link{v: U32, t: Chain}

def gen(n: Nat, +k: U32, acc: L) -> L:
  match n:
    case 0n:
      acc
    case 1n+p:
      gen(p, (k + 1 : U32), LC{k, acc})

def mix(xs: L, +s: U32) -> U32:
  match xs:
    case LN{}:
      s
    case LC{h, t}:
      mix(t, ((s * 31 : U32) + h : U32))

def copy(xs: L) -> Chain:
  match xs:
    case LN{}:
      End{}
    case LC{h, t}:
      r = copy(t)
      Link{(h + 1 : U32), r}

def csum(c: Chain, +s: U32) -> U32:
  match c:
    case End{}:
      s
    case Link{v, t}:
      csum(t, ((s * 31 : U32) + v : U32))

def lend(n: Nat) -> U32:
  +xs = gen(n, 1, LN{})
  c = copy(xs)
  (csum(c, 0) + mix(xs, 0) : U32)

def wrap(n: Nat) -> Chain:
  xs = gen(n, 7, LN{})
  t = copy(xs)
  Link{9, t}

type Two is Data:
  TZ{}
  TN{a: Chain, b: Chain}
  TW{t: Two}

def pair(+n: Nat) -> Two:
  xs = gen(n, 3, LN{})
  ys = gen(n, 5, LN{})
  a b = copy(xs) copy(ys)
  TN{a, b}

def tsum(t: Two) -> U32:
  match t:
    case TZ{}:
      0
    case TN{a, b}:
      (csum(a, 0) + csum(b, 1) : U32)
    case TW{t}:
      tsum(t)

def main() -> IO(Unit):
  do IO<Unit>:
    Unit <- IO.print(U32.show(lend(1000n)))
    IO.print(U32.show(tsum(pair(1000n))))
p3.bend
import Base

type L is Data:
  LN{}
  LC{h: U32, t: L}

type Chain is Data:
  End{}
  Link{v: U32, t: Chain}

def gen(n: Nat, +k: U32, acc: L) -> L:
  match n:
    case 0n:
      acc
    case 1n+p:
      gen(p, (k + 1 : U32), LC{k, acc})

def mix(xs: L, +s: U32) -> U32:
  match xs:
    case LN{}:
      s
    case LC{h, t}:
      mix(t, ((s * 31 : U32) + h : U32))

def copy(xs: L) -> Chain:
  match xs:
    case LN{}:
      End{}
    case LC{h, t}:
      r = copy(t)
      Link{(h + 1 : U32), r}

def csum(c: Chain, +s: U32) -> U32:
  match c:
    case End{}:
      s
    case Link{v, t}:
      csum(t, ((s * 31 : U32) + v : U32))

def lend(n: Nat) -> U32:
  +xs = gen(n, 1, LN{})
  c = copy(xs)
  (csum(c, 0) + mix(xs, 0) : U32)

def wrap(n: Nat) -> Chain:
  xs = gen(n, 7, LN{})
  t = copy(xs)
  Link{9, t}

def lsum(xs: L, +s: U32) -> U32:
  match xs:
    case LN{}:
      s
    case LC{h, t}:
      lsum(t, (s + h : U32))

def wrap2(n: Nat) -> Chain:
  +xs = gen(n, 7, LN{})
  t = copy(xs)
  Link{lsum(xs, 0), t}

def main() -> IO(Unit):
  do IO<Unit>:
    Unit <- IO.print(U32.show(lend(1000n)))
    IO.print(U32.show(csum(wrap2(1000n), 0)))

There are also two minor notes inline (an unused tree-mode segment and the pnd bookkeeping).

Comment thread bend2/comp.ts
ty_ann(x.v[i])!));
const [r] = val_own(sc, emit_expr(sc, b, null, BOX));
if (ps.length === 1) {
bind_dead(sc, []);

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.

Use-after-free (p1). For a single call this now becomes a tail jump with no frame, but bind_dead here frees every owned value still in scope, including one that emit_args(jump=false) only lent to the call. wrap in p1 emits term_sink(e, _v_0); WL_DEST(...); r0 = _v_0; WL_JMP(FID_copy);, so copy walks a freed list.

Comment thread bend2/comp.ts
const { ps, b } = term_open(x);
ps.forEach((p, i) => bind_uses(sc, p, val_new([""], BOX), [b],
ty_ann(x.v[i])!));
const [r] = val_own(sc, emit_expr(sc, b, null, BOX));

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.

Use-after-free (p3). The constructor's other fields are now evaluated before the call. A field can consume a value the call only borrows: in +xs = gen(...); t = copy(xs); Link{lsum(xs, 0), t}, the flat call lsum owns xs and is inlined here, so it frees xs's cells before WL_JMP(FID_copy) reads them. This is why dropping the bind_dead above wouldn't be enough.

Comment thread bend2/comp.ts
spares.slice() }, x, hole);
}
if (tree && last) {
bind_dead({ ...sc, uses: new Map(sc.uses) }, []);

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.

Use-after-free (p2, +RTS --threads 1). The last call's sequential arm runs bind_dead on every held value, including ones lent to that last call, then WL_POPN and the tail jump pass the freed value. pair_k9 in p2 emits term_sink(e, _v_3); WL_POPN(4); r0 = _v_3; WL_JMP(FID_copy);.

Comment thread bend2/comp.ts Outdated
let pnd = 0;
const nd = tree ? emit_dest(sc, x, holes.at(-1)!) : "";
calls.forEach((c, i) => {
const cargs = emit_args({ ...sc, rest: [chain[i]] }, c);

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.

This is where I'd fix it: emit_args with the default jump=false assumes a frame keeps lent values alive across the call. On the paths that become tail jumps (single call, last call of a tree), the arguments should be computed as owned like emit_body's tail call (jump=true), or the lent values kept held. Otherwise every future frame-eliding path will reintroduce this.

Comment thread bend2/comp.ts
@@ -2668,18 +2640,60 @@ function emit_fork(sc: Scope, x: Of<"Let">, ers: HTerm[]): void {
const rs = val_new(ret.ks.map(() => name_local(sc, o.ps[i].k)), ret);
sc = seg_open(sc, kn, sc.seg.ret, { pop: last ? depth : 0, at }, held, rs,

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.

Minor: in tree mode the last continuation segment is still created with seg_open (parameter loads emitted, pushed to FL.segs), but nothing references it and it has no return or jump. Only the later liveness filter drops it. It's wasted work and it shifts seg_name numbering, and if anything ever seg_refs it, the C function falls off its end without a return. The join could be opened from the previous scope instead.

Comment thread bend2/comp.ts
const pos = new Map<Of<"Var">, number>();
const pos = new Map<Probe, number>();
let depth = 0;
let pnd = 0;

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.

Minor: pnd is always the first call's held-word count, but the nested ternaries recompute it on every iteration. Computing it once after the first call would make the STK(${pnd - depth}) offsets much easier to check.

Lorenzobattistela and others added 2 commits October 23, 2026 07:21
A call that leaves no frame (a constructor tail's call, a tree fork's last
call) took arguments as if a frame held its lent values, so bind_dead or a
sibling field freed them before the jump. Such calls now take their arguments
as a tail call does, and a constructor whose other fields share an owned value
with the call builds after it. A tree fork's join opens from the scope before
its dead last continuation, which is no longer emitted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Lorenzobattistela

Copy link
Copy Markdown
Collaborator Author

Thanks, all three reproduced with -DBEND_POISON and are fixed in 1f9d9ad:

  • Calls that leave no frame (a constructor tail's call, a tree fork's last call) now take their arguments as owned, like emit_body's tail call (emit_args with jump=true). That fixes p1 and p2.
  • p3: ctr_hole now refuses when a field and the call share an owned value, since the fields run first. Word fields still qualify.
  • Your two minor notes: the dead last continuation of a tree is no longer opened (the join opens from the previous scope: held values, then each result), and pnd is set once after the first call.

tests/compile/ctor_tail_dest_lend.bend combines p1–p3 and faults under BEND_POISON on e75b98f.

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