Skip to content

[Bugfix] Clamp NVFP4 MoE activation scales to prevent NaN from dead experts - #42601

Closed
pavelzak wants to merge 1 commit into
vllm-project:mainfrom
pavelzak:fix/nvfp4-moe-dead-expert-nan
Closed

pavelzak wants to merge 1 commit into
vllm-project:mainfrom
pavelzak:fix/nvfp4-moe-dead-expert-nan

Conversation

@pavelzak

Copy link
Copy Markdown
Contributor

Summary

Some NVFP4 checkpoints (e.g. txn545/Qwen3.5-122B-A10B-NVFP4) have near-zero or denormal float32 activation scales for dead MoE experts. In make_nvfp4_moe_quant_config(), these are inverted directly (1.0 / a13_scale, 1.0 / a2_scale), producing Inf, which the FP4 quantization kernel propagates as NaN — corrupting the entire output batch.

Fix: clamp both activation global scales from below at torch.finfo(torch.float32).tiny (~1.18e-38) before inversion. Dead experts contribute zero-weighted outputs regardless of their scale value, so this clamp is numerically safe.

Test plan

  • Loaded txn545/Qwen3.5-122B-A10B-NVFP4 on DGX Spark (SM12.1, CUDA 13.1) with vLLM v0.20.2 + this patch
  • Ran multi-turn chat completions — no NaN/Inf in outputs
  • Without the patch, outputs contain NaN tokens when dead experts are routed

AI assistance was used to help identify and describe this fix.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify

mergify Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @pavelzak.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label May 14, 2026
@pavelzak
pavelzak force-pushed the fix/nvfp4-moe-dead-expert-nan branch from b68c9ca to 5c01e3a Compare May 14, 2026 05:37
@mergify mergify Bot removed the needs-rebase label May 14, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces extensive updates to vLLM, including automated Docker image publishing, support for input clamping in SwiGLU activations, and a fused hc_head kernel implemented with TileLang. Significant optimizations are added for DeepSeek-V4, such as multi-stream GEMM overlap and expert-dtype-aware MoE dispatch. Additionally, the V1 engine's KV cache management is refined to better handle block recycling for sliding window and chunked-local attention. Review feedback correctly identified critical bugs in the DeepSeek-V4 attention layer where ReplicatedLinear outputs were not unpacked from their return tuples before being reshaped, which would cause attribute errors at runtime.

I am having trouble creating individual review comments. Click here to see my feedback.

vllm/model_executor/layers/deepseek_v4_attention.py (427)

critical

The ReplicatedLinear layer returns a tuple of (output, bias). Attempting to call .view() directly on the return value will raise an AttributeError because tuple does not have a view method. You must unpack the output first.

                q, _ = self.wq_b(qr)
                q = q.view(-1, self.n_local_heads, self.head_dim)

vllm/model_executor/layers/deepseek_v4_attention.py (453)

critical

Similar to the issue above, self.wq_b(qr) returns a tuple (output, bias). You need to unpack the output before calling .view().

                q, _ = self.wq_b(qr)
                q = q.view(-1, self.n_local_heads, self.head_dim)

vllm/model_executor/layers/deepseek_v4_attention.py (466)

critical

The ReplicatedLinear layer returns a tuple. Unpack the output before applying the view transformation.

            q, _ = self.wq_b(qr)
            q = q.view(-1, self.n_local_heads, self.head_dim)

@henry2-9

Copy link
Copy Markdown

Confirmed the bug and verified this fix end-to-end on DGX Spark (GB10, sm_121, CUDA 13.0). Wanted to share a regression test since the PR doesn't currently include one — happy to
open a follow-up PR adding it to tests/kernels/moe/test_nvfp4_moe.py after this merges.

Repro on stock vllm==0.20.2

import pytest
import torch
from vllm.model_executor.layers.fused_moe.oracle.nvfp4 import (
    NvFp4MoeBackend,
    make_nvfp4_moe_quant_config,
)

# All backends that hit the buggy `1.0 / a13_scale` inversion
# (MARLIN and EMULATION take separate branches).
_BUGGY_PATH_BACKENDS = [
    NvFp4MoeBackend.VLLM_CUTLASS,
    NvFp4MoeBackend.FLASHINFER_CUTLASS,
    NvFp4MoeBackend.FLASHINFER_TRTLLM,
    NvFp4MoeBackend.FLASHINFER_CUTEDSL,
    NvFp4MoeBackend.FLASHINFER_CUTEDSL_BATCHED,
]


@pytest.mark.parametrize("backend", _BUGGY_PATH_BACKENDS)
def test_make_nvfp4_moe_quant_config_clamps_dead_expert_scales(backend):
    """Regression test for #42601 — dead-expert scales must not produce inf/NaN."""
    E = 8 
    # Two dead experts in a13 (indices 0, 3), two dead in a2 (indices 2, 7).
    a13_scale = torch.tensor(
        [0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0], dtype=torch.float32
    )
    a2_scale = torch.tensor(
        [1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0], dtype=torch.float32
    )
    w13_scale_2 = torch.ones((E,), dtype=torch.float32)
    w2_scale_2 = torch.ones((E,), dtype=torch.float32)
    w13_scale = torch.ones((E, 1, 1), dtype=torch.uint8)
    w2_scale = torch.ones((E, 1, 1), dtype=torch.uint8)

    config = make_nvfp4_moe_quant_config(
        backend=backend,
        w13_scale=w13_scale,
        w2_scale=w2_scale,
        w13_scale_2=w13_scale_2,
        w2_scale_2=w2_scale_2,
        a13_scale=a13_scale,  
        a2_scale=a2_scale,
    )

    assert torch.isfinite(config.a1_gscale).all(), (
        f"[{backend.name}] a1_gscale must be finite for dead-expert scale=0.0; "
        f"got {config.a1_gscale}"
    )
    assert torch.isfinite(config.a2_gscale).all()


def test_make_nvfp4_moe_quant_config_healthy_scales_unchanged():
    """Sanity check: clamp is a no-op when all scales are healthy."""
    E = 4
    a13_scale = torch.tensor([0.5, 1.0, 2.0, 4.0], dtype=torch.float32)
    a2_scale = torch.tensor([1.0, 2.0, 0.5, 4.0], dtype=torch.float32)
    config = make_nvfp4_moe_quant_config(
        backend=NvFp4MoeBackend.VLLM_CUTLASS,
        w13_scale=torch.ones((E, 1, 1), dtype=torch.uint8),
        w2_scale=torch.ones((E, 1, 1), dtype=torch.uint8),
        w13_scale_2=torch.ones((E,), dtype=torch.float32),
        w2_scale_2=torch.ones((E,), dtype=torch.float32),
        a13_scale=a13_scale,
        a2_scale=a2_scale,
    )
    torch.testing.assert_close(config.a1_gscale, 1.0 / a13_scale)
    torch.testing.assert_close(config.a2_gscale, 1.0 / a2_scale)

Results

Without this PR (stock vllm==0.20.2): 5 of 6 fail; sanity-check passes.

FAILED test_make_nvfp4_moe_quant_config_clamps_dead_expert_scales[NvFp4MoeBackend.VLLM_CUTLASS]
   ... got tensor([inf, 1., 1., inf, 1., 1., 1., 1.])
FAILED ...FLASHINFER_CUTLASS
FAILED ...FLASHINFER_TRTLLM
FAILED ...FLASHINFER_CUTEDSL
FAILED ...FLASHINFER_CUTEDSL_BATCHED
PASSED  test_make_nvfp4_moe_quant_config_healthy_scales_unchanged
== 5 failed, 1 passed in 2.84s ==

With this PR applied (clamp at torch.finfo(float32).tiny): all 6 green.

== 6 passed in 2.82s ==

Tested on:

  • NVIDIA GB10 (DGX Spark, sm_121), CUDA 13.0, vLLM 0.20.2 + this PR cherry-picked
  • The healthy_scales_unchanged sanity case confirms the clamp threshold (~1.18e-38) is below any realistic activation scale, so it's a true no-op for normal checkpoints.

Glad to open the follow-up PR after this merges — looks good to me, +1!

cc @pavelzak


henry2-9 pushed a commit to henry2-9/vllm that referenced this pull request May 16, 2026
…m-project#42601)

PR vllm-project#42601 fixes a NaN bug in `make_nvfp4_moe_quant_config` where
checkpoints containing dead experts (uncalibrated, scale == 0.0)
produce `1.0 / 0.0 == inf` global activation scales, which the FP4
quantization kernel then turns into NaN tokens.

This change adds a regression test exercising the exact buggy path
(`a13_scale` / `a2_scale` containing zeros) across all five backends
that go through the inversion (VLLM_CUTLASS and the four FLASHINFER_*
variants); MARLIN and EMULATION take separate branches and are not
affected.

