Skip to content

perf(c): blk_new fills whole elements, clones keep runs; JS shares the cell depth wall - #1946

Open
nicolas-abril wants to merge 3 commits into
mainfrom
perf/blk-new-element-fill
Open

nicolas-abril wants to merge 3 commits into
mainfrom
perf/blk-new-element-fill

Conversation

@nicolas-abril

@nicolas-abril nicolas-abril commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #1928. That PR gave clang a trip count for blk_new's fill, which vectorized U32 fills; elements of two or more cells stayed scalar. This PR fills element by element so they vectorize too, fixes two blk_new error paths, keeps a clone's runs of equal cells with one refcount bump each, and makes the JS lanes refuse the same array depths as C.

comp.ts is over its cap: 66,075 > 66,000 ttok, so gates/repo.ts fails (53 / 54). This was knowingly left for now.

Fill

for (u32 i = 0, w = 1u << c; i != w; i += 1u << lgs) {
  for (u32 j = 0; j != 1u << lgs; j += 1) {
    blk_write(H, arr, l, i + j, j < n ? v[j] : 0);
  }
}

The inner count is the constant 2^lgs at every inlined call, so clang unrolls it and stores whole elements in vectors. The per-cell i % (1u << lgs) and < n test are gone. The index is u32: c <= 31, so w <= 2^31.

Telling clang c <= 31 (__builtin_assume, or c & 31) does not vectorize the old i < (1ull << c) loop. Only the != form does.

Clone

A copy that keeps its cells (Array.clone of boxed elements, and blk_node/blk_half on a shared block) made one atomic count add per cell through blk_keep. blk_fill now copies the block in one plain pass, which vectorizes. It then walks the copy and keeps each run of k equal cells with one term_keep(e, w, k), the way blk_new keeps each field once by 2^d - 1. k cells hold k counts, so k stays under RFC_CNT and rfc_bump still sees an overflow: a 2^23-cell String clone still fails with "more than 2^24-1 live copies", and a 2^22 one still runs.

The keep pass reads the copy, not the source. A first version kept from the source after the copy, so a cell that another thread wrapped in place in between would keep the wrapped term while the copy held the raw one. Reading the copy keeps exactly the term each cell was copied as, as the old per-cell blk_keep did. A wrap (k = 1, an unshared term) is still written back to the source as before.

Error paths (in effect only on the device, where err_post does not exit)

  • The depth check is d > 31 - lgs (lgs <= 31), so it cannot wrap. Runtime Nats stop at 2^48-1, so the old d + lgs > 31 could not wrap either; the new form does not depend on that cap.
  • Once ERR_CNTS is posted, the element is no longer kept with k = 2^d - 1. That bump carried out of the 24-bit count into the loc bits of the redirect cell.

JS depth wall

array_new in JS checked only d > 31, so [p : Pt^31n] set out to build 2^31 elements (still running after 5 minutes) while C refuses class 32 with "an array past the deepest block class 31". The JS emitter now passes the element's cell class (lay_arr(lay_el(T)).lgs, the value C gets) to array_new, which refuses d > 31 - lgs. C, JS and the interpreter now refuse Pt^31n, P3^30n and U32^32n alike. tests/io/array_depth_bound_wide.bend pins the Pt case. It passes on both lanes here and times out on main's JS lane.

To pay for the JS change, every tuple the JS runtime builds (the array templates, array_rmw, nat_divmod and io_tup) goes through one tup_new(a, b) helper, and lay_arr/arr_new say n || 1 for Math.max(1, n).

Benches

