Skip to content

[#14287][feat] Add Qwen3.5/3.6 MoE NVFP4 + MTP support for SM120/SM121 - #15680

Closed
mihai-chiorean wants to merge 13 commits into
NVIDIA:mainfrom
mihai-chiorean:feat/qwen3-5-moe-mtp
Closed

[#14287][feat] Add Qwen3.5/3.6 MoE NVFP4 + MTP support for SM120/SM121#15680
mihai-chiorean wants to merge 13 commits into
NVIDIA:mainfrom
mihai-chiorean:feat/qwen3-5-moe-mtp

Conversation

@mihai-chiorean

@mihai-chiorean mihai-chiorean commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds PyTorch-backend support for the Qwen3_5MoeForCausalLM / Qwen3.6 MoE family on consumer Blackwell (SM120 RTX PRO 6000, SM121 GB10 / DGX Spark), including the default TRTLLM attention path with MTP speculative decoding and CUDA graphs enabled.

The target validation model is nvidia/Qwen3.6-35B-A3B-NVFP4. This PR intentionally stays on the working default runtime path and does not include the experimental FlashInfer fixes.

The checkpoint uses the modelopt label W4A16_NVFP4, but the payload behaves as full NVFP4: expert weights and input activations are both 4-bit, per-expert input scales are present, and modelopt exposes this through the NVFP4 config family. This PR treats that label as an alias for QuantAlgo.NVFP4 and routes the model through existing CUTLASS NVFP4 kernels.

Changes

  • Recognize W4A16_NVFP4 as NVFP4 and normalize Qwen3.5/Qwen3.6 per-layer quant config keys, including model.language_model. prefix removal.
  • Exclude lm_head from NVFP4 module loading so it can be dequantized and loaded separately.
  • Strip stale multimodal/MRoPE fields from the text-only Qwen3.5/Qwen3.6 RoPE config so the text path selects the intended rotary implementation.
  • Extend Qwen3.5/Qwen3.6 weight mapping for scalar FP8 scale passthrough, excluded lm_head handling, and BF16 dequantization before packing fused linear-attention buffers with different scales.
  • Pass per-layer NVFP4 quant config into Qwen3 Next MoE creation so the loader selects the CUTLASS NVFP4 MoE path instead of BF16 fallback.
  • Route Qwen3.5/Qwen3.6 MTP model names through the existing Qwen3 Next MTP speculative-decoding implementation.
  • Add focused unit coverage for quant-key normalization, lm_head exclusion, and FP8 per-tensor linear-attention dequant-before-pack behavior.

Dependencies and related issues

Smoke test result: DGX Spark GB10 / SM121

Configuration: PyTorch backend, single GPU, attn_backend="TRTLLM", default CUDA graph behavior, max_batch_size=8, max_seq_len=4096, and disable_flashinfer_sampling=True for the local Spark environment.

  • TRTLLM no-MTP default path: coherent generation, roughly 29 ms TPOT on the validation prompt set.
  • TRTLLM + MTP default path: coherent generation, 22.5-22.7 ms TPOT, about 44 tok/s, roughly 1.28x faster than no-MTP.
  • Structural MTP confirmation: the draft layer is constructed and separate main/draft KV-cache pools are allocated.

Representative raw prompt, "The capital of France is":

Paris, a city renowned for its iconic landmarks such as the Eiffel Tower, the Louvre Museum, and the Notre-Dame Cathedral...

Out of scope

Test plan

  • Smoke on DGX Spark GB10: TRTLLM no-MTP coherent generation
  • Smoke on DGX Spark GB10: TRTLLM + MTP coherent generation
  • Smoke on DGX Spark GB10: default CUDA graphs enabled for the TRTLLM + MTP path
  • Smoke with [#11932][fix] Filter CUTLASS MoE GEMM tile configs by device shared memory on SM121 #12704 applied: MoE autotuning avoids the oversized-tactic failure mode
  • Local compile check for the changed unit-test files
  • Focused lint check for the changed unit-test files
  • Targeted unit tests in Spark TRT-LLM dev container:
    LLM_MODELS_ROOT=/models PYTHONPATH=$PWD pytest -q tests/unittest/llmapi/test_llm_quant.py::test_quant_cfg_qwen35_nvfp4_alias_and_prefix_normalization tests/unittest/_torch/models/test_qwen3_5_moe_weight_mapper.py::test_fp8_pertensor_linear_attn_weights_are_dequantized_before_pack
  • Pending: CI: /bot run --extra-stage "GB10-PyTorch-Post-Merge-1, RTXPro6000D-PyTorch-Post-Merge-1"
  • Recommended before merge: small deterministic quality check such as a GSM8K subset at temperature=0

PR Checklist

Please review the following before submitting your PR:

  • PR description clearly explains what and why.

  • PR follows TRT-LLM CODING GUIDELINES to the best of your knowledge.

  • Test cases are provided for new code paths where practical.

  • If PR introduces API changes, an appropriate PR label is added. This PR does not intend a public API change.

  • Any new dependencies have been scanned for license and vulnerabilities.

  • CODEOWNERS updated if ownership changes.

  • Documentation updated as needed.

  • Update tava architecture diagram if there is a significant design change in PR.

  • The reviewers assigned automatically/manually are appropriate for this PR.

  • Please check this after reviewing the above items as appropriate for this PR.

GitHub Bot Help

To see a list of available CI bot commands, please comment /bot help.

Four changes required to load nvidia/Qwen3.6-35B-A3B-NVFP4 on SM121:

1. quantization/mode.py: Add W4A16_NVFP4 to QuantAlgo enum as an alias
   for NVFP4.  modelopt labels the Qwen3.6 NVFP4 checkpoint with
   "W4A16_NVFP4" (a naming convention artifact -- both weight and
   activations are quantized to FP4 / W4A4).  Map it to the same
   QuantMode bits as NVFP4 so the CUTLASS kernel dispatch is unchanged.

2. _torch/model_config.py: In _build_modelopt_quant_config, normalize
   per-layer W4A16_NVFP4 -> NVFP4 for kernel dispatch and move lm_head
   to exclude_modules so it loads as BF16 (LMHead bypasses
   Linear.create_weights and cannot carry NVFP4 scale Parameters).

3. _torch/pyexecutor/config_utils.py: Strip mrope_section and
   mrope_interleaved from _Qwen35ConfigCompat._flatten_rope for the
   text executor path.  The Qwen3.6 VLM checkpoint includes mRoPE fields
   intended for the vision path; the text executor never builds 3D
   position_ids so leaving type="mrope" causes MRotaryEmbedding to
   produce silently wrong cos/sin from 2D position_ids.

4. _torch/models/modeling_speculative.py: Extend MTPDraftModel (two-engine
   mode) to dispatch qwen3_5_text and qwen3_5_moe_text to Qwen3NextMTP.
   The one-engine path (MTPOneDraftModel) already included these model
   types; this brings the two-engine path into parity.

The MTP weight key normalization (mtp.* -> model.layers.40.*), the
NVFP4 MoE scale loading, and the MTP unquantized-sublayer backend
fallback are all already handled by existing code in
Qwen3NextHfWeightMapper and Qwen3NextMTP.

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
The mRoPE strip block worked on a local copy of rope_scaling but only
wrote back when the dict was non-empty. When the strip cleared the dict,
text_config["rope_scaling"] retained the pre-strip mrope_section /
mrope_interleaved values, silently defeating the strip and producing
wrong cos/sin from MRotaryEmbeddings 2D fallback at inference time.

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
…NVFP4 dequant

Scalar FP8 scale tensors (weight_scale, input_scale) have ndim == 0 and
cannot be split or stacked across q/k/v/z projections.  Forward them
directly to the fused qkvz/ba key instead of routing through the
regular split/pack path (_SCALAR_SCALE_SUFFIXES early-exit in
_pack_split_projections).

Add _dequantize_nvfp4_weight + _dequantize_nvfp4_excluded_weights:
modules in quant_config.exclude_modules (e.g. lm_head) are expected to
load as BF16, but NVFP4 checkpoints store them as packed uint8 with
per-block fp8 scales and a global fp32 scale.  Detect by dtype, dequant
to BF16, and drop the orphaned scale tensors before the module loader
runs.  Call site added in preprocess_weights for MIXED_PRECISION quant.

Also add NVIDIA copyright header (file previously had none).

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
…r dispatch

Two correctness fixes for loading Qwen3.6-35B-A3B-NVFP4 on the
PyTorch backend:

1. model_config.py: hf_quant_config.json keys use the VLM-wrapper
   prefix "model.language_model.<...>", but TRT-LLM module names
   produced by named_modules() (after the _Qwen35ConfigCompat shim
   strips the vision wrapper) use "model.<...>". Normalize keys
   in _build_modelopt_quant_config() so apply_layerwise_quant_config()
   actually matches. Without this, every Linear/MoE module sees
   the global MIXED_PRECISION config and ignores its per-layer
   NVFP4 quant_algo.

2. modeling_qwen3_next.py: Qwen3NextSparseMoeBlock.__init__ now looks
   up the per-layer MoE NVFP4 config and passes it as
   override_quant_config to create_moe(). Without this,
   get_moe_cls() sees MIXED_PRECISION and falls back to
   CutlassFusedMoE (BF16 expectations), which then fails with a
   2048 vs 512 NVFP4 tensor shape mismatch when loading expert
   weights.

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
…ing in NVFP4

Qwen3.6-35B-A3B-NVFP4 stores linear-attention in_proj_qkv and in_proj_z as
FP8 per-tensor-scale tensors (float8_e4m3fn + scalar weight_scale).  These
are packed into in_proj_qkvz by _pack_split_projections.

With a MIXED_PRECISION global quant_algo, the in_proj_qkvz Linear receives
UnquantizedLinearMethod (BF16 weight buffer) because there is no matching
per-layer FP8 entry under the packed TRT-LLM module name.  Previously,
copy_weight cast the packed FP8 tensor to BF16 numerically — interpreting
raw FP8 bit-patterns without applying the per-tensor weight_scale.  This
inflated each weight value by ~1/weight_scale (~1000x), corrupting the
linear-attention hidden states in all 30 GatedDeltaNet layers.

Fix: add _dequantize_fp8_pertensor_excluded_split_weights, called in
preprocess_weights before _pack_split_projections for MIXED_PRECISION.
It detects split linear-attention FP8 weight tensors with a scalar
weight_scale and dequantizes each component individually:
  bf16 = fp8.to(float32) * weight_scale
Orphaned scale tensors (weight_scale, input_scale) are removed so
_pack_split_projections does not forward a now-stale scalar scale.

Smoke test on Qwen3.6-35B-A3B-NVFP4:
  raw:  "The capital of France is" -> " Paris, a city renowned..."
  chat: "What is the capital of France?" -> coherent English reasoning

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
Two defensive fixes from codex review:

1. qwen3_5_weight_mapper: the scalar weight_scale / input_scale
   bypass for FP8 attention projections was firing for any matching
   suffix regardless of dimensionality.  FP8_PER_CHANNEL_PER_TOKEN
   checkpoints have 1-D weight_scale tensors (after
   _normalize_scale_names squeezes the trailing axis), which would
   trip the ndim==0 assertion and abort weight loading.  Require
   all candidate tensors to be 0-dim before taking the bypass so
   per-channel scales fall through to the regular split/pack path.

2. _Qwen35ConfigCompat._flatten_rope: also pop the legacy "type"
   key alongside "rope_type" / "mrope_section" / "mrope_interleaved".
   HF Qwen2.5-VL configs used {"type": "mrope", ...}; the new
   rope_type form (HF transformers 5.x) is what Qwen3.5/3.6 ship,
   but defending against the legacy form is cheap and avoids
   resurrecting the broken MRotaryEmbedding path on older configs.

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
The text-only path normalizes to model_type=qwen3_5_moe_text and is
exercised by the smoke test.  The VLM-composite checkpoint format
publishes model_type=qwen3_5_moe at the top level (before the
_Qwen35ConfigCompat shim strips the wrapper), which would otherwise
fall through to the unsupported-model-type error on the MTP draft
engine path.  Both MTPForCausalLM (one-model) and MTPDraftModel
(separate engine) dispatches now cover the composite form for
forward compatibility.  Codex pre-PR review flag.

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a W4A16_NVFP4 quantization alias, updates Qwen3.5 weight preprocessing and MoE routing for mixed-precision checkpoints, routes Qwen3.5 draft MTP variants to Qwen3NextMTP, and adjusts RoPE flattening in executor config handling.

Changes

Qwen3.5 mixed-precision support

Layer / File(s) Summary
NVFP4 alias and config overrides
tensorrt_llm/quantization/mode.py, tensorrt_llm/_torch/model_config.py
QuantAlgo adds W4A16_NVFP4, QuantMode.from_quant_algo maps it to NVFP4, and mixed per-layer configs normalize that label, rewrite layer keys, and exclude lm_head.
Qwen3.5 weight dequantization helpers
tensorrt_llm/_torch/models/checkpoints/hf/qwen3_5_weight_mapper.py
New Qwen3.5 mapper constants and helper methods handle NVFP4-packed weights, excluded-module dequantization, and scalar FP8 scale handling.
Split projection packing and preprocess order
tensorrt_llm/_torch/models/checkpoints/hf/qwen3_5_weight_mapper.py
Scalar weight_scale and input_scale tensors are forwarded to fused qkvz/ba keys, and preprocess_weights runs FP8 split dequantization before packing and NVFP4 excluded-module dequantization after packing.
Qwen3.5 MoE overrides and draft routing
tensorrt_llm/_torch/models/modeling_qwen3_next.py, tensorrt_llm/_torch/models/modeling_speculative.py
Qwen3NextSparseMoeBlock selects a layer-specific override config during MIXED_PRECISION, passes it into create_moe, and speculative MTP dispatch includes the qwen3_5_* model types.
RoPE flattening cleanup
tensorrt_llm/_torch/pyexecutor/config_utils.py
_flatten_rope removes mRoPE-specific fields and markers, clears empty rope_scaling, and deletes text_config["rope_scaling"] when the flattened value is empty.

Sequence Diagram(s)

Weight preprocessing flow

sequenceDiagram
  participant preprocess_weights
  participant dequantize_fp8 as _dequantize_fp8_pertensor_excluded_split_weights
  participant pack_split_projections as _pack_split_projections
  participant dequantize_nvfp4 as _dequantize_nvfp4_excluded_weights

  preprocess_weights->>dequantize_fp8: dequantize scalar-scale split FP8 weights
  preprocess_weights->>pack_split_projections: pack split projections and forward scalar scales
  preprocess_weights->>dequantize_nvfp4: dequantize excluded NVFP4 weights
Loading

MoE override selection

sequenceDiagram
  participant moe_init as Qwen3NextSparseMoeBlock.__init__
  participant quant_config_dict as model_config.quant_config_dict
  participant create_moe as create_moe

  moe_init->>quant_config_dict: look up layer_idx-specific expert config
  moe_init->>create_moe: pass override_quant_config during MIXED_PRECISION
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • QiJune
  • xinhe-nv
  • jieli-matrix
  • leslie-fang25
  • xxi-nv
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description follows the template well, with clear summary, test coverage, checklist, and relevant dependencies/out-of-scope details.
Title check ✅ Passed The title is concise and accurately summarizes the main addition of Qwen3.5/3.6 MoE NVFP4 and MTP support for SM120/SM121.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tensorrt_llm/_torch/model_config.py`:
- Around line 435-453: After the language-model prefix normalization in
model_config handling, prefixed LM head keys like model.lm_head are not being
excluded or moved to BF16 because the current lm_head check only matches the
exact unprefixed name. Update the mixed_quant_configs /
quant_config.exclude_modules logic in the model configuration path to recognize
the normalized LM head key alongside the original lm_head name, and remove the
matching entry from mixed_quant_configs so it bypasses the NVFP4 path and loads
as BF16.

In `@tensorrt_llm/_torch/models/checkpoints/hf/qwen3_5_weight_mapper.py`:
- Around line 459-480: Avoid using tensor truthiness when picking scalar
fallback values in the Qwen3 weight mapper. In the scalar-scale handling inside
the branch that builds packed weights, update the representative selection in
the qkv/q/k/v path and the b/a path so it chooses by key presence rather than
`or`, since 0-D tensors with value 0 can be treated as false. Use the existing
`tensors` mapping in `qwen3_5_weight_mapper.py` to explicitly prefer
`"qkv"`/`"b"` when present, otherwise fall back to the other candidate keys
without relying on boolean evaluation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 1eed451b-5e0d-4b5d-a64f-b4c0fae54cc8

📥 Commits

Reviewing files that changed from the base of the PR and between 8e6a089 and 1618f82.

📒 Files selected for processing (6)
  • tensorrt_llm/_torch/model_config.py
  • tensorrt_llm/_torch/models/checkpoints/hf/qwen3_5_weight_mapper.py
  • tensorrt_llm/_torch/models/modeling_qwen3_next.py
  • tensorrt_llm/_torch/models/modeling_speculative.py
  • tensorrt_llm/_torch/pyexecutor/config_utils.py
  • tensorrt_llm/quantization/mode.py

Comment thread tensorrt_llm/_torch/model_config.py Outdated
Comment on lines +435 to +453
# Normalize "model.language_model." prefix to "model." so that
# quant_config_dict keys match TRT-LLM module names produced by
# named_modules() (which don't include the "language_model" level).
_LM_PREFIX = "model.language_model."
_MODEL_PREFIX = "model."
mixed_quant_configs = {
(_MODEL_PREFIX + k[len(_LM_PREFIX):] if k.startswith(_LM_PREFIX) else k): v
for k, v in mixed_quant_configs.items()
}
# LMHead bypasses Linear.create_weights (manual Parameter),
# so NVFP4 weight scales are never allocated there. Move
# lm_head to exclude_modules so it loads as BF16.
if mixed_quant_configs and "lm_head" in mixed_quant_configs:
if quant_config.exclude_modules is None:
quant_config.exclude_modules = []
if "lm_head" not in quant_config.exclude_modules:
quant_config.exclude_modules = list(
quant_config.exclude_modules) + ["lm_head"]
del mixed_quant_configs["lm_head"]

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle prefixed lm_head keys after language-model normalization.

Line 441 can rewrite model.language_model.lm_head to model.lm_head, but Lines 447-453 only remove/exclude exact lm_head. That leaves prefixed LM heads in the per-layer NVFP4 path and prevents excluded-weight dequantization because lm_head does not match model.lm_head.

Proposed fix
-            if mixed_quant_configs and "lm_head" in mixed_quant_configs:
+            lm_head_keys = [
+                k for k in mixed_quant_configs
+                if k == "lm_head" or k.endswith(".lm_head")
+            ]
+            if lm_head_keys:
                 if quant_config.exclude_modules is None:
                     quant_config.exclude_modules = []
-                if "lm_head" not in quant_config.exclude_modules:
-                    quant_config.exclude_modules = list(
-                        quant_config.exclude_modules) + ["lm_head"]
-                del mixed_quant_configs["lm_head"]
+                lm_head_excludes = ["lm_head", *lm_head_keys]
+                quant_config.exclude_modules = list(
+                    dict.fromkeys(list(quant_config.exclude_modules) + lm_head_excludes)
+                )
+                for key in lm_head_keys:
+                    del mixed_quant_configs[key]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Normalize "model.language_model." prefix to "model." so that
# quant_config_dict keys match TRT-LLM module names produced by
# named_modules() (which don't include the "language_model" level).
_LM_PREFIX = "model.language_model."
_MODEL_PREFIX = "model."
mixed_quant_configs = {
(_MODEL_PREFIX + k[len(_LM_PREFIX):] if k.startswith(_LM_PREFIX) else k): v
for k, v in mixed_quant_configs.items()
}
# LMHead bypasses Linear.create_weights (manual Parameter),
# so NVFP4 weight scales are never allocated there. Move
# lm_head to exclude_modules so it loads as BF16.
if mixed_quant_configs and "lm_head" in mixed_quant_configs:
if quant_config.exclude_modules is None:
quant_config.exclude_modules = []
if "lm_head" not in quant_config.exclude_modules:
quant_config.exclude_modules = list(
quant_config.exclude_modules) + ["lm_head"]
del mixed_quant_configs["lm_head"]
# Normalize "model.language_model." prefix to "model." so that
# quant_config_dict keys match TRT-LLM module names produced by
# named_modules() (which don't include the "language_model" level).
_LM_PREFIX = "model.language_model."
_MODEL_PREFIX = "model."
mixed_quant_configs = {
(_MODEL_PREFIX + k[len(_LM_PREFIX):] if k.startswith(_LM_PREFIX) else k): v
for k, v in mixed_quant_configs.items()
}
# LMHead bypasses Linear.create_weights (manual Parameter),
# so NVFP4 weight scales are never allocated there. Move
# lm_head to exclude_modules so it loads as BF16.
lm_head_keys = [
k for k in mixed_quant_configs
if k == "lm_head" or k.endswith(".lm_head")
]
if lm_head_keys:
if quant_config.exclude_modules is None:
quant_config.exclude_modules = []
lm_head_excludes = ["lm_head", *lm_head_keys]
quant_config.exclude_modules = list(
dict.fromkeys(list(quant_config.exclude_modules) + lm_head_excludes)
)
for key in lm_head_keys:
del mixed_quant_configs[key]
🧰 Tools
🪛 Ruff (0.15.18)

[warning] 451-452: Consider [*list(quant_config.exclude_modules), "lm_head"] instead of concatenation

Replace with [*list(quant_config.exclude_modules), "lm_head"]

(RUF005)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tensorrt_llm/_torch/model_config.py` around lines 435 - 453, After the
language-model prefix normalization in model_config handling, prefixed LM head
keys like model.lm_head are not being excluded or moved to BF16 because the
current lm_head check only matches the exact unprefixed name. Update the
mixed_quant_configs / quant_config.exclude_modules logic in the model
configuration path to recognize the normalized LM head key alongside the
original lm_head name, and remove the matching entry from mixed_quant_configs so
it bypasses the NVFP4 path and loads as BF16.

Comment on lines +459 to +480
if suffix in _SCALAR_SCALE_SUFFIXES and all(
t.ndim == 0 for t in tensors.values()):
qkvz_candidates = {"qkv", "q", "k", "v", "z"} & tensors.keys()
if qkvz_candidates:
# Use the scalar from "qkv" if present, else fall back to
# any available candidate (q/k/v all hold the same value).
representative = tensors.get("qkv") or next(
v for k, v in tensors.items() if k in {"q", "k", "v"}
)
assert representative.ndim == 0, (
f"Expected scalar (ndim=0) for {prefix}.in_proj_qkv.{suffix}, "
f"got shape {representative.shape}"
)
packed_name = f"{prefix}.in_proj_qkvz.{suffix}"
assert packed_name not in packed_weights, (
f"Packed projection {packed_name} already exists"
)
packed_weights[packed_name] = representative

ba_candidates = {"b", "a"} & tensors.keys()
if ba_candidates:
representative_ba = tensors.get("b") or tensors.get("a")

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

python3 - <<'PY'
from pathlib import Path

path = Path("tensorrt_llm/_torch/models/checkpoints/hf/qwen3_5_weight_mapper.py")
lines = path.read_text().splitlines()

for start, end in [(450, 490)]:
    print(f"\n--- {path}:{start}-{end} ---")
    for i in range(start - 1, min(end, len(lines))):
        print(f"{i+1:4d}: {lines[i]}")
PY

Repository: NVIDIA/TensorRT-LLM

Length of output: 2826


🏁 Script executed:

python3 - <<'PY'
try:
    import torch
except Exception as exc:
    print(f"torch_import_error: {type(exc).__name__}: {exc}")
else:
    for value in [0.0, 1.0]:
        t = torch.tensor(value)
        try:
            print(value, bool(t), (t or torch.tensor(2.0)).item())
        except Exception as exc:
            print(value, f"{type(exc).__name__}: {exc}")
PY

Repository: NVIDIA/TensorRT-LLM

Length of output: 222


Avoid tensor truthiness when selecting scalar fallbacks. In tensorrt_llm/_torch/models/checkpoints/hf/qwen3_5_weight_mapper.py:465 and :480, or on a 0-D tensor treats a valid zero-valued scale as false, so the code can skip an available candidate or raise StopIteration when it is the only one. Select by key presence instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tensorrt_llm/_torch/models/checkpoints/hf/qwen3_5_weight_mapper.py` around
lines 459 - 480, Avoid using tensor truthiness when picking scalar fallback
values in the Qwen3 weight mapper. In the scalar-scale handling inside the
branch that builds packed weights, update the representative selection in the
qkv/q/k/v path and the b/a path so it chooses by key presence rather than `or`,
since 0-D tensors with value 0 can be treated as false. Use the existing
`tensors` mapping in `qwen3_5_weight_mapper.py` to explicitly prefer
`"qkv"`/`"b"` when present, otherwise fall back to the other candidate keys
without relying on boolean evaluation.

The scalar weight_scale / input_scale lookup used "or" chains:
  representative = tensors.get("qkv") or next(...)
  representative_ba = tensors.get("b") or tensors.get("a")

For a 0-D torch tensor, bool(tensor) reduces to bool(tensor.item()),
so a legitimate zero-valued scalar would silently fall through to
the next candidate. Replace with explicit membership checks so
the value of the tensor never affects the dispatch. CodeRabbit
review nit.

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
@moraxu

moraxu commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

FYI, I'm refactoring some common utils in #14599

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
@mihai-chiorean

Copy link
Copy Markdown
Contributor Author

FYI, I'm refactoring some common utils in #14599

@moraxu sweet! how about I rebase on your branch since you have a bunch of stamps and that way I only keep what's relevant on top of yours? and... shameless plug... I have #12301 approved it just needs CI. any chance you can spare a minute to trigger the bot? I don't think it starts if I tell it :)

@ramazangur

Copy link
Copy Markdown

Data point: this also affects consumer RTX 5090 (sm_120). On tensorrt-llm==1.3.0rc20, loading nvidia/Qwen3.6-35B-A3B-NVFP4 via the LLM API fails during executor init with ValueError: 'W4A16_NVFP4' is not a valid QuantAlgo — the same enum gap this PR addresses. Looking forward to it landing.

@mihai-chiorean

Copy link
Copy Markdown
Contributor Author

Data point: this also affects consumer RTX 5090 (sm_120). On tensorrt-llm==1.3.0rc20, loading nvidia/Qwen3.6-35B-A3B-NVFP4 via the LLM API fails during executor init with ValueError: 'W4A16_NVFP4' is not a valid QuantAlgo — the same enum gap this PR addresses. Looking forward to it landing.

I have a few PRs that will get this going. I've only tested on sm121 and this is only a draft right now because I want to rebase once #14599 merges. hopefully I can get some reviews

Signed-off-by: Mihai Chiorean <mihai.v.chiorean@gmail.com>
@mihai-chiorean mihai-chiorean changed the title [#14575][feat] Add Qwen3.5/3.6 MoE NVFP4 + MTP support for SM120/SM121 [#14287][feat] Add Qwen3.5/3.6 MoE NVFP4 + MTP support for SM120/SM121 Jul 1, 2026
@mihai-chiorean

Copy link
Copy Markdown
Contributor Author

superseded upstream

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants