Skip to content

fix(gemma4): make MTP verify byte-identical to decode on CUDA - #2185

Merged
inureyes merged 6 commits into
mainfrom
fix/issue-2160-gemma4-mtp-cuda
Oct 6, 2026
Merged

inureyes merged 6 commits into
mainfrom
fix/issue-2160-gemma4-mtp-cuda

Conversation

@inureyes

@inureyes inureyes commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

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_MULTIROW changed 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:

  • 12B attention gate. The per-row verify path in attend only matched the 31B geometry, so the 12B M=4 block left the single-query sdpa_vector kernel decode uses at head_dim 256. TextConfig::mtp_requires_linear_singleton now also covers the 12B geometry (16/8/1 heads, 256/512) on CUDA, and attend reads a flag computed from it at construction (attend_verify_rows, reusing attend_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.
  • 8-bit compiled GeGLU. Decode ran the compiled MLP graph, a multi-token block the eager activation, which rounds differently. On CUDA, inputs under 8 rows (the qmv window) now take the compiled graph.
  • RoPE kernel choice. MLX CUDA rotates B=1, L=1 with rope_single and 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)

  • Startup probe, default draws (8, 8, 8, 1056), both pairs, all four MULTIROW x LARGE_D cells: pass.
  • Chat, temperature 0, 256 tokens, 3 prompts per pair, default config: byte-identical, MTP engaged (probe passed, bursts ran).
  • speculative_parity --ignored (full suite, branch rebased on main 9c0ae2e9): 7 of 8 pass. The failure is b1_batched_baseline_probe, which also fails on origin/main 9c0ae2e9 without 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, and greedy_parity_dflash_qwen35_4b pass. Qwen 3.5 DFlash still declines with the fix(speculative): Qwen 3.5 DFlash verify diverges from classic decode #1935 reason, unchanged.
  • New tests: 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 warnings on both crates and cargo fmt --check are clean.
  • Throughput (3 interleaved rounds, classic-vs-classic null arm, prompt cache off, 200 tokens): 12B MTP +106% to +122% over classic (13.2 to 28 tok/s), 31B +80% to +91% (8.3 to 15.8 tok/s); null spread -5.1% to +6.2%; acceptance 0.63 and 0.64; identical output bytes across every arm of each pair. The CPU-quiet host gate was skipped because the shared CI runner stayed busy; details in the record.

Pre-existing: b1_batched_baseline_probe

This 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)

  • attend gate 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).
  • The history-boundary prefill split in the row-wise prefill, which Metal 31B serving also takes.
  • mtp_requires_linear_singleton now consults cuda_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

@inureyes inureyes added status:review Under review type:bug Bug fixes, error corrections, or issue resolutions priority:medium Medium priority area:inference Generation, sampling, decoding (incl. speculative, DRY) platform:linux Linux (CUDA / packaging) specific labels Oct 6, 2026
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
@inureyes inureyes added status:done Completed and removed status:review Under review labels Oct 6, 2026
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
inureyes force-pushed the fix/issue-2160-gemma4-mtp-cuda branch from a328fc7 to 2827893 Compare October 6, 2026 21:13
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
@inureyes
inureyes merged commit 97f35bc into main Oct 6, 2026
26 checks passed
@inureyes
inureyes deleted the fix/issue-2160-gemma4-mtp-cuda branch October 6, 2026 22:27
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:inference Generation, sampling, decoding (incl. speculative, DRY) platform:linux Linux (CUDA / packaging) specific priority:medium Medium priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: Gemma 4 MTP never engages on CUDA (GB10), verify probe fails

1 participant