diag(MODEL-MM-QWEN4-EXP): the MoE expert selections DIFFER, and a third of the boundaries are exact bf16 ties (#2552) - #2556
Merged
Conversation
…ap the SELECTION Wave PREFILLDIV left a second, independent source of CPU/CUDA prefill divergence at decoder layer 0: with the Gated DeltaNet source removed the MoE block still turns a 2.1e-05 input difference into a 7.269e-05 output difference. Nothing measured so far can say whether that is an expert-selection flip or re-association inside the expert GEMM, because `VT_Q4EXP_LAYER_FP` taps values and a discrete selection has bimodal error rather than a tolerance. This is the spec for the instrument that can tell them apart: a selected-expert-id tap that prints the sorted top-k set per token, an FNV-1a hash of it so set equality between two arms is one string comparison per layer, and the boundary margin in bf16 ULPS rather than in probability space, because the softmax denominator is itself a device-order reduction while the bf16 logits are what the selection is a function of. Refs #2552, #2547. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
…e tap cannot see a routing flip Wave PREFILLDIV left a second source of CPU/CUDA prefill divergence at decoder layer 0 and could not classify it: the MoE block turns a 2.1e-05 input difference into a 7.269e-05 output difference with the Gated DeltaNet source removed, and a `rel(sum|x|)` cannot say whether an expert-selection flipped or the expert GEMM re-associated. A discrete selection has bimodal error rather than a tolerance, so averaging the two into one number destroys exactly the bit that decides. `VT_MOE_SEL_FP=<calls>` prints the selection instead, on the reference arm of the shared sparse-MoE block, which is the arm a stacked keep-quant checkpoint takes. Per token: the selected ids SORTED, since the assertion between two arms is set equality and `vt::MoeCombine` is order-invariant; the smallest selected and the largest rejected router logit with their raw bf16 bit patterns; and the boundary margin counted in bf16 ULPS under the sign-magnitude total order. Probability space would have been the wrong space, because the softmax denominator is itself a device-order f32 reduction while the bf16 logits are what the selection is a function of, and a probability gap reads as "small" for one representable step and for fifty alike. Per call: an FNV-1a hash of every token's sorted list, so comparing two arms at one layer is one string comparison, plus four `sum|x|` axes that decompose the block's own output. The tap reads buffers the reference path had already downloaded, so it moves no extra bytes except one guarded shared-expert readback, and it is inert with the switch unset. The three fused CUDA arms keep their ids on device and are not tapped: no `qwen4_exp` checkpoint reaches them, and a readback added to a capturable path to instrument a model that cannot enter it would be dead code. Gated in `test_qwen4_exp_moe` against `MoeReference`, that suite's independent double-precision reimplementation of the lane-pinned oracle, with the case asserting its own capture through a sentinel so a broken redirect cannot read as "the tap printed nothing". A second ctest entry re-runs the same binary with the switch set, because the budget is a read-once static and the default run can only ever observe it unset; that entry is deliberately unfiltered, since a `-tc` pattern matching nothing exits 0 having run nothing. Refs #2552, #2547. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
…oundaries are exact ties The tap ran on `thor:gpu0` over the released UD-IQ1_S artifact, three arms on one binary. At `E = 512` and `top_k = 10` the router's top-k boundary is an EXACT bf16 tie at 79 of 240 prefill token-slots on the CPU control, 32.9%, and inside one representable bf16 step at 55.8%. At a tie the selection carries no information: the lowest-index tie-break decides it, and any perturbation upstream reshuffles it. The flip rate is the tie rate, 75 of 240 slots on the `VT_GDN_CHUNKED=0` arm against 79 tied. #2552's own headline number is NOT a flip, which is the half the issue got wrong. At layer 0 with the Gated DeltaNet source removed, the arm its table was taken on, both arms select the same experts for all five tokens. The `7.269e-05` residue decomposes onto the expert GEMM instead: `x` 2.139e-05 in, `logit` 2.378e-05 (the router GEMM does not amplify), `exp` 1.421e-04 at 6.6x, `shr` 4.310e-05. It is not a defect. vLLM routes this model on bf16 logits at 512 experts too: `Qwen4ExpSparseMoeBlock` inherits Qwen3Next's plain `ReplicatedLinear` gate with no `params_dtype`, the runner applies it as a plain `F.linear`, and the f32 widening happens inside `topk_softmax` — our polarity, our widening point, our lowest-index tie-break. The expert GEMM's arm-to-arm difference is the re-association `cuda_quant_dot.cu` already documents, and llama.cpp splits its own CPU and CUDA arms the same way. Read at a forward reference 1566 commits past the pin, which carries no `qwen4_exp` at all, and recorded as a source read rather than a measurement: no vLLM process was run on this checkpoint. Two independent instruments agree bit for bit on the MoE block's input. This tap's `x` axis reads the tensor `VT_Q4EXP_LAYER_FP` calls `L00 mhc.mix`, from a different tree, and all three arms reproduce PREFILLDIV's values exactly. The decode half is VOID and is published only to say so. On `origin/main` `a99b9c69a` the CUDA arm answers `11751 271 271 271 271 271 0 0`, not the fluent sequence PREFILLDIV recorded, because that wave measured a tree carrying #2550's decode fix and #2550 has not landed. From step 1 the arms run different token sequences, so the 336-of-336 decode flip count is a different-input artifact. `docs/USAGE.md` is deliberately untouched: its checkpoint row still claims no `qwen4_exp` step reaches a CUDA queue, which is false, and #2554 already carries that correction. Refs #2547. Refs #2550. Closes #2552. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
…recting what the merge falsified `main` moved from `a99b9c69a` to `bb78d1ee8` while this wave measured, and one of the commits it brought is #2550 — the decode fix this wave's evidence file named as NOT LANDED. That sentence was true of the tree three arms were measured on and false of the merge result, which is the shape where a merge falsifies a branch's own prose. Both the spec Outcome and the evidence file are corrected in this commit rather than left to read as a claim about `main` today: the decode numbers stay VOID because the arms ran different token sequences on the measured tree, the prefill result is untouched because step 0 consumes the prompt and not a sampled token, and a post-#2550 decode comparison is named as the cheap next measurement instead of being claimed. The spec conflict is a take-BOTH, not an automatic three-way merge: `main`'s DECODEDIV section and this branch's MOEDIV section were added at the same anchor above `## Now`, so the landed one is placed first and the new one after it, and both are whole. `src/vllm/model_executor/models/qwen3_5.cpp`, `tests/CMakeLists.txt` and `scripts/env-doc-allowlist.txt` auto-merged; the tap's definition, its one production call site, its ctest entry and its allowlist row were each re-verified present afterwards, because a clean auto-merge is not evidence that a scoped edit survived. Rebuilt and re-run on the merge result, read literally: build rc=0; `test_qwen4_exp_moe` rc=0 with `assertions: 121 | 121 passed | 0 failed` switch unset and rc=0 with `172 | 172 passed | 0 failed` switch set. Refs #2547. Refs #2550. Refs #2552. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
…rather than only the merged hunks `main` moved to `35116605b`. Everything it brought is ROCm — the expert-lane guard, its spec, its reachability test and one `model_loader.cpp` change — and it auto-merged with no conflict. A clean auto-merge is exactly the case that has falsified prose on this row before, so the claims were re-checked rather than assumed: nothing merged touches the sparse-MoE seam, the router, the keep-quant grouped GEMM or `qwen4_exp`, and no sentence in this branch's spec section or evidence file describes a file the merge moved. The scoped edits were re-verified present by count instead of by eye, because a conflict-free merge is not evidence that they survived: the tap's definition and its one production call site (2), the switch read exactly once (1), the allowlist row (1), the ctest entry (2), and both spec sections whole (1 each, `## DECODEDIV` and `## Wave MOEDIV`), with zero conflict markers anywhere in the tree. Rebuilt and re-run on the merge result, read literally: build rc=0; `test_qwen4_exp_moe` rc=0 at `assertions: 121 | 121 passed | 0 failed` with the switch unset and rc=0 at `172 | 172 passed | 0 failed` with it set. Refs #2547. Refs #2552. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
… this wave's spec section onto the target `build-test-cpu` was red on #2556 and the failure was not this wave's: `test_qwen4_exp_layer_loop`, the rank-5 fixture break `main` was already carrying after #2541 bounded `dense_attn::MakeTensor` at `kMaxRank = 4` and #2538 and #2550 landed rank-5 paged-KV fixtures on top of it. Neither conflicted textually with the bound, so both merged clean and both were red, and every `ci` run on `main` is cancelled by the next push, so no verdict was ever rendered. #2554 repaired it as #2559. This branch pre-dated that repair; merging `main` is what clears the red, and it does: `test_qwen4_exp_layer_loop` rc=0 at `assertions: 426 | 426 passed | 0 failed`. The spec conflicted exactly where this wave predicted it would. #2554 added `## Wave PREFILLDIV` at the same anchor above `## Now` that this branch adds `## Wave MOEDIV` to. It is resolved on the keyed-record rule rather than by accepting a three-way merge: the complete target-branch file was taken and this wave's one contiguous block re-applied to it, and the result is proven numerically rather than eyeballed. Against the target, 0 lines removed and exactly 167 added, which is the block's own line count; deleting precisely those 167 lines reproduces the target BYTE FOR BYTE (`dd1d7ef61cedc14e61fa01f92e01a96797c4599a3e0c53d0ad9ad6396be68d3c` either side); and the re-applied block is byte-identical to the one this branch already carried. All three sibling sections survive whole and in landing order: `## DECODEDIV` (#2496), `## Wave PREFILLDIV` (#2547), `## Wave MOEDIV` (#2552). The conflict-free hunks were re-checked too, because a clean merge has falsified this tree's prose repeatedly. `scripts/env-doc-allowlist.txt` carries BOTH keys rather than one clobbering the other (`VT_MOE_SEL_FP` and #2554's `VT_Q4EXP_LAYER_FP`); the tap's definition and its one production call site (2), its switch read once (1) and its ctest entry (2) all survive; and #2554's `docs/USAGE.md` correction — that `--device cuda` NOW SERVES this artifact — is still on the merged tree rather than reverted by a branch that deliberately never touched that file. This wave's result is unchanged by the merge and is not re-derived here: it was measured on `a99b9c69a` and nothing merged since touches the sparse-MoE seam, the router or the keep-quant grouped GEMM. Read literally on the merge result: CMAKE rc=0, BUILD rc=0, `test_qwen4_exp_layer_loop` rc=0 (426/426), `test_qwen4_exp_moe` rc=0 at `121 | 121 passed` switch unset and rc=0 at `172 | 172 passed` switch set. Refs #2547. Refs #2559. Refs #2552. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5-1m [claude-code]
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.
The selections DIFFER, and it is NOT a defect. At
num_experts = 512andnum_experts_per_tok = 10the router's top-k boundary is an EXACT bf16 tie at79 of 240 prefill token-slots (32.9%) on the CPU control, and inside ONE
representable bf16 step at 55.8%. At a tie the selection carries no information:
the lowest-index tie-break decides it, and any perturbation upstream reshuffles
it. The flip rate is the tie rate — 75 of 240 token-slots flip on the
VT_GDN_CHUNKED=0arm, 78 on the production CUDA arm.And #2552's own headline number is not a flip, which is the half the issue
got wrong. At decoder layer 0 with the Gated DeltaNet source removed — the arm
its table was taken on — both arms select the same experts for all five
tokens, and the first flip is at layer 1. The
7.269e-05residue theredecomposes onto the expert GEMM instead.
Why a value tap could never have said this
VT_Q4EXP_LAYER_FPreportsrel(sum|x|). A discrete selection has bimodalerror rather than a tolerance: a token's top-k set either changes, and that
token's MoE output moves by an O(1) amount, or it does not and the residue is
rounding. Averaging both into one relative difference destroys exactly the bit
that decides which happened.
So this lands
VT_MOE_SEL_FP=<calls>, a tap on the SELECTED EXPERT IDS on thereference arm of the shared sparse-MoE block — the arm a stacked keep-quant
checkpoint takes and the only arm any
qwen4_expcheckpoint reaches. Per token:the ids sorted, since the assertion between two arms is set equality and
vt::MoeCombineis order-invariant over the k slots; the smallest selected andlargest rejected router logit with their raw bf16 bit patterns; and the margin
counted in bf16 ULPS under the sign-magnitude total order.
The margin is in logit space, not probability space, and that is the design.
The softmax denominator is itself a device-order f32 reduction and differs
between the arms, while the bf16 logits are what the selection is a function of.
A probability gap reads as "small" for one representable step and for fifty
alike;
ulps = 0says the two logits are the SAME bf16 value. Per call: anFNV-1a hash of every token's sorted list, so comparing two arms at one layer is
one string comparison, plus four
sum|x|axes decomposing the block.The measurement
thor:gpu0sm_110,rcjoba5bf074b-0f1a-490d-ab51-5d561857ef9e, one binaryb3d5d97c…, three arms, the releasedunsloth/Qwen3.8-Flash-Next-GGUFUD-IQ1_S(shard 1
88a14208…) verified inside the lease.CMAKE rc=0,BUILD rc=0 wall=921s. Full result inthe evidence file, with
the raw comparator output and all 384 digest lines per arm committed beside it so
every number is checkable rather than transcribed.
The instrument asserts it ran: every arm printed 48 prefill calls and a final
lines=576, the derivable total (48x5 prefill + 336x1 decode), and thecomparator re-derives it as
COUNTED-PROPERTY … agree=Trueon both sides.Two independent instruments agree bit for bit on the MoE block's input. This
tap's
xaxis reads the tensorVT_Q4EXP_LAYER_FPcallsL00 mhc.mix, from adifferent tree, and all three arms reproduce PREFILLDIV's values exactly
(3613.82031 / 3615.62777 / 3613.74301, and both
relvalues).Layer 0,
VT_GDN_CHUNKED=0, selections equal,relagainst the CPU control:xlogitexpshrThe router GEMM is not the amplifier. The keep-quant grouped expert GEMM is.
Why it is not a defect
vLLM routes this model on bf16 logits at 512 experts too, so upstream sits on
the same knife edge. Read at
cdefd9d499, a FORWARD REFERENCE 1566 commits pastthe pin
5559679229, which carries novllm/models/qwen4_exp/at all:Qwen4ExpSparseMoeBlock(vllm/models/qwen4_exp/nvidia/model.py:160) inheritsQwen3Next's gate untouched; that gate is a plain
ReplicatedLinearwith noparams_dtype(vllm/model_executor/models/qwen3_next.py:170-176); the runnerapplies it as a plain
F.linear(
fused_moe/runner/moe_runner.py:897-902); and the f32 widening happens insideops.topk_softmax(fused_moe/router/fused_topk_router.py:26-41). Same width,same widening point, same lowest-index tie-break as ours.
vLLM does own a fp32-capable gate —
GateLinear(
fused_moe/router/gate_linear.py:18-33), aPluggableLayerwith three of fivetiers emitting fp32 logits — and Qwen3Next does not opt into it, so it is
unreachable for this architecture. That is recorded as a thing to mirror IF
upstream flips this model onto it. Widening ours first would move this model AWAY
from the oracle.
The expert GEMM's arm-to-arm difference is a documented re-association, and the
k-quant secondary oracle has the same one.
cuda_quant_dot.cu:2158-2170statesit: the integer core is bit-identical and only the per-block float scale sum is
reassociated, warp tree against CPU sequential; the activation quantizer is a
bit-exact port either way. llama.cpp @
237ad9b96splits identically —ggml-cuda/mmvq.cu:566-641finishes withwarp_reduce_sumwhileggml-cpu/arch/x86/quants.c:841-852sums sequentially.So neither arm is wrong, and #2552 closes as answered rather than fixed.
What it says about a gate
PREFILLDIV observed that token agreement is not monotone in numerical distance
and proposed no CPU-vs-CUDA token gate. This gives the mechanism: a third of the
routing decisions carry zero margin, so the emitted sequence is a function of
tie-break order rather than accuracy. No CPU-vs-CUDA token-exactness gate is
well posed for
qwen4_exp. The selection-set agreement rate and the tie-ratehistogram ARE well posed and are now measurable. A gate against vLLM stays the
right target and stays OWED: vLLM's GGUF support is an out-of-tree plugin and
every safetensors arm of this model exceeds the largest fleet box.
Gates, read literally
test_qwen4_exp_moegates the printed ids againstMoeReference, that suite'sindependent double-precision reimplementation of the lane-pinned upstream, and
gates
ulpsagainst the reference's own bf16 logit bit patterns with thesign-magnitude ordering re-derived test-side so a sign defect in the tap has
nothing agreeing with it. The case asserts its own capture through a sentinel
written into the redirected descriptor, because a redirect that caught nothing
would otherwise read as "the tap printed nothing" — the polarity the OFF arm
asserts.
The second ctest entry re-runs the same binary with the switch set, since the
budget is a read-once static. It is deliberately unfiltered: a
-tcpatternmatching nothing exits 0 having run nothing and doctest reports
assertions: 0 … SUCCESS!, a skip wearing a pass this row has paid for. Runningthe whole binary cannot fail that way and buys a second property free — every
value case re-runs with the tap on, so a tap that perturbed the block's output
would red them.
Three mutations, each with an applied-proof count 1 to 0 and each built at
rc=0 before it was run, because a mutation whose build failed reads as a
passing test:
MoeBlock, keeping the symbol referenced so-Wunused-functioncannot stand in for the testTree restored byte-for-byte after each. Green on the final merge result: build
rc=0,
121 | 121 passedswitch unset,172 | 172 passedswitch set, both rc=0,and the same two arms ran green inside the lease on
thor(TAP GATE OFF rc=0,TAP GATE ON rc=0).scripts/agent-preflight.shrc=0.What this is NOT
n=1: one prompt, one artifact, one box, one repetition, greedy, UD-IQ1_S,
num_reqs = 1. Not a token gate — no oracle decoded this prompt and the CPUarm is a CONTROL, not an oracle, so nothing here says which arm is closer to
correct. No speed number. The "vLLM sits on the same knife edge" conclusion is
a SOURCE READ, not a measurement: no vLLM process was run on this checkpoint.
The decode columns are VOID and are published only to say so. The arms were
measured on
a99b9c69a, before #2550 landed, so the CUDA arm answered11751 271 271 271 271 271 0 0and from step 1 the two arms ran different tokensequences — the 336-of-336 decode flip count is a different-input artifact.
#2550 has since landed at
bb78d1ee8and this branch merges it; the prefillresult does not depend on it, because step 0 consumes the prompt rather than a
sampled token. A post-#2550 decode comparison is now well posed and is named as
the cheap next measurement rather than claimed.
docs/USAGE.mdis deliberately untouched. Its checkpoint row still says noqwen4_expstep reaches a CUDA queue, which is false — but #2554 already carriesthat correction, and two pull requests writing the same enormous table cell is
the lock shape AGENTS.md warns about.
Refs #2547.
Refs #2550.
Closes #2552.
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5-1m [claude-code]