Skip to content

mla: use the absorbed path for multi-token steps - #2150

Merged
Lazarus-931 merged 1 commit into
Blaizzy:mainfrom
stooit:absorbed-mla-multi-token
Sep 3, 2026
Merged

Lazarus-931 merged 1 commit into
Blaizzy:mainfrom
stooit:absorbed-mla-multi-token

Conversation

@stooit

@stooit stooit commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The absorbed MLA path is gated on L == 1. Any step with more than one query materialises K and V across the whole cached latent instead, and materialising costs O(T) in the cached length regardless of how many queries you have, while the absorbed path costs O(L). The crossover is therefore a fixed query count per model:

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

For DeepSeek V3 that is 170 queries and for GLM-4 MoE Lite it is 398, against a gate that admits exactly one. So every speculative verify step, and every short continuation on a warm cache, has been taking the expensive branch.

This adds max_absorbed_queries() to models/mla.py and applies it 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, including the attribute name, so the two runtimes pick the same width for a shared model.

It is the same reasoning as my mlx-lm PR ml-explore/mlx-lm#1817, extended here to four models that do not exist in mlx-lm.

Multi-turn chat, which needs no drafter

DEFAULT_PREFILL_STEP_SIZE is 2048, so a long prompt chunks above the crossover and is unaffected. A follow-up turn on a warm cache is not chunked though, it is one step of however many tokens the turn holds, and that is where most people will see this. Moonlight-16B-A3B-Instruct-4-bit at 32K context, full forward pass, M3 Max, medians of 5:

follow-up turn stock patched speedup
16 tokens 497 ms 85 ms 5.8x
32 tokens 515 ms 161 ms 3.2x
64 tokens 543 ms 271 ms 2.0x

Speculative verify steps

Same model and setup, at the draft lengths the in-tree MTP drafters use:

context L=1 L=2 L=4 L=8
32768 stock 17.3 ms 380.8 ms 394.6 ms 413.0 ms
32768 patched 17.3 ms 22.1 ms 28.7 ms 47.8 ms
speedup 1.00x 17.3x 13.8x 8.6x

At 16384 the same three steps are 10.7x, 8.9x and 5.9x, and at 4096 they are 4.2x, 3.4x and 2.5x. L=1 is unchanged because the gate already picked the absorbed path there, which makes that row a control on the rest of the table.

The stock row is nearly flat across L because the cost is dominated by materialising K and V over the cache, which does not depend on L at all, so one extra token costs 21x more than the first one does at 32K.

Memory

Materialising also expands the latent into per-head K and V. Peak memory for a single attention call at 32768 context drops from 2478 MB to 462 MB at L=4, and the absorbed path stays lower at every L up to the threshold (2742 MB against 3602 MB at L=144).

The sparse indexer gate is left alone

deepseek_v32, longcat_flash_sparse, glm5_next and glm_moe_dsa have a second L == 1 gate on the sparse top-k gather. That one selects with topk_indices[:, :, 0, :], the first query's top-k, which is only correct for a single query, so widening it would silently apply one query's selection to all of them. It stays at L == 1 and there is a test asserting it stays there.

Numerics

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

I want to be clear about the quantised case. On 4-bit weights the two forms reorder quantised matmuls, so a wide step is not bit-identical to the other form. Teacher-forced over 1024 positions with 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, with top-2 margins between 0.00 and 0.30 against a median margin of 0.664, on a logit scale of
23.4. Drift of this size is already present without the patch, because the stock
wide chunk diverges from sequential prefill at the same rate, and in free-running greedy decode at the same token.

Testing

Unmodified main gives 3131 passed and this branch gives 3137 passed, the difference being the six tests added here, with per-file results otherwise identical, so the change moves no existing test either way. test_smoke.py fails to collect on this branch and on unmodified main alike, which is unrelated to this change.

mlx_vlm/tests/test_absorbed_mla.py covers the threshold formula, wide-step parity against the materialised form, and the preserved indexer gate.

The glm5_next hunk overlaps #2127

glm5_next is one of the ten models changed here, and #2127 adds that file at full length, so this is the one place the two PRs touch the same lines. That overlap is what superseded my earlier #2141, which only changed glm5_next. I am happy to wait on #2127 and re-apply this hunk afterwards, or to drop glm5_next from this PR entirely if that is easier to review, since the other nine models stand on their own either way. Let me know which you prefer.

What I did not measure

Moonlight-16B is the only one of the ten models I have local weights for, so the end-to-end numbers above are all from deepseek_v3 and the other nine are covered by the unit tests and by the block being identical. I also have no cached model that pairs a dense-MLA target with a working MTP drafter, so the speculative numbers are per-step costs rather than a full generation loop with measured acceptance.

@stooit
stooit force-pushed the absorbed-mla-multi-token branch 4 times, most recently from 73807c3 to 8831903 Compare September 3, 2026 08:50
@stooit
stooit force-pushed the absorbed-mla-multi-token branch from 8831903 to 3bb364d Compare September 3, 2026 16:32
Blaizzy

This comment was marked as low quality.

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.
@stooit
stooit force-pushed the absorbed-mla-multi-token branch from 3bb364d to 64ff26c Compare September 3, 2026 17:22
@stooit

stooit commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Everything is addressed and pushed; replies are inline on each thread. Summary:

Cold-cache regression. Reproduced it before changing anything, 1.28x to 1.95x on the attention op at T == L, tracking your FLOP prediction. max_absorbed_queries now takes the cache length and uses the exact condition, falling back to the T -> inf value when omitted so it stays consistent with ml-explore/mlx-lm#1817. At whole-model level the regression measured about 2%. The same fix is now on the mlx-lm PR, which had the identical bug.

Gate wiring. Both guards now read one boolean computed once per call, so the mismatched-pair failure you described cannot be expressed rather than merely being tested for.

Tests. They run the real __call__ and force each branch, glm_moe_dsa is covered, the indexer test matches the gather rather than a substring, and the redundant triple is gone. I mutation-tested the suite against four breakages, including the one you called out, and all four fail it.

CI. Fixed, and it was isort as you said.

glm5_next. I have kept it in, because TestGatePairing now covers it and asserts one decision with two uses, so if #2127 lands afterwards and takes its side of that file, the test fails rather than the hunk vanishing quietly. Happy to drop it if you would still rather.

Two corrections. Your absorbed FLOP figure is 51.4M rather than 51.9M, which leaves the 1.75x ratio intact, and the min(prefill_step_size, N - 1) statement is at ar.py:486.

One thing I could not confirm: I verified the first half of your quantized-KV note, the materialized branch does fail on a quantized latent with TypeError: unsupported operand type(s) for @: tuple and mlx.core.array. I could not verify that the absorbed branch handles it, because my harness failed on both branches, so I have not claimed this PR fixes that.

Test suite: unmodified main 3131 passed, this branch 3140, the difference being the nine tests added here, per-file otherwise identical.

@Blaizzy Blaizzy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks!

@Lazarus-931
Lazarus-931 merged commit 5c9b5f5 into Blaizzy:main Sep 3, 2026
3 checks passed
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.

3 participants