Skip to content

fix: Speed up multi-token decode by computing the absorbed MLA threshold - #1817

Open
stooit wants to merge 1 commit into
ml-explore:mainfrom
stooit:mla-absorbed-threshold
Open

stooit wants to merge 1 commit into
ml-explore:mainfrom
stooit:mla-absorbed-threshold

Conversation

@stooit

@stooit stooit commented Sep 2, 2026

Copy link
Copy Markdown

Speculative decoding on MLA models is currently slower than plain decoding: the absorbed path is gated on L == 1, so every verify step expands the entire cached latent instead. The cost grows with cache length, so it gets worse the longer you generate. Any step with L >= 2 pays it -- e.g. mlx-lm's draft-model speculation (and any multi-token forward pass).

The crossover between the two paths is a query count set by head geometry and the cache length: L < r*d / (r*d/T + 2r - d), with d = qk_nope_head_dim + v_head_dim. For a long cache that tends to r*d / (2r - d), 170 for DeepSeek V3 and 398 for GLM-4.7-Flash; on a cold cache, where T == L, it reverses and materialising always wins. This PR adds max_absorbed_queries and latent_length to models/mla.py so each model computes it from its own config and the current cache length. deepseek_v32's third guard, the sparse top-k gather, is deliberately left at L == 1 -- that branch is about correctness, not speed.

Single-token decode is within 2% across all models, because L=1 takes the same branch either way.

Example: on GLM-4.7-Flash with an 8K prompt, mlx_lm.generate --draft-model runs at 2.8 tok/s against 55.8 tok/s autoregressive. This change takes that to 14.7 tok/s (5.2×). Whether speculation then wins depends on draft cost (e.g. with a cheap draft head it does, with a same-architecture draft model it still loses).

Prior art: SGLang exposes --speculative-attention-mode {prefill,decode} to pick which path target-verify uses (server_args.py, deepseek_v2.py); vLLM tracks the same problem class in 21984; the formula is from Simon Dong's write-up.

Results

M3 Max 128GB. Stock and patched are two checkouts of the same commit selected via sys.path, so nothing but the two guards differs -- no runtime patching.

Verify-step cost (ms), stock → patched

Model Module Threshold cache 512 cache 2048 cache 8192
GLM-4.7-Flash glm4_moe_lite 398 29.4 → 2.4 93.8 → 2.9 363.7 → 3.3
Moonlight-16B-A3B deepseek_v3 170 6.6 → 2.3 21.7 → 2.4 83.0 → 2.5
Kimi-Linear-48B kimi_linear 170 4.4 → 1.9 12.8 → 1.7 45.9 → 2.0

Marginal cost of the second token in a decode step -- what a verify pays. Stock grows with the cache, patched is flat. Kimi-Linear is per-step only: mlx-lm refuses speculative decoding on MLA hybrids (ValueError: Speculative decoding requires a trimmable prompt cache (got {'ArraysCache'})), since linear-attention state cannot be rewound on rejection.

Where the paths cross

One GLM-4.7-Flash layer at cache 8192 with each branch forced:

L absorbed materialised
1 0.5 7.3
398 16.0 16.9
512 19.9 18.6
2048 89.8 59.6

The empirical crossover is between 398 and 512 and the formula computes 379 at this cache length (398 in the limit), so it lands on the correct side with a small margin. Prefill steps are 512 or 2048, where materialised correctly wins -- prefill is unaffected.

Downstream: MTPLX

MTPLX depends on mlx-lm and gets its MLA implementation from these files. Its tuner benchmarks autoregressive decode against each speculative depth and picks a winner.

Context Arm AR MTP D1 vs AR Verify Acceptance
~700 tok stock 60.7 47.6 0.78× 41.6 ms
~700 tok patched 61.1 87.0 1.42× 20.1 ms 90.3%
2K–16K stock 47.8 7.1 0.15× 413.9 ms 72.1%
2K–16K patched 47.7 64.4 1.35× 27.7 ms 73.8%

Acceptance barely moves while verify cost falls 14.9×: the drafting was always fine but the verification was not. Every draft depth flips from losing to winning -- 0.83× / 0.82× / 0.64× at depths 1/2/3 becomes 1.39× / 1.15× / 1.06×.

