Skip to content

perf(c): hoist arm allocations, return through frame addresses, keep escaping fields of borrowed parameters - #1596

Closed
viridale wants to merge 5 commits into
bendlang:mainfrom
viridale:c_runtime_improvements
Closed

viridale wants to merge 5 commits into
bendlang:mainfrom
viridale:c_runtime_improvements

Conversation

@viridale

@viridale viridale commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What changes

Rebased on main at 881cb2e. After review, the refcount commit is gone, and the fork frame write-back went in on its own as #1615. file_new keeps its typed literal and emit_fold keeps its early returns. All five commits are in bend2/comp.ts; the fourth also adds or updates borrow witnesses under tests/reg.

  1. Shed 25 comp.ts tokens with the same output. Compacts scope_new, folds call_eta's early return, and joins wrapped lines that fit in 80 columns. C and JS output is byte-identical to main for all 840 programs that build (tests, benches and demos).
  2. Hoist the node allocation every match arm makes. When each arm of an if/else chain allocates one node of the same size class outside any inner block, emit_chain allocates it 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. The C of 41 programs changes, 3 of them benches (tree-bitonic, tree-radix and symreg).
  3. Return through code addresses in host frames. A host continuation frame stores its segment's address (WL_K), so WL_RETN tail-calls STK(0) directly instead of loading it from wl_tab first. Device frames keep fids. WL_JMP still compiles to a direct branch.
  4. Keep a borrowed parameter's field where it escapes. A def that only reads a borrowed parameter, but passes one field of it to an owned position, used to own the whole parameter. A lookup that returns its match's value is the typical case. Owning it sent every node it walked through ctr_take, which on a shared value bumps each field, releases the node and sinks the rest. Now val_own keeps that field where it escapes (term_keep), and the parameter stays borrowed, so its nodes are read with term_peek. The root itself is never kept.
    • A kept field must already be sealed, so the root's type is heated (FL.heat), but only once a pass changes no fact. The first pass borrows every parameter, and heating there left types hot for roots that later become owned (merkle's Mt).
    • The root's def is read from its key, not from the scope, because a fused callee's body runs under the callee's def. tests/reg/brw_field_fuse.bend printed freed memory when that callee's parameter types were heated instead.
    • The C of 12 test programs and the pure_hvm5_mini demo changes, and of no bench.
  5. Cut comp.ts back under its cap. See below.

comp.ts tokens

Main is at 65,916 / 66,000 by itself (#1626 raised the cap to 66,000). Commits 1 to 4 take it to 66,223, and commit 5 brings it to 65,811. On the rebase, one cut met a new sentence from #1653 in the same comment: the cut is kept and the new sentence stays.

Commit 5 makes two code edits, both with the same output. emit_native hands emit_body its own f, because fun_at's copy holds the same h. It also sizes a spin with body.join("\n") instead of a reduce. Everything else in it is a comment cut, each one restating code a few lines away or a twin elsewhere in the file:

  • comp.ts emitter:
    • the header's note that the runtimes close the file (the section headers show it);
    • the constants' SPIN_FAR sentence (the C runtime's long-spin comment says it in full);
    • "the Array operations have no C text" (array_clone and the atomics have some; arr_op's call shows the rest);
    • the Optimized comment's gloss of Native's fields (its type);
    • char_new's encoding (its body) and the note that the JS runtime holds its text (the line that splices it in);
    • the Term comment's literal sentence (lit_call, below it);
    • ty_holds' lead clause (its signature);
    • the Lay comment's parenthetical on an element's open layout (lay_el);
    • lay_at's comment (its one-line body);
    • the Mat comment's U32 table rule (mat_rows' condition);
    • the normal-form comment's erased-application clause (anf's Var case);
    • the Emit comment's "a read asks a lend" (the File comment);
    • emit_open's note on a foreign def's continuation (the Emit comment and fun_of's lays);
    • "an effect source is read once, in one namespace" (effect_srcs' map and its error);
    • the JS comment's closure, $pc and direct-call clauses (js_lib and js_def);
    • host's U+FFFD clause (the Char comment).
  • C runtime:
    • the Dialect comment's segment-dispatch sentence (the WL_ macros below it);
    • the Globals comment's device-program source (BEND_SRC);
    • the Gpu comment's "a launch loads it, else notes and compiles" (both gpu_load bodies);
    • the Corpus comment's GPU sentence (corpus_grow and corpus_setup);
    • the Io comment's request and parked-field sentences (io_step and io_wait_on);
    • io_loop's busy-loop comment (the (n & 63) block);
    • the Show comment's show_f32 sentence (f32_text's round trip).
  • JS runtime:
    • the Io comment's deadline-order sentence (io_park_on's sorted insert, and the C io_park comment);
    • io_fail's errno fallback (its first line).

