Repository navigation
fix(gemma4): make MTP verify byte-identical to decode on CUDA - #2185
Merged
Merged
Conversation
inureyes
added a commit
that referenced
this pull request
Oct 6, 2026
Three interleaved rounds per pair with a classic-versus-classic null arm and the prompt cache off: 12B MTP +106% to +122% over classic, 31B +80% to +91%, null spread -5.1% to +6.2%, identical output bytes across every arm of a pair. The record explains why the prompt cache is off for this measurement and that the CPU-quiet host gate was skipped while the shared CI runner was busy. Adds the bilingual technical report for PR #2185. Refs #2160
10 tasks done
Gemma 4 MTP never engaged on CUDA: the startup block-vs-chain probe failed on the first 8-token draw for both the 12B and the 31B pair, so every pairing declined to classic decode. An attribution matrix over MLXCEL_QMV_MULTIROW and MLXCEL_SDPA_VECTOR_LARGE_D (plus MLXCEL_COMPILED_QGELU_MLP and finer probe captures) found three causes, none of them the multirow qmv kernel: - 12B attention: the per-row verify gates in Gemma4 attend were hard-coded to the 31B geometry, so the 12B M=4 block missed the single-query sdpa_vector kernel classic decode takes at head_dim 256, and the full-attention layers picked different M=4 vs M=1 reductions. The gate is now a flag computed at construction from TextConfig::mtp_requires_linear_singleton, which also returns true for the 12B geometry (16/8/1 heads, 256/512 dims) when CUDA is available. The 12B pair on CUDA therefore inherits the 31B restrictions (B=1 linear verify, rotating verify buffer, no tree rounds, no buffered snapshot donation). - 12B MLP (8-bit affine): single-token decode ran the compiled GeGLU graph while a multi-token block took the op-at-a-time activation, which rounds differently. On CUDA, blocks inside the qmv window (fewer than 8 rows) now take the compiled graph. - Both pairs: MLX's CUDA RoPE uses rope_single for B=1, L=1 and the general kernel otherwise, and the two differ in the last float bit. Row-wise verify on CUDA now rotates Q and K one row at a time through the same chain decode runs. Metal keeps the 31B-only predicate, the existing RoPE path, and the #680 MLP gate. With all three changes the probe passes every draw, including the 1,056-token buffered draw, on both pairs on GB10. Refs #2160
With the verify block byte-exact, default-config chat still diverged from classic decode on one of three 256-token prompts per pair on GB10. Classic serving prefills a chat prompt in two forwards when the prompt cache is on (capture_history_boundary_snapshot, #1143), while the MTP burst bypasses that prefill and forwarded the prompt in one piece, so the two built the prompt KV from different partitions. With --no-cache-prompt, and through /v1/completions, all prompts matched. The scheduler now reports the boundary it would split at (history_boundary_split, same gates as the capture) to the B=1 MTP slice and burst, and the row-wise Gemma 4 prefill forwards the segment as one chunk, then the suffix in prefill_chunk_size chunks from the boundary (mtp_prefill_ranges). Batched bursts pass no boundary. Non-row-wise Gemma 4 prefill is unchanged. Refs #2160
Adds the #2160 benchmark record with the before/after attribution matrix (QMV_MULTIROW x SDPA_VECTOR_LARGE_D, plus the compiled-GeGLU arm), the query-level capture that located the RoPE kernel residual, and the 256-token chat parity runs before the boundary fix, with the prompt cache off, and in the default configuration. The harness scripts used for the parity and throughput runs are included. The environment-variables entry for MLXCEL_MTP_ALLOW_INEXACT no longer says Gemma 4 MTP declines on GB10. Refs #2160
Three interleaved rounds per pair with a classic-versus-classic null arm and the prompt cache off: 12B MTP +106% to +122% over classic, 31B +80% to +91%, null spread -5.1% to +6.2%, identical output bytes across every arm of a pair. The record explains why the prompt cache is off for this measurement and that the CPU-quiet host gate was skipped while the shared CI runner was busy. Adds the bilingual technical report for PR #2185. Refs #2160
inureyes
force-pushed
the
fix/issue-2160-gemma4-mtp-cuda
branch
from
October 6, 2026 21:13
a328fc7 to
2827893
Compare
The ignored 31B diagnostic fails on origin/main without this change (row 1 at token 1). On this branch more rows differ because the B=1 linear reference now runs decode's kernels while the batched adapter, which serving never uses for these geometries, does not. Refs #2160
This was referenced Oct 6, 2026
inureyes
added a commit
that referenced
this pull request
Oct 7, 2026
The batched Gemma 4 MTP adapter never set `mtp_verify`, so its verify skipped the #2185 row-wise path and B=1 batched runs drifted from the B=1 linear adapter and classic decode on the 31B and the CUDA 12B. The batched adapter now marks verify forwards (prefill stays unmarked). On the row-wise geometries every row is prefilled on its own cache over the classic chunk partition and the caches are stacked with history first and a zero tail, so a short row is a row whose valid end lags the shared offset. A B>1 verify runs each row as its own [1, K] call through the B=1 helpers (Q/K/V, RoPE, attention, o_proj, MLP, LM head) and keeps only the cache write batched, cutting each row's stale gap out of its keys and using the ring cursor for the row's own length. A lagging row that lost sliding keys to shared-offset compaction is reported as an error instead of producing inexact tokens. Refs #2190
7 tasks done
inureyes
added a commit
that referenced
this pull request
Oct 7, 2026
The batched Gemma 4 MTP adapter never set `mtp_verify`, so its verify skipped the #2185 row-wise path and B=1 batched runs drifted from the B=1 linear adapter and classic decode on the 31B and the CUDA 12B. The batched adapter now marks verify forwards (prefill stays unmarked). On the row-wise geometries every row is prefilled on its own cache over the classic chunk partition and the caches are stacked with history first and a zero tail, so a short row is a row whose valid end lags the shared offset. A B>1 verify runs each row as its own [1, K] call through the B=1 helpers (Q/K/V, RoPE, attention, o_proj, MLP, LM head) and keeps only the cache write batched, cutting each row's stale gap out of its keys and using the ring cursor for the row's own length. A lagging row that lost sliding keys to shared-offset compaction is reported as an error instead of producing inexact tokens. Refs #2190
inureyes
added a commit
that referenced
this pull request
Oct 7, 2026
…ies (#2203) ## Summary The batched Gemma 4 MTP adapter never set `mtp_verify`, so on the row-wise geometries (31B everywhere, 12B on CUDA) it missed the #2185 decode-exact path: `b1_batched_baseline_probe` failed on main (31B rows 1, 2, 3 at tokens 1, 9, 17). Batched MTP is now byte-identical to per-sequence classic decode at B=1, 2 and 4, and on CUDA the 31B serves those windows because they measured 53 to 77% faster than classic batched decode. ## What changed - The batched adapter marks verify forwards only. On the row-wise geometries each row is prefilled on its own cache over the classic partition (chunks plus history boundary, last-row LM head) and the caches are stacked with history first and a zero tail, so a short or ragged row is a row lagging the shared offset; no left padding, no shifted RoPE frame. - A B>1 verify runs each row as its own `[1, K]` call (Q/K/V, row-wise RoPE at the row's offset, `attend_verify_rows` on keys physically cut to the row's history plus block, per-row ring cursor, `o_proj`, MLP, LM head). Only the cache write stays batched, so finalize, rollback and drafter slabs are unchanged. A lagging row that lost sliding keys to shared-offset compaction makes the verify refuse the round. - `run_mtp_burst_batched` replaces the unconditional row-wise decline with `RowWiseBatchedWindow`: CUDA only (Metal and ROCm builds keep declining), B <= 4, dense FP16 caches, prompts within the sliding window, and `max_prompt_len + K * (max_tokens + 1) <= window + 32`, so compaction cannot happen mid-burst. Windows carry per-row history boundaries. Still behind `MLXCEL_ENABLE_MTP_BATCH`. - In-process parity tests apply the server's MLX graph/SDPA cache sizes; the new test crossed MLX's lifetime-miss abort at the bare default. ## Test plan (GB10, CUDA, release) - [x] `speculative_parity --ignored --test-threads=1` (full, no env): 9 passed, 0 failed: `b1_batched_baseline_probe` (now 31B and 12B, all rows identical), `divergent_hidden_probe_31b`, `gemma4_batched::greedy_parity_mtp_gemma4_batched_matches_classic` (new: B=2/B=4, equal and ragged, both pairs, 24/24 rows identical to classic, divergent rounds in every case), `greedy_parity_dflash_qwen35_4b`, `greedy_parity_mtp_gemma4_31b`, `greedy_parity_mtp_gemma4_batched_b4_matches_b1`, `greedy_parity_mtp_gemma4_batched_b4_ragged_matches_b1`, `greedy_parity_mtp_gemma4_unified_12b`, `mtp_gemma4_ragged_throughput_probe`. Main: 7 of 8, probe failing. - [x] Lib `gemma4 speculative_burst`: 327 passed. New hermetic tests for verify marking (AC-2), per-row verify vs B=1 linear verify (fails under a shared-cursor mutation and with row mode disabled), cache stacking, the window gate. - [x] Clippy `-D warnings` (lib, tests), `cargo fmt --check`. - After rebasing onto `d54b209b` (#2201, #2205) the lib selectors (327 passed), the full suite (9 passed, 0 failed) and one served B=2 window (batched, bytes identical to classic) were rerun on the pushed head. - [x] Throughput, 3 interleaved rounds, null arm, identical bytes: 31B B=2 +75 to 77%, B=4 +53 to 55%, null -2.3 to +3.1%. Shipped binary round: batched windows engaged, bytes identical. See `docs/benchmark_results/gemma4-mtp-batched-gb10-2026-10-07.md`, which also records why default serving never forms a window (classic batched prefill, `--ignore-eos`, unseeded requests) and that the 12B serves single-slot. ## Not verified on this host Metal. Metal 31B is row-wise too; changes reaching it are listed on #2158 (verify marking, per-row prefill and stacking, per-row verify and LM head at B>1, `finish_layer` extraction). The window gate keeps Metal declined. Closes #2190
inureyes
added a commit
that referenced
this pull request
Oct 7, 2026
## Summary Qwen 3.5 DFlash never ran on CUDA because #1944 declined head_dim 256/288 while `MLXCEL_SDPA_VECTOR_LARGE_D` was on. The cause is MLX's CUDA RoPE kernel choice, the same one #2185 found for Gemma 4. MLX uses `rope_single` for a `B=1, L=1` input and the general `rope` kernel for a wider block, and the two sometimes round a bf16 element differently. The verify block's rotated K went into the KV cache, so the difference stayed there. - On CUDA a `B=1` verify block now rotates Q and K one row at a time at `offset + row`, the same call classic decode makes. The flag is read once at construction. Metal and `B>1` keep the block call. - `cuda_sdpa_vector_verify_hazard` and its decline are removed. In its place the CUDA probe prefills 160 tokens and adds a 128-token long draw that walks the blocks and the chain together. With the fix reverted it diverges at walk position 19; with the fix it returns `Equal`. - On CUDA, `greedy_parity_dflash_qwen35_4b` now requires bursts at widths 2 and 4, and it accepts a decline only when the probe measured a divergence. Attribution, in logit bytes over the recorded 200-token transcript (n=3 per cell, deterministic): with block RoPE, 67 or 129 of 200 rows differ under either `LARGE_D` setting. With per-row RoPE, 0 rows differ under both. A sub-op capture names post-RoPE Q or K as the first difference. `LARGE_D=0` moves the divergence instead of removing it, which corrects the #1944 attribution. Record: `docs/benchmark_results/qwen35-dflash-verify-rope-gb10-2026-10-07.md`. ## Test plan (GB10, CUDA, release) - [x] Served `/v1/completions` at temperature 0 with no env overrides, three prompts of 200 tokens. Widths 2 and 4 and the unset width (which resolves to 4) run bursts, and their output is byte-identical to classic. Widths 8 and 16 decline with a measured `Diverges`. - [x] Full `speculative_parity --ignored --test-threads=1`: 7 passed, 1 failed. The failure is `b1_batched_baseline_probe`, the known Gemma 4 failure that #2190 fixes. `greedy_parity_dflash_qwen35_4b` ran bursts at [2, 4] and declined [8, 16]. - [x] `long_probe_draw_sees_the_verify_rope_hazard_and_passes_with_the_fix` passed 3 of 3 runs. The op-level, capture and matrix diagnostics are in `qwen3_5_rope_attribution_tests.rs`. - [x] Throughput at width 4 (`price_rope.py`, which wraps `price_options.py`): 3 interleaved rounds with a classic null arm. DFlash paired deltas were +6.7% to +10.7%; the null arm spread was -0.6% to +3.7%. All arms produced the same completion. Round 2 skipped the CI-job gate because the shared runner stayed busy for more than two hours. - [x] Per-row RoPE cost per verify: within noise at width 2, +1.2% to +1.6% at width 4. - [x] Probe CUDA graph-cache cost: startup at width 4 fits a 200-entry cache. Walking from an 8-token prompt needed more than 400 entries. - [x] `cargo clippy -p mlxcel --lib --tests --features cuda -- -D warnings` and `cargo fmt --check` both pass. ## Not verified on this host Metal. Nothing to list for #2158: every change is gated on `cuda_is_available()`, and the decline that was removed only applied on CUDA. B>1 DFlash verify. Everything above ran with `--max-batch-size 1`. The per-row RoPE applies only to `b == 1`, so a batched verify still rotates the whole block at once. Exposure does not change: `run_dflash_burst_batched` never consulted `probe_block_chain_exactness` or the #1944 decline before this change, and still does not. Closes #2191
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.
Gemma 4 MTP now engages on CUDA. On GB10 the startup block-vs-chain probe passes for both the 12B and the 31B pair at
--draft-block-size 4, including the 1,056-token buffered draw, and default-config greedy chat output is byte-identical to classic decode. Before this PR both pairs declined at the first 8-token draw.Attribution
Full matrix, before and after, in
docs/benchmark_results/gemma4-mtp-cuda-gb10-2026-10-07.md.MLXCEL_QMV_MULTIROWchanged nothing in any of the cells, so the multirow qmv kernel is not a cause and step 3 (kernel fix or retry arm) was not needed. Three causes were found:attendonly matched the 31B geometry, so the 12B M=4 block left the single-querysdpa_vectorkernel decode uses at head_dim 256.TextConfig::mtp_requires_linear_singletonnow also covers the 12B geometry (16/8/1 heads, 256/512) on CUDA, andattendreads a flag computed from it at construction (attend_verify_rows, reusingattend_query_rows/attend_ring_rows). The 12B pair on CUDA inherits the 31B restrictions: B=1 linear verify, no tree rounds, no buffered snapshot donation. The decline reason is now geometry-neutral.B=1, L=1withrope_singleand wider inputs with the general kernel; they differ in the last float bit. Row-wise verify on CUDA rotates Q and K one row at a time through decode's exact chain.The issue's step 4 exit (NotRun decline) was not needed: no
sdpa_vector-only residual remained.Chat history boundary
After those fixes, default-config chat still diverged on one of three 256-token prompts per pair. Classic serving splits a chat prefill at the history boundary (#1143); the MTP burst bypassed that and prefilled in one piece. With
--no-cache-prompt, and via/v1/completions, all prompts matched. The scheduler now passes the boundary (history_boundary_split) to the B=1 MTP slice and burst, and the row-wise prefill splits there (mtp_prefill_ranges). After that, 3 of 3 match on both pairs.Verification (GB10, release,
--features cuda)MULTIROWxLARGE_Dcells: pass.speculative_parity --ignored(full suite, branch rebased on main9c0ae2e9): 7 of 8 pass. The failure isb1_batched_baseline_probe, which also fails onorigin/main9c0ae2e9without this PR (row 1 at token 1, reproduced twice); see below. Passing:greedy_parity_mtp_gemma4_31b,greedy_parity_mtp_gemma4_unified_12b(the issue's 12B parity test; the pairing existed, its draft dir is fixed to the lowercase name so it resolves on Linux), both batched Gemma 4 tests, andgreedy_parity_dflash_qwen35_4bpass. Qwen 3.5 DFlash still declines with the fix(speculative): Qwen 3.5 DFlash verify diverges from classic decode #1935 reason, unchanged.gemma4_12b_sliding_verify_rows_match_decode_byte_for_byte,linear_singleton_geometry_is_31b_everywhere_and_12b_on_cuda,qmv_multirow_matches_per_row_qmv_bitwise_gemma4_12b_mlp_shapes,compiled_qgelu_mlp_small_block_rows_match_single_token_bitwise,mtp_prefill_ranges_mirror_classic_history_boundary_split. The gemma4 lib suite (250), scheduler burst/slice/prompt-cache selectors (127) and the core qmv/qgelu tests pass; clippy-D warningson both crates andcargo fmt --checkare clean.Pre-existing:
b1_batched_baseline_probeThis 31B diagnostic compares the batched adapter run at B=1 against the B=1 linear adapter. On main row 1 differs; on this branch rows 1, 2 and 3 differ (tokens 1, 9, 17). The B=1 linear adapter is now decode-exact (probe, chat parity, the parity tests); the batched adapter never takes the row-wise verify path and serving never uses it for these geometries (B>1 is declined for linear-singleton targets). So the wider mismatch is the reference moving onto decode's kernels. Making the batched adapter row-wise is outside #2160; noted in the benchmark record.
Shared code that reaches Metal (for #2158)
attendgate refactor: same 31B behavior, plus a new per-row fallback for a sliding layer with no ring cursor while keys fit the window (unreachable in serving and the probe, which always enable the buffer).mtp_requires_linear_singletonnow consultscuda_is_available(); the Metal answer is unchanged.The 12B predicate arm, the RoPE change and the compiled-GeGLU gate are CUDA-only.
Not verified on this host
Metal behavior (no Apple hardware here). No local Qwen 3.8 MTP drafter, so that pairing is unmeasured. The first bonus token still comes from an M=prompt LM-head projection in MTP prefill against classic's last-position projection; it matched in every run but is not covered by construction.
Closes #2160