Repository navigation
Fix incorrect nvfp4 quantized_matmul through the split-K path - #3854
Conversation
qmm_splitk caps split_k only by the quantization group count (K / group_size), not by the kernel K-tile width BK (32). For nvfp4 (group_size=16) this yields a per-partition K of 16 < BK; fp_qmm_t_splitk then reads a full BK-wide K-tile with no K bound, spilling past the partition into the next group's weights and fp8 scales. The result is corrupted (non-uniform ~2x error) with NaN/inf on the last partition. Affine (group_size >= 32) is unaffected. Require each K partition to be a whole number of BK-wide tiles as well as whole quantization groups by aligning split_k to max(group_size, BK). Only changes behavior for group_size < 32.
|
Independent confirmation of both the bug and the fix, plus scoping data suggesting the trigger We hit this in a kernel-dispatch seam audit on an M5 (~25k The corruption triggers for any Corrupt-cell predicate, verified exhaustively across the sweep on both 0.32.0 (PyPI) and Minimal repro (M5, both versions above): import mlx.core as mx
mx.random.seed(7)
w = mx.random.normal((1024, 544)).astype(mx.float32) / 544**0.5
w_q, scales = mx.quantize(w, mode="nvfp4")
w_ref = mx.dequantize(w_q, scales, mode="nvfp4").astype(mx.float32)
mx.random.seed(8)
x = mx.random.normal((32, 544)).astype(mx.float16)
y = mx.quantized_matmul(x, w_q, scales, transpose=True, mode="nvfp4")
ref = x.astype(mx.float32) @ w_ref.T
print(float(mx.abs(y.astype(mx.float32) - ref).max()))
# 1.10 (unit-scale outputs) K=512 aligned control: 0.0036
# m=8 (vector path) is clean: 0.0010Since every Happy to re-run the full seam sweep on an M5 with this patch applied if useful. |
…urces so they apply The release scan added in magicnight#108 listed 33 upstream fixes the fork does not carry. Triaging the three that touch quantized matmul — the path most users actually run — found two that reach us and one that cannot. ml-explore/mlx#3497: qvm mishandles a stride-0 batch dimension. That is exactly the shape mlx-swift-lm's quantizedScaledDotProductAttention produces, where a quantized KV cache is broadcast across GQA repeat groups with expandDimensions. Only the non-transposed product (scores @ values) reaches the affected kernel, and only for M >= 2, which is why ordinary single-token decode never showed it while speculative decoding sits squarely in range. Not hardware-gated — upstream reproduced on an M4 Pro. Measured here before the fix: max |diff| 0.139 at N=2048 and 0.104 at N=4096 against a dequantized reference, versus ~2e-4 quantization noise, with M=1 correct — matching upstream's numbers and its stated boundary. ml-explore/mlx#3631: NAX qmm edge-tile bounds computed in short(), wrapping past 32767, corrupting a column band for unaligned N above 2^15. Reached through an lm_head whose vocab is unaligned. MiniCPM3-4B's 73448 is one, and one this project lists as supported; every other vocab we support is a multiple of 64 and takes the aligned branch. Measured here before the fix: max |diff| 236 at N=73448, K=2560, M=16. ml-explore/mlx#3854 is NOT carried: upstream states affine quantization is unaffected, and every model this project loads is affine. Bumping the submodule was not enough. mlx-swift does not compile the submodule's Metal kernel headers — it embeds them, stringified, into checked-in files under Source/Cmlx/mlx-generated, and the JIT compiles those. After the bump both tests still failed with byte-identical numbers, because the generated copies still held the pre-fix kernel text. The fork now regenerates them. #3810 hid this trap earlier: it changed host C++, which is compiled from source, so a plain submodule bump was sufficient there and looked like the rule. QuantizedMatmulParityTests pins both, using upstream's own repro shapes, and includes the M=1 case as a control so the M>=2 boundary is asserted rather than assumed.
Summary
nvfp4quantized matmuls that take the split-K path (qmm_splitk/fp_qmm_t_splitk) produce incorrect results — non-uniform ~2x error andNaN/inf.affineis unaffected.Root cause
qmm_splitkcapssplit_kby the quantization-group count but not by the kernel's K-tile widthBK(=32):For
nvfp4(group_size == 16) this can pick a per-partitionKsmaller thanBK. Example:K=64,group_size=16→split_k=4,k_partition_size=16 < BK=32.The
qmm_tkernels tile K inBK-wide chunks and do not bound the K dimension —load_safe/load_unsafeonly bound M/N, andQuantizedBlockLoaderadvances scales byscale_step = BCOLS/group_sizeperBKstep. So when a partition is smaller thanBK, the single tile load reads a fullBK-wide slice that spills past the partition into the next group's packed weights and fp8 scales (and past the buffer on the last partition →NaN/inf).affinenever hits this becausegroup_size >= 32keeps partitions>= BK(orsplit_kcollapses to 1 and falls back toqmm).Fix
Require each K partition to be a whole number of
BK-wide tiles as well as whole quantization groups, by aligningsplit_ktomax(group_size, BK):This only changes behavior for
group_size < 32(i.e.nvfp4); forgroup_size >= 32it is identical to today. It preserves split-K where valid (e.g. theK=64case still runs 2-way withpartition=32=BK) and otherwise falls back toqmmvia the existingsplit_k <= 1path.Testing
nvfp4quantized_matmulwithM >= vector_limit,transpose=True, single batch (e.g.x=[32,64],w=[128,64],group_size=16): incorrect (NaN + ~2x error) before, matches the reference after.affineand largergroup_sizeunchanged.Open questions for reviewers
BKis hardcoded as32here to match theqmm_t_splitktemplate default (consistent with the existing localbm/bn = 32). Happy to use a named constant / template-derivedBKif preferred.max(group_size, BK)is correct because all supported group sizes (16/32/64/128) are in a divisor relationship with 32;std::lcm(group_size, BK)would be the fully general form.mxfp4(samefp_qmm_t_splitkkernel) get a regression test too? Its defaultgroup_size=32avoids the sub-BKpartition.