Apple Silicon, Apple clang 17, clang -std=c11 -O3 as the CLI builds. Each probe makes 100 arrays (fresh, or grown by doubling from one leaf as in #1928) and reads one cell of each into a checksum. Cycles are from /usr/bin/time -l, medians of three. Every probe prints the same checksum before and after. Most of what is left in the clone probes is dropping the clone (term_drop walks every cell), which this does not change.

These numbers are from before the rebase on f1273d3 and weren't run again.

probe main (after #1928) this PR change
Array<U32> doubled to 2^24 1.83G 1.85G flat
Array<Pt> (2 × U32) doubled to 2^23 3.11G 1.82G −41%
Array<P3> (3 × U32) doubled to 2^22 3.10G 2.04G −34%
Array<String> doubled to 2^20 1.00G 1.00G flat (7 interleaved runs)
fresh Array.new(Pt, 23n, v) 1.71G 0.42G −76%
fresh Array.new(P3, 22n, v) 1.76G 0.65G −63%
fresh Array.new(String, 20n, v) 0.88G 0.88G flat
100 clones of Array.new(String, 20n, v) 1.60G 1.07G −33%
100 clones of 2^18 distinct strings 474M 424M −10%

Checks

  • bun gates/repo.ts: 53 / 54. comp.ts is 66,075 ttok, over its 66k cap (main is 65,960).
  • bun gates/test.ts on the minis, rebased on f1273d3: 1800 / 1800.
  • Every test, C and JS lanes, run locally with the gate's rules (5 s alarm), on main and on this branch. The results are the same apart from the two new tests: 878 C and 881 JS pass here. The 3 C failures happen on main too and come from this machine: gfx/window and gfx/clicks expect "no display", and io/stack_fault_trap exits 138 under the runner where the test expects 1. io/chan_reclaim_limit failed once on main's JS lane and passed on rerun (timing).
  • A readback test sums every cell at runtime depths 0–9 for U32, Pt, P3, String, {String, U32} and {U32, String, U32}, made fresh and by doubling. C, JS and the interpreter print the same value.
  • ERR_CNTS (Array.new(String, 24n, s)) and ERR_ARRS still fire on the host.
  • tests/reg/array_clone_runs.bend clones String and {String, U32} arrays with one long run and with runs broken up by Array.set, writes one copy, and sums every cell of both, depths 0–11. C, JS and the interpreter agree, and so does main's C. With the run bump forced to 1 it ends in a memory fault. An overcount only leaks, which it cannot see.

Not covered

  • GPU execution, on Metal and on CUDA. stencil3d builds with Metal and passes, which only shows that the runtime compiles there.
  • gates/perf.ts. No bench pins the multi-cell fill.
  • JS speed of tup_new (one small call per get, size, clone and divmod, which the JIT should inline). Not measured.
  • ANode of two class-31 blocks: C refuses class 32, while JS concatenates. It needs more than 2^31 cells to reach.
  • Structs with a boxed field, such as {String, U32}, interleave their cells, so their clones have runs of one and gain only from the plain copy pass.
  • blk_node/blk_half on a shared block take the new keep path too. Only the suite and the readback test cover them, not a bench.

🤖 Generated with Claude Code

@nicolas-abril nicolas-abril changed the title perf(c): blk_new fills whole elements; JS shares the cell depth wall perf(c): blk_new fills whole elements, clones keep runs; JS shares the cell depth wall Oct 29, 2026
nicolas-abril and others added 2 commits October 30, 2026 18:15
blk_new's fill now loops over elements and, inside, over the 2^lgs cells
of one, so clang vectorizes multi-cell elements (Pt, P3, structs) as it
already did U32. The per-cell `i % (1u << lgs)` and `< n` test are gone.

Two error paths in blk_new, both device-only in effect (the host exits
on err_post):
- the depth check is `d > 31 - lgs`, which cannot wrap;
- once ERR_CNTS is posted the element is not kept, so a 2^d-1 bump no
  longer carries out of the 24-bit count into the loc bits.

The JS array_new took only the depth, so [p : Pt^31n] tried to build
2^31 elements where C refuses class 32. The JS emitter now passes the
element's cell class and array_new refuses d > 31 - lgs, as C does;
tests/io/array_depth_bound_wide.bend pins it. To fit the cap, the JS
array templates share array_pair and lay_arr/arr_new say `n || 1`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A copy that keeps its cells (Array.clone of boxed elements, and node or
half of a shared block) made one atomic count add per cell. blk_fill now
copies the block in one plain pass, which vectorizes, then walks the
copy and keeps each run of k equal cells with one bump of k, the way
blk_new keeps each field once by 2^d - 1. k cells hold k counts, so a
run stays under RFC_CNT and rfc_bump still sees an overflow.

The keep pass reads the copy, not the source, so each cell keeps the
term it was copied as even while another thread wraps the shared source
in place. tests/reg/array_clone_runs.bend reads back every cell of both
copies of String and {String, U32} clones with long and short runs.

comp.ts goes past its 66k ttok cap (66,124).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nicolas-abril
nicolas-abril force-pushed the perf/blk-new-element-fill branch from 4ea590b to 9a60419 Compare October 30, 2026 17:15
array_pair wasn't only about arrays once nat_divmod and io_tup could use it, so
it is tup_new now, beside cmp_new. blk_fill declares v where it's assigned and
passes k without a cast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant