[Bugfix] Size and iterate w13 by shard count for non-gated MoE - #51125
Conversation
|
/run ci |
|
✅ @aoshen02, CI is now available for this PR.
|
|
/ci run |
|
✅ Triggered Buildkite CI #82469 for commit |
Non-gated MoE (is_act_and_mul=False) fuses only the up projection into w13, so w13 holds a single intermediate_size shard rather than two gate/up shards. 13 MoE quantization methods already conditionalize on this; 14 others hardcode 2, over-allocating w13 (and its per-shard scales and biases) with the extra shard left uninitialized, and walking the tensor as two shards when zeroing roundup padding. Add FusedMoEConfig.w13_num_shards as the single source for the count and use it at every site. Gated models are unaffected: the property returns 2 and every expression reduces to the previous one. Co-authored-by: Matej Sirovatka <matej.sirovatka@gmail.com> Co-authored-by: Rishi Puri <riship@nvidia.com> Co-authored-by: Hsiao-Yuan Chen <littlecircle0730@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: aoshen02 <aoshen@inferact.ai>
e6fc021 to
4beebe3
Compare
|
Pushed a fix for the two
Reading # test_auto_gptq.py
method.moe = SimpleNamespace(w13_num_shards=2)
# test_auto_round.py — bound to a name so the pass-through assertion still has a sentinel
expected_moe_config = SimpleNamespace(w13_num_shards=2)
method = INCMxfp4MoEMethod(moe=cast(Any, expected_moe_config))
...
assert captured["kernel_kwargs"]["moe_config"] is expected_moe_configThe second file needed the assertion updated too, since This follows the pattern already in Verified on the $ pytest tests/quantization/test_auto_gptq.py::test_auto_gptq_moe_creates_zero_initialized_expert_biases \
tests/quantization/test_auto_round.py::test_inc_mxfp4_moe_method_registers_weights_and_builds_kernel -q
2 passed in 5.24s
$ pytest tests/quantization/test_auto_gptq.py tests/quantization/test_auto_round.py tests/quantization/test_moe_wna16.py -q
7 failed, 62 passed, 1 skipped in 26.81sThe 7 remaining failures are all Also checked
The two remaining red jobs in build 82469 look unrelated:
|
|
/ci run |
1 similar comment
|
/ci run |
|
✅ Triggered Buildkite CI #82494 for commit |
…change Signed-off-by: Felix Marty <Felix.Marty@amd.com>
Purpose
Non-gated MoE (
is_act_and_mul=False, e.g. NemotronH'srelu2_no_mul) fusesonly the up projection into
w13, sow13holds a singleintermediate_size_per_partitionshard rather than two gate/up shards.13 MoE quantization methods already conditionalize on this —
modelopt.py,compressed_tensors_moe_*,flashinfer,unquantized_fused_moe_method.py, … —each with its own local
w13_num_shards = 2 if self.moe.is_act_and_mul else 1.14 other methods hardcode 2. For a non-gated model those:
w13to2 * Irows when onlyIrows are ever written,so the second shard stays whatever the allocator handed back. On the online
path
reload/meta.pymaterializes withtorch.empty_strided, so this isuninitialized memory that then flows into the block/per-tensor amax, the
quantized weight, and the GEMM (
0 × NaN = NaN);torch.ones(E, 2),torch.zeros(E, 2 * I),(E, 2 * I // block, …));w13as two shards when zeroing the roundup padding(
Fp8PerBlockOnlineMoEMethod._zero_padding) or when requantizing(
for shard_id in range(2)inquark_moe.py), so the pad band boundariesare computed from a half that is twice too small.
Failure mode 3 is the one that bites even where the allocation happens to be
tolerated: the pad rows are never zeroed, so uninitialized values contaminate
the shared per-block weight scale of real rows.
Rather than adding a 14th copy of the same local variable, this PR adds one
property next to
is_act_and_muland uses it everywhere:Gated models are bit-for-bit unaffected: the property returns 2 and every
changed expression reduces to the one it replaced.
Changes
vllm/model_executor/layers/fused_moe/config.py— addFusedMoEConfig.w13_num_shards.68 substitution sites across 13 files:
quantization/quark/quark_moe.pyw13allocations, 5 per-shard(E, 2)scale/zero-point tensors, 3range(2)requant loopsquantization/mxfp4.pyGptOssMxfp4MoEMethod+Mxfp4MoEMethodweights/scales/bias and the_setup_kernelshape assertsquantization/fp8.pyw13_weight,w13_bias, per-tensortorch.ones(E, 2)scale, block scale, plusis_act_and_mul=passthrough toprocess_fp8_weight_tensor_strategy_moequantization/auto_gptq.pyquantization/online/fp8.pyFp8PerBlockOnlineMoEMethod._zero_padding, weight and biasquantization/auto_awq.pyintermediate_size_per_partition * 2spellingquantization/bitsandbytes.pyquantization/moe_wna16.pycompressed_tensors_moe_w4a8_fp8.pyquantization/humming.pyquantization/inc/schemes/inc_mxfp4_moe.pycompressed_tensors_moe_w4a4_mxfp4.pyquantization/online/moe_base.pyw13_weight+w13_biasDeliberately not changed:
w13_weight_shape/w2_weight_shapetorch.empty(num_experts, 2)in thecompressed-tensors files — that
2is a(rows, cols)shape descriptor, nota shard count.
w13_num_shards. Collapsingthem onto the new property is a pure identity refactor and belongs in its own
change, not in a bugfix.
moe_wna16.py's weight loaderparam.data[expert_id, : shard_size // 2]—that
2is loader shard semantics, only reachable withhas_zpandnon-gated, and needs its own analysis.
Why this does not duplicate an existing PR
Three open PRs touch part of this. This PR is a strict superset of their
substantive content in the weight-allocation / post-load-zeroing layer:
littlecircle0730) —Fp8MoEMethodw13_weight,w13_bias,block scale. All three covered here, plus the per-tensor
torch.ones(num_experts, 2)scale and theis_act_and_mulpassthrough toprocess_fp8_weight_tensor_strategy_moethat it misses. Its second hunk addsan
Fp8OnlineMoEMethod(Fp8MoEMethod)class that no longer exists after theonline/refactor, so that part is not carried over.puririshi98) —online/moe_base.pyand_zero_padding; both equivalent to the versions here (that PR merges theweight and bias loops, this one keeps the original two-block structure).
S1ro1) —online/moe_base.py; equivalent.All three authors are credited as co-authors.
Two adjacent problems are deliberately left out of scope because they are
independently reachable and already have owners:
utils/marlin_utils_fp8.py([Bugfix] Handle single-shard FP8 Marlin MoE padding #50568mikekg, [Bugfix] Support non-gated MoE in online quantization and Marlin MoE tile padding #48028's second half) —_moe_pad_shard_rowshardcodesview(e, 2, n, …), so a non-gatedw13cannot be viewed as two shards when Marlin pads a tile-misaligned
intermediate. Same root assumption, but a different layer (kernel-format
conversion) and live on
maintoday via a path this PR does not touch:ModelOptFp8MoEMethodis one of the 13 already-correct allocators, so thecrash reproduces standalone (
shape '[128, 2, 464, 2688]' is invalid for input of size 159645696). [Bugfix] Handle single-shard FP8 Marlin MoE padding #50568 already validates its fix on the realnvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8checkpoint at TP4 includingGSM8K; folding it in here would replace validated work with unvalidated work.
online/nvfp4.py([Quantization] Preserve precision in online NVFP4 expert packing #50029's second half) — folding the per-expert globalscale into the weights rounds the product back through
weight.dtypebeforequantizing, which is a precision bug, not a shard-count one. Separate PR.
The remaining 11 quantization files in the table above are covered by no open
PR.
Also checked and non-overlapping: #46795 (TPU config only), #48624
(
flashinfer_fp4_moe.py), #50196 (oracle/int_wna16.py), #43386 / #47106(non-gated activation kernels).
Test Plan
Python-only diff, so it can be applied onto an installed
vllmwithout arebuild. Every case loads a model,
collective_rpcs a probe that dumps everyMoE expert-tensor shape and measures the
w13padding band, thengreedy-generates; baseline and patched runs are compared shape-by-shape and
token-by-token.
Synthetic cases are real upstream
config.jsons with the layer and expertcounts shrunk, loaded with
--load-format dummy --skip-tokenizer-init, whichexercises
create_weights→process_weights_after_loading→ kernel setupwithout needing checkpoints.
Test Result
The fix does what it claims.
nemotron_hrouted experts are non-gated(
activation_without_mul("relu2")→relu2_no_mul) andmoe_intermediate_size=1856is not a multiple of 128, so the online per-blockpath rounds 1856 → 1920:
w13_weight[8, 3840, 2688](2 × 1920)[8, 1920, 2688]w13_weight_scale_inv[8, 30, 21][8, 15, 21]w13pad bandThe patched band is exactly 8 experts × 64 rows × 2688 — the 1856→1920 padding
— and fully zeroed. Baseline allocates twice the rows, leaves the entire extra
shard uninitialized, and hands it to the block-FP8 quantizer and the GEMM. The
93.5 % figure reconciles: the old code's two 64-row bands account for 2,752,512
zeros against 2,752,599 measured, i.e. only 87 incidentally-zero elements out of
the 39.9 M it never touched.
Neither run crashes. With dummy weights nothing validates the shape, so this is
the silent-contamination failure mode rather than a load error — which is why
the probe checks the band directly.
No regression on gated paths.
status=ok, MoE tensor shapes byte-identicalbaseline vs patched, identical generated token ids:
offline_fp8_blockFp8MoEMethodblock branchoffline_mxfp4GptOssMxfp4MoEMethodincl. the new_setup_kernelassertsoffline_gptq_int4AutoGPTQMoEMethodoffline_ct_fp8_tensorCompressedTensorsW8A8Fp8MoEMethod(untouched file)Real weights,
RedHatAI/Mixtral-8x7B-Instruct-v0.1-FP8, TP1, greedy, 4baseline + 4 patched: MoE shapes identical in all 8 runs; 7/8 produce identical
text. The one divergent patched run is vLLM's run-to-run greedy
nondeterminism, not the diff — that model's MoE method lives in a file this PR
does not touch, and 3 further baseline + 3 further patched runs all agreed.
Since gated shapes and outputs are unchanged and the non-gated path previously
consumed uninitialized memory, there is no accuracy delta to measure on a
supported configuration; the shape and pad-band evidence above stands in for a
model eval.
Not covered on this hardware (stated explicitly rather than implied):
moe_intermediate_size % 128 != 0(qwen3_moe, all-MoE qwen3_moe,glm4_moe_lite, deepseek_v2) fail identically before and after this diff,
inside the linear block-scaled-mm kernel (
the last dimension of x … must be divisible by group_size 128, plus atriton_scaled_mmscale-shapeassert). Unrelated to this change. The gated
_zero_paddingslices areunchanged by construction — shard count 2 reproduces the same two bands.
available locally. These are shape-only substitutions that reduce to the
previous expression when gated.
Disclosure for reproducibility: the patched runs applied this diff together
with an unrelated
online/nvfp4.pychange (the #50029 follow-up mentionedabove). That change is inert here —
Nvfp4OnlineMoEMethodrefuses non-SM100and H200 is SM90 — so it cannot affect any result reported above.
AI assistance disclosure
This PR was developed with AI assistance (Claude Code), including the
cross-PR overlap analysis and the integration harness. The human submitter has
reviewed every changed line, ran the tests above, and can defend the change
end-to-end.