Skip to content

perf(c): hoist the node allocation every match arm makes - #1792

Open
viridale wants to merge 4 commits into
bendlang:mainfrom
viridale:arm-alloc-hoist
Open

viridale wants to merge 4 commits into
bendlang:mainfrom
viridale:arm-alloc-hoist

Conversation

@viridale

@viridale viridale commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What changes

This is commit 2 of #1596 on its own, as asked in the closing review there, now merged with main at b8664b0. Everything is in bend2/comp.ts.

  1. Hoist the node allocation every match arm makes. When each arm of an if/else chain opens with its one node allocation, all of one size class, with no inner block, emit_chain allocates the node once before the chain. Before, the inline heap_alloc fast/slow branch in each arm kept clang from merging the arms. Now their stores become selects instead of a branch on the tag.
    • An arm qualifies only if its first line is its alloc, so nothing in it (a free, a jump, an exit) runs before the alloc. An arm that frees a node and then allocates one keeps reusing the slot it just freed.
    • ctr_build and seg_clo write the class as a literal (heap_alloc(e, 2), not heap_alloc(e, cls_fit(3))), so arms are compared by class.
    • The JS backend never emits heap_alloc, so on FL.js the arm callback returns right after the arm's body and scans nothing.
  2. Keep comp.ts under its cap. Main is at 66,000 / 66,000, and the hoist adds 179 tokens. These comment sentences come out, each restating code near it, which brings comp.ts to 65,998:
    • the header's "The C and JS runtimes close the file" (the RuntimeC and RuntimeJs section headers);
    • the C-only ops' list of their own four names (shortened to "intr_of skips an op with no JS, so a JS file runs Base's def");
    • compile_book's "the datatypes of the constructors a pure main prints are roots too" (the file_book call below it);
    • the Compile comment's "An effect source is read once, in one namespace" (effect_src's FL.effs check and its error);
    • the Globals comment's device-program sentence (BEND_SRC, below it);
    • the Gpu comment's "a launch loads it or compiles" (both gpu_load bodies);
    • gpu_pass's "A bang launches 1, 2, 4 .. 64 rounds between syncs, until the pack empties the frontier" (its loop);
    • the C Io comment's request and parked-request sentences (io_step, io_wait_on and io_wait);
    • io_loop's "A busy loop still checks due timers, and fds periodically" (the block below it);
    • io_list's "io_unlist answers null if a value is past 255" (io_unlist, a few lines down).

Checks

  • bun gates/repo.ts: 54 / 54.
  • tsc -p bend2/pack/tsconfig.json (TypeScript 5.9.3): the same 11 errors as main, none in comp.ts.
  • gates/test.ts, run locally: 1705 / 1707. The two failures, gfx_clicks and gfx_window, expect "no display". This Mac has a desktop session, so they fail the same way on main here.
  • Every .bend file under tests/, bench/ and demos/ built to C and JS with main and with this branch (866 build to C, 910 to JS; the same builds fail on both):
    • JS: outside comment lines, identical in all 910.
    • C: 49 chains with 98 arms in 35 programs are hoisted. After writing main's cls_fit(n) as its class and undoing every hoisted chain (putting the shared heap_alloc back in each arm), 865 of the 866 programs are main's C outside comment lines. The last, fork_leaf_result_loop, differs only in how its _nd_ locals are numbered.
  • Of the 17 runtime benches, only tree-bitonic and tree-radix change, with one chain each. The other 15 compile with the gate's cc -std=c11 -O3 to the same machine code as on main (otool -tX text identical); only BEND_SRC's embedded text differs. That includes symreg, whose chain has an arm that computes a predecessor before its alloc, and editdist, kmeans and tree-matmul.
  • C emission time (bend main.bend -o main.c, the whole bun run, 7 interleaved rounds, medians): merkle takes 0.183 s on main and 0.188 s here, and tree-bitonic takes 0.112 s on both. The ranges overlap (merkle 0.174–0.196 s on main, 0.178–0.198 s here).

Benches (the hoist alone)

These were measured on an Apple M3 Max, not the minis' M4, with main at b8664b0 against this branch's head. Builds use the gate's cc -std=c11 -O3 main.c -lpthread and run with +RTS --threads N --gpu off. Each binary runs once to warm up. Then main and this branch alternate, for 5 rounds on SEQ (--threads 1) and 9 rounds on PAR (--threads 16). Ratios are PR / main, so lower is faster. Every run printed the pinned output.

bench mode main (s, median) PR (s, median) PR / main, min PR / main, median
tree-bitonic SEQ 9.921 8.211 0.827 0.828
tree-radix SEQ 9.316 9.106 0.941 0.977
tree-bitonic PAR 1.367 1.129 0.850 0.826
tree-radix PAR 0.479 0.463 0.975 0.967