Tests

test_models covers all seven touched architectures: 85 OK on both arms. test_generate 30, test_gated_delta 8, test_utils 11, test_losses 4 -- all OK on both. test_prompt_cache (4 errors) and test_sample_utils (1 failure) are identical on both arms and reproduce on unmodified main.

tests/test_absorbed_mla.py is new: it covers the large-cache and cold-cache limits, the quantised latent form, that each of the seven models makes one gate decision used by both guards, and that deepseek_v32's indexer gate is untouched.

Scope

Measured on deepseek_v3, glm4_moe_lite and kimi_linear. kimi_k3, longcat_flash and bailing_moe_v3 share the same 512/128/128 head geometry and threshold of 170 but had no checkpoint small enough to run here. deepseek_v32 is sparse, so its benefit is limited to caches below the top-k budget and neutral above (3.4× at 512, 0.99-1.02× at 2048/8192/32768 on the equivalent mlx-vlm model).

Outputs are not bit-identical: at 4-bit the two branches differ by 4.883e-04 max on values of scale ~0.1, against a bf16 ULP of ~2.44e-04 -- one to two units in the last place, and flat across L, so it is quantised-matmul ordering.

  • ☑️ I understand it is strictly prohibited to use AI to write PR description
  • AI usage disclosure: Claude Code (Opus 5) wrote the patch and the benchmark scripts. I reviewed the diff, ran every measurement myself on an M3 Max, and take responsibility for every line. This description is mine.

stooit added a commit to stooit/mlx-vlm that referenced this pull request Sep 3, 2026
The absorbed MLA path was gated on `L == 1`, so any step with more than
one query materialized K and V over the whole cached latent instead.
Materializing costs O(T) in the cached length and is independent of L,
while the absorbed path costs O(L), so the crossover is a fixed query
count per model:

    kv_lora_rank * (nope + v) / (2 * kv_lora_rank - nope - v)

That is 170 queries for DeepSeek V3 and 398 for GLM-4 MoE Lite, against
a gate that admitted exactly one. Every speculative-verify step and
every short continuation on a warm cache took the wrong branch.

`models/mla.py` gains `max_absorbed_queries()`, applied in the ten
models with an absorbed path: deepseek_v3, deepseek_v32, kimi_k3,
kimi_linear, longcat_flash, longcat_flash_sparse, glm4_moe_lite,
glm_moe_dsa, glm5_next and youtu_vl. A 0.85 factor keeps the choice on
the measured-safe side of the analytical break-even.

This applies the same reasoning as ml-explore/mlx-lm#1817 to mlx-vlm,
and extends it to four models that PR does not cover:
longcat_flash_sparse, glm_moe_dsa, glm5_next and youtu_vl.

The sparse-indexer gate is deliberately left at `L == 1`. It selects
with `topk_indices[:, :, 0, :]`, the first query's top-k, which is
correct for a single query only. That is a correctness gate, not a
performance one.

Full forward pass, Moonlight-16B-A3B-Instruct-4-bit (DeepSeek V3
architecture, 27 layers), M3 Max, medians of 5:

| context | L=1 | L=2 | L=4 | L=8 | L=16 | L=32 |
|--------:|----:|----:|----:|----:|-----:|-----:|
| 32768 stock | 17.3 | 380.8 | 394.6 | 413.0 | 497.1 | 515.1 |
| 32768 patched | 17.3 | 22.1 | 28.7 | 47.8 | 85.2 | 161.0 |
| speedup | 1.00x | 17.3x | 13.8x | 8.6x | 5.8x | 3.2x |

At 16384 the same steps are 10.7x, 8.9x and 5.9x; at 4096, 4.2x, 3.4x
and 2.5x. L=1 is unchanged, as the gate already selected the absorbed
path there.

Materializing also allocates per-head K and V. Peak memory for one
attention call at 32768 context falls from 2478 MB to 462 MB at L=4,
and stays lower at every L up to the threshold (2742 MB against 3602 MB
at L=144).

Numerics. The two forms are the same linear algebra: in float32 they
agree to a relative 3.4e-06 across L = 1 to 170 at batch 1, 2 and 4. At
L = 1 the patch is a no-op and output is bit-identical.

