Status: done in 2.16.0 (2026-10-08); see Done.
Batches of mid-size matrices (about 64 to 512) go to the blocked QR (qr-blocked.md), whose work for a batch is the band reduction’s panel kernels and batched MPS products: some forty dependent dispatches a call at 128 x 128, each small. The SVD and the eigensolver have a kernel of their own for this range (block Jacobi); QR has none. Build one as LAPACK’s blocked QR runs on one core, in one threadgroup a matrix:
simd_sums and one slot a simdgroup:
two barriers a column. T built on the way (slarft’s recurrence, the
reflector’s dot products with the columns left of it in the same sums).The unblocked backend hands it every matrix the register kernel does not take, and the routing’s kernel crossover is re-measured.
M5 Pro, 2.15.0’s routing and kernels, through MLX (ms):
| batch x shape | CPU | blocked QR | GPU flops rate |
|---|---|---|---|
| 1024 x 128 x 128 | 25.4 | 17.2 | 0.3 TFLOP/s |
| 256 x 256 x 256 | 18.6 | 17.3 | 0.7 TFLOP/s |
| 64 x 512 x 512 | 23.6 | 18.7 | 1.2 TFLOP/s |
| 16 x 2048 x 64 | 2.9 | 2.1 | |
| 4096 x 80 x 80 | 24.3 | 26.7 |
The GPU is level with the CPU here, at a tenth of the rate the same products reach for one large matrix.
2-3 days.
2-4x the CPU on large batches of 96 x 96 to 256 x 256.
Built as planned (qr_householder_wy in shaders/QR_Householder.metal,
src/qr_householder.mm), then changed by what the measurements showed. GPU
time (command buffer timestamps) unless a call is given:
| step | 1024 x 128^2 | 256 x 256^2 | 64 x 512^2 |
|---|---|---|---|
| first cut: panels of 16, each applied at once, two accumulators a simdgroup | 4.8 ms | 7.1 | 12.3 |
| two rows a thread (four for n <= 32), not one | 4.4 | 6.4 | 12.2 |
| blocks of 32 columns (T merged from the panels’), two column tiles a simdgroup; threadgroup memory sized to the block | 4.4 | 6.4 | 11.9 |
| Q’s [I; 0] and its unwritten zeros made in registers, not read; R written block by block; the input copied while scanned | 4.2 | 6.1 | 11.7 |
It replaced two kernels. Against the register kernel it wins from 48
columns (4096 of 48 x 48: 2.3 ms against 3.3; 64 x 64: 3.6 against 4.4), so
the register kernel keeps n <= 32 and loses its 64-column instances. The
threadgroup-memory kernel of qr-small-kernel.md lost
everywhere (1024 of 200 x 30: 1.3 against 2.2; 4096 of 80 x 80: 7.4 against
34) and is gone; so is the unblocked backend’s own kernel,
QR_Unblocked.metal (2.5-6x slower wherever it ran). The unblocked backend
is now the two Householder kernels, up to 4096 rows, and the blocked QR
beyond.
Measured (M5 Pro, through MLX, ms a call, with MLX’s buffers reused, see known-buffers.md, and MLX’s buffer cache off, as the sweeps had it then):
| batch x shape | CPU | blocked QR | this kernel |
|---|---|---|---|
| 1024 x 128 x 128 | 25.1 | 17.8 | 6.4 |
| 256 x 256 x 256 | 18.7 | 17.8 | 8.3 |
| 64 x 512 x 512 | 24.0 | 19.2 | 14.6 |
| 64 x 384 x 384 | 10.6 | 10.8 | 7.6 |
| 4096 x 64 x 64 | 10.8 | 16.1 | 5.9 |
| 16 x 1024 x 64 | 1.45 | 1.34 | 0.67 |
| 64 x 4096 x 64 | 17.3 | 13.5 | 8.8 |
| 256 x 2048 x 32 | 10.0 | 11.7 | 5.2 |
| 256 x 32 x 512 | 1.65 | 1.63 | 1.16 |
Lone matrices and small batches stay with the CPU or the blocked QR (one threadgroup a matrix is latency-bound: one 512 x 512 takes 4.9 ms, the blocked QR 2.9, the CPU 3.7); the re-measured routing (kernel epoch qr 6) finds the crossovers.
Then, the same night:
Left: the fixed passes are most of what remains at 128^2. Keeping a 128 x 128 matrix on chip would save most of them, but at 64 KB it is twice a threadgroup’s memory. A lone matrix of 256-384 is still a little slower than the CPU (0.98 ms against 0.84 at 256): its panels’ two barriers a column are the chain.