Repository navigation
comp.ts: a fact stales only the units that read it absent - #1601
nicolas-abril wants to merge 2 commits into
Conversation
|
Reviewed merged onto main f5cee56. All gates pass: repo 54/54, ping 63/63, test 1670/1670, perf 124/124, safe 1090 agree / 0 false reject. Reproduced (PR vs main, built in both trees):
From reading, not run:
Also: #1386 does the same per-unit fact invalidation; one of the two should close. |
- JS: a constant array stays out of op templates (op_bare), so Array.set writes the array it returns (tests/run/const_array_op_js). - A pure main streams: a show def of a word is a String, any other is IO writes, so big outputs no longer build the whole String first. - String.quote walks its String with a tail call per char. - A fold that ran out of fuel is not cached for later units. - emit_ctr registers its ctr facts once per (def, ctr, type). - mat_lits compares a replayed default with its parent's instantiation. - arr_op and array intrinsics keep no root they just owned. - A host Nat past 2^53 fails with the host-range message on C, as on JS.
…s print through Bend The 68 commits of bendlang#1601 (ai/1601-prerebase, 053163d) squashed onto 642d8e1; bendlang#1786's comment and code cuts kept where they overlap.
053163d to
da7942c
Compare
|
Thanks for the review. All 8 items are addressed. The PR has been rebased onto main 642d8e1 as three commits (see the description). Results on the current head, da7942c, compared with main 642d8e1:
Each fix was checked by emitting every program under tests/, bench/ and demos/ (C and JS). Only the pure-main printers, the JS array case and the runtime error text changed. Gates on an M4 Max: repo 54/54, ping 62/62, test 1689/1689, perf 121–122/124. Main 642d8e1 gets 122/124 on the same machine and fails the same mandelbrot SEQ-CPU cell. CUDA and Linux have not been run. 🤖 Generated with Claude Code |
Lorenzobattistela
left a comment
There was a problem hiding this comment.
Thanks, all 8 hold up on da7942c (on main 642d8e1). The array program prints 7 on both lanes, a 200k-char String and a 3M-element list print the same bytes as main, and the list peaks at 49.7 MB against main's 49.5 MB. Gates: repo 54/54, ping 63/63, test 1689/1689, perf 124/124, safe 1098 agree / 0 false reject. Pure-main output matches main on 15 value shapes on C and JS, and a 1M-element list now prints on JS (main overflows).
One new break, two follow-ups:
- A pure main returning an imported type doesn't build (comp.ts ~2140). main_pure writes arms with the raw constructor id, so the generated source has
case lib2:Circ{f0}:and fails to parse:# lib2.bend import Base type Shape is Data: Circ{r: U32} Sq{s: U32, t: Shape} # main.bend import Base import ./lib2.bend as L def main() -> L.Shape: L.Sq{4, L.Circ{5}}bend main.bend -o xand-o x.jsboth fail; main printslib2.Sq{4, lib2.Circ{5}}. TheArray.to_list(~${term_show(E)})splice looks exposed the same way (Array<L.Shape>). A test with an imported type would cover it. - JS printing is ~15x slower on large values (~2091). Each piece is its own
IO.write, so onefs.writeSyncthrough the scheduler: a depth-20 tree (17.8 MB) prints in 5.73 s against 0.37 s on main (C 0.65 s vs 0.50 s). Buffering the writes would avoid it. - C host Nat (~6020): the check is now
> 2^53with JS's message, but a C Nat tops out at 2^48-1, so an answer in 2^48..2^53 still enters unchecked. NAT_IMM is the C bound.
Smaller:
- e6c2c5f drops
turn_fits; turn_set still writes 8 words from H_GROUPS, and nothing guards that against H_ROOT_DONE now. - A pure main's C is ~2.5x bigger (82 KB → 204 KB for a 3-type main) from the printer defs, and compile_book builds a file_new it throws away before file_new(main_pure(book)).
…s print through Bend The 68 commits of bendlang#1601 (ai/1601-prerebase, 053163d) squashed onto 642d8e1; bendlang#1786's comment and code cuts kept where they overlap.
da7942c to
d680a47
Compare
|
I pushed two commits on top of the buffering:
Three small things left on your side:
Note on 73525e6: since JS now builds the whole text, a 200k-element |
|
I pushed three commits on top of 33a0dcd. They make comp.ts shorter and don't change what programs print:
comp.ts is now 65,472 tokens (it was 65,917 at 73525e6; the cap is 66,000). Checks on 67d3e7b (macOS):
Your three items (the |
|
@Lorenzobattistela thanks. 524050d handles all three:
On the 200k Checked: repo 54/54, ping 62/62, test 1696/1696. JS depth-20 tree is unchanged (0.36 s, same md5). comp.ts is 65,502 tok (cap 66,000). Perf, CUDA and Linux not run. |
nicolas-abril
left a comment
There was a problem hiding this comment.
Review of 524050d against its merge-base 60ca80a. Everything below was measured or reproduced on an M4 Max unless marked otherwise. Items 1–4 should be fixed before merging.
1. A pure main's Array should be printed in place, not through Array.to_list
main_pure prints an Array<T> as show(Array.to_list(~T, x)). That copies the whole array into a linked list before printing anything, and on JS the list printer recurses once per element.
main() -> Array<U32> = Array.new(U32, d, 7) |
main | PR |
|---|---|---|
| C, 2^20, peak RSS | 5.4 MB | 29.7 MB |
| C, 2^22, peak RSS / time | 17.3 MB / 225 ms | 113.7 MB / 287 ms |
| JS, 2^16 | prints | bend: memory fault (machine stack overflow?) |
| JS, 2^22 | prints (147 MB) | overflows |
Proposal: the generated printer should read the array directly. I wrote both candidates by hand and measured them:
- Index loop with
Array.get(first/loopoverArray.size, oneIO.writeper element on C, a text-so-far accumulator on JS): C at 2^20 is 5.5 MB and at 2^22 is 17.5 MB / 237 ms, the same as main. Output is byte-identical to main's. On JS it prints 2^22 (499 MB, against main's 147 MB; the extra is the string ropes).Array.getneedsT: Data, though, andArray<Array<U32>>prints today on both sides. - Tree walk matching
ALeaf/ANode, with a "first" and a "rest" function for the separators. It works for anyT, and its recursion depth is only the tree depth. It costs more than the index loop: C at 2^20 is 13.7 MB and at 2^22 is 49.7 MB / 281 ms.
I'd use the index loop when the element type is Data and the tree walk otherwise.
2. Pure-main Lists still overflow the JS stack, and the description's 3M claim doesn't hold
Since 1b44649, the JS show functions return ", " ++ show(h) ++ show_tail(t), which isn't a tail call. Even before that, the accumulator held the text after the element, so the recursion still wasn't a tail call (33a0dcd also overflows).
List<U32> length, JS |
10k | 30k | 50k | 3M |
|---|---|---|---|---|
| main | prints | stack overflow | stack overflow | stack overflow |
| PR | prints | prints | memory fault | memory fault |
The fix is to carry the text printed so far: tail(t, acc ++ ", " ++ U32.show(h)), with Nil returning acc ++ "]". That compiles to a loop. Written by hand, it prints a 3M-element list on JS in 0.19 s (389 MB peak). The same form fixes the JS side of item 1.
3. unit_order overflows on long def chains (C)
visit recurses once per unit. Main folds such chains into a handful of functions; the PR makes a unit per def and walks them all recursively. Measured with compile_book, after load and check pass:
| Chain | main C | PR C |
|---|---|---|
| each def calls the next, one call site | builds up to 200k | overflows in unit_order from 10k |
| lend chain | builds at 50k | overflows in visit at 20k |
| two call sites per def | builds up to 100k | overflows from 10k |
This is a regression against main, unlike the JS lane's existing deep-recursion limits:
- The JS lane never calls
unit_order. - On JS, a one-call-site chain already overflows at about 5k on main (
term_higherunderopen_of) and between 5k and 9k on the PR (the loop-detectionvisitat comp.ts:1365, which main has too).
An explicit stack gives the same order. It produced byte-identical C and JS on 133 programs, a temporary check found no differences from the recursive order, and it builds the 200k / 50k / 100k chains above. It costs 116 ttok, which brings comp.ts to 65,618:
@@ -2016,14 +2016,28 @@
FL.units.forEach((u) => u.segs.forEach((s) => at.set(s.fid, u)));
const seen = new Set<Unit>();
const out: Unit[] = [];
+ // a stack of units and the callees left to visit, reversed: a chain of
+ // calls longer than the JS stack still sorts
+ const todo: [Unit, (Unit | undefined)[]][] = [];
const visit = (u: Unit | undefined) => {
if (u !== undefined && !seen.has(u)) {
seen.add(u);
- u.segs.forEach((s) => s.refs.forEach((r) => visit(at.get(r))));
- out.push(u);
+ todo.push([u, u.segs.flatMap((s) => [...s.refs].map((r) => at.get(r)))
+ .reverse()]);
}
};
- FL.units.forEach(visit);
+ FL.units.forEach((u) => {
+ visit(u);
+ while (todo.length > 0) {
+ const [v, rs] = todo.at(-1)!;
+ if (rs.length > 0) {
+ visit(rs.pop());
+ } else {
+ out.push(v);
+ todo.pop();
+ }
+ }
+ });
return out;
}4. op_bare: a negative F32 constant breaks the JS output
op_bare treats every constant as safe to inline in JS. js_word prints an F32 with its sign bit set as -1.5 or -0, so f32_neg's template (-$0) becomes (--1.5). Main's ATOM regex sent anything starting with - to a local.
Repro: F32.neg of an F32 built from bits 0xBFC00000, written as 32 nested WCon{…}.
| main C | main JS | PR C | PR JS | |
|---|---|---|---|---|
| bits of -1.5 | 1.5 |
1.5 |
1.5 |
(--1.5) → SyntaxError at load |
| bits of -0 | 0.0 |
0.0 |
0.0 |
(--0) → same |
Only the JS lane breaks, since C constants are …ull words. Source literals can't be negative, so the constructor form is the only way in. Fix, deciding from the term rather than the emitted text:
- return s.$ === "Var"
- || term_const(s) && !(js && s.$ === "Ctr" && s.k in OPTIMIZED.Array);
+ return s.$ === "Var" || term_const(s) && !(js && s.$ === "Ctr"
+ && (s.k in OPTIMIZED.Array || Bend.u32_from_term(s, "F32")! >= 2 ** 31));With this, both repros print correctly on both lanes, and the 66 buildable tests that mention F32 emit byte-identical JS.
5. The fold cache lets one unit inline far past FOLD_FUEL
FL.folds lives for the whole file, and a cache hit costs no fuel. So a unit gets for free calls that an earlier unit already folded, and the cap no longer holds per segment.
Synthetic test: 200 flat defs g{j}(+x: U32) with roughly 80-node bodies, plus a recursive a that sums g{j}(5) and a recursive b that sums g{j}(7). The table counts the g calls each def leaves unfolded (fewer means more inlined):
a (of 200) |
b with a present (of 400) |
b alone |
|
|---|---|---|---|
| PR | 163 | 385 | 371 |
| PR, fold cache cleared per unit | 185 | 385 | 385 |
| main | 189 | 385 | 393 |
On the PR, a inlines 37 bodies against main's 11, and its C spin is 3,826 lines instead of 2,154. b's code also changes when an unrelated a is added.
Fix (matches main's per-def memo_gc for folds):
function unit_emit(u: Unit): void {
const up = FL.unit;
FL.stale.delete(u);
+ FL.folds.clear();With this fix, all 856 C and 898 JS programs in tests/, bench/, demos/ and evals/ emit byte-identical output. Emit time is within noise on boss_3d, hvm5_mini, walkers_test and par_sort. I wouldn't fix this by not caching fuel-limited results instead: that runs the compiler out of memory on the synthetic test.
6. Smaller things
-
The
m.args.length === 0guard (comp.ts:2641) should come back. Nothing reaches the case it guarded:- A non-Ref head with live arguments is always
CLO_APPLY, andanflifts it into alet. - Flat bodies, the only ones
emit_unfoldcopies, can't contain one. - I made the branch exit and built all 1,801
.bendfiles under tests, demos, bench and evals: no hits.
But if a future path reaches it, the PR silently compiles
f(x)tof, where main stopped with a compiler error. Restoring the guard changes no output:- ?? (m.t.$ !== "Ref" ? m.h : null); + ?? (m.t.$ !== "Ref" && m.args.length === 0 ? m.h : null);
- A non-Ref head with live arguments is always
-
ownfacts are derived while some units are still stale (comp.ts:~3061). This is safe today, and main has the same pattern.- It happens in 846 programs, but the only fact read on a path that records a lend is
own, and adding it only removes lends. So stale lends are a superset of the fresh ones, and theownfacts derived from them are a subset of the correct ones. - The last derivation always runs on fresh data.
- Deriving only when nothing is stale (
if (FL.stale.size > 0) continue;) gives byte-identical output if you want the guard.
- It happens in 846 programs, but the only fact read on a path that records a lend is
-
Compiler memory isn't a regression. The PR uses less than main everywhere I measured. Each cell is peak RSS / compile time / heap still held after compile:
Program Lane main PR boss_3d C 584 MB / 1004 ms / 131 MB 344 MB / 361 ms / 83 MB boss_3d JS 354 MB / 202 ms / 76 MB 255 MB / 112 ms / 43 MB hvm5_mini C 268 / 261 / 42 200 / 156 / 37 walkers_test C 222 / 263 / 24 166 / 101 / 20 netcode server C 204 / 149 / 38 159 / 80 / 22 17 perf benches C 114–148 MB peak 114–143 MB peak - Every program settles in 3 sweeps, with 1.4–2× re-emission, so cache growth stays bounded in practice.
- Clearing the term caches and
PROBESper unit, as main'smemo_gcdid, saves about 11 MB of held heap on boss_3d (6 MB on hvm5_mini) for 0–6% compile time, with identical C. Optional. - Narrowing the
fact_whencallbacks todefsaves nothing measurable: boss_3d ends with 591 callbacks. It's still cleaner, sincefacts_hotandfacts_ctronly readsc.def. - Separately, the C lane never resets
PROBESaftercompile_book(js_libdoes), which only matters for in-process users like the loader.
-
The description is stale:
The simplification half of bendlang#1601, on main's pass loop: compile_book still emits every def again until a pass adds no fact. - Facts are one set of strings (own, stat, ctr, type, fam, arg, open) read through fact(), replacing Hot, FL.stat, FL.own, the brws memo and the reset of ten fields per pass; a fact that only leads to others is an implication (fact_when). Each def and spin keeps its own output. - A value carries the borrowed root of each box (Val.rs); a Nat's Succs are counted on the term (nat_peel); a table's rows are words on both lanes, folded on values, and an op's JS runs on them only if it calls nothing past Math.fround and Math.imul; a local is a name name_local made; an op's argument is held unless it is a variable or a constant (op_bare); a JS call that waits on its callee calls its entry ($$k); a JS closure captures what is free in it. - A match arm that rebuilds its scrutinee reads it (arm_self), so JS needs no views. - A pure main prints through a Bend printer of main's value (main_pure), instead of a descriptor and a C and a JS printer; Base gains Char.quote, String.quote, F32.show.dot, U32.hex, Array.write and Array.show. JS holds stdout off a terminal and writes it before a wait, an error, an exit, a File.write or at 64 KiB. - The term caches live as long as the file; the fold cache is cleared per def. Reserved names are refused before anything else, on both lanes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
compile_book emitted every def again until a pass added no fact. Now a fact read as absent records its reader, and adding it marks only those units stale; a sweep emits the stale ones callee-first (unit_order). A flat def, a def with one call site and a spin are units only once a segment names them (unit_lazy), so unreachable ones are never emitted. C and JS emit time (C and JS, median of 5, M4 Max): boss_3d 587 ms -> 391 ms; the 35 demos and benches that build, 1.89 s -> 1.38 s in total (main: 1.13 s and 3.04 s). JS is byte-identical; C matches up to the order of segments, spins, tables and static nodes, except where a def no segment reaches no longer adds facts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
039126f to
188bfa9
Compare
The compile-speed half of #1601, on top of #1817 (this PR shows both commits until #1817 merges; the second, 188bfa9, is this one's).
compile_bookemitted every def again until a pass added no fact. Now a fact read as absent records its reader, and adding the fact marks only those units stale; a sweep emits the stale units callee-first (unit_order, an explicit stack, so a 200,000-def chain still sorts). A flat def, a def with one call site and a spin become units only once a segment names them (unit_lazy), so unreachable ones are never emitted.comp.ts is 66,489 ttok, 280 more than #1817 and 489 over the 66,000 cap, so the repo gate fails (53/54). Test gate: 1715/1715 on the cluster.
Compile time (C and JS emit, median of 5, M4 Max)
Output: JS is byte-identical to #1817's on all 1,820 programs. C matches up to the order of segments, spins, tables and static nodes, except 17 programs where a def nothing reaches no longer adds facts (a constructor marked hot, a lend that kept a parameter borrowed, dead static nodes); those 17 pass on all three lanes on both PRs.
This PR's earlier description covered both halves; the simplification half, with the review fixes and its tests, is now #1817. Main 9bc7df3 is in both: #1542's
ty_holdscomes over whole, and #1805's rule (only a box roots) sits inval_new, as roots travel on values here.🤖 Generated with Claude Code