On 4-bit weights the two forms reorder quantized matmuls, so a wide
step is not bit-identical to the other form. Measured teacher-forced
over 1024 positions against token-by-token prefill as the reference,
the stock wide chunk agrees on 98.54% of argmaxes and the patched wide
chunk on 98.34%. Every disagreement sits at a near-tie: top-2 margins
of 0.00 to 0.30 against a median margin of 0.664, on a logit scale of
23.4. Wide-step drift of this size is pre-existing, not introduced --
the stock wide chunk already diverges from sequential prefill at the
same rate and, in free-running greedy decode, at the same token.

Test suite: 3077 passed on both arms, with identical per-file results.
stooit added a commit to stooit/mlx-vlm that referenced this pull request Sep 3, 2026
The absorbed MLA path was gated on `L == 1`, so any step with more than
one query materialized K and V over the whole cached latent instead.
Materializing costs O(T) in the cached length and is independent of L,
while the absorbed path costs O(L), so the crossover is a fixed query
count per model:

    kv_lora_rank * (nope + v) / (2 * kv_lora_rank - nope - v)

That is 170 queries for DeepSeek V3 and 398 for GLM-4 MoE Lite, against
a gate that admitted exactly one. Every speculative-verify step and
every short continuation on a warm cache took the wrong branch.

`models/mla.py` gains `max_absorbed_queries()`, applied in the ten
models with an absorbed path: deepseek_v3, deepseek_v32, kimi_k3,
kimi_linear, longcat_flash, longcat_flash_sparse, glm4_moe_lite,
glm_moe_dsa, glm5_next and youtu_vl. A 0.85 factor keeps the choice on
the measured-safe side of the analytical break-even.

This applies the same reasoning as ml-explore/mlx-lm#1817 to mlx-vlm,
and extends it to four models that PR does not cover:
longcat_flash_sparse, glm_moe_dsa, glm5_next and youtu_vl.

The sparse-indexer gate is deliberately left at `L == 1`. It selects
with `topk_indices[:, :, 0, :]`, the first query's top-k, which is
correct for a single query only. That is a correctness gate, not a
performance one.

Full forward pass, Moonlight-16B-A3B-Instruct-4-bit (DeepSeek V3
architecture, 27 layers), M3 Max, medians of 5:

| context | L=1 | L=2 | L=4 | L=8 | L=16 | L=32 |
|--------:|----:|----:|----:|----:|-----:|-----:|
| 32768 stock | 17.3 | 380.8 | 394.6 | 413.0 | 497.1 | 515.1 |
| 32768 patched | 17.3 | 22.1 | 28.7 | 47.8 | 85.2 | 161.0 |
| speedup | 1.00x | 17.3x | 13.8x | 8.6x | 5.8x | 3.2x |

At 16384 the same steps are 10.7x, 8.9x and 5.9x; at 4096, 4.2x, 3.4x
and 2.5x. L=1 is unchanged, as the gate already selected the absorbed
path there.

Materializing also allocates per-head K and V. Peak memory for one
attention call at 32768 context falls from 2478 MB to 462 MB at L=4,
and stays lower at every L up to the threshold (2742 MB against 3602 MB
at L=144).

Numerics. The two forms are the same linear algebra: in float32 they
agree to a relative 3.4e-06 across L = 1 to 170 at batch 1, 2 and 4. At
L = 1 the patch is a no-op and output is bit-identical.

On 4-bit weights the two forms reorder quantized matmuls, so a wide
step is not bit-identical to the other form. Measured teacher-forced
over 1024 positions against token-by-token prefill as the reference,
the stock wide chunk agrees on 98.54% of argmaxes and the patched wide
chunk on 98.34%. Every disagreement sits at a near-tie: top-2 margins
of 0.00 to 0.30 against a median margin of 0.664, on a logit scale of
23.4. Wide-step drift of this size is pre-existing, not introduced --
the stock wide chunk already diverges from sequential prefill at the
same rate and, in free-running greedy decode, at the same token.

Test suite: 3137 passed and 3 skipped on this branch. Measured against the
tree this branch was first written on, stock and patched both gave 3077
passed with matching per-file results, so the change moves no test either
way; the count rose to 3137 because main has added tests since.
`test_smoke.py` fails to collect, on this branch and on unmodified main
alike, which is unrelated to this change.
stooit added a commit to stooit/mlx-vlm that referenced this pull request Sep 3, 2026
The absorbed MLA path was gated on `L == 1`, so any step with more than
one query materialized K and V over the whole cached latent instead.
Materializing costs O(T) in the cached length and is independent of L,
while the absorbed path costs O(L), so the crossover is a fixed query
count per model:

    kv_lora_rank * (nope + v) / (2 * kv_lora_rank - nope - v)

That is 170 queries for DeepSeek V3 and 398 for GLM-4 MoE Lite, against
a gate that admitted exactly one. Every speculative-verify step and
every short continuation on a warm cache took the wrong branch.

`models/mla.py` gains `max_absorbed_queries()`, applied in the ten
models with an absorbed path: deepseek_v3, deepseek_v32, kimi_k3,
kimi_linear, longcat_flash, longcat_flash_sparse, glm4_moe_lite,
glm_moe_dsa, glm5_next and youtu_vl. A 0.85 factor keeps the choice on
the measured-safe side of the analytical break-even.

This applies the same reasoning as ml-explore/mlx-lm#1817 to mlx-vlm,
and extends it to four models that PR does not cover:
longcat_flash_sparse, glm_moe_dsa, glm5_next and youtu_vl.

The sparse-indexer gate is deliberately left at `L == 1`. It selects
with `topk_indices[:, :, 0, :]`, the first query's top-k, which is
correct for a single query only. That is a correctness gate, not a
performance one.

Full forward pass, Moonlight-16B-A3B-Instruct-4-bit (DeepSeek V3
architecture, 27 layers), M3 Max, medians of 5:

| context | L=1 | L=2 | L=4 | L=8 | L=16 | L=32 |
|--------:|----:|----:|----:|----:|-----:|-----:|
| 32768 stock | 17.3 | 380.8 | 394.6 | 413.0 | 497.1 | 515.1 |
| 32768 patched | 17.3 | 22.1 | 28.7 | 47.8 | 85.2 | 161.0 |
| speedup | 1.00x | 17.3x | 13.8x | 8.6x | 5.8x | 3.2x |

At 16384 the same steps are 10.7x, 8.9x and 5.9x; at 4096, 4.2x, 3.4x
and 2.5x. L=1 is unchanged, as the gate already selected the absorbed
path there.

Materializing also allocates per-head K and V. Peak memory for one
attention call at 32768 context falls from 2478 MB to 462 MB at L=4,
and stays lower at every L up to the threshold (2742 MB against 3602 MB
at L=144).

Numerics. The two forms are the same linear algebra: in float32 they
agree to a relative 3.4e-06 across L = 1 to 170 at batch 1, 2 and 4. At
L = 1 the patch is a no-op and output is bit-identical.

On 4-bit weights the two forms reorder quantized matmuls, so a wide
step is not bit-identical to the other form. Measured teacher-forced
over 1024 positions against token-by-token prefill as the reference,
the stock wide chunk agrees on 98.54% of argmaxes and the patched wide
chunk on 98.34%. Every disagreement sits at a near-tie: top-2 margins
of 0.00 to 0.30 against a median margin of 0.664, on a logit scale of
23.4. Wide-step drift of this size is pre-existing, not introduced --
the stock wide chunk already diverges from sequential prefill at the
same rate and, in free-running greedy decode, at the same token.

Test suite: unmodified main gives 3131 passed and this branch gives 3137,
the difference being the six tests added here, with per-file results
otherwise identical. `test_smoke.py` fails to collect on this branch and on
unmodified main alike, which is unrelated to this change.
stooit added a commit to stooit/mlx-vlm that referenced this pull request Sep 3, 2026
The absorbed MLA path was gated on `L == 1`, so any step with more than
one query materialized K and V over the whole cached latent instead.
Materializing costs O(T) in the cached length and is independent of L,
while the absorbed path costs O(L), so the crossover is a fixed query
count per model:

    kv_lora_rank * (nope + v) / (2 * kv_lora_rank - nope - v)

That is 170 queries for DeepSeek V3 and 398 for GLM-4 MoE Lite, against
a gate that admitted exactly one. Every speculative-verify step and
every short continuation on a warm cache took the wrong branch.

`models/mla.py` gains `max_absorbed_queries()`, applied in the ten
models with an absorbed path: deepseek_v3, deepseek_v32, kimi_k3,
kimi_linear, longcat_flash, longcat_flash_sparse, glm4_moe_lite,
glm_moe_dsa, glm5_next and youtu_vl. The helper is byte-identical to the
one in ml-explore/mlx-lm#1817 so the two runtimes pick the same width.

This applies the same reasoning as ml-explore/mlx-lm#1817 to mlx-vlm,
and extends it to four models that PR does not cover:
longcat_flash_sparse, glm_moe_dsa, glm5_next and youtu_vl.

The sparse-indexer gate is deliberately left at `L == 1`. It selects
with `topk_indices[:, :, 0, :]`, the first query's top-k, which is
correct for a single query only. That is a correctness gate, not a
performance one.

Full forward pass, Moonlight-16B-A3B-Instruct-4-bit (DeepSeek V3
architecture, 27 layers), M3 Max, medians of 5:

| context | L=1 | L=2 | L=4 | L=8 | L=16 | L=32 |
|--------:|----:|----:|----:|----:|-----:|-----:|
| 32768 stock | 17.3 | 380.8 | 394.6 | 413.0 | 497.1 | 515.1 |
| 32768 patched | 17.3 | 22.1 | 28.7 | 47.8 | 85.2 | 161.0 |
| speedup | 1.00x | 17.3x | 13.8x | 8.6x | 5.8x | 3.2x |

At 16384 the same steps are 10.7x, 8.9x and 5.9x; at 4096, 4.2x, 3.4x
and 2.5x. L=1 is unchanged, as the gate already selected the absorbed
path there.

Materializing also allocates per-head K and V. Peak memory for one
attention call at 32768 context falls from 2478 MB to 462 MB at L=4,
and stays lower at every L up to the threshold (2742 MB against 3602 MB
at L=144).

Numerics. The two forms are the same linear algebra: in float32 they
agree to a relative 3.4e-06 across L = 1 to 170 at batch 1, 2 and 4. At
L = 1 the patch is a no-op and output is bit-identical.

On 4-bit weights the two forms reorder quantized matmuls, so a wide
step is not bit-identical to the other form. Measured teacher-forced
over 1024 positions against token-by-token prefill as the reference,
the stock wide chunk agrees on 98.54% of argmaxes and the patched wide
chunk on 98.34%. Every disagreement sits at a near-tie: top-2 margins
of 0.00 to 0.30 against a median margin of 0.664, on a logit scale of
23.4. Wide-step drift of this size is pre-existing, not introduced --
the stock wide chunk already diverges from sequential prefill at the
same rate and, in free-running greedy decode, at the same token.

Test suite: unmodified main gives 3131 passed and this branch gives 3137,
the difference being the six tests added here, with per-file results
otherwise identical. `test_smoke.py` fails to collect on this branch and on
unmodified main alike, which is unrelated to this change.
stooit added a commit to stooit/mlx-vlm that referenced this pull request Sep 3, 2026
The absorbed MLA path was gated on `L == 1`, so any step with more than
one query materialized K and V over the whole cached latent instead.
Materializing costs O(T) in the cached length and is independent of L,
while the absorbed path costs O(L), so the crossover is a fixed query
count per model:

    kv_lora_rank * (nope + v) / (2 * kv_lora_rank - nope - v)

That is 170 queries for DeepSeek V3 and 398 for GLM-4 MoE Lite, against
a gate that admitted exactly one. Every speculative-verify step and
every short continuation on a warm cache took the wrong branch.

`models/mla.py` gains `max_absorbed_queries()`, applied in the ten
models with an absorbed path: deepseek_v3, deepseek_v32, kimi_k3,
kimi_linear, longcat_flash, longcat_flash_sparse, glm4_moe_lite,
glm_moe_dsa, glm5_next and youtu_vl. The helper is byte-identical to the
one in ml-explore/mlx-lm#1817 so the two runtimes pick the same width.

This applies the same reasoning as ml-explore/mlx-lm#1817 to mlx-vlm,
and extends it to four models that PR does not cover:
longcat_flash_sparse, glm_moe_dsa, glm5_next and youtu_vl.

