$cat ~/posts/bend-ml-v3

bilingual · en / pt

← /blog

bend-ml after the launch: copying was the cost, and the bug was in the compiler

Languages and compilers#Bend#compilers#performance

The previous post told how I built bend-ml, a machine learning library in Bend where a shape error doesn't compile. It ended with a sentence about what would come next:

$ quote ~/posts/bend-ml

The obvious next step is splitting the weight array between tasks without copying: since the Array is a tree underneath, its branches can be separated and handed one to each task.

a pytorch where a shape error doesn't compile2026-10-04en

I took that step. Along the way the parallelism vanished without a single error, and the reason was in Bend's compiler, not in my code. I opened an issue, went after the cause, and the fix was accepted into the official repository. This post covers those three days the way the other one covered the first two: explaining each piece before using it, and with every number, including the ones that didn't improve.

  1. 2026-10-04v2.11 daywhere the previous post left off: flat array, CI, 24 laws.
  2. 2026-10-05launch and v31 daythe questions from the launch, the bands, and the parallelism that vanished.
  3. 2026-10-06v3.1 and three issues1 dayloading the weights, the one-thread cost, and three reports for the bend team.
  4. 2026-10-07the fixthe cause in the compiler, the PR, and the merge.
each bar is the interval until the next milestone.

the questions from the launch

On the 5th I posted the project on X, as an answer to Taelin's wishlist. Three questions came back and became the plan for v3.

The first: "doesn't dfdx already do this?". dfdx is an ML library in Rust that also checks shapes before running, using a Rust feature called const generics: fixed numbers inside the type. The question was fair, and the answer is in one detail: fixed numbers. When a size is only known while the program runs (the batch size read from a file, for example), it becomes a regular variable in Rust, and from then on dfdx checks the shape the old way, with a run-time check that brings the program down if it fails.

In Bend, a size that only exists at run time is still a variable inside the type. This reshape regroups a matrix of n rows and 6 columns into n·2 rows and 3 columns, with n coming from the command line:

def regroup(+n: Nat, x: T.Mat<n, 6n>) -> T.Mat<Nat.mul(n, 2n), 3n>:
  T.Mat.reshape(n, 6n, Nat.mul(n, 2n), 3n, NL.mul_assoc(n, 2n, 3n), x)

Remember that reshape demands a proof that the total number of elements doesn't change? Here the total is n·6 on one side and (n·2)·3 on the other, and the proof is mul_assoc, the associativity of multiplication, one of the lemmas in the first package. It holds for every n, so the checker accepts the function once, for whatever size arrives. Asking for n×5 instead doesn't compile: there is no proof that n·6 equals n·5.

That catches a bug no fixed-number checker can catch. In GPT-2, the attention's output projection has 768×768 weights. The weights are stored one row per output, so the right product is X·Wᵀ. If someone writes X·W, with 768 on both sides the shapes fit just the same, and the program runs and gives wrong numbers. Written for any input and output size, the same layer doesn't compile:

def proj(+n: Nat, +din: Nat, +dout: Nat, x: TA.Mat<n, din>, w: TA.Mat<dout, din>) -> TA.MMul<n, din, dout>:
  TA.Mat.matmul(n, din, dout, 0n, x, w)
Error:
- expected : TA.Mat<din, dout>
- observed : TA.Mat<dout, din>

To the checker, din and dout are different names, so W has dout rows and not din, even if the program only ever uses the layer with 768 and 768.

The second question was about compile times: with types this detailed, what happens on a deep network? I measured. Checking 8 layers with all-different sizes takes 0.22 seconds; 128 layers, 0.27. The build is dominated by clang, the C compiler: 1.1 to 4.7 seconds. The whole GPT-2 checks in 0.38 seconds.

The third wasn't a question. The author of Bend-Conv, a tensor and convolution library in Bend, pointed out that the real cost of parallelism was copying data. That was exactly the problem from experiment 3 in the previous post, and it took up the rest of v3.

copying was the cost

A reminder of experiment 3: to generate text, GPT-2 multiplies matrices by a vector, a single row of input. Parallelizing that by splitting the matrix into blocks made everything 10 times slower, because each task got a full copy of the weights, and copying cost more than doing the math. The sentence at the end of the post was the way out: instead of copying, hand each task only its own piece.

Bend-Conv already stored matrices in row bands. That's the idea I took from it, and what I added was the type. In the package, the banded matrix is called Bands:

type Bands<-r: Nat, -c: Nat> is Type:
  BLeaf{m: Mat<r, c>}
  BNode{x: Bands<half(r), c>, y: Bands<Nat.sub(r, half(r)), c>}

A Bands of r rows is one of two things. Either it's a leaf (BLeaf) holding a regular matrix of r rows, or it's a node (BNode) that splits the rows into two halves, each one another Bands: the top one with half(r) rows, the bottom one with the rest. Each leaf is a band, with its own array.

splitting the tree copies nothing: each task gets its own branch. the only thing copied is the input vector, which is small.

What makes this work with no tricks is that Bend's Array is, underneath, a plain tree from the standard library. Opening a node with match hands over both halves right away, without copying and without @unsafe. Because of affine types, whoever opens the node becomes the owner of the halves and can give one to each task. The input vector x is still copied for each task, but it has 768 numbers, against the 38 million of GPT-2's largest matrix.

And the type guarantees the part that goes wrong easily: the two halves add up to exactly r rows, none lost and none counted twice. That's a law:

law half_cover:
  for +n: Nat
  {n == Nat.add(half(n), Nat.sub(n, half(n))) : Nat}

A matrix built with the wrong number of rows doesn't compile. And every function that walks the tree carries an erased proof, {rr == r}, tying the row count used at run time (rr) to the one in the type (r). The proof costs nothing when it runs, and it's the one that gave me trouble, as you'll see in a moment.

Experiment 11. The same matrix-by-vector product, on GPT-2's shapes, with 16 threads (for bands, the best of 16 and 32 bands):

matrixsequentialblocks with copiesbands, no copies
2304 × 7680.80 ms10.4 ms0.32 ms
3072 × 7681.07 ms23.5 ms0.40 ms
50257 × 76825.1 ms337 ms4.5 ms

The middle column is experiment 3's idea, with blocks that copy the weights: slower than doing everything on one thread. Without copies, the largest matrix, the logits one (which scores each of the 50257 tokens), got 5.6 times faster than the sequential product. And with the same result bit for bit: each band runs the same math as the sequential product, so GPT-2's 11 test prompts give the same tokens and the same logits as v2.1, digit for digit.

But that table is from the end of the day. The first version of the bands wasn't faster.

the parallelism that vanished

The first version compiled, gave the right result, and took the same time on 1 and on 16 threads. No error, no warning.

To see why, you need a piece of how Bend parallelizes. In Bend, you write two calls side by side in the same let, and it runs both at the same time:

p q = bmv(..., top_half, ...) bmv(..., bottom_half, ...)

That's called a parallel let. The compiler turns the program into C, and in the C, to run the two calls in parallel, it creates a join function: the part that waits for both halves to finish and puts the results together. If there's no join, the two calls become a plain sequence, one after the other.

the result is the same in both cases. only the time changes, which is why no test complains.

What led me to the cause was reading the C the compiler generates (bend X.bend -o x.c). In the slow version, the join function wasn't there. And the difference between the slow version and one that worked was only the order of the parameters:

bmv(+c, -r, b, xp, +rr, -e)    no join: 1 core
bmv(+c, -r, b, +rr, -e, xp)    join: 16 cores

The -e is the erased proof {rr == r}. It disappears from the compiled program, so in theory its position shouldn't matter. But when it was the last parameter, the parallel let became two calls in sequence. I moved the proof, with a real parameter after it, and the join came back.

I shrank the problem down to the smallest program that showed the same effect: a tree of calls that sums leaves, with an erased parameter -z that does nothing. With -z at the end, 1.01 seconds on 1 thread and 1.01 on 16. With -z in the middle, 0.17 on 16. On GPT-2's logits matrix, the same change of position took the product from 26.7 ms to 3.3 ms.

There was a second, smaller trap. The half function, which computes the size of the top half, was recursive in the simplest way (half(n) = 1 + half(n − 2)) and was called at every node of the tree. Recursion like that, which still has work to do after it returns, gets in the way of the parallelism around it. Rewritten as a loop that carries the result in an accumulator, the same batch of 40 products went from 0.69 to 0.36 seconds.

Both traps became rules in the project: when parallel code doesn't scale, compare the time on 1 and on 16 threads and read the generated C. And so the first one doesn't come back, CI compiles the banded product to C and fails if the join function isn't there. I checked that the check catches it: with the proof moved back to the end, it fails.

On GPT-2, generating 8 tokens, v3 got faster than v2.1 on 16 threads. The exact numbers moved with the machine's load, and I come back to them at the end. Something else showed up too: on one thread, v3 was about 12% slower than v2.1. That was the start of v3.1.

