Skip to content

Fix sorted gather_qmm on ragged K - #4009

Open
erwinzhang7 wants to merge 1 commit into
ml-explore:mainfrom
erwinzhang7:fix-gather-qmm-unaligned-k
Open

Fix sorted gather_qmm on ragged K#4009
erwinzhang7 wants to merge 1 commit into
ml-explore:mainfrom
erwinzhang7:fix-gather-qmm-unaligned-k

Conversation

@erwinzhang7

Copy link
Copy Markdown

Fixes #3887.

mx.gather_qmm(..., sorted_indices=True) returns silently wrong results on M5 whenever K % BK != 0. Aligned K is unaffected and sorted_indices=False is unaffected, so quantized MoE inference degrades quietly instead of failing.

Root cause

There are two separate bounds defects in the NAX path, both in the K tail.

1. Activation tile bounded with BK instead of the K remainder. In affine_gather_qmm_rhs_nax and its fp counterpart:

const short psk = min(int(SK), max(0, (BK - kk1)));

k_remain is already computed higher up and used for the weight tile (tile_w), so the tail simply needs to use it here too.

2. QuantizedBlockLoader::load_safe zeroes partial tiles on the wrong axis. src_tile_dim.y bounds the rows of the tile and src_tile_dim.x bounds the columns, for both reduction dims. The reduction_dim == 1 branch compares the row index against the column bound:

if (reduction_dim == 1 && bi >= src_tile_dim.x) {

In the transpose case BROWS = BN, and tile_w = short2(k_remain, tgp_bn). With k_remain = 32 this zeroes tile rows 32 to 63, which are output columns, instead of masking the columns past the K edge. Those rows are exactly one of the two N direction simdgroups (SN = BN / WN = 32), so half the output columns lose their tail contribution entirely.

Fixing only the first defect is not sufficient. It narrows the corruption from every output column to the tn = 32 half, which is why the columns with col % 64 >= 32 are the ones left wrong.

Evidence

Measured on an M5 Max (40 core, applegpu_g17s), on 0.32.0 and at 2c46b95. The check compares sorted_indices=True against sorted_indices=False on identical, already sorted inputs. The flag is only a performance hint, so the two must agree and there is no reference implementation ambiguity. E=8, N=256, M=512, group_size=32, fp16.

Max absolute difference relative to mean(abs(unsorted)), and the fraction of output elements that differ:

mode K K % 64 before after
affine 4 bit 512 0 0.005 (0.0%) 0.005 (0.0%)
affine 4 bit 160 32 3.198 (97.1%) 0.006 (0.0%)
affine 4 bit 288 32 2.394 (96.1%) 0.005 (0.0%)
affine 4 bit 544 32 1.474 (94.5%) 0.005 (0.0%)
affine 4 bit 1056 32 1.071 (92.2%) 0.005 (0.0%)
affine 8 bit 160 32 3.187 (97.1%) 0.003 (0.0%)
affine 8 bit 544 32 1.431 (94.5%) 0.003 (0.0%)
mxfp4 160 32 3.452 (96.9%) 0.001 (0.0%)
mxfp4 544 32 1.469 (94.5%) 0.002 (0.0%)

Across the full sweep of 21 configurations, every failure had K % 64 != 0 and sorted_indices=True, and no configuration with those two properties passed. After the change all 21 agree.

The relative error shrinks as K grows, which is consistent with a fixed size corrupted tail region while the correct aligned bulk grows around it.

Test

test_gather_qmm_sorted parameterizes over K, but every case uses K = 512, and the affine cases use group_size = 64, which forces K to be a multiple of 64. The K tail was therefore never exercised for this kernel.

Added test_gather_qmm_sorted_unaligned_k covering K in {160, 288, 544} for affine (group_size 32) and mxfp4. On 0.32.0 all six subtests fail, one of them producing 5.24887e+29 from uninitialized threadgroup memory. All pass with this change.

test_quantized.py, test_blas.py, test_nn.py and test_ops.py pass (278 tests) on top of the new one.

Performance

No measurable change. On a gather_qmm benchmark (E=8, N=4096, M=4096, 4 bit, group_size=32, sorted) the run to run variance at a fixed build was larger than the difference between the patched and unpatched builds at the same commit, so I would not claim a delta in either direction.

@erwinzhang7

Copy link
Copy Markdown
Author

Ran a wider sweep while reviewing this, and the fix turns out to cover more than the description above claims. Posting the numbers since they change how the patch should be judged.

Sweep: 264 configs over quantized_matmul and gather_qmm, transpose both ways, sorted and unsorted, affine 4/8 with group sizes 32 and 64, plus mxfp4, mxfp8 and nvfp4, crossing aligned and unaligned K with aligned and unaligned N. Reference is dequantize followed by a dense matmul. Error is max absolute difference relative to mean(abs(reference)).

Result: 42 configs improve, 0 regress, 219 unchanged.

The severity on the fp modes is much worse than the affine numbers in the description suggested. With unaligned K, several configs return values consistent with uninitialized memory rather than merely inaccurate ones:

config before after
gather_qmm t sorted, nvfp4, K=288, N=256 1.6e38 0.0000
gather_qmm t sorted, nvfp4, K=288, N=555 1.5e38 0.0000
gather_qmm t sorted, mxfp8, K=288, N=256 1.4e38 0.0000
gather_qmm t sorted, mxfp8, K=288, N=200 4.0e37 0.0000
gather_qmm t sorted, mxfp8, K=544, N=200 26.1 0.0000
quantized_matmul t, nvfp4, K=288, N=256 6.33 0.041

Two things worth noting from that table:

  1. Unaligned N is affected as well as unaligned K, so the reach is wider than the sorted gather path alone.
  2. The last row is plain quantized_matmul, not a gather. The loader is shared with qmm_t_nax_tgp_impl, so the row-bound half of this patch fixes that path too.

Three configs move in the other direction, all nvfp4: 0.0002 to 0.0012, and two from 0.0000 to 0.0009 and 0.0001. These are deterministic across repeated runs, and they sit well below the roughly 0.05 quantization floor for the fp4 modes, so I do not think they are meaningful. Flagging them rather than leaving them out.

Also for whoever picks these up: this PR and #4010 both add a test immediately before test_gather_qmm_grad, so they conflict on that anchor. Whichever lands second needs a trivial rebase. The code changes themselves touch different lines and do not conflict.

@erwinzhang7

Copy link
Copy Markdown
Author

Some context I found afterwards that makes the case better than the description does, including an alternative fix a reviewer may prefer.

The NAX dispatch condition already carries a K % 64 == 0 guard, at two of the three sites in mlx/backend/metal/quantized.cpp:

// line 1007, qmm_nax
if (metal::is_nax_available() && transpose && (K % 64 == 0) && ...)

// line 1202, gather_qmm_nax
if (metal::is_nax_available() && transpose && (K % 64 == 0) && ...)

// line 1563, gather_qmm_rhs_nax
if (metal::is_nax_available() && transpose && ...)

The third one does not have it, and that is the entry point this pull request fixes. The guard came from 0dbc7e5, "Centralize NAX condition" (#2811), so the invariant is established, one call site just does not honour it.

That suggests a second, smaller fix: add (K % 64 == 0) at line 1563 so unaligned K falls back to the non-NAX kernel, matching the other two sites. One line, obviously correct, and it fixes the reported corruption.

The trade is the fast path. Falling back gives up NAX for every unaligned-K shape, and in a quantized MoE that is a normal shape rather than an edge case, so the cost lands on real workloads. This pull request instead makes the kernel correct for those shapes and keeps NAX, which is why I went that way. Happy to switch to the guard, or to add it as a belt-and-braces alongside the kernel fix, if you would rather be conservative here.

Worth noting either way that the two are not equivalent: the guard hides the defect, the kernel fix removes it. The wrong-axis bound in load_safe is also reachable through #4015, which consolidates the two loader copies and carries that guard forward unchanged, so fixing the kernel keeps that path correct as well.

@zcbenz

zcbenz commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closing since there are already PRs with similar changes.

@zcbenz zcbenz closed this Aug 6, 2026
@zcbenz

zcbenz commented Aug 8, 2026

Copy link
Copy Markdown
Member

@erwinzhang7 There are also other fixes (#3912, #3922) which I do not have much confidence in, I'm not familiar with this code so it will take some before I can verify the correctness of the fixes, but can you check if your PR could be missing anything?

@zcbenz zcbenz reopened this Aug 8, 2026
@zcbenz zcbenz added the await verification This pull request is non-trivial and requires a human expert to verify its correctness. label Aug 8, 2026
Two bounds defects in the NAX quantized GEMM path made gather_qmm with
sorted_indices=True return silently wrong results whenever K % BK != 0.

The activation tile in the K tail was bounded with BK rather than the
number of K elements actually remaining, so it read past the end of the
row into the next one.

The quantized block loader zeroed partial tiles using the wrong component
of src_tile_dim. src_tile_dim.y bounds the rows and src_tile_dim.x bounds
the columns for both reduction dims, but the reduction_dim == 1 branch
compared the row index against the column bound. With BROWS = BN and a K
remainder of 32, that zeroed output rows 32 to 63 instead of masking the
columns past the K edge, so half the output columns silently lost their
tail contribution.

Both defects only affect the M5 NAX kernels. Earlier hardware takes a
different code path.
@erwinzhang7
erwinzhang7 force-pushed the fix-gather-qmm-unaligned-k branch from b7c5341 to 31d685f Compare August 8, 2026 16:44
@erwinzhang7 erwinzhang7 changed the title Fix gather_qmm sorted path when K is not a multiple of the block size Fix quantized matmul and sorted gather_qmm corruption on ragged K Aug 8, 2026
@erwinzhang7

Copy link
Copy Markdown
Author

Rebased onto main. I measured all three PRs against each other and found something I did not
expect: they fix four different failure classes and overlap on only one. None are
redundant, and no two of them together are sufficient.

Since you said you wanted to verify these before merging, I have put all three on this branch
so there is one thing to check rather than three interacting ones: @kapellirohith's commit
from #3912 cherry-picked whole, and @PhilipJohnBasile's clamp from #3922, which is a two-line
carry-over. Both keep their authorship.

That packaging is only for convenience and it is entirely your call. If you would rather
land the three separately, say so and I will drop the other two commits and reduce this back
to my own fix; nothing here depends on them being combined. @kapellirohith and
@PhilipJohnBasile, likewise, if you would prefer your own PRs to land on their own, that is
completely fine and I will pull them straight back out. The point of this comment is the
measurement, not the packaging.

What I did

Built each PR separately on an M5 Max and measured against a dequantize-then-dense-matmul
reference, which shares no code with the quantized kernels. A config counts as corrupted if
the error exceeds 5% of mean|reference|, or is NaN, or is inf.

failure class main #3912 #3922 #4009 this branch
nvfp4 quantized_matmul, K % 32 == 16, M > 1 4 bad fix 4 bad 4 bad fix
nvfp4 gather_qmm sorted, ragged K 2 bad 2 bad 2 bad fix fix
affine / mxfp4 / mxfp8 gather_qmm sorted 24 bad 24 bad fix fix fix
sorted gather_qmm past 32768 rows 1 bad 1 bad fix 1 bad fix

Each PR is the only one that fixes one of those rows.

On #3912

It reproduces on M5 and its diagnosis holds exactly. nvfp4 quantized_matmul at M=50 gives
2.13 at K=144, 111.4 at K=272, NaN at K=528 and inf at K=1040, while K=512 and K=1024 are
clean. The M=1 vector path is clean, as reported.

It does not regress anything. nvfp4 sorted gather_qmm is broken on its branch, but the
values are byte-identical to main, so it simply does not touch that path.

This one matters beyond M5. On an M4 Pro, which has no NAX path at all, main is still
corrupted on all four nvfp4 quantized_matmul shapes and on nvfp4 sorted gather_qmm — six
failures where none of the NAX work applies. nvfp4 is broken on that hardware today.

On #3922

Its sgp_sm / sgp_sn clamp fixes a real defect nothing else covers: sorted gather_qmm
at L=32769 leaves 32 output rows unwritten, while L=32767, L=32768 and L=40000 are clean.
That is the commit cherry-picked here. It was also my #4010, which I closed as a duplicate;
the credit is his.

I did not take the other two parts of that PR, and I want to be explicit about why, because
it is a judgement call rather than a defect report.

The column term is not independently safe. #3922 widens the partial-tile guard to
bi >= src_tile_dim.y || bj * pack_factor >= src_tile_dim.x, where this PR has only the row
half. Applying only that column term on top of this branch, two lines and nothing else,
corrupts ragged mxfp4 with errors of 2.06, 3.20, 3.62 and three NaN across six configs, with
mxfp8 unaffected. #3922 as submitted is clean because its dispatch guard routes those shapes
to the fallback before they reach the changed loader, so the guard and the column term have
to move together. I have not worked out why, and it may be that src_tile_dim.x does not
mean the same thing for mx formats as it does for affine.

On the affine path I could not construct any shape where the column term changes a result:
the activation tile is already bounded by psk, so out-of-range weight columns are
multiplied by zeroed activations, and Metal shader validation reports no invalid load on
this branch.

The dispatch guard costs about 1.75x. Median of 5 after a global warm-up, without which
the first config measured in a process runs 50-70% slow on clock ramp, which is larger than
the effect:

shape on NAX via the fallback
mxfp4 K=1568 N=2048 282.5 us (±1.8%) 505.4 us (±5.6%) 1.79x
mxfp8 K=1568 N=2048 300.4 us (±1.9%) 510.1 us (±2.6%) 1.70x
aligned controls 223 / 223 / 220 223 / 228 / 220 matched

Smaller shapes ran at 15-28% spread and I would not read anything into them. The aligned
controls agreeing within 3% is the check that the two builds are otherwise identical.

Non-NAX hardware (M4 Pro)

Everything above is M5, where NAX exists. I re-ran the whole thing on an M4 Pro, which has no
NAX path at all — MLX reports Building without NAX kernels there — to check that these
changes cannot disturb the hardware most people are on.

nvfp4 affine / mxfp4 / mxfp8 gather >32768 rows
main on M4 Pro 6 bad of 16 0 bad 0 bad
this branch on M4 Pro 0 bad 0 bad 0 bad

Three things follow:

  • nvfp4 is broken on M4 Pro today. Those six failures are the plain-kernel defect, and
    @kapellirohith's commit is what fixes them. This is not an M5-only problem.
  • The NAX work is provably inert off NAX. Across the 27-config gather sweep, main and
    this branch are byte-identical on M4 Pro. Not "should be unaffected" — measured.
  • The gather and >32768-row defects do not occur on M4 Pro at all, confirming they are
    NAX-only.

Coverage of the rest of the surface

  • transpose=False: covered. It exercises the loader's other reduction_dim, which
    none of these commits should touch, and does not: across six affine configs crossing
    ragged K with ragged N, main and this branch produce byte-identical results.

  • CUDA: every changed source file is under mlx/backend/metal/, so the CUDA runtime is
    not reachable from this diff. The new tests were the remaining exposure; they mirror
    test_gather_qmm_sorted, which upstream runs on CUDA unguarded, and all four run and pass.

  • CI: 22/22 green on a fork mirror of the upstream matrix — Linux x86_64 and aarch64 on
    cpu and CUDA 12.6 / 12.9 / 13.0, Windows x86_64 and aarch64 on cpu and CUDA, macOS 14, 15
    and 26.2 on cpu, metal and jit, plus lint. The four new tests ran and passed on the macOS
    and Linux cpu legs (811 tests, OK).

    Being precise about what that does and does not show: no CI job exercises Metal. Tests
    are gated to the cpu toolkit because the GPU legs have no device, and upstream routes its
    Metal tests to a self-hosted runner a fork cannot use. So CI establishes that this compiles
    everywhere and that the new tests are valid and pass on CPU. The Metal kernels themselves
    are covered by the M5 Max and M4 Pro runs above, not by CI.

  • Hardware: M5 Max (macOS 26.5.1, Xcode 26.5) for the NAX paths and M4 Pro (48 GB, same
    toolchain) for the non-NAX paths. Both built from source at 8d666298.

  • Fix sorted gather_qmm NAX boundary handling #3922 is 88 commits behind main and predates Normalize biases before encoding in gather_qmm_rhs #4056, which touches the same function it
    modifies. Rebasing it changed none of the results above, but it will need one to merge.

Reproduction is three short scripts that each take a --label, so identical code runs
against every build. Happy to post them as a gist.

@PhilipJohnBasile

Copy link
Copy Markdown

Yeah, if I could get my own commit in there, it would be nice. This would be my first official commit with Apple. A little notch on my belt <3.

@kapellirohith

Copy link
Copy Markdown
Contributor

The non-NAX half of this is separable, and it is verifiable without an M5.

#3912 touches only fp_quantized.h and quantized.h, nothing in quantized.cpp. On 39d9a8a each defect reproduces and is clean after: the K overread (0.69 rel err, 72% of outputs wrong), the N store race (200 identical runs give 61 distinct results, 1 after), and the dropped K tail (wrong columns are exactly 16..31 mod 32 and equal the reference minus the last 16 K elements). Metal shader validation flags both out of bounds accesses on main and is clean after: invalid load at 2129920 in nvfp4_qmm_t, invalid store at 1064976 in nvfp4_qmm_n. It also flags a read past the buffer in affine today, invalid load at 213056 in affine_qmm_t_splitk at gs=32 N=100, which the same change removes.

On not slowing other modes down: I compiled fp_quantized.metal against main and against the branch and diffed every emitted kernel after normalizing metadata ids. Of 306 gs_32 kernels, 253 are byte identical and none gained an instruction. Plus 4515 randomized differential cases and a 289 case misalignment sweep against an fp64 reference, zero failures, and the added tests fail on main for the right reason (17 of 19 subtests) and pass on the branch.

Upstream CI on #3912 is 28/28 green. I also reproduced the cpu job locally with MLX_BUILD_METAL=OFF: 807 python tests OK, C++ suite 245/245.

Suggestion: land #3912 first for the non-NAX half, then rebase the NAX commits on top as a follow-up. That splits the review into a part you can verify on hardware you have and a part that needs an M5, and each gets its own CI run. No objection to the NAX work landing right behind it.

@erwinzhang7

Copy link
Copy Markdown
Author

Dropping the two cherry-picked commits, this PR is back to just my own fix. @kapellirohith
and @PhilipJohnBasile, your PRs are yours.

I checked and found MLX squash-merges, so all three commits would have collapsed into one commit under my name at merge. The authorship the cherry-pick was meant to preserve would not have survived it. Philip
was right to ask.

@kapellirohith's sequencing is also better for testing, so: land #3912 first. It is
orthogonal (only fp_quantized.h and quantized.h, nothing in quantized.cpp), it is
28/28 green on upstream CI already, and unlike the NAX work it can be verified on hardware
without an M5.

Three things from the measurements above that are worth having in one place, since they are
not visible from reading the three PRs separately:

  1. Fix fp quantized matmul corruption when the quantized dim is not a multiple of 32 #3912 is independent of both others. Different files, different mode, different
    failure. It can land whenever.
  2. Fix sorted gather_qmm NAX boundary handling #3922 and Fix sorted gather_qmm on ragged K #4009 conflict in quantized_nax.h: both rewrite the same partial-tile
    guard. They cannot both land as-is; whichever goes second needs a rebase. Fix sorted gather_qmm NAX boundary handling #3922 is also 88
    commits behind main and predates Normalize biases before encoding in gather_qmm_rhs #4056, which touches the same function it modifies.
  3. The open question. Fix sorted gather_qmm NAX boundary handling #3922's FP dispatch guard is load-bearing for its own column term,
    not just conservatism: narrow the guard so ragged group-size-32 FP returns to NAX and
    mxfp4 corrupts, six configs, errors 2.06/3.20/3.62 plus three NaN, mxfp8 unaffected. So
    the end state where ragged FP stays on NAX, which is worth about 1.75x, needs someone to
    work out why that column term is unsafe for mx formats. I have not, and it is the one real
    unknown left in this area.

The coverage matrix above stands whoever ships what. Happy to rebase this PR onto whatever
lands first.

@erwinzhang7
erwinzhang7 force-pushed the fix-gather-qmm-unaligned-k branch from 31d685f to d9cbbdc Compare August 8, 2026 17:59
@erwinzhang7 erwinzhang7 changed the title Fix quantized matmul and sorted gather_qmm corruption on ragged K Fix sorted gather_qmm on ragged K Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

await verification This pull request is non-trivial and requires a human expert to verify its correctness.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] gather_qmm sorted-rhs path corrupt for K % 64 != 0 on M5/NAX: !align_K tail bounds the load with BK instead of the K remainder (affine + mxfp4)

4 participants