The sparse-indexer gate is deliberately left at `L == 1`. It selects
with `topk_indices[:, :, 0, :]`, the first query's top-k, which is
correct for a single query only. That is a correctness gate, not a
performance one.

Full forward pass, Moonlight-16B-A3B-Instruct-4-bit (DeepSeek V3
architecture, 27 layers), M3 Max, medians of 5:

| context | L=1 | L=2 | L=4 | L=8 | L=16 | L=32 |
|--------:|----:|----:|----:|----:|-----:|-----:|
| 32768 stock | 17.3 | 380.8 | 394.6 | 413.0 | 497.1 | 515.1 |
| 32768 patched | 17.3 | 22.1 | 28.7 | 47.8 | 85.2 | 161.0 |
| speedup | 1.00x | 17.3x | 13.8x | 8.6x | 5.8x | 3.2x |

At 16384 the same steps are 10.7x, 8.9x and 5.9x; at 4096, 4.2x, 3.4x
and 2.5x. L=1 is unchanged, as the gate already selected the absorbed
path there.

Materializing also allocates per-head K and V. Peak memory for one
attention call at 32768 context falls from 2478 MB to 462 MB at L=4,
and stays lower at every L up to the threshold (2742 MB against 3602 MB
at L=144).

Numerics. The two forms are the same linear algebra: in float32 they
agree to a relative 3.4e-06 across L = 1 to 170 at batch 1, 2 and 4. At
L = 1 the patch is a no-op and output is bit-identical.

On 4-bit weights the two forms reorder quantized matmuls, so a wide
step is not bit-identical to the other form. Measured teacher-forced
over 1024 positions against token-by-token prefill as the reference,
the stock wide chunk agrees on 98.54% of argmaxes and the patched wide
chunk on 98.34%. Every disagreement sits at a near-tie: top-2 margins
of 0.00 to 0.30 against a median margin of 0.664, on a logit scale of
23.4. Wide-step drift of this size is pre-existing, not introduced --
the stock wide chunk already diverges from sequential prefill at the
same rate and, in free-running greedy decode, at the same token.

Test suite: unmodified main gives 3131 passed and this branch gives 3137,
the difference being the six tests added here, with per-file results
otherwise identical. `test_smoke.py` fails to collect on this branch and on
unmodified main alike, which is unrelated to this change.
The absorbed MLA path was gated on `L == 1`, so any step with more than
one query materialized K and V across the whole cached latent instead.
Materializing costs O(T) in the cached length and does not depend on L,
while the absorbed path costs O(L), so on a warm cache the crossover is a
fixed query count per model, 170 for DeepSeek V3 and 398 for GLM-4 MoE
Lite, against a gate that admitted exactly one. Every speculative verify
step and every short continuation on a warm cache took the wrong branch.

`models/mla.py` gains `max_absorbed_queries()` and `latent_length()`,
applied in the seven models with an absorbed path: bailing_moe_v3,
deepseek_v3, deepseek_v32, glm4_moe_lite, kimi_k3, kimi_linear and
longcat_flash.

The threshold takes the cache length, so it is right at both ends. Per
head, with r = kv_lora_rank and d = qk_nope_head_dim + v_head_dim, the
absorbed path costs L*r*d + 2*L*T*r and materializing costs
T*r*d + L*T*d, giving

    L < r*d / (r*d/T + 2*r - d)

As T grows this tends to r*d / (2*r - d), the fixed count above. On a
cold cache, where T == L, it reduces to 2*r < d, which is false for every
model here, so the absorbed path is correctly rejected rather than
chosen. A prompt shorter than the prefill chunk size is exactly that
case, so it is reachable rather than theoretical.

Both gates are now driven by one boolean computed once per call. With two
independent comparisons a mechanical edit across seven files can widen
one and leave the other, and the shape of the code does nothing to stop
it; with one decision that failure cannot be expressed.

The cache length is read from the latent rather than from `offset`, and
`latent_length()` tolerates the quantized 3-tuple form, so the gate is
correct when the KV cache is quantized.

`deepseek_v32` keeps its sparse-indexer gate at `L == 1`. That branch
selects with `topk_indices[:, :, 0, :]`, the first query's top-k, which
is correct for a single query only.