v3.1: one thread, loading the weights, and a negative result

On a single thread, bands don't help: the tasks run one after the other, and what's left is the cost of building the tree and joining the results. I found two causes. The typed API converted the result to a Mat and the demo converted it back to a list; a Bands.matvec_l, which takes and returns lists, removed that round trip. And with 2 threads or fewer the demo now uses a single band, which runs exactly v2.1's math. Measuring 36 forward passes of the model:

36 GPT-2 forward passes1 thread16 threads
v2.14.7 s4.7 s
v3.05.2 s3.8 to 4.2 s
v3.14.8 s3.6 to 3.8 s

The gain on the whole model is smaller than on the product alone because attention, which still runs on lists, takes a growing share as the text gets longer.

Experiment 12. The other target was loading the weights. A profile showed about 50 nanoseconds per number, spent turning each 1 MB block into a list of bytes, then a list of F32, and only then writing to the array. Decoding straight into the array, 4 bytes become an F32 and go right into place, and loading dropped by about 25%.

The next idea was loading the 12 layers at the same time. Bend has IO.fork, which starts several input and output tasks, and the language guide says each one runs its pure code "in parallel, on every core". Loading each layer in an IO.fork made it slower: from 5.1–5.6 to 6.6–7.1 seconds, the same on 1 and on 16 threads. In a minimal test, four forks of a long loop took 1.86 seconds on 1 thread and 1.73 on 16, with CPU time equal to wall-clock time: a single core. The same loops in a parallel let scale. I reverted the change, wrote down the negative result, and wrote a report.

There's also a check at the boundary now: each weight file must hold exactly r·c·4 bytes, otherwise the program stops with a message that says which file, how many bytes it expected and which script to run again, instead of quietly loading half a matrix.

the proof that didn't close

v3.1 had a law I really wanted: "the banded product returns exactly r numbers". I proved half of it. band_len says a band of rows rows returns rows numbers, and join_len says joining two halves adds their lengths. With both, the whole tree looked like an easy induction: each node joins its two halves, and each half, by induction, has the right length.

Except the induction needs to talk about the two recursive calls twice: once to use the result, once to state its length. Each call carries its band's array, and an array is affine, it can only be used once. The checker refused it (consumed more than once), even inside the proof's rewrite. Erased copies didn't solve it either, because of the language's rules on where an erased value can be used.

That law stayed out of the README and is in the notes as debt. The output length is covered by tests: 20 shapes, every depth of the tree, checking every number. The project's rule is that a law that isn't proved yet doesn't show up as proved.

A trick that worked for the smaller laws, and that I wouldn't have found reading the guide: pass the expression that mentions the arrays only in an erased argument, which doesn't count as a use, and the arrays themselves just once.

three reports for the bend team

By the end of the 6th I had three things that weren't in my code, they were in the language. I wrote each one up as a report, with a minimal program that reproduces it, the numbers, and what I had ruled out:

  • #1374: an erased parameter at the end of the list turns the parallel let into sequential calls.
  • #1375: IO.fork runs pure work on a single core, and the guide says it runs on all of them.
  • #1376: a way for several tasks to read the same array without copying and without @unsafe. Today the only way, Array.fork, is @unsafe, and a package that uses it doesn't pass as proved.

Before posting, I checked each one against Bend's latest code, not only the version the project uses. All three were still there.

going after the cause

Working around it in my code fixed my case. #1374 was still there for anyone else who wrote a proof at the end of the list, and worse: with no error at all, just slow. So, on the 7th, I cloned the compiler.

Bend 2's compiler is written in TypeScript, and the part that generates code lives in a single file, comp.ts, with a size limit the repository itself checks. I didn't know the code. I added temporary logs to the step that reorganizes calls before generating C, ran the minimal program in its three versions (proof at the end, proof in the middle, no proof) and compared what each one printed.

The cause, in plain words: before generating C, the compiler breaks a call with several arguments into pieces, applying one argument at a time. When it finds a piece that already has every argument that exists at run time, it treats that piece as a complete call and splits it off into its own sequential step. With the proof in the middle, the piece before it is still incomplete, and nothing gets split off. With the proof at the end, the piece before it already has every real argument. The compiler split the call off as a plain step, and the parallel let was left with two ready values instead of two calls: nothing to parallelize, no join.

The fix: only an argument that exists at run time can make the compiler split the call off. An erased argument stays where it is, as it already did when it sat in the middle. In comp.ts, it was five lines:

- if (m.t.$ === "Var" && !m.args.includes(f.x)) {
+ const on = m.args.includes(f.x);
+ if (m.t.$ === "Var" && !on) {
    return spine(f.f);
  }
- const g = cut(spine(f.f), ty_ann(f.f));
- const x = m.args.includes(f.x) ? go(f.x, false, null) : f.x;
+ const g = on ? cut(spine(f.f), ty_ann(f.f)) : spine(f.f);
+ const x = on ? go(f.x, false, null) : f.x;

on says whether the argument f.x is among the ones that exist at run time. cut is what split the call off; now it only happens when on is true. With the fix, the minimal program went from 1.01 to 0.17 seconds on 16 threads, and bend-ml's bmv got its join even with the proof at the end.

The part that took longest wasn't the fix, it was showing that it didn't break anything. Touching the step every program goes through can change anything. So I generated the C and the JavaScript for all 1,659 programs in Bend's repository (tests, benchmarks and demos) with the compiler before and after, and compared them byte for byte. The JavaScript was all identical. In the C, two files changed, and both have an explanation: in one, eight calls the bug split off became tail calls (the last thing the function does, with nothing kept for later), for the same reason as the bug; in the other, only the name of an internal variable changed, and the binary came out identical. I ran every test in the three ways Bend runs code (interpreter, C and JavaScript), before and after: the same output. The 17 benchmarks built identical binaries. And I said in the PR what I had no way to run, like the tests that need the team's cluster of machines.

I also wrote a test in the repository's format. It has a limitation I left written in it: a program's output doesn't show whether it ran in parallel, so the test checks the result on every path, but wouldn't catch the fix being reverted. The evidence that the join came back is the generated C and the timings, which are in the PR's description.

the merge

The PR was approved and went into the official repository the same day, with no requested changes, exactly as it was sent. #1374 closed with it. It was my first contribution to Bend.

It isn't in a release yet: 2.0.36 came out hours before the merge. Until the next one, bend-ml stays pinned to 2.0.35, with the proof kept off the end in bmv. When the release comes out, the workaround can go, but the join check in CI stays. #1375 and #1376 are still open, with the Bend team.

the numbers, no varnish

All on the same laptop as the previous post (Core Ultra 7 155H, 22 threads, 32 GB). This time the machine was not idle: an Android emulator and a Gradle daemon ran the whole session. So the versions were measured alternately, under the same conditions, and the numbers show up as ranges.

GPT-2 smallv2.1v3.0v3.1
36 passes, 16 threads4.7 s3.8 to 4.2 s3.6 to 3.8 s
36 passes, 1 thread4.7 s5.2 s4.8 s
loading the weights6 to 7 s5.1 to 6.1 s3.9 to 5.1 s
peak memory1462 MB895 MB903 MB
matrix · vector product, 16 threadsv2.1v3
2304 × 7680.80 ms0.32 ms
50257 × 76825.1 ms4.5 ms

What didn't improve or is still unexplained:

  • with a single band, on the logits matrix, the product with lists is about 25% slower than v2.1's (29.6 against 23.5 ms). It's what's left of the one-thread cost. Likely cause: the 50 thousand-number result built as a list, against writing into an array. I didn't look into it.
  • the process's peak virtual size went from 10.6 GB to 41 GB. It's reserved address space, not memory in use (the real peak went down), but I didn't look into the cause.
  • the README chart still shows v2's numbers, until I can measure on an idle machine.

what's left

27 laws in 5 packages, all passing --verdict, with no @unsafe and no ?TODO. bend-ml-tensor-array went to version 0.1.5.0 on BendHub, with the bands. CI runs 43 checks, GPT-2 and MNIST included, now on the development branch too, and the stable version only reaches the main branch through a pull request.

And what I take from these three days:

  • the question "doesn't dfdx already do this?" led me to the project's best example: the bug that only shows up when the layer is written for any size.
  • the person who pointed out the copying problem had already faced it in another library. Publishing early brought in the right idea.
  • when parallel code doesn't scale, the time on 1 and on 16 threads and the generated C say more than rereading your own code.
  • a measured negative result (IO.fork) is worth a report, and an unproved law is worth a line in the notes, not a line in the README.
  • when the problem is in the tool, you can go to it. Bend's compiler was a file I had never opened, and the fix fit in a few lines. What took the most work was the boring part: showing that nothing else changed.

The code and all the notes, with the command to reproduce every number, are in the repository.

follow the next posts

Add this RSS feed to your feed reader to follow new posts. No account or email required.

open RSS feed ↗