Repository navigation
Conversation
kernel_mul_mm takes its token tile as a template parameter (32 by default, so every existing instantiation is unchanged) and gains _n64 instantiations for every type the Rust dispatch serves. A threadgroup dequantizes its 64 weight rows once per K step for the tokens of its tile, so a 64-token tile fetches and dequantizes each weight half as often per token. call_quantized_matmul_mm_t now runs the 64-token tile; call_quantized_matmul_mm_t_with_tile and QuantizedMmTile choose one. A single token still goes through fwd_mv and the matrix-vector kernel, unchanged. Each output element is accumulated in the same order at either tile. The new test mm_tile64_is_byte_identical_to_tile32 checks that bit for bit for all 13 types at four shapes, including odd token counts and a batch dimension.
…en tile QuantizedMmTile::for_tokens picks the tile from the rows of src1 per batch entry: the 64-token tile only when it pads no more tokens than the 32-token tile (tokens % 64 == 0 or tokens % 64 > 32), else the 32-token tile. So up to 32 tokens it is always the 32-token tile, the kernel this ran before this branch. Both call_quantized_matmul_mm_t and candle-core's QMetalStorage::fwd use it. On an M4 Pro over the seven Qwen3-Embedding-4B Q4_K_M projection shapes, in six interleaved rounds: - Where the 64-token tile pads more than the 32-token one, it was slower at 2, 4, 8, 16 and 32 tokens (1.37 to 1.49 times as long) and at 96, 136, 160, 200, 224 and 288 (1.03 to 1.16). It was even at 352 and 400. - Where it pads no more, it took 0.89 to 1.02 of the time at 100, 128, 176, 192, 240, 256, 300, 320, 384, 512, 1024 and 2048. At 64 tokens two runs read 0.94 and 1.09. The byte-identical test gains a 32-token shape. A new test pins the choice at 2, 32, 33, 64, 96, 100, 128, 136 and 2048 tokens.
This branch has not been deployed
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.
Metal: 64-token tile for the quantized matrix-matrix kernel
What changes
The Metal quantized matmul for more than one token (
kernel_mul_mm) now multiplies each tile ofdequantized weights by 64 tokens at a time instead of 32, wherever that pads no more tokens than the
32-token tile (see "Where the 64-token tile is used", below). A single token is unchanged: it still
goes through
fwd_mvand the matrix-vector kernel.kernel_mul_mmtakes the token tile as a template parameter. It defaults to 32, so every existinginstantiation compiles to what it was.
_n64instantiations exist for every type the Rust dispatch serves: F32, F16, BF16, Q4_0, Q4_1,Q5_0, Q5_1, Q8_0, Q2_K, Q3_K, Q4_K, Q5_K and Q6_K.
call_quantized_matmul_mm_tkeeps its signature and runs the tileQuantizedMmTile::for_tokenspicks. candle-core's
QMetalStorage::fwduses the same function.call_quantized_matmul_mm_t_with_tileand aQuantizedMmTileenum let a caller pick either tile.Why the tile sets the weight reuse
A threadgroup owns 64 weight rows. On every step along K it reads those rows' quantized blocks,
dequantizes them into threadgroup memory, and multiplies them by the tokens of its tile. Nothing
outside the tile shares that work. So each weight is fetched and dequantized once per tile of tokens:
16 times per 512-token sequence at a 32-token tile, 8 times at 64. A batch of sequences does not
change this, because the batch is only more tiles. Doubling the tile halves the weight traffic and
the dequantize work per token. The cost is the threadgroup memory for the wider activation tile,
and twice the accumulators per simdgroup.
Where the 64-token tile is used
The 64-token tile is used only when it pads no more tokens than the 32-token tile: when the token
count is a multiple of 64, or leaves more than 32 over a multiple of 64. Otherwise its last tile is
mostly padding, and that wasted work costs more than the weight reuse saves. The measurements below
show that pattern: it was slower at every count that pads more (up to 32, and at 96, 136, 160, 200,
224 and 288 tokens). At every count that pads no more it was faster or even, with one exception: a
20-call run at 64 tokens read 1.09, while the other run there read 0.94. So the rule says why the
tile helps, and it is not a cut-off tuned to one machine.
Same results, bit for bit
Each output element is accumulated in exactly the same order at either tile: the same K steps and
the same 8x8 multiply-accumulates, only grouped into larger threadgroups. The new test
quantized::metal::tests::mm_tile64_is_byte_identical_to_tile32runs both tiles and requires everyoutput to be equal bit for bit:
two tiles), 130 (several tiles and a partial one, with 96 weight rows), and a batch of 2 at 65
tokens with 128 rows and K = 768;
To check the test catches a real defect, I made every token row of a 64-token tile load the first
row's activations. The test failed ("Q4_0 1x33x200x512: 200 of 6600 outputs differ between the
tiles"). With the change restored it passes again.
A second test,
mm_tile_for_tokens_follows_padding, pins the choice at 2, 32, 33, 64, 96, 100, 128,136 and 2048 tokens. With the rule's
<=changed to<it fails ("33 tokens: Tokens32 vs Tokens64").Checks run
On an Apple M4 Pro with macOS's Metal, in this branch's own target directory:
cargo fmt --all -- --check: clean.cargo clippy -p candle-metal-kernels -p candle-core --features candle-core/metal -- -D warnings:clean.
cargo clippy -p candle-core --features metal --lib --tests -- -D warnings -A clippy::items_after_test_module:clean. The one allowed lint is reported on
candle-core/src/metal_backend/device.rs, which thischange does not touch.
candle-metal-kernels' own tests do not build on main at present:src/tests.rsusesrandandrand_distr, which are not among its dependencies. This change does not touch that file.--features metal:quantized_tests: 83 passed;metal_concurrent, pth and serialization tests pass. Run the last two from
candle-core/, becausethey read fixture files by a relative path.
(
12bb5d1f, built in a separate worktree):conv2d_grad_noncontiguous_kernel_metal;index_select_strided_ids_grad_metal("Metal index_select requires contiguous ids");meshgrid_with_empty_axis_metal.Numbers
All on one Apple M4 Pro (20 GPU cores), shared with other jobs (load average 160 to 220 during the
candle runs).
This branch, kernel level
fwd_with_tilewas timed at each tile on the seven Qwen3-Embedding-4B Q4_K_M projection shapes:The weights are random, quantized to those types. Each sample is 20 or 50 calls between two
synchronizes (column "calls"). Each round runs both tiles, and the order alternates every round.
There were six rounds per run.
"Ratio" is the 64-token tile's time divided by the 32-token tile's, each the median ms per
projection summed over the seven shapes (one layer's multiplies). A ratio below 1 means the 64-token
tile is faster. "Rounds faster" counts the rounds in which it was. Two values mean two separate
runs. "Chosen" is the tile the dispatch now picks.
The full model, from a standalone crate
qkernels-benchcarries the same 64-token tiling of this kernel, and its 64-token result isbyte-identical to candle's. There, the full 36-layer forward of Qwen3-Embedding-4B Q4_K_M ran with
every projection at the 64-token tile, over three rounds, each against a same-round rerun of
candle's path:
The pooled embeddings were byte-identical to candle's.
Two commits
QuantizedMmTile::for_tokensholds the rule, and bothcall_quantized_matmul_mm_tandcandle-core's
QMetalStorage::fwduse it. Up to 32 tokens it is always the 32-token tile, so ashort prefill runs exactly the kernel it ran before.
mm_tile_for_tokens_follows_paddingtest pins the rule.https://claude.ai/code/session_01Lodcb7ZaRA8icgxCC8cudg
🍄