Skip to content

compile_book emits a def again only when a fact it missed was added, so the passes after the first cost what changed, not the whole program - #1386

Closed
Iann29 wants to merge 3 commits into
bendlang:mainfrom
Iann29:emit-changed-defs
Closed

Iann29 wants to merge 3 commits into
bendlang:mainfrom
Iann29:emit-changed-defs

Conversation

@Iann29

@Iann29 Iann29 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

compile_book emits every live def in every pass until a pass adds no fact (own, hot, stat). Big programs take several passes, and each one re-emits everything, although after the first pass or two only a few defs see a new fact. This PR emits a def again only when a fact it missed was added. The C output is the same.

How

  • hot and stat are a Set subclass, Facts, whose has records each miss of the def being emitted (TRACE).
  • brw_of records each row it hands out, and the own facts that would turn its borrowed slots owned.
  • In a later pass, a def none of whose misses was added is skipped; its row asks and its lends are replayed in its place.
  • When a pass adds no fact, one more pass emits every def. Its C is the result, so the output is always a full emission at the fixpoint, as before.

Foreign defs, which main now emits in the same loop, are traced and skipped the same way.

This branch is the core commit only, rebased on main. The two token-trim commits are dropped.

Same output

  • All 1,726 .bend files under tests/, demos/, bench/ and evals/: same exit codes and stderr as main, and every emitted .c (803), .js (833) and .mjs (1,139) is byte-identical.
  • AMAGE Eco (amage-si/chromi, examples/eco/main.bend): byte-identical C (13.3 MB).

Speed

bend main.bend -o main.c on an Apple M-series Mac, Bun 1.4.0, median of 3 interleaved runs:

program main this PR
AMAGE Eco 13.6 s 6.8 s
demos/app_slash_boss_3d 1.12 s 0.93 s
demos/pure_hvm5_mini 0.39 s 0.39 s
small programs 0.07-0.19 s same, within noise

Eco's passes emit 2,526, 1,849, 545, 197, 86, 54, 24 and 2 defs, then the final 2,526 (main: every pass emits all 2,526). Peak memory on Eco: 3.17 GB on main, 2.79 GB here.

Checks

  • gates/repo.ts 54/54, comp.ts at 63,998 ttok (cap 64,000)
  • gates/test.ts 1623/1623, gates/ping.ts 58/58, gates/perf.ts 124/124
  • Not run: CUDA, Linux, Windows.

@Lorenzobattistela

Copy link
Copy Markdown
Collaborator

