Skip to content

Add a BM=16 qmm tile for the small-M decode dead-zone#3863

Open
katlun-lgtm wants to merge 8 commits into
ml-explore:mainfrom
katlun-lgtm:feat/qmm-bm16
Open

Add a BM=16 qmm tile for the small-M decode dead-zone#3863
katlun-lgtm wants to merge 8 commits into
ml-explore:mainfrom
katlun-lgtm:feat/qmm-bm16

Conversation

@katlun-lgtm

@katlun-lgtm katlun-lgtm commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Affine quantized_matmul at small M (past the qmv batch-limit, up to 32) always pays for a full 32-row tile: the qmm path uses a single BM=32 tile and launches ceil(M/32) M-tiles, so M=10 and M=32 take the same absolute time. This shows up as a flat plateau — see #3852 (@ARahim3): qmm is flat from M=10 to M=32, then steps at 33 / 65 / 97 (one step per 32-row tile).

This adds a BM=16 tile and selects it for M <= 16 in affine mode, recovering the [qmv-limit, 16] band that was rounding up to 32.

Mechanism

qmm() launches grid = (ceil(N/32), ceil(M/32), B), so for M∈[1,32] that's one M-tile and the kernel computes 32 rows regardless of the real M. A fine M-sweep (M3 Max, 5120→17408, 4-bit) confirms the tile quantization is the whole story: flat ~0.51 ms for M=10→32, then +95% at M=33, +49% at M=65 — i.e. time = ceil(M/32) × per-tile cost. At M=32 the op runs at ~92 GB/s, far under the ~400 GB/s roofline, so it's tile/compute-bound with real headroom (not bandwidth-bound) — halving the tile actually helps rather than just re-reading weights.

Change

  • qmm() (host): bm = (mode == "affine" && M <= 16) ? 16 : 32, and thread BM into the kernel name + template instantiation. The bm == 32 path is byte-identical to before, so all existing kernels resolve unchanged.
  • quantized.metal: two _bm-suffixed instantiation macros, plus affine_qmm_t / affine_qmm_n instantiated at BM=16 (BK=BN=32).

The BM=16 tile reuses the existing qmm_t_impl / qmm_n_impl (already templated on BM), so there is no new kernel logic. BM=8 is intentionally not added: BM/wm = 8/2 = 4 rows per simdgroup breaks the 8-row MMA fragment tiling (TM = BM/wm/8), so BM=16 (TM=1) is the smallest valid tile.

Results (M3 Max, min of 15×30, baseline = BM=32 plateau at M=24)

shape bits M∈[10,16] (BM=16) plateau (BM=32) speedup
5120→17408 4 ~0.31 ms 0.515 ms 1.63–1.68×
5120→17408 2 ~0.31 ms 0.511 ms 1.63–1.66×
5120→248320 (lm_head) 4 ~4.31 ms 7.16 ms 1.64–1.70×
5120→248320 (lm_head) 2 ~4.28 ms 7.14 ms 1.65–1.68×

M≥17 is unchanged; the qmv / qmv_wide paths (M below the batch-limit) are untouched.

The win is size-dependent. Comparing BM=16 (M=16) against BM=32 (M=17) inside one run, across shapes:

shape K·N BM=32 / BM=16
2048→2048 4.2M 0.98×
4096→4096 16.8M 1.02×
2048→8192 16.8M 1.02×
4096→14336 58.7M 1.50×
3584→18944 67.9M 1.54×
5120→17408 89.1M 1.55×
5120→248320 (lm_head) 1.27G 1.52×

So it pays ~1.5× on MLP- and lm_head-class layers and is neutral below ~58M weight elements: the smaller shapes are already flat in M across the whole band, i.e. not tile-quantization-bound to begin with. The two headline shapes above are both large, so they overstate how general the win is.

Correctness

  • Exact vs dequantize-then-matmul (max|Δ| = 0) for 4-bit and 2-bit at M = 10 / 12 / 16 / 17 / 32.
  • python/tests/test_quantized.py: 32 passed, 2896 subtests passed.

Scope

  • Helps small continuous batches and n > 1 sampling (M in the [limit, 16] band), on layers large enough to be tile-bound — see the size table above; neutral on smaller ones. It deliberately does not touch the qmv_wide slope where single-stream speculative-decode verify (M = 2–6) lives — that path is already well-tuned (also noted in 2-bit quantized_matmul loses its size advantage over 4-bit at M >= 3 #3852).
  • Affine only for now; the fp modes have the identical plateau and the change mirrors mechanically. Happy to fold that in here or as a follow-up.
  • The [limit, 16] band itself is set by get_qmv_batch_limit. A sweep of where the qmv_wideqmm crossover actually sits (7 shapes, both bit-widths) is in the thread below; it does not change this PR, but it does show the current limit sits above the crossover on large layers and below it on small ones.

Refs #3852.

katlun-lgtm and others added 6 commits July 5, 2026 08:52
This workflow is designed to build a CMake project on multiple platforms, including Windows and Ubuntu, using different compilers.
This workflow installs Python dependencies, runs linting with flake8, and executes tests with pytest on push and pull request events.
Past the qmv batch-limit, affine qmm rounds M up to its 32-row tile, so M in
[limit,16] all pay the M=32 price (flat plateau). Add a 16-row tile for M<=16:
~1.65x on M in [10,16] across 4-/2-bit and both projection and lm_head shapes;
M>=17 and every other path byte-identical. Correctness exact vs dequantize-then-
matmul; test_quantized.py green (32 passed, 2896 subtests).

Refs ml-explore#3852
@angeloskath

Copy link
Copy Markdown
Member

Can you please provide the benchmark you ran? I think part of this should be handled by qmv_wide so not sure where the benefits lie.

@katlun-lgtm

Copy link
Copy Markdown
Contributor Author

Sure. Script is at the bottom; here's the full M sweep across the dispatch boundary, plus a measurement of the qmv_wide question since that turned out to be the more interesting part.

Where the boundary actually is. On this M3 Max (applegpu_g15s) at 5120→17408, get_qmv_batch_limit(D=5120, O=17408) returns 10 (gen 15, non-d, both dims > 4096), so M >= 10 already leaves the qmv family and goes to qmm — and qmm_splitk hands straight back to qmm at this shape because ceil(N/32) = 544 > 512 makes split_k = 1. So M ∈ [10,16] never reaches qmv_wide here; what it hits is qmm's own M-tiling, where M=10 pays for a full 32 rows.

4-bit, cold harness (hot agrees within ~1%; min of 15 trials × 30 reps):

M main this PR
1 0.129 0.128
2 0.129 0.129
4 0.165 0.167
6 0.263 0.265
8 0.323 0.324
9 0.405 0.406
10 0.508 0.312
12 0.508 0.311
14 0.507 0.317
16 0.509 0.306
17 0.513 0.512
24 0.514 0.513
32 0.513 0.511
33 1.002 1.002

M < 10 is the unchanged qmv/qmv_wide path and M ≥ 17 is the unchanged BM=32 path — both match main to noise. 2-bit tracks the same shape (0.507 → 0.311 in the band).

Would qmv_wide cover it instead? I measured that rather than guess. qmv_wide tiles M into ceil(M/5) blocks and each block re-reads the whole weight matrix, so running M as ceil(M/5) launches of ≤5 rows is what extending its range would cost:

M qmv_wide-style ceil(M/5)×≤5 rows BM=16 qmm
10 0.408 0.312
12 0.634 0.311
16 0.834 0.306

It's linear in M by construction, so it diverges exactly where this PR helps — at M=16 it's 2.7× the BM=16 tile.

The flip side is probably the more useful finding: with a 16-row tile available the crossover moves down. qmv_wide at M=9 costs 0.405 ms, already more than the BM=16 tile costs at M=16 (0.306 ms), and it passes 0.31 ms somewhere around M=6–7. So on this config the batch limit of 10 looks 2–3 too high once a 16-row tile exists. I'd need to lower the limit and re-measure to pin the real crossover — happy to sweep get_qmv_batch_limit across shapes and fold a retune into this PR, or keep that separate if you'd rather land the tile first.

Scope, to be explicit: the win band is [vector_limit, 16], so this is a no-op wherever the limit is already ≥ 17 (D, O ≤ 2048 → 32; d parts → 12/18), and the split-K path (small N) is untouched.

benchmark script
import time

import mlx.core as mx

mx.random.seed(0)

K, N, GS = 5120, 17408, 128
REPS, TRIALS = 30, 15
MS = list(range(1, 21)) + [24, 32, 33]


def make_bank(bits):
    w = mx.random.normal((N, K)).astype(mx.bfloat16)
    b = mx.quantize(w, group_size=GS, bits=bits)
    mx.eval(b)
    return b


def timeit(run):
    run()
    run()
    ts = []
    for _ in range(TRIALS):
        t0 = time.perf_counter()
        run()
        ts.append((time.perf_counter() - t0) / REPS)
    return min(ts)


def bench(M, bits, banks):
    nb = len(banks)
    xs = [mx.random.normal((1, M, K)).astype(mx.bfloat16) for _ in range(REPS)]
    mx.eval(xs)

    def run():
        outs = [
            mx.quantized_matmul(
                xs[i], *banks[i % nb], transpose=True, group_size=GS, bits=bits
            )
            for i in range(REPS)
        ]
        mx.eval(outs)

    t = timeit(run)
    mx.clear_cache()
    return t


def bench_split(M, bits, banks, chunk=5):
    """What qmv_wide would cost above its batch limit: it tiles M into
    ceil(M/5) blocks and each block re-reads the weights."""
    nb = len(banks)
    parts = [min(chunk, M - i) for i in range(0, M, chunk)]
    xs = [
        [mx.random.normal((1, p, K)).astype(mx.bfloat16) for p in parts]
        for _ in range(REPS)
    ]
    mx.eval(xs)

    def run():
        outs = []
        for i in range(REPS):
            for x in xs[i]:
                outs.append(
                    mx.quantized_matmul(
                        x, *banks[i % nb], transpose=True, group_size=GS, bits=bits
                    )
                )
        mx.eval(outs)

    t = timeit(run)
    mx.clear_cache()
    return t


info = mx.device_info()
print(f"mlx {mx.__version__} | {info['device_name']} | {info['architecture']}")

for bits in (4, 2):
    hot = [make_bank(bits)]  # one weight reused: cache-warm
    cold = [make_bank(bits) for _ in range(REPS)]  # ~1.4 GB working set

    print(f"=== {bits}-bit ===")
    for M in MS:
        print(f"{M:>4}  hot {bench(M, bits, hot) * 1e3:7.3f}  cold {bench(M, bits, cold) * 1e3:7.3f}")

    for M in (10, 12, 16):
        th, tc = bench_split(M, bits, hot), bench_split(M, bits, cold)
        print(f"  M={M} as {-(-M // 5)}x<=5 rows: hot {th * 1e3:7.3f}  cold {tc * 1e3:7.3f}")

    del hot, cold
    mx.clear_cache()

"cold" = 30 distinct weight banks round-robin (~1.4 GB) so the weights can't sit in cache; that distinction matters at M=1 (it's what hid the 2-bit advantage in #3852) but not in this band, where the two harnesses agree to ~1%.

@katlun-lgtm

Copy link
Copy Markdown
Contributor Author

Correction to my numbers above, plus the sweep behind the crossover claim — which turned out to contradict part of what I said.

The correction. The qmv_wide column in my previous comment was an emulation: M split into ceil(M/5) separate launches of ≤5 rows, because the batch limit is compile-time and the real kernel can't be reached above it. That proxy pays an extra dispatch per tile. I patched get_qmv_batch_limit to be settable at runtime (local experiment only, not in this PR) and measured the real thing. On 5120→17408, 4-bit, cold:

M qmv_wide native qmv_wide as I emulated it BM=16 qmm
10 0.490 0.408 0.344
12 0.572 0.634 0.345
16 0.771 0.834 0.333

Same conclusion, but the emulated row shouldn't stand. (Absolute times in this run are ~10% above my first table — different build and session — so only compare within a table.)

Sweep. Both routings measured at the same M in one process, 7 shapes × {4,2}-bit, M ∈ [2,20] ∪ {24,32}, cold (31–64 distinct weight banks, ~1.5 GB, so nothing is cache-resident — that matters because qmv_wide re-reads the weights once per tile). Crossover = first M where qmm wins and keeps winning.

shape K·N current limit crossover (4-bit) (2-bit)
2048→2048 4.2M 18 17 17
4096→4096 16.8M 12 13 13
2048→8192 16.8M 10 13 13
4096→14336 58.7M 10 7 7
3584→18944 67.9M 10 7 7
5120→17408 89.1M 10 7 7
5120→248320 1.27G 10 6 7

Where I was wrong. I said the limit looked "2–3 too high". That holds for the MLP-sized layers — at M=9, the last M currently routed to qmv_wide, qmm is 1.34× / 1.62× / 1.42× / 1.56× faster on 4096→14336, 3584→18944, 5120→17408, lm_head. It does not hold for the two smaller shapes: at 4096→4096 and 2048→8192, qmv_wide is still ahead at the current limit (1.12× and 1.31× at the first M that routes to qmm), so there the limit is if anything too low. It isn't a one-way retune.

What is clean is that the crossover tracks K·N and nothing else I varied. The two shapes with identical K·N — 4096→4096 and 2048→8192 — land on the same crossover of 13, while the current tiers give them different limits (12 and 10). And 4-bit and 2-bit agree on every shape, i.e. both kernels are element-bound rather than byte-bound in this band, which is consistent with a limit keyed on K·N rather than on D and O separately.

And a scope limit on this PR that the same run exposes. Comparing BM=16 (M=16) against BM=32 (M=17) inside one run:

shape K·N BM=32 / BM=16
2048→2048 4.2M 0.98×
4096→4096 16.8M 1.02×
2048→8192 16.8M 1.02×
4096→14336 58.7M 1.50×
3584→18944 67.9M 1.54×
5120→17408 89.1M 1.55×
5120→248320 1.27G 1.52×

So the tile buys ~1.5× on MLP- and lm_head-class layers and nothing at all below ~58M elements — the small shapes are flat in M across the whole band, so they're not tile-quantization-bound in the first place. My original numbers were both large shapes, which overstated how general the win is. Worth adding to the PR body if you'd like; it doesn't change the change, but it does narrow the claim.

I'm not proposing a retune off this. It's one part (M3 Max, gen-15 non-d) and those constants are shared with gen 13/14 and the d dies I can't measure. If a K·N-keyed limit is something you'd want, I'm happy to put it up separately with whatever hardware coverage you'd need — or leave it alone. Script and the full per-M tables are available if they're useful.

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