Commit 4 also rewords the File, Scope and val_own comments to share less. It drops compile_book's note that a pure main's descriptor roots the datatypes it prints: the Show section describes the descriptor, and the next lines root them.

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. The TS2352 from file_new's cast is gone.
  • Each commit against its parent, over the 840 programs that build to C and JS:
    • commit 1: byte-identical;
    • commit 2: only the hoisted chains change, in 41 programs;
    • commit 3: only the frame lines and the dispatch macros change;
    • commit 4: the C of 13 programs changes, and JS of none;
    • commit 5: only comment lines change, in the C and JS of all 840 programs (the cut runtime comments).
  • Commit 4's 13 programs, built before and after it: all 12 tests print their expected output at --threads 1, 8 and 16, and pure_hvm5_mini prints the same under both builds.
  • gates/test.ts, run locally: PASS 1675 / 1677. The two failures, gfx_clicks and gfx_window, expect "no display" and fail the same way on main here, because this Mac has a desktop session.

Lookup repro (commit 4)

The program below walks a shared 20000-entry List<&2, Entry> 500 times, missing every time. find returns the match's value and has returns a Bool. It was built with clang -O3, each binary warmed once, then run 3 interleaved rounds with ./walk --threads 1 -- 20000 500 on an Apple M3 Max:

build has find
main 44–45 ms 146–147 ms
this branch before commit 4 44 ms 146–148 ms
this branch 44 ms 46–47 ms
main.bend
# Purpose: time two walks over one shared list of records. Both compare every
# key and miss. find returns a field of the match; has returns a Bool.
# Run: bend main.bend -o walk && ./walk --threads 1 -- 20000 500

import Base

type Entry is Data:
  Entry{key: U32, value: String}

def entries(n: Nat, +i: U32) -> List<&2, Entry>:
  match n:
    case 0n: []
    case 1n+p: Entry{i, U32.show(i)} <> entries(p, (i + 1 : U32))

def hit(same: Bool, value: String) -> Maybe<&2, String>:
  match same:
    case True{}: Some{value}
    case False{}: None{}

def entry_hit(e: Entry, +query: U32) -> Maybe<&2, String>:
  match e:
    case Entry{key, value}: hit(U32.is_eq(key, query), value)

# The value of the first entry keyed query.
def find(es: List<&2, Entry>, +query: U32, found: Maybe<&2, String>) ->
    Maybe<&2, String>:
  match es found:
    case Nil{} f: f
    case e <> tail Some{v}: Some{v}
    case e <> tail None{}: find(tail, query, entry_hit(e, query))

def entry_is(e: Entry, +query: U32) -> Bool:
  match e:
    case Entry{key, value}: U32.is_eq(key, query)

# Whether some entry is keyed query.
def has(es: List<&2, Entry>, +query: U32, found: Bool) -> Bool:
  match es found:
    case Nil{} f: f
    case e <> tail True{}: True{}
    case e <> tail False{}: has(tail, query, entry_is(e, query))

def score(m: Maybe<&2, String>) -> Nat:
  match m:
    case Some{s}: String.length(s)
    case None{}: 1n