Notes:

  • tree-bitonic
    • SEQ is steady. Main ran 9.89–9.97 s in four rounds and 12.68 s in one. This branch ran 8.19–8.23 s every round.
    • PAR is noisier: main took 1.21–1.45 s and this branch 1.03–1.33 s.
  • tree-radix
    • SEQ is bimodal in both builds. Most rounds take 9.0–9.4 s and a few take 7.1–7.8 s, so the minimum only shows which build drew a fast round. Read the median, 0.98.
    • PAR is a steady 0.97.
  • Layout: an earlier run, main at 10256a7 against f6c6d21, also built both sides with -falign-functions=64, which takes function layout out of the comparison. The aligned medians were 0.79 (SEQ) and 0.80 (PAR) for tree-bitonic, and 0.99 and 0.97 for tree-radix. So the gain is not a layout effect.
  • symreg measured 0.98–0.99 at f6c6d21. It now compiles to main's code.
  • tree-matmul and kmeans have the same machine code as main here, so their −7% and −3% in the perf(c): hoist arm allocations, return through frame addresses, keep escaping fields of borrowed parameters #1596 table came from the other commits.
  • editdist's code is also unchanged, so commit 3's regression is gone.

When each arm of an if/else chain allocates one node of the same class
outside any inner block, allocate it once before the chain. Clang then
turns the arms' stores into selects instead of a branch on the tag.

The JS backend never emits heap_alloc, so emit_chain skips the scan
there and returns after each arm's body.
Main is at 65,909 of 66,000 tokens and the hoist takes it to 66,085.
These comment sentences restate code a few lines away, and cutting them
brings comp.ts to 65,975:

- the header's "The C and JS runtimes close the file" (the RuntimeC and
  RuntimeJs section headers);
- char_new's "The JS runtime holds this function's text" (the JS
  runtime's `const char_new = ${char_new};`);
- the Globals comment's device-program source (BEND_SRC, below it);
- the Gpu comment's "a launch loads it or compiles" (both gpu_load
  bodies);
- the Compile comment's "An effect source is read once, in one
  namespace" (effect_src's FL.effs check and its error);
- the C Io comment's request and parked-request sentences (io_step,
  io_wait_on and io_wait);
- the JS Io comment's "Waits stay in deadline order" (io_park_on's
  sorted insert).
@viridale

Copy link
Copy Markdown
Contributor Author

@Lorenzobattistela per your request

@Lorenzobattistela Lorenzobattistela self-assigned this Oct 21, 2026

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

Thanks for splitting this out. I merged it onto main 5ce1e07 and ran all the gates: repo 54/54, test 1697/1697, perf 124/124, ping 73/73, safe 0 false rejects. comp.ts is at 65,678 / 66,000. I found no correctness bug in the hoist. Three things before merging:

  1. The hoisted alloc now runs before frees in the arm. Some arms free a node and then allocate one. In tests/io/tcp_try.bend, the else arm used to do term_sink(e, _m_4); u64 _nd_8 = heap_alloc(e, cls_fit(1));, so the alloc reused the slot that was just freed, which is still in cache. It now allocates before the if, so it takes a different slot and the peak heap is one node larger. A recursive function that frees one node and builds one per step pays this on every step, and the benches only measured arms that don't free first. Could you skip the hoist when an arm frees before its alloc, or show this costs nothing on such a loop?

  2. Arms are compared by the text of the heap_alloc call, not by size class. heap_alloc(e, cls_fit(3)) and heap_alloc(e, cls_fit(4)) are both class 2, but they are different strings, so that chain is never hoisted. The new comment says "one node of one class". Emitting the class as a literal in ctr and seg_clo (heap_alloc(e, ${cls_fit(n)})) would match the comment and catch those chains.

  3. Please keep two of the cut comments. With the hoist, comp.ts has about 320 tokens to spare, and these two say things the code nearby doesn't:

    • "The JS runtime holds this function's text" (char_new). It is the only warning that char_new's source is pasted into every JS program (line 488), so the function has to stay self-contained. If it ever calls a comp.ts helper, it still type-checks, but every JS program throws a ReferenceError at run time.
    • "Waits stay in deadline order" (JS Io). io_wait (line 6444) and io_run (line 6505) read io.waits[0] as the earliest deadline, far from the sorted insert in io_park_on.

Optional: the hoist finds the alloc by regex-matching the emitted C, and treats a { as the only sign of a block. If node_fill's line format changes, the optimization silently turns off. WL_JMP, WL_RETN and WL_AGAIN contain no {, so an arm that jumps out before its alloc would still be hoisted. Recording the alloc in the Scope when ctr or seg_clo emits it would avoid reading C back, and would also drop the u64 _nd_k = _nd_K; alias each arm keeps now.

An arm that frees a node before it allocates one (tcp_try's else arm
sinks a term first) let the alloc reuse the freed slot. Hoisting the
alloc above the chain took a different slot and grew the peak heap by
a node. Now an arm qualifies only if its first line is its one alloc,
so nothing in it, a free, a jump or an exit, runs before the alloc.

ctr_build and seg_clo write the size class as a literal, so arms are
compared by class, as the comment says, not by word count.

Keep char_new's "The JS runtime holds this function's text" and the JS
Io comment's "Waits stay in deadline order". Main has reached 66,000
tokens since the review, so these comment sentences come out instead,
each restating the code right below it, which brings comp.ts merged
with main to 65,998:

- the C-only ops' list of their own four names (shortened);
- compile_book's "the datatypes of the constructors a pure main prints
  are roots too" (the file_book call below it);
- gpu_pass's "A bang launches 1, 2, 4 .. 64 rounds between syncs, until
  the pack empties the frontier" (its loop);
- io_loop's "A busy loop still checks due timers, and fds
  periodically" (the block below it);
- io_list's "io_unlist answers null if a value is past 255" (io_unlist,
  a few lines down).
