Stuart Rowlands

mlx · speculative-decoding · apple-silicon

One guard, 110× off the verify step. Speculative decoding on MLA models in MLX.

By updated 2 September 202613 min read

I run large language models on a Mac Studio and spend a lot of time trying to make them faster. Last week I worked out why speculative decoding was a net loss on every DeepSeek-lineage model I tried in mlx-lm. It came down to one comparison operator that had been copied into eight files across three codebases.

The mlx-lm change is open as ml-explore/mlx-lm#1817 and the mlx-vlm change as Blaizzy/mlx-vlm#2141. This is the longer version with all the measurements, since a PR description is not the place for them.

The defect

Multi-head Latent Attention (MLA) keeps its key-value cache as a compressed latent, not as full keys and values. At attention time there are two mathematically equivalent ways to use that cache:

  • Absorbed. Fold the up-projection into the query and attend directly against the compressed latent. Cost scales with the number of new queries.
  • Materialised. Expand the entire cached latent back into full K and V, then attend normally. Cost scales with the cached length.

mlx-lm picks the absorbed path with if L == 1:, where L is the number of query positions in this forward call. Plain autoregressive decode is always L == 1, so it gets the fast path. Speculative decoding is never L == 1: a verify step scores the draft tokens plus one bonus token in one call, so L is 2 or more. So every verify step re-expanded the whole cache, and a step that is supposed to be cheap grew linearly with context instead.

The same guard, character for character, sits in seven mlx-lm model files, in mlx-vlm’s glm5_next, and in the vendored copy of that file inside oMLX. I found the oMLX one telling, because that file had been hand-optimised around custom sparse-MLA Metal kernels and the L == 1 boundary still survived the rewrite.

The threshold is not a constant

Because the two costs scale on different axes, the crossover between them is a fixed number of queries that does not move with context length. Per head, the absorbed path does roughly 2·L·Kv·r work on attention plus L·r·(n+v) on the fold. The materialised path does Kv·r·(n+v) to expand and L·Kv·(n+v) to attend. Here r is the latent rank, n the no-position-encoding head dimension, v the value head dimension and Kv the cached length. Set them equal, drop the fold term because the cache is much longer than either r or L, and Kv cancels:

max_absorbed_queries = r · (n + v) / (2r − n − v)

I derived this myself and then went looking to see whether anyone else had. Simon Dong published the identical formula in Understanding MLA Decoding (Materialize vs Absorb) in July 2026, with “about 171 tokens” for DeepSeek-V3. The integer floor gives 170.

Plugging in real checkpoint configs:

geometry (r / n / v) threshold models
512 / 128 / 128 170 DeepSeek-V3, Moonlight-16B, Kimi-Linear, Ling-3.0-flash, LongCat
512 / 192 / 256 398 GLM-4.7-Flash
512 / 256 / 256 512 GLM-5.3-Flash

A hardcoded constant would be wrong for one family or the other, which is why the patch computes it from the config at construction time rather than picking a number.

An empirical sweep on GLM-4.7-Flash’s attention layer, cache primed to 8192 tokens, agrees with the formula. Absorbed wins at every width up to and including 398, and materialised wins from 512 up. So the formula lands on the right side of the real crossover, with a small margin.

One attention layer, cache 8192: cost per call by query count
0.501251020501001251020501002005001,0002,000queries in the step (L)ms per callcomputed threshold 398absorbed: 1, 0.53absorbed: 2, 0.55absorbed: 4, 0.64absorbed: 8, 0.83absorbed: 16, 1.3absorbed: 32, 1.7absorbed: 64, 2.9absorbed: 128, 5.1absorbed: 170, 7.3absorbed: 256, 10.1absorbed: 398, 16.0absorbed: 512, 19.9absorbed: 1,024, 41.2absorbed: 2,048, 89.8absorbedmaterialised: 1, 7.3materialised: 2, 8.2materialised: 4, 8.2materialised: 8, 8.3materialised: 16, 8.4materialised: 32, 8.5materialised: 64, 8.7materialised: 128, 9.9materialised: 170, 11.5materialised: 256, 12.8materialised: 398, 16.9materialised: 512, 18.6materialised: 1,024, 31.2materialised: 2,048, 59.6materialised