Thanks @Iann29! Merged into current main (after #1385), comp.ts is at 64,015 ttok, so gates/repo.ts fails
(the green check ran before #1385 landed). We can't merge until it fits under the 64k cap. Could you rebase
and add the rewrites you mentioned? We'll review once the gate passes.

Iann29 added a commit to Iann29/bend that referenced this pull request Oct 7, 2026
…d too

bendlang#1385 brought main's comp.ts to 63,975 ttok, which put this branch at
64,017. These rewrites take 38 back, with the same output: js_host moves
into js_lib, its one caller, with the js_marshal calls in the same
order; six OPERATIONS entries with a short C template take two lines,
not four; lay_box, val_box and val_unbox read lay.arms as truthiness,
since it is a record when present. The branch is at 63,979, and merged
with bendlang#1386 (which takes its own share) at 63,989.
@Iann29

Iann29 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Lorenzobattistela! Added 9377930 on top of your rebase: 30 more ttok of rewrites of the same kinds, same output.

@Lorenzobattistela

Copy link
Copy Markdown
Collaborator

Thanks @Iann29, and thanks for chasing the token cap. The compile_book change looks good, but we can't merge the trimming commits (ae35362 and 9377930) as they are. They save tokens by writing comp.ts in a style the rest of the repo doesn't use. bend.ts is our reference for style, and these commits move away from it:

  • They inline named helpers into their one call site (emit_intr, emit_native, arr_leaf, tpl_deep, W64). Small helpers with good names are how we keep the compiler readable.
  • They turn if/return blocks into nested ternaries, some with side effects inside: die() inside a ternary, and a comma expression with seen.add in ty_holds.
  • They swap explicit === null / === undefined checks for truthiness. It doesn't change behavior here, but the explicit checks say what the value can be, and bend.ts always writes them out.
  • Smaller things: n++ where we write n += 1, and type fields packed onto shared lines.

Behavior is the same, so this is only about style, but the cap only does its job if we stay under it with simplifications that read at least as well.

Where things stand: main is now at 63,848 ttok after #1395, which removed 443 tokens. Your compile_book commit (8c94532) adds about 317 tokens. It also conflicts with main, because #1395 rewrote compile_book as one loop. Could you drop the two trimming commits, rebase the feature onto current main, and find the missing room with real simplifications (dead code, duplicated logic, helpers that can be shared) instead of compressing code?

We're also discussing whether to raise the token cap, so if you prefer, you can wait for that decision before doing the cuts. We'll review once the gate passes.

@Lorenzobattistela Lorenzobattistela 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.

We measured this PR against #1262 (C emission memory) on a synthetic 27.6k-line program (2,634 defs, 14.4 MB of C). The C is byte-identical and it emits about 21% fewer defs, about 10% faster, but peak memory goes up by 40-65%:

program main (dbb3157) main + this PR
18k lines 1.56 GB / 4.5 s 2.53-2.61 GB / 4.0 s
27k lines 2.18-2.28 GB / 6.5 s 3.08-3.61 GB / 5.8 s
36k lines 2.89 GB / 8.6 s 3.80 GB / 7.9 s

The cause is the traces: Facts.has pushes a closure for every missed lookup, so the same key missed many times by one def is recorded many times. At 27k lines that's 1.84M closures after the first pass.

Could you record each missed key only once per trace (keyed by set and key)? Something like:

if (!got && TRACE) {
  const id = (this === FL.hot ? "h" : "s") + k;
  if (!TRACE.seen.has(id)) { TRACE.seen.add(id); TRACE.miss.push(() => super.has(k)); }
}

with seen: new Set() added where the trace is made. Locally that drops misses at 27k from 1.84M to 73k, brings peak back to about main's (2.0 vs 2.2 GB), and keeps the speedup and the byte-identical C at every size we tried.

Iann29 and others added 3 commits October 17, 2026 11:27
A def's emission reads facts through FL.hot.has, FL.stat.has and its
callees' borrow rows. hot and stat now note each miss of the def being
emitted (Facts), and brw_of notes the rows it hands out with the own
facts that would clear their borrowed slots. In a later pass, a def none
of whose misses was added would emit what it did, so it is replayed
instead: its row asks and its lends, in its place. After a pass that adds
no fact, one more pass emits every def, and its C is the result.

The AMAGE Eco demo's 7 passes of 1,253 defs become 1,253, 911, 263, 62,
16, 8 and 5, then 1,253, with the same C.
A def looks up the same key in hot and stat many times, and Facts.has
pushed a closure at every miss. On three copies of the repo's demos
(28.1k lines) the traces held 1.83M closures after the first pass, and
the peak of -o main.c went from main's 1.92 GB to 2.93 GB. A trace now
keeps the keys it missed, by set and key, and pushes one closure per
key: 73k after the first pass, and a 1.87 GB peak. The closures it
drops answered the same question as the one it keeps, so a def is
emitted again exactly when it was before, and the passes and the C are
the same.
@Iann29
Iann29 force-pushed the emit-changed-defs branch from 58065f1 to f935178 Compare October 17, 2026 15:37
@Iann29

Iann29 commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Lorenzobattistela, for the restyle and for tracking the peak down to the traces.

The dedupe is in as you suggested, in f935178: Facts.has records a missed key once per trace, keyed by set and key ("h" or "s" and the key, in seen, which is made with the trace). The closures it drops answered the same question as the one it keeps, so a def is emitted again exactly when it was before.

The branch is rebased on main (e5527d5), over #1617, with your two commits unchanged. The one conflict was the lend graph at the end of a pass, which main now builds with split("<").reverse(): the branch keeps main's line and only sets FL.lend to the union of the traces' lends before it. #1617's PROBES.length = 1 stays right after memo_gc(), behind the replay check. A replayed def emits nothing and only asks its rows again, which hold no probes, and every def that is emitted starts from fresh probes, as on main.

I rebuilt your synthetic program from #1262's description: copies of the repo's 13 demos whose main is IO(Unit), each copy its own module, run in order by one entry. At three copies (28.1k lines, 2,656 live defs) the traces held 1.83M misses after the first pass, and 73k with the dedupe, so it should be close to yours (the generator is a short script; I can post it). bend main.bend -o main.c, medians of 10 interleaved runs (13 for the demo) on a Ryzen 7 5800H (16 threads, 32 GB, Linux) with Bun 1.4.3:

program main (e5527d5) this PR before the dedupe this PR
18.7k lines 1.47 GB / 10.7 s 1.53 GB / 9.3 s 1.40 GB / 9.2 s
28.1k lines 1.92 GB / 15.5 s 2.93 GB / 13.4 s 1.87 GB / 13.4 s
37.5k lines 2.59 GB / 20.4 s 3.86 GB / 18.2 s 2.59 GB / 17.6 s
AMAGE Eco demo (13 MB of C) 2.56 GB / 19.4 s 2.19 GB / 11.2 s 2.35 GB / 11.4 s

The heap left after a full collection at the end of the first pass shows the same without the collector's timing: at 28.1k lines, 301 MB on main, 565 MB before the dedupe and 320 MB with it. The synthetic programs take 5 passes on main, and this PR emits 21% fewer defs (10,477 instead of 13,280 at 28.1k lines). The Eco demo takes 8 passes of 2,520 defs on main and emits 7,780 here. Its traces are small (191k misses after the first pass, 52k with the dedupe; 461 and 449 MB left after that collection, 435 MB on main), so its peak only follows the collector: 2.03-2.81 GB over the 13 runs, and 2.41-3.80 GB on main.

Same output:

  • For all 1,753 .bend files under tests/, demos/, bench/ and evals/, main and this branch give the same exit status and stderr, and every emitted .c (818), .js (852) and .mjs (1,163) is byte-identical.
  • AMAGE Eco's demo, its grid example and its 23 test suites emit byte-identical C, and so do the synthetic programs on every run. Three of the Eco programs, the demo among them, stop at duplicate declaration: Text on current main, since Base's Event got a Text constructor in feat(window): emit native text input events #1441, so as in Emission allocates 40% less on large files: per-file literal steps, per-def probes, no memo closures (#1262) #1617 they were compared with Eco's own Text constructor renamed to AText. The other 22 were also compared as they are.
  • gates/test.ts, its shards and state_run, run locally (Linux, no display, no CUDA): 1648 / 1648, as on main.
  • comp.ts is 64,558 ttok (main: 64,180; the dedupe adds 55), and gates/repo.ts passes, 54 / 54. tsc -p bend2/pack reports the same 11 errors as main, none in comp.ts.

@Lorenzobattistela

Copy link
Copy Markdown
Collaborator

Thanks @Iann29, for this and for the dedupe follow-up. We reproduced it on current main (257f896): byte-identical C, about 13-15% faster on a synthetic program built from copies of the demos, and peak memory now matches main.

We're closing it in favor of #1601, which tracks the same facts at the level of each def and spin: a missing fact records who read it, and adding it re-emits only those readers, callee-first. On the same synthetic it emits about 3.4x faster than its base with 25% less peak memory, and it replaces the per-pass loop this PR hooks into, so the two can't both land. comp.ts would also be at 66,309 ttok with this PR, over the 66k cap.

Your measurements (the Eco numbers and the miss counts per pass) were useful in judging #1601. Thanks again.

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