Skip to content

Array.q4mv on Metal: one kernel thread per row for products of 4M weights or more, bit-identical (stacked on #1431; over comp.ts cap) - #1435

Closed
totaland wants to merge 3 commits into
bendlang:mainfrom
totaland:q4mv-metal
Closed

totaland wants to merge 3 commits into
bendlang:mainfrom
totaland:q4mv-metal

Conversation

@totaland

@totaland totaland commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1431. Only the last commit is new here; the first is #1431's.

Why

#1431 adds Array.q4mv on the CPU. An earlier revision of it also had a Metal kernel, which I removed to keep comp.ts small. Without it, a program that runs on Metal still does every 4-bit product on the host. For a 2B-parameter 4-bit model, the output head alone (248320x2048) costs about 30 ms per decode step on 8 CPU threads.

Change

bend2/comp.ts only.

  • When a Metal heap is live and a product has n * cols >= 2^22 weights, the host hands it to a new kernel, bend_q4mv, instead of the CPU loop.
  • The kernel runs one thread per row and adds in the Bend body's order, so every row keeps the body's bits.
  • Each call commits its own command buffer and waits for it. Smaller products, and programs without a Metal heap, stay on the CPU loop.
  • The kernel ships in the program's Metal library, so it needs a program that already builds one (one with a bang).

Verification

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

uvx ttok@0.3 < bend2/comp.ts: 64,660 (#1431) → 66,466 (+1,806).

Limits / open

James Nguyen added 3 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
@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