`tests/test_absorbed_mla.py` covers the asymptotic and cold-cache limits,
the quantized latent form, that each model makes one gate decision used
by both gates, and that the indexer gate is untouched.
@stooit
stooit force-pushed the mla-absorbed-threshold branch from 76e2ac8 to 49b7ac7 Compare September 3, 2026 17:22
stooit added a commit to stooit/mlx-vlm that referenced this pull request Sep 3, 2026
The absorbed MLA path was gated on `L == 1`, so any step with more than
one query materialized K and V across the whole cached latent instead.
Materializing costs O(T) in the cached length and does not depend on L,
while the absorbed path costs O(L), so on a warm cache the crossover is a
fixed query count per model, 170 for DeepSeek V3 and 398 for GLM-4 MoE
Lite, against a gate that admitted exactly one.

`models/mla.py` gains `max_absorbed_queries()` and `latent_length()`,
applied in the ten models with an absorbed path: deepseek_v3,
deepseek_v32, kimi_k3, kimi_linear, longcat_flash, longcat_flash_sparse,
glm4_moe_lite, glm_moe_dsa, glm5_next and youtu_vl. The same change for
mlx-lm is ml-explore/mlx-lm#1817.

The threshold takes the cache length, so it is right at both ends. Per
head, with r = kv_lora_rank and d = nope + v, the absorbed path costs
L*r*d + 2*L*T*r and materializing costs T*r*d + L*T*d, giving

    L < r*d / (r*d/T + 2*r - d)

As T grows this tends to r*d / (2*r - d), the fixed count above. On a
cold cache, where T == L, it reduces to 2*r < d, false for all ten
models, so the absorbed path is correctly rejected. That case is
reachable: `generate/ar.py` chunks at min(prefill_step_size, N - 1), so
a prompt shorter than the chunk size is one step with T == L. Taking the
absorbed path there costs about 1.95x the attention FLOPs at L = T = 169,
which is 136.5 ms against 133.7 ms on a whole-model forward.

Both gates are now driven by one boolean computed once per call. With
two independent comparisons a mechanical edit across ten files can widen
one and leave the other, and the shape of the code does nothing to stop
it; with one decision that failure cannot be expressed.

The cache length comes from the latent rather than from `offset`, since
`offset` is not in scope at two of the ten sites, and `latent_length()`
tolerates the quantized 3-tuple form.

The sparse-indexer gate stays at `L == 1` in deepseek_v32,
longcat_flash_sparse, glm5_next and glm_moe_dsa. It selects with
`topk_indices[:, :, 0, :]`, the first query's top-k, which is correct for
a single query only.

Full forward pass, Moonlight-16B-A3B-Instruct-4-bit (DeepSeek V3
architecture, 27 layers), M3 Max, medians of 5, cache 32768:

| L | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
|--:|--:|--:|--:|--:|---:|---:|---:|
| stock | 17.4 | 385.4 | 394.3 | 419.3 | 489.1 | 512.9 | 546.4 |
| patched | 17.3 | 22.0 | 28.5 | 48.7 | 84.2 | 152.7 | 281.0 |
| speedup | 1.00x | 17.5x | 13.8x | 8.6x | 5.8x | 3.4x | 1.9x |

L = 1 is unchanged. Cold-cache prompts are unchanged as well, at 32, 64,
128, 169 and 400 tokens.

Peak memory for one attention call at 32768 context falls from 2478 MB to
462 MB at L = 4, and stays lower at every L up to the threshold.

Numerics: in float32 the two forms agree to a relative 3.4e-06 across
L = 1 to 170 at batch 1, 2 and 4, and at L = 1 output is bit-identical.
On 4-bit weights they reorder quantized matmuls, so a wide step is not
bit-identical to the other form; teacher-forced over 1024 positions
against token-by-token prefill, stock agrees on 98.54% of argmaxes and
patched on 98.34%, with every disagreement at a near-tie.

Tests: unmodified main gives 3131 passed, this branch 3140, the
difference being the nine tests added here, with per-file results
otherwise identical. The new tests run the real `__call__` and force each
branch, and were checked against four mutations: unpaired gates, a full
revert of one file, a widened indexer gate, and dropping the cache-length
term. All four fail the suite.
Lazarus-931 pushed a commit to Blaizzy/mlx-vlm that referenced this pull request Sep 3, 2026
The absorbed MLA path was gated on `L == 1`, so any step with more than
one query materialized K and V across the whole cached latent instead.
Materializing costs O(T) in the cached length and does not depend on L,
while the absorbed path costs O(L), so on a warm cache the crossover is a
fixed query count per model, 170 for DeepSeek V3 and 398 for GLM-4 MoE
Lite, against a gate that admitted exactly one.

`models/mla.py` gains `max_absorbed_queries()` and `latent_length()`,
applied in the ten models with an absorbed path: deepseek_v3,
deepseek_v32, kimi_k3, kimi_linear, longcat_flash, longcat_flash_sparse,
glm4_moe_lite, glm_moe_dsa, glm5_next and youtu_vl. The same change for
mlx-lm is ml-explore/mlx-lm#1817.

The threshold takes the cache length, so it is right at both ends. Per
head, with r = kv_lora_rank and d = nope + v, the absorbed path costs
L*r*d + 2*L*T*r and materializing costs T*r*d + L*T*d, giving

    L < r*d / (r*d/T + 2*r - d)

As T grows this tends to r*d / (2*r - d), the fixed count above. On a
cold cache, where T == L, it reduces to 2*r < d, false for all ten
models, so the absorbed path is correctly rejected. That case is
reachable: `generate/ar.py` chunks at min(prefill_step_size, N - 1), so
a prompt shorter than the chunk size is one step with T == L. Taking the
absorbed path there costs about 1.95x the attention FLOPs at L = T = 169,
which is 136.5 ms against 133.7 ms on a whole-model forward.

Both gates are now driven by one boolean computed once per call. With
two independent comparisons a mechanical edit across ten files can widen
one and leave the other, and the shape of the code does nothing to stop
it; with one decision that failure cannot be expressed.

The cache length comes from the latent rather than from `offset`, since
`offset` is not in scope at two of the ten sites, and `latent_length()`
tolerates the quantized 3-tuple form.

The sparse-indexer gate stays at `L == 1` in deepseek_v32,
longcat_flash_sparse, glm5_next and glm_moe_dsa. It selects with
`topk_indices[:, :, 0, :]`, the first query's top-k, which is correct for
a single query only.

Full forward pass, Moonlight-16B-A3B-Instruct-4-bit (DeepSeek V3
architecture, 27 layers), M3 Max, medians of 5, cache 32768:

| L | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
|--:|--:|--:|--:|--:|---:|---:|---:|
| stock | 17.4 | 385.4 | 394.3 | 419.3 | 489.1 | 512.9 | 546.4 |
| patched | 17.3 | 22.0 | 28.5 | 48.7 | 84.2 | 152.7 | 281.0 |
| speedup | 1.00x | 17.5x | 13.8x | 8.6x | 5.8x | 3.4x | 1.9x |

L = 1 is unchanged. Cold-cache prompts are unchanged as well, at 32, 64,
128, 169 and 400 tokens.

Peak memory for one attention call at 32768 context falls from 2478 MB to
462 MB at L = 4, and stays lower at every L up to the threshold.

Numerics: in float32 the two forms agree to a relative 3.4e-06 across
L = 1 to 170 at batch 1, 2 and 4, and at L = 1 output is bit-identical.
On 4-bit weights they reorder quantized matmuls, so a wide step is not
bit-identical to the other form; teacher-forced over 1024 positions
against token-by-token prefill, stock agrees on 98.54% of argmaxes and
patched on 98.34%, with every disagreement at a near-tie.

Tests: unmodified main gives 3131 passed, this branch 3140, the
difference being the nine tests added here, with per-file results
otherwise identical. The new tests run the real `__call__` and force each
branch, and were checked against four mutations: unpaired gates, a full
revert of one file, a widened indexer gate, and dropping the cache-length
term. All four fail the suite.
@pierre427

Copy link
Copy Markdown
Contributor

Worked on something similar recently, dropping a couple of tips for you:

consider exact integer arithmetic for the crossover calculation to avoid float-boundary rounding; our implementation also applies the same gate consistently across more MLA model families.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants