Skip to content

fix(speculative): Qwen 3.5 DFlash verify diverges from classic decode #1935

Description

@inureyes

Problem / Background

On GB10 (sm_121), models/mlx/qwen3.5-4b-4bit served with models/mlx/qwen3.5-4b-dflash, greedy (temperature 0) completions from mlxcel-server diverge from classic decode at every DFlash verify width measured: 2, 3, 4, 5, 6, 7, 8 and 16. Width 16 is the width main resolves today, so this is the status quo and not a regression introduced by #1797. The two classic arms of the same session, measured from two separate server processes at the start and the end, are byte-identical to each other, so greedy decode itself is reproducible and the divergence is specific to the speculative path. A typical divergence, about 30 tokens in: the classic arm emits for attempt in range(self.max_attempts): where the width-4 arm emits for i in range(self.max_attempts):.

Grouping the arms by the sha256 of their completion text, at MLX's default MLX_ENABLE_TF32=1, gives exactly two groups: the two classic arms, and every speculative width from 2 to 16. All ten speculative arms are byte-identical to each other and none matches classic. So this is not width-specific, not a rollback or first-bonus accident, and not non-determinism: it is the speculative verify path disagreeing with the single-token decode chain, uniformly.

Forcing MLX_ENABLE_TF32=0 splits the speculative arms into two groups instead, 2 to 7 against 8 and 16, which is exactly the M * B < 8 boundary between qmv_multirow_kernel and qmm_sm80_kernel in mlx/backend/cuda/quantized/quantized.cpp. That the grouping tracks the kernel dispatch boundary points at the same failure class the Metal MLXCEL_MTP_ALLOW_INEXACT gate exists for: a quantized projection dispatching to a different kernel at M = K than at M = 1 and not being bit-equal to it. On the CUDA DFlash path that class is unguarded.

