Skip to content

perf(runtime): matching ANode views the block instead of copying it (draft) - #2019

Draft
nicolas-abril wants to merge 3 commits into
mainfrom
ai/1991-array-views
Draft

nicolas-abril wants to merge 3 commits into
mainfrom
ai/1991-array-views

Conversation

@nicolas-abril

@nicolas-abril nicolas-abril commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Refs #1991. A draft to look at, not ready to merge: comp.ts is 713 tokens over its cap, so gates/repo.ts fails, and no perf bench uses array splits yet. This doesn't fix #1991 either; see the numbers below.

Matching ANode copies both halves today. Views were removed in 64adb37db because a block changed size class on every split and leaked (#804). This brings them back on the host and merges the pieces when they're freed, so nothing leaks. The device still copies.

How it works

  • Views: blk_half returns a view of the same block. Its aux has bit 5 set and the root block's span in bits 6-10.
  • Merging: freeing a view flips its parent's midpoint bit in a global bitmap (VIEW_BITS). The second sibling to die climbs a level, and the last one frees the root. This is why large blocks must be aligned to their own size.
  • Alignment: heap_alloc_miss takes only the gap it needs, with a CAS on the bump pointer. Only large host blocks use the CAS. Using it on every miss made GPU time much worse (merkle 3.16x).
  • Bounded retention: a piece more than 16x smaller than its root is copied into its own block, except under 8 words, where the copy costs more than it saves.
  • Inlining: blk_half is forced inline and its copy path moved out to blk_part. Otherwise clang stops inlining it and loses the constant arr and hi.

Results

All local on an ARM Mac, one thread, best of 3. main is 017f83fd, which differs from this branch's base only in bend.ts.

Program main this PR
#1991 repro, Array.to_list 0.52 s, 10.14 B instr, 29.7 MB 0.35 s, 9.49 B instr, 21.8 MB
#1991 repro, Array.get loop 0.12 s, 2.27 B instr, 21.4 MB 0.12 s, 2.27 B instr, 21.5 MB
Walk 2^16 Array<U32> by matching, 1600 times 0.53 s 0.45 s
Split 2^16 once, 16000 times 0.10 s 0.03 s
Rebuild 2^16 by matching (map), 400 times 0.37 s 0.32 s
Small arrays, cycles 0.21 B 0.24 B
Slice kept 12 levels down, 250 arrays 2.3 MB 1.8 MB
Slice kept 4 levels down, 250 arrays 6.3 MB 65 MB
bfs peak memory, 8 threads 1.91 MB 1.91 MB

On the #1991 repro Array.to_list gets about 31% faster but still takes 2.8x the time and 4.2x the instructions of the Array.get loop, so a flat walk is still the direct fix for that issue.

Rebuilding as a view (second commit)

Matching gave views, but ANode{xs, ys} still allocated a block and copied both halves, so a fork tree that splits, writes its leaves and joins paid a copy per level. Now blk_node checks whether both halves are live, unshared views, the left aligned to their parent and the right right after it. If so, it returns the parent as a view, or the block itself at its root. No sibling has died, so no midpoint bit is set, and freeing climbs from the parent as before. Halves out of order (ANode{ys, xs}) or from two arrays are still copied. tests/run/array_view_join.bend covers all of these.

This is the #1777 pattern written with plain match/ANode, no @unsafe: 2^20 U32s, a fork tree 4 deep, each leaf writing its 2^16 cells with Array.set, 300 rounds, one thread, best of 3 (local ARM Mac, on this branch rebased onto 05c0dba):

Program main first commit this commit
fork tree, match/ANode 0.24 s, 3.62 B instr, 13.6 MB 0.17 s, 3.13 B instr, 12.8 MB 0.09 s, 2.65 B instr, 5.8 MB
the same tree with @unsafe Array.fork/Array.join 0.09 s, 2.76 B instr, 13.6 MB 0.09 s, 2.69 B instr, 9.7 MB 0.09 s, 2.69 B instr, 9.7 MB

So the safe version now matches the unsafe one. Not changed:

  • Deep trees still copy. The 16x retention cutoff copies any piece more than 4 levels below its root, and a rebuild only joins views, so a tree deeper than that copies again below the cutoff. Without the cutoff, the fork tree is the same (0.09 s, 5.5 MB).
  • Full-depth rebuilds don't gain, even without the cutoff. In map-style code, ALeaf{f(x)} allocates a fresh one-cell block, so the bottom halves are never views. On 2^16 cells, 400 times, it is 0.21 s on every variant. Rebuilding a matched leaf in place would be a separate change.
  • The device still copies on both split and rebuild.
  • Tokens: this adds 174 tokens, putting comp.ts at 66,881 / 66,000.

Gates for this commit: gates/test.ts passes 1820 / 1820 on the branch as pushed, and 1839 / 1839 rebased onto 05c0dba. gates/perf.ts gives 120 / 124 on both commits in back-to-back runs, and its time ratios swing 0.7x to 1.3x between runs on a busy cluster, so that is noise. Memory per bench matches the first commit within 0.2 MB.

Views with a GPU (third commit)

The first commit corrupted the heap in GPU runs, and leaked there. See the comment below for the repro.

  • Corruption. A view finds its parent by masking its address, so its block must be aligned to its own size. Only the host's large blocks are; the device's are aligned to a page. When the host split an array a bang made, the second half to die freed memory that wasn't the block. Now blk_half splits a block in place only when it's aligned, and copies it otherwise. This costs nothing on the host, whose large blocks are aligned already.
  • Leak. The device read a host view as a plain block and freed it as one, so its whole block was never freed. With a GPU, the midpoint bits now live in the heap past the banks, at H[H_VIEW], where both sides can reach them, and the device frees views the same way the host does. They're 32-bit words, through atomic_fetch_xor with a fence before and an acquire after, the way the reference counts drop. Without a GPU they stay in the separate 128 GB reservation: that heap grows by being laid out again, which copies the banks.
  • Dropping a view of terms no longer recurses, since Metal can't. Its cells move to a block of their own, the view is given back, and the generic path drops that block.

Probes on an Apple M4, with the GPU on:

main first two commits this commit
a bang builds 2^16 cells and the host matches them, 200 times: output 1408921600 477252 523776 458745 (wrong) 1408921600 477252
the host splits 2^16 cells and a bang sums one half: peak RSS at 300 → 600 iterations 20 → 25 MB 96 → 177 MB 20 → 25 MB
the fork tree above, with a bang in the program, one thread 0.31 s, 27 MB 0.16 s, 19 MB 0.21 s, 19 MB

The last row's gap is code layout, not work. The leaves' 7-instruction loop retires the same 2.68 B instructions, but in this binary clang places it across a 64-byte line. Built with -falign-loops=64, it runs in 0.09 s, the same as before. Without a bang, it's 0.09 s on both.

tests/run/array_view_device.bend is the first probe at 4 iterations. It runs on the GPU in the test gate, and the first two commits fail it. The leak needs a heap the gate can't size, so it has no test.

Costs:

  • In GPU runs the bitmap takes 2 words per heap page, so about 1.4% fewer heap pages.
  • On CUDA, the bitmap relies on fresh managed memory being zero. I could only run Metal.
  • This commit adds 291 tokens, putting comp.ts at 67,172 / 66,000.

Gates for this commit: gates/test.ts passes 1821 / 1821. gates/perf.ts gives 117 / 124, against 119 / 124 for the second commit, back to back. Memory is the same on every bench, and compile, parallel and GPU times are within 1% geomean. Sequential time is 1.04x geomean because of kmeans (1.60x) and editdist (1.21x). Run locally on one thread, both are identical to the second commit (8.29 B against 8.30 B and 8.46 B against 8.48 B cycles), so that's the cluster.

Gates

  • gates/test.ts: 1819/1819 on this branch. It can't see leaks: the old leaking views passed it too. No test is added here.
  • gates/repo.ts: fails, bend2/comp.ts: 66713 > 66000 ttok.
  • gates/perf.ts, 5 rounds interleaved with main, run on 017f83fd: CPU time 1.000-1.003x, GPU time 0.995x, memory within 0.2%, compile time 1.035x. It passed 124/124 three times and 123/124 twice, against main's five clean passes. The miss is merkle compile at about 1.16x against the 1.15x limit, and main reaches 1.15x there too.

Known costs

  • Tokens: 713 over a permanent cap.
  • Small arrays: about 14% more cycles and 8% more instructions, not yet explained.
  • Retention: a slice kept 4 levels down or fewer pins its whole block, up to 16x its own size. Main doesn't have this.
  • Compile time: tree-radix goes from 0.48 s to 0.68 s, not yet explained; it still passes its pin. Without it the compile geomean is about 1.015x.
  • Startup: every binary without a GPU reserves 128 GB of virtual memory for the bitmap with MAP_NORESERVE and stops if the mapping fails (with a GPU, the bitmap is in the heap). This has only run on macOS, so a Linux box with strict overcommit is a real question.

🤖 Generated with Claude Code

blk_half returns a view on the host. Freed views merge through midpoint
bits and the last one frees the root, so splits no longer leak. Large
blocks are aligned to their size for that, and a piece over 16x smaller
than its root is copied so a slice can't pin a large block unbounded.

comp.ts is over its ttok cap: this is a draft.

Refs #1991

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…view of it

Matching an ANode views the block, but rebuilding one allocated a block
and copied both halves into it, so a fork tree that splits, writes its
leaves and joins still paid a copy per level. When both halves are live,
unshared views, the left aligned to their parent and the right right
after it, blk_node returns the parent as a view (or the block itself at
its root). No sibling has died, so no midpoint bit is set, and freeing
the result climbs from the parent as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nicolas-abril

Copy link
Copy Markdown
Collaborator Author

This corrupts the heap in GPU runs. Host views assume the block they split is aligned to its own size, because view_free finds the parent by masking the address. Only host allocations of large blocks are aligned, though (m = !DEVICE && cls >= NCLS ? ... : 0); a large block the device allocates is aligned only to a page. When the host matches a large array a bang made, the parent arithmetic points at the wrong address. Then either the second half to die frees memory that isn't the block, or a bit left at the wrong midpoint is later read as "sibling died".

Repro: each iteration, a bang fills a 2^16-cell Array<U32> with Array.new and Array.set, the host matches and sums it, and an array the host made beforehand is checked at the end. 200 iterations, Apple M4:

--gpu off --gpu on
main 1408921600 477252 1408921600 477252
this PR (first commit alone, and with the second) 1408921600 477252 523776 458745

The second number is the sum of the array that only the host touched, so live host memory is being overwritten. It reproduces every time.

There's also a leak in the other direction: the device has no views, so it frees a host view it receives as a plain block, and the whole block is never freed. With the host splitting a 2^16-cell array and a bang summing one half, peak memory goes 96 MB → 177 MB from 300 to 600 iterations (main: 20 → 25 MB, then flat around 33 MB).

I'm working on a fix here: a view only of a block aligned to its own size, and the bitmap moved into the shared heap so the device frees views as well. Both repros will become tests.

A view finds its parent by masking its address, so its block must be
aligned to its own size. Only the host's large blocks are: the device's
are aligned to a page, so when the host split an array a bang made, the
second half to die freed memory that was not the block, and live host
cells were overwritten. blk_half now splits a block in place only when it
is aligned, and copies it otherwise.

The device read a host view as a plain block and freed it as one, so its
block was never freed. With a GPU the midpoint bits now sit in the heap
past the banks, where both sides reach them (H[H_VIEW]), and the device
frees views as the host does. A view of terms that dies moves its cells
to a block of its own first, so term_drop no longer recurses.

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.

C backend: Base's Array.to_list copies both halves at every level, ~190 instructions a cell (a loop of Array.get takes ~35)

1 participant