def finds(k: Nat, +es: List<&2, Entry>, acc: Nat) -> Nat:
  match k:
    case 0n: acc
    case 1n+p:
      finds(p, es, Nat.add(acc, score(find(es, 4000000000, None{}))))

def hass(k: Nat, +es: List<&2, Entry>, acc: Nat) -> Nat:
  match k:
    case 0n: acc
    case 1n+p:
      hass(p, es, Nat.add(acc,
        Bool.pick(Nat, has(es, 4000000000, False{}), 2n, 1n)))

def nat_of(s: String) -> Nat:
  Maybe.default(&2, Nat, Nat.read(s), 0n)

def nums(args: List<String>) -> (Nat & Nat):
  match args:
    case _ <> (n <> (k <> _)): (nat_of(n), nat_of(k))
    case _: (0n, 0n)

def timed(+n: Nat, +k: Nat) -> IO(Unit):
  do IO<Unit>:
    +es : List<&2, Entry> = entries(n, 0)
    +t0 : Nat <- IO.now()
    a : Nat = hass(k, es, 0n)
    +t1 : Nat <- IO.now()
    b : Nat = finds(k, es, 0n)
    +t2 : Nat <- IO.now()
    IO.print(String.concat(["has: ", Nat.show(Nat.sub(t1, t0)), " ms, find: ",
      Nat.show(Nat.sub(t2, t1)), " ms (", Nat.show(Nat.add(a, b)), ")"]))

def run(nk: (Nat & Nat)) -> IO(Unit):
  match nk:
    case (+n, +k): timed(n, k)

def main() -> IO(Unit):
  do IO<Unit>:
    args : List<String> <- IO.args()
    run(nums(args))

Benches

Measured on the previous base, main at 234cb9b. The rebase left the five commits' diffs unchanged but for the one resolved comment. Apple M3 Max, --gpu off. Each binary runs once to warm up, then the builds alternate for 3 rounds (SEQ, --threads 1) or 5 rounds (PAR, --threads 16). The tables show the minimum, and each ratio is to main (lower is faster). "PR, aligned" compares main and this branch with both built with -falign-functions=64. That column takes function layout out of the comparison, since layout alone moves some of these benches by a few percent. The PR binaries are built from commit 4; commit 5 changes only comments in the C.

SEQ

bench main (s) PR PR, aligned
bfs 5.498 0.993 1.005
editdist 3.818 0.802 1.003
gameoflife 11.619 1.001 1.000
hashmap 4.088 1.002 0.992
kmeans 6.725 0.973 0.968
lexer 3.218 0.982 0.980
mandelbrot 7.238 0.997 0.999
merkle 7.884 1.000 1.000
nbody 9.585 1.000 1.000
queens 8.367 0.983 0.982
raytrace 7.531 0.993 0.996
symreg 6.541 0.965 0.982
terrain 3.549 1.000 1.000
tree-bitonic 10.027 0.769 0.799
tree-matmul 5.064 0.977 0.986
tree-radix 8.897 0.983 0.911
geomean 0.961 0.974

PAR

bench main (s) PR PR, aligned
bfs 0.397 0.987 0.985
editdist 0.301 0.827 0.866
gameoflife 0.859 1.000 0.996
hashmap 0.306 0.997 1.010
kmeans 0.612 0.977 0.966
lexer 0.254 1.098 1.004
mandelbrot 0.517 0.998 0.983
merkle 0.599 0.993 1.007
nbody 0.676 1.003 1.001
queens 0.621 0.955 0.954
raytrace 0.567 0.998 1.018
symreg 0.457 1.002 1.011
terrain 0.266 1.008 1.000
tree-bitonic 1.165 0.823 0.837
tree-matmul 0.426 1.042 0.978
tree-radix 0.472 0.970 0.942
geomean 0.978 0.971

Notes:

  • tree-bitonic (0.77–0.84) gains from commits 2 and 3. Measured one commit at a time before the rebuild, the hoist gave about 0.85 and the address frames a further 0.90. Of the benches, commit 2 changes only tree-bitonic, tree-radix and symreg, and commits 1 and 4 change none, so the 0.95–0.98 on queens and kmeans comes from the address frames.
  • hashmap is now 0.99–1.01. Its 0.89 SEQ and 0.93 PAR in the earlier tables came from the dropped refcount commit.
  • editdist SEQ is 0.80 in the default builds but 1.00 aligned. Main's default build of it is laid out badly: 3.82 s, against 3.06 s for main built aligned. Its PAR aligned 0.87 is a single run at 0.25–0.30 s; the run before the rebuild had it at 1.00, so it is not counted as a gain.
  • lexer PAR (1.10) is the same layout effect the other way round: main's default build ran at 0.254 s, and main built aligned at 0.279 s, the same as this branch. Aligned, it is 1.00, and SEQ is 0.98 in both builds.
  • tree-radix SEQ is bimodal between runs of the same binary (about 8.2 s vs 8.7 s before the rebuild), and its 0.98 and 0.91 here fall within that spread. PAR shows 0.94–0.97.
  • tree-matmul PAR default (1.04) and aligned (0.98) disagree in sign, and SEQ is 0.98–0.99 in both builds.

@Lorenzobattistela Lorenzobattistela self-assigned this Oct 16, 2026
@Lorenzobattistela

Copy link
Copy Markdown
Collaborator

Thanks for this. The per-commit write-up made it easy to check, and the numbers hold up. A few things before it can go in:

  1. comp.ts is over the cap once merged. On its base the branch is at 63,977, but main has since gained perf(runtime): reuse rounded digits in native F32 formatting #1591 (+123). Merged onto current main, bun gates/repo.ts fails: FAIL bend2/comp.ts: 64100 > 64000 ttok (PASS: 53 / 54). Please drop the refcount commit (2dfad81). It costs the most (+274) and on M4 (the minis and an M4 Pro) it buys about 2% on hashmap SEQ and nothing elsewhere. Without it, commits 2-6 apply cleanly onto main at 63,826, and repo/test/ping/perf all pass.
  2. Please split the write-back fix (b0b6b2f) into its own PR. It fixes a hang that is on main today and doesn't depend on the other commits. Alone on main it applies cleanly and makes comp.ts 9 tokens smaller. brw_fork_push goes from hanging at --threads 1/2/8 to printing 823296, and repo/test/ping/perf pass.
  3. file_new's as File (aab1343) is a new tsc error: bend2/comp.ts(1347,10): error TS2352: Conversion of type '{ book: Bend.Book; ... }' to type 'File' may be a mistake .... comp.ts was clean under tsc -p bend2/pack/tsconfig.json before. The of("...") field lists also stop TypeScript from checking File's fields. Restoring the typed literal costs about 71 tokens and still fits without the refcount commit. emit_fold's (FUEL -= 1, false) chain reads as token golf too; the joined lines are fine.

What I checked, with the PR merged onto main: test 1639/1639, ping 59/59, perf 124/124 (on the minis, vs main: SEQ-CPU geomean 0.972, PAR-CPU 0.982; tree-bitonic 0.85, symreg 0.89, tree-matmul 0.92), and safe tests with 0 false rejects. 800 programs run natively at --threads 1/8/default print the same as on main, except brw_fork_push. Heap-checked and ASan/UBSan builds are clean. The JS output is identical apart from the cut comments. The lookup repro holds: find goes from 96 to 31 ms on an M4 Pro.

@viridale
viridale force-pushed the c_runtime_improvements branch from b0b6b2f to 82130a1 Compare October 16, 2026 17:29
@viridale viridale changed the title perf(c): hoist arm allocations, return through frame addresses, cheaper single-worker refcount releases perf(c): hoist arm allocations, return through frame addresses, keep escaping fields of borrowed parameters Oct 16, 2026
@viridale

Copy link
Copy Markdown
Contributor Author

Thanks, all three are done, and the branch is rebuilt on main at 234cb9b:

  1. The refcount commit is dropped.
  2. The write-back fix is its own PR now, fix(c): write a shared word back to the fork frame slot that holds it #1615.
  3. file_new is back to its typed literal, and tsc is down to main's 11 errors, none in comp.ts. emit_fold has its early returns again; the joined lines stay.

Main is at 64,092 by itself, so a fifth commit cuts comments to bring comp.ts to 63,988. The body names each one. The fix PR makes a disjoint set of cuts and lands at 63,990. The two merge cleanly, at 63,886 together. I re-ran the bench tables without the refcount commit.

Compacts scope_new, folds call_eta's early return, and joins wrapped
lines that fit in 80 columns. C and JS output is byte-identical for
every bench and runnable test.
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.
A host continuation frame now stores its segment's address (WL_K), so
WL_RETN tail-calls it directly instead of loading it from wl_tab first.
Device frames keep fids. WL_JMP still compiles to a direct branch.
A def that only reads a borrowed parameter, but passes one field of it
to an owned position (a lookup returning its match's value), owned the
whole parameter: every node it walked went through ctr_take, which on a
shared value bumps each field, releases the node and sinks the rest.
val_own now keeps such a field where it escapes (term_keep) and leaves
the parameter borrowed, so its nodes are read with term_peek. The root
itself is never kept.

A kept field must already be sealed, so its root's type heats (heat),
but only once a pass changes no fact: the first pass borrows every
parameter, and heating there left types hot for roots that later
become owned (merkle's Mt). The root's def comes from its key, not the
scope, as a fused callee's body runs under the callee's def.

A walk over a shared 20000-entry List<&2, Entry> that returns its
match's value, missing 500 times at --threads 1, goes from 147 ms to
46 ms, about the time of the same walk returning Bool (44 ms).

The C of 12 test programs and the hvm5_mini demo changes, and of no
bench; JS is unchanged. New witnesses: tests/reg/brw_field_keep.bend
and brw_field_fuse.bend (heating the fused callee's parameters printed
freed memory). The comments of brw_field_store, brw_fork_cross and
brw_mixed_args follow the new conventions; brw_twins hands its twin to
Xs.push, which still owns its parameter, so it still covers one
borrowed and one owned use.

comp.ts: 66,120 -> 66,223 tokens; the next commit brings it under the
cap. Comments are cut here too: the Scope comment no longer lists the
fields its type declares, the File and val_own comments share less, and
compile_book drops its note that a pure main's descriptor roots the
datatypes it prints (the Show section describes the descriptor, and the
next lines root them).
On main, commits 1 to 4 take comp.ts from 65,916 to 66,223 tokens; this
commit brings it to 65,811 / 66,000. Two code edits keep their output:
emit_native hands emit_body its own f (fun_at's copy holds the same h),
and sizes a spin with body.join("\n") instead of a reduce. The rest are
comments that restate code a few lines away, or a twin elsewhere in the
file:

- the header's note that the runtimes close the file (the section
  headers show it);
- the constants' SPIN_FAR sentence (the C runtime's long-spin comment
  says it in full);
- "the Array operations have no C text" (array_clone and the atomics
  have some; arr_op's call shows the rest);
- the Optimized comment's gloss of Native's fields (its type);
- char_new's encoding (its body) and that the JS runtime holds its text
  (the line that splices it);
- the Term comment's literal sentence (lit_call, below it);
- ty_holds' lead clause (its signature);
- the Lay comment's parenthetical on an element's open layout (lay_el);
- lay_at's comment (its one-line body);
- the Mat comment's U32 table rule (mat_rows' condition);
- the normal-form comment's erased-application clause (anf's Var case);
- the Emit comment's "a read asks a lend" (the File comment);
- emit_open's note on a foreign def's continuation (the Emit comment
  and fun_of's lays);
- "an effect source is read once, in one namespace" (effect_srcs' map
  and its error);
- the JS comment's closure, $pc and direct-call clauses (js_lib and
  js_def);