The test is platform-independent (no CUDA kernel calls) so it runs in
the standard CI matrix and provides fast regression coverage if anyone
later removes the clamp.

Validated locally on DGX Spark (GB10, sm_121, CUDA 13.0):
  - On `vllm==0.20.2` (pre-fix): 5 of 6 fail with `a1_gscale = inf` /
    `a2_gscale = inf` exactly at the dead-expert indices; the
    `_healthy_scales_unchanged` sanity case passes (clamp is a no-op
    for normal scales).
  - With the vllm-project#42601 patch applied: all 6 pass in ~3 s.

Depends on vllm-project#42601. This PR will be marked ready once that lands; until
then this branch's CI will surface the pre-fix failure as documented.

Signed-off-by: Henry Lu <Henry.Lu@aiunion.com.tw>
@rjmayott

Copy link
Copy Markdown

Confirmed both bug and fix on SM120.

…xperts

Some NVFP4 checkpoints (e.g. Qwen3.5-122B-A10B-NVFP4) contain
near-zero or denormal activation scales for dead MoE experts. Inverting
these scales (1/scale) produces Inf, which propagates as NaN through the
FP4 quantization kernel and corrupts the entire batch.

Fix: clamp a1_gscale and a2_gscale inputs from below at
torch.finfo(float32).tiny (~1.18e-38) before inversion in
make_nvfp4_moe_quant_config(). This is mathematically safe — dead
experts produce zero-weighted outputs regardless of their scale.

Signed-off-by: Pavel Zakharov <pavel.zakharov@gmail.com>
@pavelzak
pavelzak force-pushed the fix/nvfp4-moe-dead-expert-nan branch from b2682da to 942c353 Compare May 24, 2026 21:09
@pavelzak

Copy link
Copy Markdown
Contributor Author

Could a maintainer add the ready label to trigger CI? The fix has been validated on DGX Spark (@henry2-9) and SM120 (@rjmayott). Happy to address any review feedback.

@pavelzak

Copy link
Copy Markdown
Contributor Author

Friendly ping — this has been open since May and still shows mergeable against main. The fix has since been battle-tested further: it's been running in production on a 2× DGX Spark (GB10) TP=2 cluster serving DeepSeek-V4-Flash-0731 (NVFP4 experts) as part of the stack described in #52499#52503; without the clamp, dead experts produce NaN activations on this checkpoint. Happy to rebase if maintainers prefer a fresh CI run.

@arpera arpera moved this to Unsorted queue in Structured Output (arpera) Aug 26, 2026
@arpera arpera moved this from Unsorted queue to Not related in Structured Output (arpera) Aug 27, 2026
@Jie-Fang

Jie-Fang commented Sep 6, 2026

Copy link
Copy Markdown
Contributor
  • clamp(min=tiny) does not fix NaN: if the input is NaN, the output remains NaN.

  • +Inf is inverted to 0, while negative scales are silently clamped to tiny, masking these invalid values.

  • The current ModelOpt path allocates scales using torch.empty. A missing value is not guaranteed to be zero; it may contain an arbitrary finite garbage value, which this PR cannot detect at all.

Refer to #45320 / #54444, they have a better way to deal with such situation.

And input_scale will be used in:

a_gscale = 1.0 / input_scale
weight_scale_2 *= input_scale

if the input_scale is zero, the original path will make a_gscale inf and weight_scale_2 0, the result is inf * 0 = NaN which can be observed by the numeric issue. But this PR makes the result become zero, how can we make sure it's a good number?

@pavelzak

pavelzak commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I agree that making the reciprocal finite does not establish output correctness, and my numerical-safety claim was too strong. I no longer use the affected model and cannot commit to the additional validation needed, so I’m closing this workaround. The load-time validation approach in #45320 / #54444 is a better direction for missing or invalid scales.

@pavelzak pavelzak closed this Sep 8, 2026
@github-project-automation github-project-automation Bot moved this to Done in NVIDIA Sep 8, 2026
@github-project-automation github-project-automation Bot moved this from Not related to In review in Structured Output (arpera) Sep 8, 2026
@arpera arpera moved this from In review to Not related in Structured Output (arpera) Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working ci/build cpu Related to CPU backends deepseek Related to DeepSeek models documentation Improvements or additions to documentation frontend nvidia qwen Related to Qwen models structured-output tool-calling v1

Projects

Status: Done
Status: Done
Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants