Conversation
This was referenced Sep 2, 2026
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
force-pushed
the
mla-absorbed-threshold
branch
from
September 3, 2026 17:22
76e2ac8 to
49b7ac7
Compare
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.
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. |
5 tasks
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.
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), withd = qk_nope_head_dim + v_head_dim. For a long cache that tends tor*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-modelruns 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
glm4_moe_litedeepseek_v3kimi_linearMarginal 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:
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.
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_modelscovers all seven touched architectures: 85 OK on both arms.test_generate30,test_gated_delta8,test_utils11,test_losses4 -- all OK on both.test_prompt_cache(4 errors) andtest_sample_utils(1 failure) are identical on both arms and reproduce on unmodifiedmain.tests/test_absorbed_mla.pyis 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_liteandkimi_linear.kimi_k3,longcat_flashandbailing_moe_v3share the same 512/128/128 head geometry and threshold of 170 but had no checkpoint small enough to run here.deepseek_v32is 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.