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:
039126f to
188bfa9
Compare
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>
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>
d78cdb3 to
eb3b7f5
Compare
compile_book emitted every def again until a pass added no fact. Now a fact read as absent registers its reader in FL.when, and adding it marks only those units stale; a sweep re-emits the stale units until none remain. A unit is created only once something names it (a "ref" fact from seg_ref), so unreachable defs are never emitted. C and JS emit time (median of 3, M4 Max, 15 demos): 1.09 s -> 0.75 s in total; boss_3d 555 ms -> 340 ms, hvm5_mini 170 ms -> 125 ms. JS is byte-identical to main; 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>
Units are discovered caller-first, so an own fact climbing a chain took one sweep per def: a 3,200-def chain took 3,202 sweeps and 5.1 s. Each sweep now goes forward, which finds the callees, then backward, which lets a fact climb the chain at once. That chain takes 2 rounds and 0.34 s, and it scales linearly. A fact keeps one reader set, and only a read of an absent fact registers, so a re-emit no longer queues closures. A def becomes a unit when seg_ref names it, through FL.lazy, and the roots are main, bangs and fids. The own pass walks the own facts some unit read instead of every def, so a def that has no unit yet keeps its borrowed parameters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
49a13a7 to
47528f8
Compare
The compile-speed half of #1601, rebased on main f1273d3 (after #1817 and #1968). Two commits,
comp.tsonly.compile_bookemitted every def again until a pass added no fact. Now a read of an absent fact registers its unit inFL.when, and adding that fact marks only those units stale (FL.stale).c_unitssweeps stale units forward, which finds the callees, then backward, which lets a fact climb a call chain at once, until none remain.A def becomes a unit only once something names it:
seg_refruns its entry inFL.lazy. The roots aremain, bangs and fids, so unreachable defs are never emitted. The own pass walks the own facts some unit read, not every def, so a def with no unit yet keeps its borrowed parameters.Compared with the earlier version of this PR,
unit_order,unit_lazyandFL.missare gone, andemit_nativeis back to main's.FL.lazyis back in a smaller form, a map from fid to the function that makes its unit.Compile time (C emit, median of 7 on an M4 Max under load)
C and JS emit over all 1,378 tests, demos and benches took 24.4 s on main and 21.3 s here, in one concurrent run. The chain scales linearly: 108 ms, 344 ms and 1,240 ms for 800, 3,200 and 12,800 defs. It takes 2 rounds, and no program in the corpus takes more than 2.
Output: JS is byte-identical to main on all 1,378 programs. C differs from main on 903 programs. The earlier analysis of the previous 845 put these down to the order of segments, spins, tables and static nodes, plus defs that nothing reaches no longer adding facts. I have not re-classified the 903. 99 programs also change C size compared with the previous commit of this PR, by small amounts on the largest ones (boss_3d by 56 bytes), and I have not classified those either.
Gates: test 1798/1798, perf 124/124. Repo 53/54:
comp.tsis 66,174 ttok, 174 over the 66,000 cap (main is 65,960). Ping was skipped, as the checkout has no site repo beside it. CUDA and Linux not run.🤖 Generated with Claude Code