Skip to content

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

Closed
totaland wants to merge 4 commits into
bendlang:mainfrom
totaland:q4mv-tree
Closed

totaland wants to merge 4 commits into
bendlang:mainfrom
totaland:q4mv-tree

Conversation

@totaland

@totaland totaland commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1431 and #1435. Only the last commit is new here.

Why

Array.q4mv's body adds each row's terms in index order: one dependent F32 chain per row. The CPU loop can only go wide across rows, and the Metal kernel from #1435 needs one thread per row, which reads the weights strided. A faster kernel has to add in a different order. To keep the same bits on every lane, the Bend body has to define that order.

Change

The order (T32), for row i and group g (64 weights, code words k = 0..7, nibbles j = 0..7):

  1. Lane j: L_j = 0; for k in 0..7: L_j = L_j + q(k,j) * x[64g+8k+j]. Each mul and add is rounded; there is no FMA.
  2. qx = ((L0+L4)+(L2+L6)) + ((L1+L5)+(L3+L7)).
  3. t_g = scale_g*qx + bias_g*xsum_g.
  4. 32 partials: p[l] = 0.0 + t_l + t_{l+32} + ..., with groups in order.
  5. Fold p[l] += p[l+o] for o = 16, 8, 4, 2, 1, then round p[0] to BF16 (nearest even).
  • bend2/base.bend: the body and its doc follow T32.

  • bend2/comp.ts:

    • a scalar row (q4_row) and a NEON loop that runs 4 rows at once (q4_neon4, under __ARM_NEON);
    • the Metal kernel uses one SIMD group per row, 8 rows per 256-thread group. Lane l owns groups l, l+32, …, and step 5 is simd_shuffle_down;
    • one bounds check is shared by the NEON and GPU paths.
  • tests/base/array_q4mv_tree.bend (new):

    • a 5x2176 product: 34 groups, so the partials wrap;
    • rows 1..4 alone;
    • lane and fold cancellations at ±2^25 and ±2^24.

    The expected bits come from an independent C model of the order.

This changes results. After BF16 rounding, 1 of 6144, 1 of 2048 and 58 of 248320 rows differ from #1431's order on real weights. array_q4mv.bend's expected lines do not change.

Verification

Apple M1 Pro, macOS, GPU runs one at a time.

The CPU gain differs between the two measurements: 2.2x in the 400-call program, but 1.06–1.17x in the microbench. I have not explained the difference. Treat the CPU gain as somewhere in that range. In the inference engine, CPU decode improved by about 4% (127 → 121.5 ms/step, 10 pairs), which is inside the noise.

uvx ttok@0.3 < bend2/comp.ts: 66,466 (#1435) → 67,215 (+749).

Limits / open

James Nguyen added 4 commits October 9, 2026 11:08
…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.
@nicolas-abril

Copy link
Copy Markdown
Collaborator

Thanks @totaland for the careful work and measurements on this stack.

We're closing #1435, #1436 and #1437 for the same reasons as #1431:

One difference from #1431: there, plain Bend on main already matched the CPU speedup. The measurements here suggest stock bangs don't yet match a dedicated kernel on Metal (the ~11.5x head and ~15 vs ~11 tokens/s). That gap is worth closing, but with a general mechanism (faster bangs for this kind of loop, or a general async GPU dispatch/wait) rather than one specific to the 4-bit format. If you want to explore that, please open an issue first so we can agree on the design.

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