You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
If it broke, bisect between e391ae9c and main and fix the divergence, trying the candidates in the order listed above.
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.
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.
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.
--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).
Problem / Background
On GB10 (sm_121),
models/mlx/qwen3.5-4b-4bitserved withmodels/mlx/qwen3.5-4b-dflash, greedy (temperature 0) completions frommlxcel-serverdiverge from classic decode at every DFlash verify width measured: 2, 3, 4, 5, 6, 7, 8 and 16. Width 16 is the widthmainresolves 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 emitsfor attempt in range(self.max_attempts):where the width-4 arm emitsfor 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=0splits the speculative arms into two groups instead, 2 to 7 against 8 and 16, which is exactly theM * B < 8boundary betweenqmv_multirow_kernelandqmm_sm80_kernelinmlx/backend/cuda/quantized/quantized.cpp. That the grouping tracks the kernel dispatch boundary points at the same failure class the MetalMLXCEL_MTP_ALLOW_INEXACTgate exists for: a quantized projection dispatching to a different kernel atM = Kthan atM = 1and 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 sameqmv_kernelclassic 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 to57d291a..., which is exactly the hash it has with the multirow path enabled, while classic keeps2c76b0a.... 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 toTsingle-token steps when the chain-parity path is taken (MLXCEL_GDN_CHAIN_PARITY, anddocs/environment-variables.md:327says 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 atT > 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.mdand the per-arm completion text indocs/benchmark_results/data/draft-block-width-gb10-2026-09-20/*.jsonl. Both land with the pull request for issue #1797 and are not onmainyet.Current Behavior
DFlashTargetModel::exactness_allowsdefaults totrue(src/server/batch/dflash_target.rs:212), and neither theQwen35Modelimpl (src/server/batch/dflash_target.rs:251) nor theQwen35VLModelimpl (:257) overrides it, so the burst gate atsrc/server/batch/speculative_burst.rs:1670-1671always reads permissive for this pairing.Lfm2Model(:270),MuseGlimmerTextWrapper(:322) andLagunaWrapper(: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
e391ae9cand 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.shandrun_session.sh).e391ae9candmainand fix the divergence, trying the candidates in the order listed above.Qwen35Model::dflash_exactness_allows(block_size)mirroringLfm2Model::dflash_exactness_allows(src/models/lfm2_speculative.rs:256): build aProbeKeyfrom block size, hidden size and layer count, and callmtp_exactness_gate(key, || self.probe_block_chain_exactness(block_size)), reusing the probe already on the model atsrc/models/qwen3_5.rs:1571. Then overrideexactness_allowsin theQwen35ModelandQwen35VLModelimpls indflash_target.rsto call it. Do not route this throughQwen35Model::mtp_exactness_allows(src/models/qwen3_5.rs:1518): its first precondition ismetal_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, theqmv_wideretry, the decline log line and theMLXCEL_MTP_ALLOW_INEXACToverride, and it treats any non-Equalverdict (NotRunincluded) as a decline, so no new policy code is needed.tests/speculative_parity.rs:610already holds the#[ignore]-gatedgreedy_parity_dflash_qwen35_4b, which spawnsmlxcel-serverwith and without--draft-kind dflashat 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 underdocs/benchmark_results/for thee391ae9cre-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
docs/benchmark_results/states, per width, whether classic and speculative greedy text were byte-identical ate391ae9con 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 ate391ae9c, so the property never held as stated. Seedocs/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).MLXCEL_MTP_ALLOW_INEXACTunset, a GB10mlxcel-serverstart 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=1still engages the burst and logs the forfeit warning, so the existing escape hatch governs the override. Verified at width 16.greedy_parity_dflash_qwen35_4bcovers widths 2, 4, 8 and 16, asserts the server loggedSpeculative burst completedbefore comparing, and fails on currentmainon 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.sdpa_vectorpath, whichMLXCEL_SDPA_VECTOR_LARGE_D=0collapses 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=1engages the burst anyway, andMLXCEL_SDPA_VECTOR_LARGE_D=0buys byte-identity back rather than forfeiting it.Verification
--test-threads=1is 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).