Each branch forced on GLM-4.7-Flash's first attention layer. The lines cross between 398 and 512. Prefill chunks of 512 and 2048 sit to the right, where materialised is still the cheaper path.

The change

The patch is small. mlx_lm/models/mla.py gets one helper:

def max_absorbed_queries(kv_lora_rank, qk_nope_head_dim, v_head_dim) -> int:
    denom = 2 * kv_lora_rank - qk_nope_head_dim - v_head_dim
    if denom <= 0:
        return 1
    return max(1, int(kv_lora_rank * (qk_nope_head_dim + v_head_dim) / denom))

Each MLA model computes self._max_absorbed in its constructor, after the three head dimensions exist, and its two guards become if L <= self._max_absorbed:. In mlx-lm that is seven model files plus the helper, +56/−21 lines. In mlx-vlm it is two files, +20/−3.

I left two L == 1 guards alone on purpose. deepseek_v32 has a third one that gathers the top-k sparse indices for a single query, and that branch is there for correctness rather than speed, since the wider case already builds a mask. rwkv7 has one in its token-shift code that has nothing to do with attention.

What it buys

Everything below was measured on an M3 Max with 128 GB. Both arms are real clones of mlx-lm at the same upstream commit, selected by sys.path, so there is no monkey-patching involved. The number that matters for a verify step is the marginal cost of the second query, which I take as whole-model time at L=2 minus L=1, median of nine runs.

The harness and the patch were written with an AI coding agent driving the terminal, with me directing and checking, and every number here was run by me.

Verify-step marginal cost, ms (whole model, L=2 minus L=1, median of 9)
stockpatched25102050100200500 msGLM-4.7-Flash · glm4_moe_lite · threshold 398cache 512cache 512 stock: 29.4 mscache 512 patched: 2.4 ms29.4 ms2.4 mscache 2048cache 2048 stock: 93.8 mscache 2048 patched: 2.9 ms93.8 ms2.9 mscache 8192cache 8192 stock: 363.7 mscache 8192 patched: 3.3 ms363.7 ms3.3 msMoonlight-16B-A3B · deepseek_v3 · threshold 170cache 512cache 512 stock: 6.6 mscache 512 patched: 2.3 ms6.6 ms2.3 mscache 2048cache 2048 stock: 21.7 mscache 2048 patched: 2.4 ms21.7 ms2.4 mscache 8192cache 8192 stock: 83.0 mscache 8192 patched: 2.5 ms83.0 ms2.5 msKimi-Linear-48B · kimi_linear · threshold 170cache 512cache 512 stock: 4.4 mscache 512 patched: 1.9 ms4.4 ms1.9 mscache 2048cache 2048 stock: 12.8 mscache 2048 patched: 1.7 ms12.8 ms1.7 mscache 8192cache 8192 stock: 45.9 mscache 8192 patched: 2.0 ms45.9 ms2.0 ms

Log scale. Every patched dot sits in the same 1.7 to 3.3 ms band regardless of cache length.

Stock’s marginal grows with the cache, while patched stays between 1.7 and 3.3 ms at every length I tried. That flat line is the whole point of the change.

Here is the same thing from the whole model’s point of view, as cost per decode step at cache 8192 while the step widens from one query to four:

Whole-model decode, ms per step at cache 8192
20501002001234queries in the step (L)ms per stepGLM-4.7 stock: 1, 20.8GLM-4.7 stock: 2, 385GLM-4.7 stock: 3, 388GLM-4.7 stock: 4, 391GLM-4.7 stockMoonlight stock: 1, 12.6Moonlight stock: 2, 95.6Moonlight stock: 3, 98Moonlight stock: 4, 102Moonlight stockGLM-4.7 patched: 1, 20.9GLM-4.7 patched: 2, 24.2GLM-4.7 patched: 3, 27.5GLM-4.7 patched: 4, 33.5GLM-4.7 patchedMoonlight patched: 1, 12.7Moonlight patched: 2, 15.2Moonlight patched: 3, 17.3Moonlight patched: 4, 19Moonlight patched

L=1 is identical in both arms. The entire penalty is the step from one query to two. Past that, stock's marginals are only 2 to 3 ms because it is already materialising.

Plain decode at L=1 takes the same branch in both arms and is unchanged, within 2 % on every model. I actually use that as a validity check. If the L=1 timings differ between arms, something other than the patch is different and I throw the run away. That caught one genuinely bad run, which I describe further down.

End to end

Marginal attention cost is not the same thing as throughput, so to see whether the fix changes what a runtime actually does, I ran MTPLX’s tune command, which benchmarks plain decode against each speculative depth and picks a winner, on a pack forged from the original zai-org/GLM-4.7-Flash checkpoint with its multi-token-prediction head. The only difference between arms is the two guards.

arm context AR tok/s MTP depth 1 tok/s ratio verify ms acceptance verdict
stock ~700 tokens 60.7 47.6 0.78× 41.6 rejected
patched ~700 tokens 61.1 87.0 1.42× 20.1 90.3 %
stock 2K–16K tokens 47.8 7.1 0.15× 413.9 72.1 % rejected
patched 2K–16K tokens 47.7 64.4 1.35× 27.7 73.8 % selected

The long-context row is the one to look at. Verify cost falls 14.9× and throughput rises 9.1×, while acceptance barely moves. The draft head was producing good drafts the whole time, and it was the cost of checking them that made speculation a loss.

MTPLX tune, GLM-4.7-Flash: MTP depth-1 decode, tokens per second
stockpatched20406080100 tok/s~700 token context~700 token context stock: 47.6 tok/s~700 token context patched: 87.0 tok/s47.6 tok/s87.0 tok/s2K to 16K context2K to 16K context stock: 7.1 tok/s2K to 16K context patched: 64.4 tok/s7.1 tok/s64.4 tok/s

Plain decode is 61 and 48 tok/s in the two rows, so the patched arm beats it in both and the stock arm beats it in neither.

Every draft depth moves the same way. This is one run, both arms, depths one to three, with plain decode as the control at 62.6 and 62.9 tok/s:

MTPLX tune, GLM-4.7-Flash, ~700 token context: decode tok/s by draft depth
stockpatched30405060708090100 tok/splain decodedepth 1depth 1 stock: 51.7 tok/sdepth 1 patched: 87.4 tok/s51.7 tok/s87.4 tok/sdepth 2depth 2 stock: 51.4 tok/sdepth 2 patched: 72.5 tok/s51.4 tok/s72.5 tok/sdepth 3depth 3 stock: 40.2 tok/sdepth 3 patched: 66.6 tok/s40.2 tok/s66.6 tok/s

Without the change there is no depth at which speculation is worth enabling on this model. With it, every depth beats plain decode. Depth 1 wins because this model's position-2 acceptance is only about 11 percent.

The drop-off with context is even clearer in MTPLX’s depth sweep (greedy, 256 tokens generated, same prompts in both arms). Ratio of speculative decode throughput to plain decode:

Speculative decode throughput as a ratio of plain decode, by context length
stockpatched00.200.400.600.80parity2,048 tokens2,048 tokens stock: 0.19×2,048 tokens patched: 0.80×0.19×0.80×8,192 tokens8,192 tokens stock: 0.08×8,192 tokens patched: 0.76×0.08×0.76×16,384 tokens16,384 tokens stock: 0.04×16,384 tokens patched: 0.79×0.04×0.79×

Greedy, 256 tokens generated per case, same prompts in both arms. Above 1.0 speculation wins. This harness uses a diagnostic history policy with lower acceptance than tune, so read the shape, not the absolute level.

At 16K, turning on speculative decoding without the patch made the model about 25× slower than leaving it off. The two MTPLX harnesses report different absolute acceptance because they use different history policies, so I quote tune for the headline numbers and use the sweep only for the shape of the curve. They agree on the direction.

The sparse model is different

GLM-5.3-Flash has sparse attention. An indexer selects the top 2048 cached positions and attention only runs over those. Below that budget the MLA fix gives 3.4× on the verify step, but above it the work is already capped and there is nothing left for the absorbed path to save. At 2048, 8192 and 32768 tokens of cache, patched is within 2 % of stock, so there is no regression and no gain either.

That means the attention fix stops helping this model at exactly the context lengths where speculation would be most useful. The remaining cost turned out to be in a different place.

The same bug, one layer over

The indexer that picks those 2048 positions pools the cached keys into windows of four before scoring them. Pooling the whole cache every step is O(T), so the mlx-vlm implementation caches completed windows and recomputes only the tail. That path was gated on S == 1, where S is the number of new tokens in the step. It is the same mistake as the attention guard, with an optimisation that is valid for any small S only switched on for one value, so every speculative verify fell back to pooling the entire cache.

Widening it is safe because windows start at multiples of four and tokens only append, so a window completed before the tail cannot change. The fix drops the S == 1 condition and keeps the guards that do the real work: the cache has a pool, there is no padding, and the batch shape matches. A review pass added a fourth one, that the cache is exactly as long as the pool remembers. That covers speculative rejection, where the cache gets trimmed and a stale pool would otherwise carry the rejected tokens into the next step.

GLM-5.3-Flash indexer, ms per sparse layer per step, by context length
1258,19232,768131,072tokens in cachems per layerstock, S=4: 8,192, 1.2stock, S=4: 32,768, 2.0stock, S=4: 131,072, 5.9stock, S=4stock, S=2: 8,192, 1.2stock, S=2: 32,768, 2.0stock, S=2: 131,072, 5.7stock, S=2patched, S=4: 8,192, 0.78patched, S=4: 32,768, 0.88patched, S=4: 131,072, 1.4patched, S=4patched, S=2: 8,192, 0.83patched, S=2: 32,768, 0.86patched, S=2: 131,072, 1.1patched, S=2

S is the number of new tokens in the step. S=1 is unchanged at about 0.7 ms at 8K and 1.0 ms at 128K in both arms. Across the model's eleven sparse layers, a verify step at 128K spends about 63 ms in the indexer before the change and about 12 ms after.

A wide step now pools identically to the same tokens fed one at a time, checked for two, three, four and eight new tokens at aligned and misaligned cache lengths. Window indices, cache length and the chosen top-k match exactly. The pooled keys agree to a few float32 ULP because the two paths sum the same windows in a different order.

Neither fix does anything for drafting itself. GLM-5.3-Flash’s MTP head does work. It is stored under a prefix that mlx-vlm’s sanitize() drops on load, which is why no public runtime uses it. Read from the raw safetensors, with a properly aligned cache for the draft head, conditional acceptance is 0.84 at position one and decays gently: 0.73, 0.63, 0.62. Combined with the measured verify costs, the best depth is 2 to 3 for about 1.6×. Getting a runtime to actually do that is a separate job.

Things I have to disclose

Outputs of the attention change are not bit-identical. Forcing the absorbed and materialised paths on identical inputs through one 4-bit GLM-4.7-Flash attention layer gives a maximum absolute difference of 4.883e-04 on values near 0.1. bf16 spacing at 0.1 is 2⁻¹¹, or 4.88e-04, so the two paths differ by exactly one bf16 ULP. The delta is the same at L=2 and L=8, so it comes from quantised matmuls running in a different order rather than from anything that grows with query count. Over hundreds of greedy tokens that is enough for trajectories to diverge, and it showed up as MTP acceptance differing between arms on otherwise identical runs. vLLM and SGLang accept the same property for the same reason.

Three of the seven patched modules are unmeasured. kimi_k3, longcat_flash and bailing_moe_v3 share the 170 geometry but every checkpoint I could find is 255 GB or larger, or has no mlx-lm module at all. The argument carries by construction, but I want to be clear that for those three it is an argument and not a measurement.

One run was thrown out. A patched 16K sampled run reported 118 tok/s, three times plain decode at the same context. It had drafted 12 of 256 tokens, so the number was an artefact of a run that barely speculated. The greedy re-run gave a sane 31 tok/s and that is what appears above. A separate oMLX run was invalidated by the L=1 check: the second arm inherited a saturated MLX buffer cache on a 103 GB model and every point sat near 1550 ms. On a machine that size you have to interleave the arms sample by sample and check that L=1 matches, or the second arm quietly measures the allocator instead of the code.

Prefill is essentially untouched. Prefill runs in chunks of 512 or 2048, both above every computed threshold. The only change is that a short final partial chunk can now take the absorbed path, and the sweep above shows that is faster, not slower.

If you maintain an MLA runtime, it is worth grepping for L == 1 near your embed_q. It was in all three of the codebases I looked at.