- host's U+FFFD clause (the Char comment);
- the Dialect comment's segment-dispatch sentence (the WL macros below);
- the Globals comment's device-program source (BEND_SRC);
- the Gpu comment's "a launch loads it, else notes and compiles" (both
  gpu_load bodies);
- the Corpus comment's GPU sentence (corpus_grow, corpus_setup);
- the Io comment's request and parked-field sentences (io_step,
  io_wait_on);
- io_loop's busy-loop comment (the (n & 63) block);
- the Show comment's show_f32 sentence (f32_text's round trip);
- the JS Io comment's deadline-order sentence (io_park_on's sorted
  insert, and the C io_park comment);
- io_fail's errno fallback (its first line).

C and JS output lose only those comment lines.
@viridale
viridale force-pushed the c_runtime_improvements branch from 82130a1 to e603584 Compare October 20, 2026 00:51
@viridale

Copy link
Copy Markdown
Contributor Author

fixed conflicts @Lorenzobattistela

@Lorenzobattistela

Copy link
Copy Markdown
Collaborator

Thanks for the work on this. We're closing it in favour of a smaller PR. Please open a new one with just the allocation hoist (commit 2), rebased on current main. It's the one change here that clearly pays off, and it's small.

What we measured, with the PR merged onto main b0d1ca6 (the 17 conflict hunks with #1786 are comment-only and were resolved to main's side):

  • repo 54/54, test 1694/1694, ping 63/63, safe 0 false rejects.
  • perf 122/124 (main 124/124), the same over three runs on the minis:
    • editdist SEQ-CPU 1.24x (2.379 s vs main 1.906 s), PAR-CPU 1.11x vs main 0.95x;
    • merkle COMPILER 1.14-1.18x vs main 0.94x.

SEQ-CPU on the minis, main → PR:

bench main PR
tree-bitonic 5.554s 4.640s -16%
symreg 2.809s 2.467s -12%
tree-matmul 2.008s 1.857s -7%
tree-radix 3.696s 3.515s -5%
kmeans 3.873s 3.747s -3%
editdist 1.906s 2.379s +25%

The other benches are within 1%. PAR-CPU follows the same pattern: tree-bitonic -10%, symreg -8%, editdist +16%.

Commit 3 (address frames) is the editdist regression. With commits 1-4 cherry-picked onto main one at a time and built on an M4 Pro, commit 3 alone takes editdist from 2.05 s to 2.47 s. With -falign-functions=64, both builds run at 2.08 s, so it is layout, but on M4 it goes the other way from your M3 Max, and the gate grades the default build. Its other gains are a few percent (kmeans), and host and device frames would hold different things for good, so we'd rather not take it.

Commit 4 (the borrowed-field keep) changes no bench and breaks three programs that run on main, all at --threads 1:

  1. Array field: val_own's seal branch emits term_keep(e, w, 1); and drops the result. rfc_seal wraps only constructors, so an Array field escapes raw while the caller still frees the root. Base List.get(&1, Array<U32>, xs, 1n) on a lent list prints bend: memory fault; main prints 16.
  2. Closure field: term_keep on a raw closure goes to rfc_wrap, which rejects TAG_CLO. A Hold<b> with a U32 -> U32 payload, lent twice, prints bend: runtime fail-stop; main prints 1116.
  3. Closure capture: seg_open carries seal into closure segments, so emit_clo keeps the field once at capture, and the body never releases it and keeps it again at each owned use. 9M calls over one shared list fail with "more than 2^24-1 live copies"; main prints 36000000.

It also adds a full extra emit pass for the heat step, and a seal set that every copy of brwl has to remember (val_arms forgets it). If you want to come back to it, it should be its own PR, with those three fixed and tests for Array fields, closure fields and captures.

For the hoist PR:

  • emit_chain scans every arm's lines on the JS backend too, which never emits heap_alloc, so please return early when FL.js is set;
  • include the perf numbers for the hoist alone.

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