Repository navigation
Conversation
…CPU and one kernel thread per row on Metal, bit-identical to the Bend body
…ne falls back to the body The C lowering is now one function: eight rows side by side, each row its own chain of adds in base.bend's order, writing only the rows asked. The Metal kernel, its host dispatch and the scalar tail macro are gone; inside a bang the same loop runs on the device. comp.ts goes from 66,465 to 64,660 ttok (ttok 0.3; upstream 63,905). The collector now reads intrinsics per lane (intr_of(k, FL.js)), so a C-only intrinsic keeps its body on the JS lane. Before, the interpreter and JS builds failed with "$Array$q4mv$ is not defined". The emitted JS of every other test is byte-identical. tests/base/array_q4mv.bend checks 13 rows (a block of eight and a tail of five) and rows 3..8 alone against an independent float32 reference, on the interpreter, C and JS lanes.
…en a Metal heap is live, one thread per row, bit-identical to the Bend body
…and one Metal SIMD group per row give the Bend body's bits on every lane Lane j of a group sums its 8 nibbles in code-word order, the 8 lanes meet as ((0+4)+(2+6))+((1+5)+(3+7)), group terms go into 32 partials, and the partials fold 16, 8, 4, 2, 1. tests/base/array_q4mv_tree.bend pins the order.
…fer and wait once, on a flag kernel Array.q4go is Array.q4mv with the same body, so the interpreter and JS run it unchanged. In a native build with a Metal heap it encodes the product into one open concurrent command buffer. Array.q4wait commits the buffer, ends it with a barrier and a 1-thread kernel that stores a sequence number into shared memory, and spins on it (falling back on the buffer status). BEND_Q4_TRACE=1 prints each wait's products, GPU time and wall time.
…s 4 rows per SIMD group, so a model's decode token can be one command buffer with one wait
|
Thanks @totaland, this is a lot of careful work, and the measurements are useful. This PR went up before Nico's note on #1435 to #1437, so to close the loop: we're closing it for the same reasons, plus a few we found while testing it on an M4 Pro.
The per-token kernels fit better in a library. A package can already ship its own .c and .js effects that take an Array and run Metal kernels. We tried a small one that queues three kernels in one command buffer and waits once. As effects, the order of queueing and waiting is explicit in IO. The cost is that --verdict reports them as foreign code in any program that imports them. The sync cost you found is real, and it isn't specific to Q4. On main, each bang is one command buffer with a blocking wait, about 0.23 ms on an M4 Pro even for an empty call. A general way to avoid that wait is worth an issue, as Nico suggested, if you want to take it on. |
Stacked on #1431, #1435, #1436 and #1437. Only the last commit is new here.
Why
With #1437, a model's Q4 products can share one Metal command buffer, but everything between them (norms, activations, recurrent and attention mixers) still runs on the CPU. So a decode step still has one wait per dependent stage. For Qwen3.5-2B (MLX 4-bit) that is 97 waits per token, and the GPU idles between short command buffers, which also makes the products themselves 2–3× slower than when they run back to back.
This PR adds the missing per-token kernels as queued intrinsics, so the whole token can be encoded into one command buffer and waited on once.
Change
New Base intrinsics in
bend2/base.bend. Each has a pure Base body that defines its meaning (the interpreter and JS run it), a C twin inbend2/comp.tsthat follows the body's order, and a Metal kernel that is queued likeArray.q4go: barriers on both sides, and the result is ready after the nextArray.q4wait. Without a Metal heap, each runs its C twin at once.Array.rmsq: RMS norm with weights asArray<U32>(F32 bits), plus the 64-value group sums thatArray.q4mvreads. The Metal kernel sums withsimd_sumtrees in one threadgroup.Array.swq(a, o, n): SwiGLU,round(silu(a[i]) * a[n + i]), plus the group sums. One SIMD group per 64-value group.Array.gdn(...): one token of a gated DeltaNet mixer: conv window and activation, q/k L2 norms, decay and beta, the delta-rule rows, the gated norm and group sums. One threadgroup per value head; every phase is local to its head, so there is no cross-threadgroup sync.Array.atq(...): one token of gated attention over GPU-resident key and value caches: q/k RMS norm and rotation, the new k/v into the caches, scores, softmax, weighted values, sigmoid gate and group sums. One threadgroup per query head. The first head of each KV group writes the cache row; the others use the new k/v from threadgroup memory, so no head reads a row another head writes in the same dispatch. Metal handles positions below 4096 (ATQ_MAX); past that the C twin runs at once on the CPU, so the caller must wait first.bend_q4mv: a SIMD group now does 4 rows and reads each group's 64 activations once for all of them. Each row's arithmetic and reduction tree are unchanged from Array.q4mv sums in a fixed tree order, so NEON and one Metal SIMD group per row keep the body's bits (stacked; over comp.ts cap) #1436, so it stays bit-identical to the Bend body.tplincomp.tsnow reads$10and up (/\$(\d+)/). Before,$10was split into$1followed by0. No upstream intrinsic has 10 or more arguments, so this only shows withgdnandatq.tests/base/array_rmsq.bend,array_swq.bend,array_gdn.bend,array_atq.bend.q4mv,rmsqand the plumbing are exact.swq,gdnandatqare not bit-identical on Metal: Metal'sexp, its summation order and fused multiply-adds move the last bits.array_gdn.bendprints its state rounded to bfloat16 for that reason (Metal moves the last F32 bit of 5 of its 64 values).Verification
Apple M1 Pro, macOS 26, GPU runs one at a time.
array_q4mv.bend,array_q4mv_tree.bend,array_q4go.bendpass on the interpreter, C--gpu off, C--gpu 8GB(Metal) and JS against their#|lines.60fa05dchas intests/baseandtests/iogive byte-identical interpreter, C and JS output on upstream and on this branch (temp paths in JS build-error messages normalized). 120 of 186 match their#|lines on both trees. The 7 new Q4/kernel tests pass on all three lanes.uvx ttok@0.3 < bend2/comp.ts: 68,289 (#1437) → 78,654 (+10,365). Most of it is the C twins ofgdnandatq.Limits / open
comp.tscap (64,000), like the rest of this stack. I'd welcome direction on whether model-specific kernels likegdnandatqbelong in the compiler at all, or whether a smaller set of generic queued primitives would be preferred.swq,gdnandatq(see above). Greedy outputs matched in every run, but logits drift.q4go, every array the GPU reads or writes must stay alive untilArray.q4wait; nothing in the types enforces it.atqpast 4096 positions falls back to the CPU; that path is implemented but was not exercised (runs stopped at 205 positions).