Skip to content

Use the fast qmv kernel for affine outputs not divisible by 8 - #4516

Open
wyanzhao wants to merge 2 commits into
ml-explore:mainfrom
wyanzhao:qmv-fast-rows
Open

wyanzhao wants to merge 2 commits into
ml-explore:mainfrom
wyanzhao:qmv-fast-rows

Conversation

@wyanzhao

@wyanzhao wyanzhao commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #4498.

Affine QMV currently falls back to the generic kernel when the output size is not divisible by 8, even when the input meets the qmv_fast alignment requirement. This is particularly expensive for narrow, dependent projections such as the N=1 and N=4 calls in autoregressive decode.

This PR adds affine_qmv_fast_rows. In the final SIMD group, inactive lanes reuse the last valid weight row and skip their stores, so the reduction loop can stay the same as qmv_fast. The new path is used when qmv is selected for an aligned affine call without a global scale. Gathered QMV and the floating-point quantization modes keep their existing kernels. The extra instantiations add about 1 MB to mlx.metallib.

While testing the new path, I found that the affine bias term accumulated sum(x) in the input type before widening it. That loses small contributions for float16 and bfloat16 inputs. The first commit widens each value before accumulation for 2- through 6-bit quantization; the 8-bit path already did this.

Performance

Measured on an Apple M5 Max with bfloat16 inputs. Baseline uses the generic kernel and Candidate uses affine_qmv_fast_rows; both include the precision fix. The ratio is the geometric mean of paired Baseline/Candidate time ratios from A/A-calibrated runs, so values above 1 favor Candidate.

Workload Baseline Candidate Ratio 95% CI
Chained QMV: N=1, K=2560, 8-bit, group size 64 11.37 µs/call 8.22 µs/call 1.3793 [1.3559, 1.4030]
Qwen3.8-Flash-Next layers 4–7, context 128 1.428 ms/step 1.185 ms/step 1.2040 [1.1972, 1.2108]
Qwen3.8-Flash-Next layers 4–7, context 4096 1.526 ms/step 1.287 ms/step 1.1872 [1.1817, 1.1928]

For the model-shaped benchmark, I used layers 4–7, one complete four-layer cycle with three GDN layers followed by one QSA layer. The benchmark configuration cannot run the full model, so I did not measure whole-model generation.

Correctness

test_qmv_affine_bias_sum_precision fails on main for the affected float16 and bfloat16 cells below 8 bits and passes on this branch. test_qmv_fast_rows covers output sizes 1, 3, 5, 12 and 17 across 2- through 8-bit affine quantization, including batched weights. Its M=3 cases use qmv_wide on M3 and newer GPUs, so I could not exercise their affine_qmv_fast_rows route on this machine.

For the layer benchmark, Baseline and Candidate produced bitwise-identical outputs over eight decode steps at both context lengths. Every changed call was also within half a bfloat16 step of a float64 reference.

Validation and quick reproduction

Both new tests also pass with MLX_METAL_JIT=ON. The full test_quantized run has the same 1273 failing subtests on main and this branch; 1188 are in test_qmv_wide.

The reproducer in #4498 shows the original output-size cliff. To reproduce the N=1 row above, set IN_DIM = 2560, GROUP_SIZE = 64, BITS = 8, and include out_dim = 1, then run it once against main and once against this branch. It prints differential median time rather than the paired ratio in the table, and results depend on the machine and build.

The layer benchmark uses Qwen4ExpDecoderLayer layers 4–7 from mlx-vlm 1ecf1ecdd28af102eded679be0daa5c76ab2a068 and tensors from mlx-community/Qwen3.8-Flash-Next-4bit revision 07b5dc6c54600a359b87f1e53e7adf6351c72a2c. It restores a cache prefetched with 128 or 4096 synthetic tokens before each sample and times 64 decode steps through Qwen4ExpBatchInvariantForward._layer.


  • ☑️ I understand it is strictly prohibited to use AI to write PR description
  • AI usage disclosure: AI assistance was used in preparing this contribution, including the implementation, tests and benchmark runs. I am responsible for the contribution.

load_vector and load_vector_safe add the inputs of each sub-byte chunk in
the input type before widening the sum. With bfloat16 or float16 inputs
that intermediate rounds, so the bias contribution bias * sum(x) is off
even when the exact sum is representable in float. Widen each input
before adding it, for every sub-byte width (2, 3, 4, 5 and 6 bits); the
8-bit branch already accumulates one value at a time in float.
quantized_nax.h carries unused copies of both functions and gets the same
change so the two headers stay in sync.

The test builds inputs whose running sum is not representable in the half
precision types and checks the exact result for several output sizes,
input sizes, group sizes, bit widths and dtypes.
When the input size is aligned for qmv_fast but the output size is not a
multiple of 8, qmv falls back to the generic kernel. For outputs below 8
rows its branch bounds-checks every row inside the reduction loop, which
makes each call several times slower on the GPU than the fast kernel.

For outputs of 8 rows or more that are not a multiple of 8 the generic
kernel takes its other branch, which moves the last tile back over rows
it has already computed and so needs no per-row check either. That branch
is still the slower one: it reads a narrower block per thread than
qmv_fast for every width but 2 bits, and it keeps the guarded tail that
an aligned input does not need.

Give qmv_fast_impl a partial_rows flag: rows of the last SIMD group that
fall past the output reuse the weights of the last valid output row and
are not stored, so the reduction loop stays free of per-row checks. qmv
uses the new affine_qmv_fast_rows kernel for aligned affine inputs whose
output size is not a multiple of 8; other routes are unchanged.
@wyanzhao
wyanzhao marked this pull request as ready for review September 15, 2026 08:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quantized_matmul is slower than dense bf16 when the output dimension is not a multiple of 8, and ~7x slower below 8

2 participants