@viridale

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Since then #1542 landed and brought main's comp.ts to exactly 66,000 / 66,000, so this PR merged with main read 66,066. I merged main (b8664b0) into the branch rather than rebasing, so there is no force push. The fixes are in 5d0bab8.

  1. The hoist now applies only when every arm opens with its alloc. The arm's first emitted line must be u64 nd = heap_alloc(e, k);, with no other heap_alloc and no { after it, so nothing in the arm runs before the alloc. An arm that frees and then allocates keeps its alloc inline and reuses the slot it just freed. tcp_try's else arm is back to term_sink(e, _m_4); u64 _nd_8 = heap_alloc(e, 0); inside the arm.

    • Across tests/, bench/ and demos/, hoisted chains go from 67 to 49.
    • Each of the 18 dropped chains had an arm that ran something before its alloc: a term_sink (16 arms), a term_keep (7), a ctr_take (1), or only pure reads and arithmetic.
    • symreg is in the pure group. Its default Nat arm opens with a binding it never reads, Term _p_2 = (_o_3 - 3);, which clang drops. It now compiles to main's machine code and loses its 1 to 2%. A rule that let pure lines come first would keep symreg and six other chains, but it costs about 30 more tokens and has to read more of the C to tell a pure line from a free.
    • tree-bitonic and tree-radix, the two benches that gain, compile to the same machine code as the version you reviewed, merged with the same main.
  2. Done. ctr_build and seg_clo emit the class as a literal (heap_alloc(e, 2)), so arms compare by class, as the comment says. Under the new rule, no chain in today's corpus mixes word counts within one class. The walkers chain, with 17 and 18 words in class 5, has Term _f_0 = (_fuel_0 - 1); before its alloc. So for now this changes the C only in the literal itself.

  3. Both comments are back, word for word as on main. Merged with main, the PR was already 66 over the cap without them, so five more restating comments come out. Each is said by the code beside it:

    • the C-only ops comment's list of the four ops, which now reads "C only: intr_of skips an op with no JS, so a JS file runs Base's def";
    • compile_book's "the datatypes of the constructors a pure main prints are roots too" (the file_book call under it);
    • gpu_pass's "A bang launches 1, 2, 4 .. 64 rounds between syncs, until the pack empties the frontier" (its loop);
    • io_loop's "A busy loop still checks due timers, and fds periodically" (the block under it);
    • io_list's "io_unlist answers null if a value is past 255" (io_unlist, a few lines down).

    comp.ts reads 65,998 / 66,000. The hoist, its comment included, costs 179 tokens over main.

Optional: I kept reading the C back, but narrowed what it accepts.

  • Recording the alloc in the Scope when ctr_build or seg_clo emits it would say that an arm allocated, not that nothing ran before the alloc. Frees come from val_sink, spare_free, blk_free, node_fields' ctr_take and the natives, so each of them would have to record too.
  • The text match is also what tells an unconditional alloc from ctr_build's spare form, sp >= HEAP_OFF ? sp : heap_alloc(...), which must not be hoisted.
  • The alloc must be the arm's first line, so no WL_JMP, WL_RETN or WL_AGAIN can come before it.
  • If node_fill's format changes, the hoist turns off and the C stays correct. The loss would show on tree-bitonic SEQ, which would run about 1.2x slower: past perf's 1.15x once it is pinned with the hoist.
  • The u64 _nd_k = _nd_K; alias stays. clang folds it, and dropping it would mean renaming each arm's local in the text, which reads more of the C back, not less.

Checks, on the branch (main is merged in):

  • bun gates/repo.ts: 54 / 54.
  • tsc (TypeScript 5.9.3): the same 11 errors as main, none in comp.ts.
  • The test gate, run locally: 1705 / 1707. The two failures, gfx_clicks and gfx_window, expect "no display". This Mac has a desktop session, so they fail the same way on main here.
  • Every .bend file under tests/, bench/ and demos/ was built to C and JS with main and with the branch.
    • JS: identical outside comment lines in all 910.
    • C: after writing main's cls_fit(n) as its class and undoing each hoist, 865 of 866 equal main. The last, fork_leaf_result_loop, differs only in how its _nd_ locals are numbered.
  • 15 of the 17 runtime benches compile to main's machine code (otool -tX).
  • Timed against main b8664b0 on an M3 Max (PR / main, medians): tree-bitonic 0.83 on SEQ and 0.83 on PAR, tree-radix 0.98 on SEQ and 0.97 on PAR. Every run printed the pinned outputs. C emission time is unchanged within noise: merkle goes from 0.183 to 0.188 s, and tree-bitonic is 0.112 s on both. The PR body has the table.

I couldn't run perf, ping or safe here; they need the minis.

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