A control rules the obvious candidate out. MLXCEL_QMV_MULTIROW=0 (the #725 kill switch) restores the stock per-row path, so a 4-row verify then runs the same qmv_kernel classic decode runs, launched four times instead of once. If the multirow kernel were the cause, width 4 would become byte-identical to classic under it. It does not. Width 4's completion under the kill switch hashes to 57d291a..., which is exactly the hash it has with the multirow path enabled, while classic keeps 2c76b0a.... The multirow kernel makes no difference to the result at all, so it is not what the verify and the chain disagree about, and the grouping by kernel family that the forced-TF32-off pass showed is not the mechanism it looked like.

That leaves the difference somewhere else in the verify path rather than in the quantized projection. The candidates this record cannot separate, in the order a reader should try them: the 24 gated-delta linear-attention layers, where a T-row chunked scan is only bit-equal to T single-token steps when the chain-parity path is taken (MLXCEL_GDN_CHAIN_PARITY, and docs/environment-variables.md:327 says its CUDA arm covers an unmasked scalar-gated block of at most 32 rows, which this shape should satisfy but was not verified here); the materialized attention mask the eight full-attention layers use at T > 1; and the first-bonus and rollback handling around the round loop. Separating them needs a per-layer comparison, not a throughput sweep.

Evidence: docs/benchmark_results/draft-block-width-default-gb10-2026-09-20.md and the per-arm completion text in docs/benchmark_results/data/draft-block-width-gb10-2026-09-20/*.jsonl. Both land with the pull request for issue #1797 and are not on main yet.

Current Behavior

DFlashTargetModel::exactness_allows defaults to true (src/server/batch/dflash_target.rs:212), and neither the Qwen35Model impl (src/server/batch/dflash_target.rs:251) nor the Qwen35VLModel impl (:257) overrides it, so the burst gate at src/server/batch/speculative_burst.rs:1670-1671 always reads permissive for this pairing. Lfm2Model (:270), MuseGlimmerTextWrapper (:322) and LagunaWrapper (:354) do override it and run a measured block-versus-chain probe; Qwen 3.5 on the DFlash path is permissive. So the property #1782 reported for this pairing is asserted by measurement, not enforced by a gate, and nothing re-checks it.

PR #1795's own record states byte-identity at widths 2, 4 and 8 on this pairing on 2026-09-11 (docs/benchmark_results/dflash-verify-fixed-cost-gb10-2026-09-11.md), and marks only width 16 as divergent. If that was right then, the property has been lost since. If it was measured loosely then, the record overstates a contract users may be relying on. Either way an operator turning on speculative decoding today gets different greedy text than without it, silently.

Proposed Solution

  1. Determine whether the property held at PR fix(cuda): attribute and remove the fixed cost of multi-row speculative verify #1795's merge commit e391ae9c and broke since, or never held as stated, by re-running the same comparison at that commit on the same GB10 host, with the harness that lands with the perf(speculative): the default draft block width of 16 loses on GB10 #1797 PR (docs/benchmark_results/data/draft-block-width-gb10-2026-09-20/harness/identity.sh and run_session.sh).
  2. If it broke, bisect between e391ae9c and main and fix the divergence, trying the candidates in the order listed above.
  3. Either way, enforce the contract instead of asserting it. Add Qwen35Model::dflash_exactness_allows(block_size) mirroring Lfm2Model::dflash_exactness_allows (src/models/lfm2_speculative.rs:256): build a ProbeKey from block size, hidden size and layer count, and call mtp_exactness_gate(key, || self.probe_block_chain_exactness(block_size)), reusing the probe already on the model at src/models/qwen3_5.rs:1571. Then override exactness_allows in the Qwen35Model and Qwen35VLModel impls in dflash_target.rs to call it. Do not route this through Qwen35Model::mtp_exactness_allows (src/models/qwen3_5.rs:1518): its first precondition is metal_is_available(), so on CUDA it would decline every DFlash burst instead of measuring one. mtp_exactness_gate (src/models/speculative_exactness.rs:295) already owns the per-(model, width) memoization, the qmv_wide retry, the decline log line and the MLXCEL_MTP_ALLOW_INEXACT override, and it treats any non-Equal verdict (NotRun included) as a decline, so no new policy code is needed.
  4. Extend the served-path regression test rather than adding a parallel copy: tests/speculative_parity.rs:610 already holds the #[ignore]-gated greedy_parity_dflash_qwen35_4b, which spawns mlxcel-server with and without --draft-kind dflash at temperature 0 on this exact pairing. Give it the widths this record measured (2, 4, 8, 16), and record in the PR why it did not surface this divergence. A synthetic probe passing is not the same as the served path agreeing (the VLM server-path trap).

Scope

In scope: src/models/qwen3_5.rs (DFlash-facing exactness entry point), src/server/batch/dflash_target.rs (the two Qwen 3.5 impl overrides), tests/speculative_parity.rs (widths and assertions), and a new record under docs/benchmark_results/ for the e391ae9c re-run.

Out of scope: changing the default draft block width (issue #1797), and any change to the Metal MTP gate or to the LFM2, Muse Glimmer and Laguna probes.

Acceptance Criteria

  • A record under docs/benchmark_results/ states, per width, whether classic and speculative greedy text were byte-identical at e391ae9c on GB10, and therefore whether this broke after PR fix(cuda): attribute and remove the fixed cost of multi-row speculative verify #1795 or never held as stated. Answered by code archaeology rather than a rebuild, and more conclusively: the defective prefill path was already in place at e391ae9c, so the property never held as stated. See docs/benchmark_results/dflash-verify-divergence-qwen35-gb10-2026-09-20.md (PR fix(speculative): prefill the Qwen 3.5 DFlash burst like classic decode #1939).
  • With the gate in place and MLXCEL_MTP_ALLOW_INEXACT unset, a GB10 mlxcel-server start on this pairing logs the probe verdict, and on a divergent verdict declines the burst to classic decode rather than serving speculative text. Verified: widths 8 and 16 decline and return text byte-identical to the drafter-less baseline.
  • MLXCEL_MTP_ALLOW_INEXACT=1 still engages the burst and logs the forfeit warning, so the existing escape hatch governs the override. Verified at width 16.
  • greedy_parity_dflash_qwen35_4b covers widths 2, 4, 8 and 16, asserts the server logged Speculative burst completed before comparing, and fails on current main on GB10 at every one of those widths. It now requires each width to either run a burst and match the baseline byte for byte or be declined by the gate, and names which happened rather than letting a decline read as a pass.
  • After the fix that test passes on GB10 with byte-identical completions at every covered width, or the gate declines the burst and the test records the decline as a decline rather than as a pass. Met by the second branch. The residual at widths 2 and 4 is genuine numerics in CUDA's fused sdpa_vector path, which MLXCEL_SDPA_VECTOR_LARGE_D=0 collapses to byte-identity in two independent arms, and the probe could not observe it because it compares one block immediately after a clean prefill while the difference does not begin until several dozen tokens past the prompt (no prompt length from 8 to 512 changes that verdict). The probe now declines on that configuration instead of reporting a pass it cannot support, so every width declines and every response equals the drafter-less baseline byte for byte. MLXCEL_MTP_ALLOW_INEXACT=1 engages the burst anyway, and MLXCEL_SDPA_VECTOR_LARGE_D=0 buys byte-identity back rather than forfeiting it.

Verification

cargo test --test speculative_parity --release -- --ignored greedy_parity_dflash_qwen35_4b --test-threads=1 --nocapture
docs/benchmark_results/data/draft-block-width-gb10-2026-09-20/harness/identity.sh

--test-threads=1 is required on CUDA. A pass is: the server log shows a completed speculative burst, and the classic and speculative completion sha256s match at every covered width, or the probe declines with its verdict logged and no speculative text is served.

Technical Considerations

Related: issue #1797 (draft block width default; its pull request brings the record and raw data cited above), PR #1795 (merge commit e391ae9c), issue #1782 (the chain-parity CUDA arm) and issue #725 (MLXCEL_QMV_MULTIROW).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:inferenceGeneration, sampling, decoding (incl. speculative, DRY)platform:linuxLinux (CUDA / packaging) specificpriority:highHigh prioritystatus:doneCompletedtype:bugBug fixes, error corrections, or issue resolutions

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions