Use larger tiles for qmm_t at large M - #4204
Open
katnoria wants to merge 2 commits into
Open
Conversation
In the reduction_dim == 1 branch, `bi` (which indexes BROWS -- the output axis, N for qmm_t's weight loader) was compared against src_tile_dim.x. Every call site actually passes the valid-BROWS-count in src_tile_dim.y by convention (qmm_t_impl's `short2(BK, num_outs)`, affine_gather_qmm_rhs's `short2(k_remain, tgp_bn)`), so this was comparing a row index against a K-tile-width instead of against its own valid-row count. This has been a silent no-op in every kernel instantiated to date, because every existing qmm_t/gather_qmm_t config happens to have BROWS == BK == 32, so `bi < BROWS` already implied `bi < BK` and the check never fired -- correct output "by luck": the rows it should have zero-padded were already beyond `num_outs` and discarded by store_result_safe regardless of what stale/adjacent memory they read into threadgroup memory first. It stops being a no-op once BROWS is parametrized independently of BK (an upcoming change needs exactly that), at which point some still-valid output rows fall on the wrong side of the miscompared bound and get forced to zero instead of the safe/discarded ones, producing wrong results at partial-tile N boundaries. Fix: compare against src_tile_dim.y instead. Zero behavior change for every existing BROWS==BK instantiation (.x == BROWS there too, so the two fields were interchangeable in exactly that case); correctness bug fix once BROWS != BK.
qmm_t's output tile is hardcoded to 32x32x32 with 2x2 simdgroups
regardless of M/N/K or device, sized for small-M (decode) calls. At
large M (prefill-shaped calls) this dispatches ~4x more threadgroups
than the dense steel GEMM path takes at the same shape, and each one
re-reads a larger share of both operand matrices from device memory
-- roughly 2x redundant reloads of both x and the (dequantized)
weight tile versus the dense path's adaptive 64x64 tile.
affine_qmm_t / qmm_t_impl already accept BM/BK/BN as template
parameters with defaults matching the old fixed config; the qmm()
dispatch layer in quantized.cpp simply never varied them. This adds
a dispatch branch that opts large-M (M >= 4096), transpose=true
calls into a 128x64 output tile, scoped to {float16, bfloat16} x
{group_size 32, 64} x {bits 4, 8} -- BK stays 32 since
QuantizedBlockLoader requires BK <= group_size and group_size can be
as small as 32. WM/WN are promoted from hardcoded constants to
template parameters (still defaulting to 2, 2 -- varying them showed
no measurable difference at this tile size, so the default is kept).
Every other call -- small M, transpose=false, or a combination
outside the scoped set -- takes the exact original path and kernel,
unconditionally.
Also fixes the alignment check (`aligned = N % bn == 0`, was
hardcoded `% 32`), which would otherwise silently mis-detect
alignment for any bn != 32.
Since the default (non-JIT) build looks up kernels by a fixed,
ahead-of-time-compiled name set rather than JIT-compiling on demand,
the 128x64 qmm_t variant is also AOT-instantiated in quantized.metal
for exactly the (dtype, group_size, bits) combinations the dispatch
guard allows, so both build configurations dispatch identically
rather than the fast path only working under MLX_METAL_JIT=ON.
Measured on M2 Ultra at M=37723, K=5376, N=14336, 8-bit, group_size
32: 624ms (old 32x32x32 tile) -> 474ms (this change) vs 359ms for
the equivalent dense bf16 matmul at the same shape -- closes most of
the 1.74x gap to 1.31x. Verified against a dequantize+matmul
reference: 10/10 targeted edge cases (M at/near the 4096 threshold,
unaligned N, both group sizes, both bit widths, decode-shaped M) and
547/550 in a broader stress sweep across M/N/group_size/bits (the 3
failures are all at M=1, far below the threshold, and reproduce
identically on unmodified upstream). test_quantized passes 34/34 on
both MLX_METAL_JIT=ON and the default off build.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch also includes the one-line
QuantizedBlockLoader::load_safebounds fix submitted standalone as #4203, since the tile change below
depends on it (see "Why this was never observed" in #4203 for why).
Summary
mx.quantized_matmul'stranspose=Truepath (qmm_t) dispatches afixed
32x32x32output tile with2x2simdgroups regardless ofM/N/K/device — sized for small-M (decode) calls. At largeM(prefill-shaped calls, e.g.
M >= 4096), this is ~1.7x slower than theequivalent dense
bf16matmul at the same shape, despite the quantizedweight being ~4x smaller on disk.
Root cause
qmm()'s dispatch (mlx/backend/metal/quantized.cpp, around the tileselection before
get_quantized_kernel_wrapped) hardcodesbm=32, bn=32, wm=2, wn=2unconditionally.affine_qmm_t/qmm_t_impl(
mlx/backend/metal/kernels/quantized.h) already acceptBM/BK/BNastemplate parameters with defaults matching that fixed config — the
dispatch layer simply never varies them. (
WM/WNwere hardcodedconstexprinsideqmm_t_implitself, not even template parameters,until this change.) A sibling kernel,
get_gather_qmm_kernel(
jit_kernels.cpp), already threadsbm/bn/bk/wm/wnthrough as JITtemplate params — this change brings
qmm_t's dispatch up to the samepattern.
At the fixed
32x32x32tile,qmm_tdispatches ~4x more threadgroupsthan the dense path's adaptive
64x64tile at the same shape (528,192vs. 132,160 at the measured production shape below), and each
threadgroup does 8x less useful output work per thread — concretely,
each of the
N/bncolumn-tile-groups re-reads the same row ofxfromdevice memory, and each of the
M/bmrow-tile-groups re-reads (andre-dequantizes) the same weight columns, roughly doubling redundant
reads of both operands versus the dense tile.
The fix
Add a dispatch branch in
qmm()that selects a128x64output tile(
BM=128, BN=64,BKunchanged at 32) fortranspose=truecalls withM >= 4096, scoped to{float16, bfloat16} x {group_size 32, 64} x {bits 4, 8}.BKstays 32 in every config tried:QuantizedBlockLoaderrequiresBK <= group_size, andgroup_sizecanbe as small as 32, so
BKcan't grow without also constraining whichgroup_sizes the fast path supports.WM/WNare promoted to templateparameters (still defaulting to
2, 2— anwm=1,wn=2variant, mirroringthe dense path's own simdgroup split, showed no measurable difference at
this tile size, so there was no reason to deviate from the existing
default). Every other call — small
M,transpose=false(qmm_n), or adtype/group_size/bits combination outside the scoped set — takes the
exact original path and kernel, unconditionally.
Also fixes
aligned = N % bn == 0(was hardcoded% 32), which wouldotherwise silently mis-detect alignment for any
bn != 32— latent untilthis change since
bnwas always 32 before.Why AOT instantiation matters
mlx/backend/metal/CMakeLists.txtsetsMLX_METAL_JITOFFby default —the configuration
pip install mlxactually ships — which linksnojit_kernels.cppand looks up kernels by name from a fixed,ahead-of-time-compiled set; there is no runtime JIT fallback in that
build. This PR adds a scoped AOT instantiation
(
instantiate_quantized_large_m_tile_typesinmlx/backend/metal/kernels/quantized.metal) for exactly the(dtype, group_size, bits)combinations the dispatch guard allows, keptseparate from the existing exhaustive
instantiate_quantized_all()sweepto bound this addition's compile-time/binary-size cost. Both
MLX_METAL_JIT=ONand the defaultOFFbuild were verified to produceidentical dispatch and numbers — a tile change that only touched
quantized.cppwould work underMLX_METAL_JIT=ONand then throw[metal::Device] Unable to load kernel ...under the actual defaultbuild the first time
M >= 4096was hit.Config sweep
Measured on M2 Ultra (non-NAX), production shape
M=37723, K=5376, N=14336,group_size=32,bits=8:bm=64,bn=64,bk=16,wm=1,wn=2Tried 64x64 first, then 128x64 (the winner) and 128x128 (a regression) as
the two additional configs per a fixed "at most 2 more configs" search
budget, then stopped.
Correctness
Reference for all checks:
mx.dequantize(w_q, ...)followed by an fp32dense matmul — not the original
bf16weight — so quantization noise isisolated from tile-dispatch bugs.
Targeted edge cases (10/10 pass, identical on both
MLX_METAL_JIT=ONand the default
OFFbuild):Mjust below/at/above the 4096 threshold;Na multiple of 32 but not 64 (exercises the corrected alignment check);Nnot a multiple of 32 at all;group_size32 and 64;bits4 and 8;decode-shaped
M(old path, sanity that it's untouched); the productionshape.
Broad stress sweep: 550 combinations (
Min{1, 7, 33, 4095, 4096, 4097, 4159, 5000, 8192, 20000, 40000}xNin{1, 17, 32, 63, 64, 65, 100, 4096, 4160, 8000}x(group_size, bits)in{(32,4), (32,8), (64,4), (64,8), (128,4)},K = group_size * 7) against the samedequantize+matmul reference,
rel_l2 < 1e-2threshold: 547/550 passed.The 3 failures are all at
M=1(far below the 4096 threshold — thelarge-M branch is never taken) with
group_size=64, bits=4and tinyN;these reproduce identically on unmodified
upstream/mainand arepre-existing 4-bit quantization noise at a small-sample output size,
unrelated to this change.
Guard verification: out-of-scope combinations at large
M(
bits=6,group_size=128,dtype=float32,M=5000) correctly fallthrough to the original
32x32x32path with correct output — spot-checkedon the
MLX_METAL_JIT=OFFbuild, where a missing-AOT-kernel bug wouldactually throw rather than silently JIT-compile.
Test suite
python -m unittest test_quantized -v(frompython/tests/): 34/34pass on both
MLX_METAL_JIT=ON(35.6s) and the defaultMLX_METAL_JIT=OFFbuild (9.1s). Includestest_qmm_large_dims,test_qmm_shapes,test_qmm,test_qmv*,test_gather_qmm*,test_non_multiples,test_small_matrix.Scoping / what's out of scope
qmm_n(transpose=False) andqmv/qvm/gather variants areuntouched — this stays scoped to the
qmm_t/transpose=truepath.{fp16, bf16} x {gs 32, 64} x {bits 4, 8}, not thefull
{float, float16_t, bfloat16_t} x {32, 64, 128} x {2, 3, 4, 5, 6, 8}matrix. Widening is mechanical — one moreinstantiate_quantized_large_m_tile_types(...)call per combination —but not done here; happy to extend if maintainers want broader coverage
before merge.
wm/wnare template parameters now but not varied from the existingdefault (2, 2) in the shipped config — left as knobs for a future shape
class if one needs them, not exercised here.
Open items
over) or dense parity (359ms). The remaining gap is most plausibly the
per-K-tile dequantization cost inside
QuantizedBlockLoader(bit-unpackno tile-size change removes) rather than remaining tile-geometry
overhead, since the K-loop trip count is identical across every config
tried. Not separated from tile-geometry cost by a profiler in this
round — GPU capture / per-shader-stage counters would settle it.
M=37723GEMMsmeasured above occur inside a 31B-parameter DiT's denoise step, run on a
64GB machine already at its wired-memory limit for this configuration),
the per-GEMM 1.31x improvement does not change end-to-end step time —
~466 s/step post-fix vs. ~442 s/step pre-fix, within machine-to-machine
variance but not an improvement — because the step is bound by
unified-memory pressure (denoise-phase peak ~58 GB, unchanged by this
fix) rather than by GEMM dispatch cost. The kernel win here is real and
should benefit workloads that aren't already sitting at the memory
ceiling; it just doesn't compose with this particular resident-INT8
choreography on this machine.
This surfaced a separate, latent bug in
QuantizedBlockLoader::load_safe(comparing the wrong
short2field, unreachable untilBROWS != BK,which this PR is the first change to trigger) — split out into its own
PR (#4203) since it stands alone and is correct independent of
this change.