From dd1b44544da1dcc0d786e0f66adcdffa9ebe534d Mon Sep 17 00:00:00 2001 From: oxura Date: Fri, 9 Oct 2026 13:10:01 +0600 Subject: [PATCH 1/4] fix(runtime): tail-drop final owned children --- CHANGELOG.md | 6 ++++++ bend2/comp.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ea5ee750..e9ee8cb7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Each release names what changed for a user. `bend update` installs the latest one; the GitHub release carries the same notes. +## Unreleased + +- Native erasure releases a node before dropping its last owned child. + Repeated allocation and erasure of wrapped lists no longer slow down + progressively (#1307). + ## 2.0.36 (2026-10-07) - **HOC's proving agent is now named BendAI**: `bend login` logs in to diff --git a/bend2/comp.ts b/bend2/comp.ts index 771db8e4d2..253ee8c026 100644 --- a/bend2/comp.ts +++ b/bend2/comp.ts @@ -3949,6 +3949,11 @@ FAR void term_drop(Env e, Term t) { cur += 1ull << 40; } if (!term_triv(c)) { + // The last child is held in c; its frame can be freed now. + if (j + 1 == n) { + cur = H[loc]; + heap_free(e, cls, loc); + } t = c; break; } From ceaa7748f05f3a0f43f1668f8b4aab4dc52e8689 Mon Sep 17 00:00:00 2001 From: oxura Date: Fri, 9 Oct 2026 15:49:53 +0600 Subject: [PATCH 2/4] docs: leave release changelog to maintainers --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ee8cb7dd..f9ea5ee750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,6 @@ Each release names what changed for a user. `bend update` installs the latest one; the GitHub release carries the same notes. -## Unreleased - -- Native erasure releases a node before dropping its last owned child. - Repeated allocation and erasure of wrapped lists no longer slow down - progressively (#1307). - ## 2.0.36 (2026-10-07) - **HOC's proving agent is now named BendAI**: `bend login` logs in to From ad071bfb3fc8c5ea531121345a704e067584fe8d Mon Sep 17 00:00:00 2001 From: Nicolas Abril Date: Fri, 9 Oct 2026 14:30:35 +0200 Subject: [PATCH 3/4] term_drop frees a node's frame before its recursive field, in any field order compile_book puts each constructor's spine in CID_T: the word of its last field whose type holds its own datatype (255 when none). term_drop swaps the spine into the last slot when it opens the node, so the last child's early free of #1438 fires on the spine whatever the field order: a list with its tail first, before a word or behind a List drops in constant frames. The inner loop has one pop for the last and the exhausted child, and skips the dead counter and cursor writes there. tests/rfc/drop_spine covers the three shapes, owned and shared. Co-Authored-By: Claude Opus 5.5 (1M context) --- bend2/comp.ts | 53 ++++++++++++++---------- tests/rfc/drop_spine.bend | 86 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 22 deletions(-) create mode 100644 tests/rfc/drop_spine.bend diff --git a/bend2/comp.ts b/bend2/comp.ts index 253ee8c026..55da499eac 100644 --- a/bend2/comp.ts +++ b/bend2/comp.ts @@ -2823,12 +2823,21 @@ export function compile_book(book: Bend.Book): string { "#endif"]; const entries = [...FL.segs, seg_new(IO_EMIT, BOX, [""]), seg_new(CLO_APPLY, BOX, ["", ""])]; - const cids = new Map(); - const cid = (k: Name, n: number) => - cids.set(k, n > WIDE ? 240 + Math.log2(n) : n); + const cids = new Map(); + const cid = (k: Name, n: number, tail = 255) => cids.set(k, n > WIDE + ? [240 + Math.log2(n), Number(FL.hot.has(k)), 255] + : [n, Number(FL.hot.has(k)), tail]); for (const k of FL.srcs.keys()) { for (const c of (FL.book.tlds[k] as Bend.ADT).c ?? []) { - cid(c.k, lay_node(c.k).ks.length); + let at = 0; + let tail = 255; + for (const F of ctr_doms(c)) { + if (ty_holds(F, (u) => u?.$ === "ADT" && u.k === k)) { + tail = at; + } + at += lay_of(F).ks.length; + } + cid(c.k, lay_node(c.k).ks.length, tail); } } done_defs(def_foreign).forEach(([k]) => cid(k, fun_of(k).lays.length)); @@ -2836,7 +2845,7 @@ export function compile_book(book: Bend.Book): string { .map((s) => s.fid)), [...FL.segs, { fid: seg_fid(CLO_APPLY), refs: FL.clos }].flatMap((s) => [...s.refs].map((r) => [r, s.fid]))); if (entries.some((s) => s.params.length > WIDE) - || [...cids.values()].some((n) => n > 255)) { + || [...cids.values()].some(([n]) => n > 255)) { die("an arity over " + WIDE); } const defs = [[...cids.keys()].map(cid_mac), @@ -2852,8 +2861,8 @@ export function compile_book(book: Bend.Book): string { `{ ${s.params.length}, ${s.frame === null ? 0 : s.params.length - s.frame.at.length}, ${Number(FL.bangs.has(s.def)) | Number(!forky.has(s.fid)) << 1} }`).join(", ")} };`, - `CONSTV u8 CID_T[][2] = { ${[...cids].map(([k, n]) => - `{ ${n}, ${Number(FL.hot.has(k))} }`).join(", ")} };`, + `CONSTV u8 CID_T[][3] = { ${[...cids.values()].map((r) => + `{ ${r.join(", ")} }`).join(", ")} };`, `#define STAT_LEN ${FL.img.length}`, "", `#define WL_RESW ${resw}`, `#define BANGS ${FL.bangs.size}`, "", `#define WL_BANK Term ${ws.join(", ")};`, "", @@ -3918,6 +3927,12 @@ FAR void term_drop(Env e, Term t) { u32 cls = tag == TAG_ARR ? 64 | blk_cls(t) : n > ${WIDE} ? 64 | (n - 240) : cls_fit(tag == TAG_TSK ? n + 2 : n); + u32 s = tag == TAG_CTR ? CID_T[aux][2] : n; + if (s + 1 < n) { + Term f = H[loc + s]; + H[loc + s] = H[loc + n - 1]; + H[loc + n - 1] = f; + } c0 = H[loc]; H[loc] = cur; cur = loc | ((u64)n << 48) | ((u64)cls << 56); @@ -3940,27 +3955,21 @@ FAR void term_drop(Env e, Term t) { j = (u32)H[loc + 1]; } } - if (j < n) { - Term c = j == 0 ? c0 : H[loc + j]; + Term c = j >= n ? 0 : j == 0 ? c0 : H[loc + j]; + if (j + 1 >= n) { + cur = H[loc]; + heap_free(e, cls, loc); + } else { if (arr && j > 0) { H[loc + 1] = j + 1; } if (!arr || i < 2) { cur += 1ull << 40; } - if (!term_triv(c)) { - // The last child is held in c; its frame can be freed now. - if (j + 1 == n) { - cur = H[loc]; - heap_free(e, cls, loc); - } - t = c; - break; - } - } else { - u64 up = H[loc]; - heap_free(e, cls, loc); - cur = up; + } + if (!term_triv(c)) { + t = c; + break; } } } diff --git a/tests/rfc/drop_spine.bend b/tests/rfc/drop_spine.bend new file mode 100644 index 0000000000..96a56a1837 --- /dev/null +++ b/tests/rfc/drop_spine.bend @@ -0,0 +1,86 @@ +# drop order: term_drop moves a constructor's recursive field to its +# last slot, so its frame frees before the spine drops. RCon has the +# spine first, MCon before a word, and Rose behind a List; each item is +# a boxed one-cell List. Each round drops a fresh owned copy whole, +# then shares one, sums it through one holder and drops it through the +# other; a misplaced field would read back wrong from reused memory in +# a later round. Expect 3 rounds of 3 * 5050 + 100 * 7. +import Base + +type RL is Data: + RNil{} + RCon{rest: RL, item: List<&2, U32>} + +type ML is Data: + MNil{} + MCon{item: List<&2, U32>, rest: ML, tag: U32} + +type Rose is Data: + Rose{kids: List<&2, Rose>, item: List<&2, U32>} + +def rl(+n: Nat) -> RL: + match n: + case 0n: + RNil{} + case 1n+m: + RCon{rl(m), [U32.from_nat(n)]} + +def ml(+n: Nat) -> ML: + match n: + case 0n: + MNil{} + case 1n+m: + MCon{[U32.from_nat(n)], ml(m), 7} + +def rose(+n: Nat) -> Rose: + match n: + case 0n: + Rose{[], [0]} + case 1n+m: + Rose{[rose(m)], [U32.from_nat(n)]} + +def first(xs: List<&2, U32>) -> U32: + match xs: + case []: + 0 + case x <> rest: + x + +def rl_sum(xs: RL) -> U32: + match xs: + case RNil{}: + 0 + case RCon{rest, item}: + U32.add(first(item), rl_sum(rest)) + +def ml_sum(xs: ML) -> U32: + match xs: + case MNil{}: + 0 + case MCon{item, rest, tag}: + U32.add(U32.add(first(item), tag), ml_sum(rest)) + +def rose_sum(r: Rose) -> U32: + match r: + case Rose{[], item}: + first(item) + case Rose{k <> rest, item}: + U32.add(first(item), rose_sum(k)) + +def waste(r: RL, m: ML, o: Rose, x: U32) -> U32: + x + +def round(+n: Nat) -> U32: + a = waste(rl(n), ml(n), rose(n), 0) + +r = rl(n) + +m = ml(n) + +o = rose(n) + b = U32.add(rl_sum(r), U32.add(ml_sum(m), rose_sum(o))) + U32.add(a, waste(r, m, o, b)) + +def main() -> IO(Unit): + IO.print(Nat.show(U32.to_nat(round(100n))) + ++ " " ++ Nat.show(U32.to_nat(round(100n))) + ++ " " ++ Nat.show(U32.to_nat(round(100n)))) + +#|15850 15850 15850 From 40f9cc36ee7863888ca1d1f9f151d7daf7a5f48e Mon Sep 17 00:00:00 2001 From: Nicolas Abril Date: Mon, 12 Oct 2026 19:35:22 +0200 Subject: [PATCH 4/4] term_drop frees a frame at its last nontrivial slot, for every node kind A pushed frame drops its trailing trivial slots, so a constructor, closure, task, Array or wide node frees before its last live child whatever words follow it; an Array or wide node whose live end is under 256 becomes a plain frame at its block's class. The tail column of CID_T is computed where the table is printed: the last box of the last field that holds the node's own datatype (a Maybe's pointer, not its tag), so a spine declared first or wrapped frees as early as one declared last. drop_spine adds a list whose spine sits in a flat Maybe; drop_array_spine runs a spine through a 4-cell and a 512-cell Array. Co-Authored-By: Claude Opus 5.5 (1M context) --- bend2/comp.ts | 52 +++++++++++++++------- tests/rfc/drop_array_spine.bend | 77 +++++++++++++++++++++++++++++++++ tests/rfc/drop_spine.bend | 44 ++++++++++++++----- 3 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 tests/rfc/drop_array_spine.bend diff --git a/bend2/comp.ts b/bend2/comp.ts index 55da499eac..4bfe5666b2 100644 --- a/bend2/comp.ts +++ b/bend2/comp.ts @@ -1029,6 +1029,21 @@ function ctr_doms(ctr: Bend.Ctr, xs?: HTerm[]): HTerm[] { return ctr_live(ctr, xs).map(([, , A]) => A); } +// The slot term_drop moves last, so a node frees before its spine: the +// last box of the last field that holds the node's own datatype. +function ctr_spine(k: Name, n: number): number { + const ctr = FL.book.ctrs[k]; + if (!ctr || n > WIDE) { + return 255; + } + const fam = Bend.book_fam(FL.book, k); + const lays = lay_node(k).arms![k]; + const i = ctr_doms(ctr).findLastIndex((F) => + ty_holds(F, (u) => u?.$ === "ADT" && u.k === fam)); + return i < 0 ? 255 : lays.slice(0, i).reduce((at, lay) => + at + lay.ks.length, lays[i].ks.lastIndexOf("box")); +} + function ctr_flds(k: Name, xs: HTerm[]): HTerm[] { const ds = FL.book.ctrs[k] ? ctr_tail(FL.book.ctrs[k]) : []; return xs.filter((_, j) => !ds[j] || dom_live(ds[j])); @@ -2823,21 +2838,12 @@ export function compile_book(book: Bend.Book): string { "#endif"]; const entries = [...FL.segs, seg_new(IO_EMIT, BOX, [""]), seg_new(CLO_APPLY, BOX, ["", ""])]; - const cids = new Map(); - const cid = (k: Name, n: number, tail = 255) => cids.set(k, n > WIDE - ? [240 + Math.log2(n), Number(FL.hot.has(k)), 255] - : [n, Number(FL.hot.has(k)), tail]); + const cids = new Map(); + const cid = (k: Name, n: number) => + cids.set(k, n > WIDE ? 240 + Math.log2(n) : n); for (const k of FL.srcs.keys()) { for (const c of (FL.book.tlds[k] as Bend.ADT).c ?? []) { - let at = 0; - let tail = 255; - for (const F of ctr_doms(c)) { - if (ty_holds(F, (u) => u?.$ === "ADT" && u.k === k)) { - tail = at; - } - at += lay_of(F).ks.length; - } - cid(c.k, lay_node(c.k).ks.length, tail); + cid(c.k, lay_node(c.k).ks.length); } } done_defs(def_foreign).forEach(([k]) => cid(k, fun_of(k).lays.length)); @@ -2845,7 +2851,7 @@ export function compile_book(book: Bend.Book): string { .map((s) => s.fid)), [...FL.segs, { fid: seg_fid(CLO_APPLY), refs: FL.clos }].flatMap((s) => [...s.refs].map((r) => [r, s.fid]))); if (entries.some((s) => s.params.length > WIDE) - || [...cids.values()].some(([n]) => n > 255)) { + || [...cids.values()].some((n) => n > 255)) { die("an arity over " + WIDE); } const defs = [[...cids.keys()].map(cid_mac), @@ -2861,8 +2867,8 @@ export function compile_book(book: Bend.Book): string { `{ ${s.params.length}, ${s.frame === null ? 0 : s.params.length - s.frame.at.length}, ${Number(FL.bangs.has(s.def)) | Number(!forky.has(s.fid)) << 1} }`).join(", ")} };`, - `CONSTV u8 CID_T[][3] = { ${[...cids.values()].map((r) => - `{ ${r.join(", ")} }`).join(", ")} };`, + `CONSTV u8 CID_T[][3] = { ${[...cids].map(([k, n]) => + `{ ${n}, ${Number(FL.hot.has(k))}, ${ctr_spine(k, n)} }`).join(", ")} };`, `#define STAT_LEN ${FL.img.length}`, "", `#define WL_RESW ${resw}`, `#define BANGS ${FL.bangs.size}`, "", `#define WL_BANK Term ${ws.join(", ")};`, "", @@ -3933,6 +3939,20 @@ FAR void term_drop(Env e, Term t) { H[loc + s] = H[loc + n - 1]; H[loc + n - 1] = f; } + if (cls < 64) { + while (n > 1 && term_triv(H[loc + n - 1])) { + n -= 1; + } + } else { + u32 m = 1u << (cls & 63); + while (m > 1 && term_triv(H[loc + m - 1])) { + m -= 1; + } + if (m < 256) { + n = m; + cls &= 63; + } + } c0 = H[loc]; H[loc] = cur; cur = loc | ((u64)n << 48) | ((u64)cls << 56); diff --git a/tests/rfc/drop_array_spine.bend b/tests/rfc/drop_array_spine.bend new file mode 100644 index 0000000000..194d061465 --- /dev/null +++ b/tests/rfc/drop_array_spine.bend @@ -0,0 +1,77 @@ +# drop order through an Array: term_drop frees an Array's frame once only +# trivial cells follow, so a chain whose spine runs through one cell frees +# each Array before it drops the next link. ACon moves its kids behind its +# item. A 4-cell Array with the child in cell 1 becomes a plain frame of 2; +# a 512-cell one stays an Array frame, with the child last (512) and in +# cell 255 (live end 256, one past a plain frame). Each round drops fresh +# chains whole, then sums fresh ones; a cell freed early would read back +# wrong from reused memory in a later round. Expect 3 rounds of 3 * 5050. +import Base + +type AT is Type: + ANil{} + ACon{kids: Array, item: List<&2, U32>} + +def first(xs: List<&2, U32>) -> U32: + match xs: + case []: + 0 + case x <> rest: + x + +def nils(+d: Nat) -> Array: + match d: + case 0n: + ALeaf{ANil{}} + case 1n+p: + ANode{nils(p), nils(p)} + +# the child in the last cell, or with mid in the last cell of the low half +def kids(+d: Nat, mid: Bool, t: AT) -> Array: + match d mid: + case 0n _: + ALeaf{t} + case 1n+p True{}: + ANode{kids(p, False{}, t), nils(p)} + case 1n+p False{}: + ANode{nils(p), kids(p, False{}, t)} + +def at(+d: Nat, +mid: Bool, +n: Nat) -> AT: + match n: + case 0n: + ANil{} + case 1n+m: + ACon{kids(d, mid, at(d, mid, m)), [U32.from_nat(n)]} + +def pick(a: Array, mid: Bool) -> AT: + match a mid: + case ALeaf{t} _: + t + case ANode{xs, ys} True{}: + pick(xs, False{}) + case ANode{xs, ys} False{}: + pick(ys, False{}) + +def at_sum(+n: Nat, +mid: Bool, t: AT) -> U32: + match n t: + case 0n _: + 0 + case _ ANil{}: + 0 + case 1n+m ACon{ks, item}: + U32.add(first(item), at_sum(m, mid, pick(ks, mid))) + +def waste(a: AT, b: AT, c: AT, x: U32) -> U32: + x + +def round(+n: Nat) -> U32: + a = waste(at(2n, True{}, n), at(9n, False{}, n), at(9n, True{}, n), 0) + b = U32.add(at_sum(n, True{}, at(2n, True{}, n)), at_sum(n, False{}, at(9n, False{}, n))) + U32.add(a, U32.add(b, at_sum(n, True{}, at(9n, True{}, n)))) + +def main() -> IO(Unit): + IO.print(Nat.show(U32.to_nat(round(100n))) + ++ " " ++ Nat.show(U32.to_nat(round(100n))) + ++ " " ++ Nat.show(U32.to_nat(round(100n)))) + +#|15150 15150 15150 diff --git a/tests/rfc/drop_spine.bend b/tests/rfc/drop_spine.bend index 96a56a1837..4a5815a14a 100644 --- a/tests/rfc/drop_spine.bend +++ b/tests/rfc/drop_spine.bend @@ -1,10 +1,11 @@ # drop order: term_drop moves a constructor's recursive field to its -# last slot, so its frame frees before the spine drops. RCon has the -# spine first, MCon before a word, and Rose behind a List; each item is -# a boxed one-cell List. Each round drops a fresh owned copy whole, -# then shares one, sums it through one holder and drops it through the -# other; a misplaced field would read back wrong from reused memory in -# a later round. Expect 3 rounds of 3 * 5050 + 100 * 7. +# last slot and frees a frame once only trivial slots follow, so the +# frame frees before the spine drops. RCon has the spine first, MCon +# before a word, Rose behind a List and OCon behind a flat Maybe's tag; +# each item is a boxed one-cell List. Each round drops a fresh owned copy +# whole, then shares one, sums it through one holder and drops it through +# the other; a misplaced field would read back wrong from reused memory +# in a later round. Expect 3 rounds of 4 * 5050 + 100 * 7. import Base type RL is Data: @@ -15,6 +16,10 @@ type ML is Data: MNil{} MCon{item: List<&2, U32>, rest: ML, tag: U32} +type OL is Data: + ONil{} + OCon{next: Maybe<&2, OL>, item: List<&2, U32>} + type Rose is Data: Rose{kids: List<&2, Rose>, item: List<&2, U32>} @@ -32,6 +37,13 @@ def ml(+n: Nat) -> ML: case 1n+m: MCon{[U32.from_nat(n)], ml(m), 7} +def ol(+n: Nat) -> OL: + match n: + case 0n: + ONil{} + case 1n+m: + OCon{Some{ol(m)}, [U32.from_nat(n)]} + def rose(+n: Nat) -> Rose: match n: case 0n: @@ -60,6 +72,15 @@ def ml_sum(xs: ML) -> U32: case MCon{item, rest, tag}: U32.add(U32.add(first(item), tag), ml_sum(rest)) +def ol_sum(xs: OL) -> U32: + match xs: + case ONil{}: + 0 + case OCon{None{}, item}: + first(item) + case OCon{Some{rest}, item}: + U32.add(first(item), ol_sum(rest)) + def rose_sum(r: Rose) -> U32: match r: case Rose{[], item}: @@ -67,20 +88,21 @@ def rose_sum(r: Rose) -> U32: case Rose{k <> rest, item}: U32.add(first(item), rose_sum(k)) -def waste(r: RL, m: ML, o: Rose, x: U32) -> U32: +def waste(r: RL, m: ML, o: Rose, l: OL, x: U32) -> U32: x def round(+n: Nat) -> U32: - a = waste(rl(n), ml(n), rose(n), 0) + a = waste(rl(n), ml(n), rose(n), ol(n), 0) +r = rl(n) +m = ml(n) +o = rose(n) - b = U32.add(rl_sum(r), U32.add(ml_sum(m), rose_sum(o))) - U32.add(a, waste(r, m, o, b)) + +l = ol(n) + b = U32.add(U32.add(rl_sum(r), ml_sum(m)), U32.add(rose_sum(o), ol_sum(l))) + U32.add(a, waste(r, m, o, l, b)) def main() -> IO(Unit): IO.print(Nat.show(U32.to_nat(round(100n))) ++ " " ++ Nat.show(U32.to_nat(round(100n))) ++ " " ++ Nat.show(U32.to_nat(round(100n)))) -#|15850 15850 15850 +#|20900 20900 20900