Repository navigation
Conversation
manual_rope and manual_rope_with_freqs build cos and sin in float32 and multiply them with the bf16/fp16 input without casting back, so a sparse prefill hands float32 keys to the KV cache. From the first decode step every activation is promoted to float32. The verify-width quantized kernels only accept bf16/fp16, so about 95% of the MTP verify projections fell back to the stock qmm and the MTP controller settled on depth 1. On a 16K prompt with SpecPrefill on, a Qwen3.5 oQ4e checkpoint decoded at 25.8 tok/s on the batched engine against 38.1 with SpecPrefill off. mx.fast.rope, which the unpatched path uses, returns the input dtype; cast the result back to x.dtype the same way. Refs jundot#4454
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.
Problem
manual_ropeandmanual_rope_with_freqsbuildcosandsinin float32 and return the product with the bf16/fp16xwithout casting back.mx.fast.rope, which the normal path uses, returns the input dtype. After a SpecPrefill sparse prefill the KV cache holds float32 keys, and from the first full-attention layer on, every decode forward runs in float32.On Qwen3.5 this matters because the verify-width quantized kernels (
sg8,mma,vkinqwen35_verify_qmm) only accept bf16/fp16. I counted which route each MTP verify projection took with SpecPrefill on (batched engine, Qwen3.5 oQ4e, 16K prompt). Before the change about 95% of the calls had float32 activations and used the stock qmm. The other 5% matches the three linear-attention layers ahead of the first full-attention layer (3 of 64). After it, all calls are bf16 and 48.7% of the 232,596 calls use a fast kernel (two-row verifies have none, sincevk_eligibleneeds at least three rows).Change
Cast the result of both functions back to
x.dtype.Reproduction
The dtype needs no model:
End to end: serve a Qwen3.5-family MTP checkpoint on the batched engine (a text-only copy, with
vision_configand the image/video/vision token ids removed fromconfig.json, is served byBatchedEngine), enablemtp_enabledandspecprefill_enabledwith a draft model andspecprefill_threshold8192 in the model settings, send a chat request of about 16K prompt tokens withtemperature0 andmax_tokens400, and comparegeneration_tokens_per_secondinusagewith and without this change.Measurements
Upstream
mainf3ad34d2with and without this change, batched engine, Mac Studio M4 Max (40-core GPU) 64 GB. 16K prompt, 400 output tokens, temperature 0, one request at a time, MTP on, SpecPrefill on (threshold 8192, keep 20%, Qwen3.5-0.8B 4-bit draft). Sparse prefill bypasses the prefix cache, so every run is a cold prefill. Decode and TTFT are the median of three runs after one warmup; per-cycle figures are the range over those runs.Qwen3.5 oQ4e text-only checkpoint with MTP head, 16,043-token prompt:
Two other checkpoints, served text-only so the batched engine runs them (vision config removed from a copy, weights unchanged), with 16,053- and 16,031-token prompts:
manual_ropereturned float32 on every call before and never after. With SpecPrefill off neither function is called, and decode doesn't change beyond run-to-run noise: 43.0 and 44.6 tok/s for the 27B, 136.0 and 133.1 tok/s for the 35B-A3B. For the Qwen3.5 oQ4e checkpoint SpecPrefill off gives 38.1 tok/s. Acceptance is a little lower after the change, with about twice as many drafted tokens per cycle.For reference, the same checkpoint on the VLM engine, which doesn't call these functions, decodes at 47.6 tok/s with SpecPrefill on. This isn't an equal-work comparison: on main the VLM path doesn't map the selected tokens back to their original positions (#3811), so its decode runs over a differently positioned context.
Scope
_PositionMappedRoPEis the only caller of the two functions and is installed only for attention modules with a.ropeattribute. That covers mlx-lm RoPE models, including the custom-frequency ones that usemanual_rope_with_freqs. mRoPE attention (rotary_emb) doesn't reach them. I measured three Qwen3.5-family checkpoints. None of them usesmanual_rope_with_freqs, which I only checked numerically. Other families that reach these functions weren't measured.Tests
Eleven new tests in
tests/test_specprefill.py: both functions keep bf16, fp16 and float32 inputs in their dtype,_PositionMappedRoPEkeeps bf16 and fp16, and contiguous bf16/fp16 calls agree withmx.fast.rope(including a partial-rotary case). Nine fail onmain; the other two use float32 inputs.pytest tests -k "specprefill or rope"passes (282 passed, 17 skipped). I didn't run the whole default suite locally.Numerical agreement
pre_scale, partial rotary), in bf16 and fp16, the new output equals the old float32 output cast to the input dtype.mx.fast.ropeon contiguous positions the largest absolute difference was 0.0625 for bf16 and 0.0078 for fp16 in the cases I ran. The new tests use a tolerance.Not verified
Refs #4454