Repository navigation
Build constructors before the calls that fill them (constructor tails and forks) - #1810
Lorenzobattistela wants to merge 7 commits into
Conversation
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>
# Conflicts: # bend2/comp.ts
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>
nicolas-abril
left a comment
There was a problem hiding this comment.
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).
| ty_ann(x.v[i])!)); | ||
| const [r] = val_own(sc, emit_expr(sc, b, null, BOX)); | ||
| if (ps.length === 1) { | ||
| bind_dead(sc, []); |
There was a problem hiding this comment.
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.
| 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)); |
There was a problem hiding this comment.
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.
| spares.slice() }, x, hole); | ||
| } | ||
| if (tree && last) { | ||
| bind_dead({ ...sc, uses: new Map(sc.uses) }, []); |
There was a problem hiding this comment.
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);.
| 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); |
There was a problem hiding this comment.
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.
| @@ -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, | |||
There was a problem hiding this comment.
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.
| const pos = new Map<Of<"Var">, number>(); | ||
| const pos = new Map<Probe, number>(); | ||
| let depth = 0; | ||
| let pnd = 0; |
There was a problem hiding this comment.
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.
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>
# Conflicts: # bend2/comp.ts
|
Thanks, all three reproduced with -DBEND_POISON and are fixed in 1f9d9ad:
tests/compile/ctor_tail_dest_lend.bend combines p1–p3 and faults under BEND_POISON on e75b98f. |
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.
t = f(p); Link{t}): list, string and chain builders (List.map, append, aLinkchain, aStringbuilder) run as loops with constant stack, and reuse the cell they consume:List.mapover a list allocates and frees nothing.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_holefinds a call's field: a non-hot, non-ArrayCtrthat holds the result once, in aBOXfield, with the segment and the callee returning oneBOX.emit_fork's sequential arm):emit_destemits the constructor with an empty placeholder (node_fillskips the store), hands over the consumed node's spare, thenWL_DEST(root, field)and the existing tail jump.emit_fork):emit_destbuilds the node before the first call and publishes the root throughWL_DESTto 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.Dst~putframe[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 afterDst~put, soWL_DESTrecognizes 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 aDst~put.!seqheap 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 changecommits make room under the 66k cap (type aliases, anop()helper, a variadicfile_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.ts65,989 / 66,000), test 1699/1699, ping 73/73, safe 0 false rejects. Perf fails onlyhistogram:PARin both runs, which main fails in both runs too.Perf against main, best of two cluster runs each:
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
Qdbefore its fouraddkids, 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.mapin place, a three-way fork filled in turn, and a shared result that must keep the old order) andctor_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