Skip to content

Array.q4mv: affine 4-bit matvec primitive (CPU 3.2-3.6x, bit-identical; over comp.ts cap) - #1431

Closed
totaland wants to merge 2 commits into
bendlang:mainfrom
totaland:q4mv
Closed

totaland wants to merge 2 commits into
bendlang:mainfrom
totaland:q4mv

Conversation

@totaland

@totaland totaland commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds Array.q4mv(w, x, y, +r, +n, +cols, +rows), a matvec over affine 4-bit weights with group size 64. One Array<U32> holds the codes, then the BF16 scales, then the BF16 biases. Activations are one Array<F32> with the per-group sums stored after the cols values. Each output row is rounded to BF16 and written to y at its index.

  • bend2/base.bend: the pure Bend body defines the result. It sums each row group by group, left to right, then rounds to nearest-even BF16. --check-only passes.
  • bend2/comp.ts: lowers the call to one C function, the same way Array.clone is lowered. It sums 8 rows side by side, and each row keeps its own add chain in the body's order. Clang vectorizes across rows, so every output keeps the body's bits. Only the rows asked for are written. Inside a bang, the same loop runs on the device.
  • bend2/comp.ts: the collector now reads intrinsics per lane (intr_of(k, FL.js)). A C-only intrinsic therefore keeps its Bend body on the JS lane, and the interpreter and JS builds run the body.
  • tests/base/array_q4mv.bend: 13 rows (a block of 8 and a tail of 5), then rows 3..8 alone. Expected bits come from an independent float32 reference. Slots that the call must not write keep their old value.

Why

A Bend inference engine for a 2B-parameter 4-bit model spends its matvec time in one dependent F32 add chain per row. That is about 3 cycles per weight, or about 12 G weights/s on 10 cores. The same loop in hand-written C runs at the same speed, so codegen is not the limit. Reordering the sum would change the bits, so this PR uses independent chains across rows instead.

Results

Apple M1 Pro, macOS. Each case ran 3 palindromic rounds (stock, patched, patched, stock), with milliseconds per call measured in-process after equal warm-up. Outputs matched stock and a float32 Python reference on every run. The 1-minute load average was 40 to 47 during these runs, so treat the spreads as wide.

case stock ms patched ms speedup (median) per-round speedups
CPU 2048x2048 0.750 0.235 3.19x 4.10, 1.89, 2.70
CPU 6144x2048 1.570 0.440 3.57x 4.02, 3.20, 3.29
CPU 2048x6144 1.630 0.470 3.47x 3.57, 3.21, 3.27
CPU 248320x2048 46.4 12.95 3.58x 3.62, 3.62, 3.51

Bend's Mandelbrot and tree-matmul benchmarks do not call the primitive. Their outputs match stock on CPU and Metal. CPU medians are 966 vs 964 ms and 676 vs 679 ms (stock vs patched, 3 ABBA rounds). Metal fast-slot times are also equal: Mandelbrot 218-232 vs 207-233 ms and tree-matmul 1654-1735 vs 1646-1733 ms.

Every other test that imports Base produces byte-identical JS (1,281 tests). Its C output is byte-identical apart from the added function.

Limits / open

  • The repo gate still fails: bend2/comp.ts is 64,660 ttok (ttok 0.3, as CI uses) against the 64,000 cap. Upstream main is 63,905, so this change adds 755 tokens. No behavior-preserving cut of that size was found elsewhere in comp.ts. The gate forbids moving code out of the file, so this PR cannot merge as is.
  • An earlier revision had a Metal kernel that made the 248320x2048 head about 11.5x faster than the best stock bang. That kernel was removed to save tokens.
  • The same speedup is reachable in plain Bend on stock main, with no compiler change. A generated Bend kernel that sums 8 rows per pass, with one tuple per group, measured 3.0-4.1x and bit-identical in the same runs. That may make this primitive unnecessary in Base.
  • No CUDA-specific path. Linux is untested.
  • Out-of-range indices wrap like Array.get.
  • A format-specific primitive in Base is the maintainers' call.

James Nguyen added 2 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.
@totaland totaland changed the title Array.q4mv: affine 4-bit matvec primitive (CPU 3-3.6x, Metal head 11.5x, bit-identical) Array.q4mv: affine 4-bit matvec primitive (CPU 3.2-3.6x, bit-identical; over comp.ts cap) Oct 9, 2026
@totaland

totaland commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

A question before I take this further. This draft can't pass the repo gate as it stands, and I'd like your direction rather than guess.

bend2/comp.ts comes to 64,660 tokens with this change (ttok 0.3, as CI uses), against the 64,000 cap. main is at 63,905, so the change adds 755. The gate says an over-cap file must shrink by simplification, not by moving code out, and I couldn't find a ~660-token cut elsewhere in comp.ts that keeps behavior the same.

I also found that plain Bend on stock main gets the same 3.0-4.1x speedup with bit-identical output. A generated kernel that sums 8 rows per pass, with one tuple per group, does it with no compiler change. So the primitive may not be needed in Base at all.

Which would you prefer?

  1. Close this PR. Format-specific matvecs stay in user code, written as 8-row Bend kernels.
  2. Keep the primitive, if a cut in comp.ts you would accept, or a different home for intrinsic lowerings, can make room for it.
  3. Split out only the lane fix. The collector read intrinsics with the C lane's intr_of, so an op lowered only for C lost its Bend body on the interpreter and JS lanes. Reading per lane (intr_of(k, FL.js)) fixes that. No current Base op hits this, as far as I can tell, so it may only matter if C-only intrinsics are wanted later.

I'll keep this as a draft until you've had a look. @VictorTaelin

@Lorenzobattistela

Copy link
Copy Markdown
Collaborator

Thanks @totaland for the detailed write-up and for asking before going further.

We'll go with option 1 and close this. comp.ts is over its token cap with the change, and since plain Bend on
main already gets the same 3.0-4.1x speedup with bit-identical output, a format-specific matvec belongs in
user code rather than in Base.

The per-lane intr_of fix is real, but no Base op needs it today, so we'll leave it until a C-only intrinsic
actually shows up.

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