-
Notifications
You must be signed in to change notification settings - Fork 2.7k
[None][fix] Honour producer ignored layers for fused MoE and any quant format #17551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -654,11 +654,8 @@ def load_hf_quant_config(hf_quant_config, moe_backend, checkpoint_dir=None): | |
| default_exclude = ["*kv_b_proj*", "*k_b_proj*", "*eh_proj"] | ||
|
|
||
| # Merge HF config's modules_to_not_convert with default exclude_modules | ||
| if hf_exclude_modules is not None: | ||
| quant_config.exclude_modules = list( | ||
| set(hf_exclude_modules + default_exclude)) | ||
| else: | ||
| quant_config.exclude_modules = default_exclude | ||
| merged = list(hf_exclude_modules or []) + default_exclude | ||
| quant_config.exclude_modules = list(dict.fromkeys(merged)) | ||
| # MXFP4 checkpoints. | ||
| elif hf_quant_config.get("quant_method") == "mxfp4": | ||
| quant_config.quant_algo = ModelConfig.get_mxfp4_quant_algo( | ||
|
|
@@ -713,6 +710,21 @@ def load_hf_quant_config(hf_quant_config, moe_backend, checkpoint_dir=None): | |
| dict.fromkeys(hf_exclude_modules + default_exclude)) | ||
| else: | ||
| quant_config.exclude_modules = default_exclude | ||
|
|
||
| # Honour the producer's "leave these layers unquantized" list, whatever | ||
| # the quant format. Compressed-tensors spells it "ignore"; HF fp8 and | ||
| # mxfp8 spell it "ignored_layers" (modelopt returns early above, so it | ||
| # never reaches here). These layers carry no quant scales in the | ||
| # checkpoint and must be built as bf16 (per-head g_proj whose out dim is | ||
| # not a multiple of the block alignment, and MoE experts the producer | ||
| # kept in bf16 for quality). Merged on top of whatever per-format | ||
| # defaults were set above. | ||
| producer_ignored = list(hf_quant_config.get("ignored_layers", []) or []) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new cross-format merge already folds in |
||
| producer_ignored += list(hf_quant_config.get("ignore", []) or []) | ||
| if producer_ignored: | ||
| existing = quant_config.exclude_modules or [] | ||
| quant_config.exclude_modules = list( | ||
| dict.fromkeys(list(existing) + producer_ignored)) | ||
| return quant_config, layer_quant_config | ||
|
|
||
| @staticmethod | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |
| from transformers import PretrainedConfig | ||
|
|
||
| from tensorrt_llm.functional import PositionEmbeddingType, RotaryScalingType | ||
| from tensorrt_llm.models.modeling_utils import QuantConfig | ||
|
|
||
| from ..attention_backend import AttentionMetadata | ||
| from ..attention_backend.interface import ( | ||
|
|
@@ -31,6 +32,7 @@ | |
| RopeParams, | ||
| ) | ||
| from ..distributed import AllReduce, AllReduceParams | ||
| from ..model_config import ModelConfig | ||
| from ..modules.attention import _helix_cp_allgather_input, _helix_cp_output_projection | ||
| from ..modules.decoder_layer import DecoderLayer | ||
| from ..modules.embedding import Embedding | ||
|
|
@@ -226,6 +228,39 @@ def forward( | |
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def g_proj_quant_config( | ||
| model_config: ModelConfig[PretrainedConfig], layer_idx: Optional[int] | ||
| ) -> Optional[QuantConfig]: | ||
| """Resolve g_proj's quant config, dropping it if the checkpoint excludes it. | ||
|
|
||
| g_proj's output dim is the head count, not a multiple of the FP8 block | ||
| alignment, so a quantised g_proj trips Linear.__init__'s assert under | ||
| tp_size > 1. __post_init__'s generic exclusion pass runs too late. | ||
| """ | ||
| if layer_idx is None: | ||
| return model_config.get_quant_config() | ||
|
|
||
| name = f"model.layers.{layer_idx}.self_attn.g_proj" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This hardcodes the module path that |
||
| # Resolve this layer's own config for a MIXED_PRECISION checkpoint; fall | ||
| # back to the global config when there is no per-layer entry for g_proj. | ||
| try: | ||
| quant_config = model_config.get_quant_config(name) | ||
| except ValueError: | ||
| quant_config = model_config.get_quant_config() | ||
| if quant_config is None: | ||
| return quant_config | ||
|
|
||
| if not quant_config.is_module_excluded_from_quantization(name): | ||
| return quant_config | ||
|
|
||
| # Keep kv_cache_quant_algo: QuantMode derives the KV-cache mode from it, so | ||
| # dropping it would silently disable FP8 KV cache for this module. | ||
| return QuantConfig( | ||
| quant_algo=None, | ||
| kv_cache_quant_algo=quant_config.kv_cache_quant_algo, | ||
| ) | ||
|
|
||
|
|
||
| class LagunaAttention(QKNormRoPEAttention): | ||
| """Laguna attention with per-head softplus gating, per-layer heads, | ||
| dual RoPE, and sliding window.""" | ||
|
|
@@ -298,7 +333,7 @@ def __init__( | |
| dtype=config.torch_dtype, | ||
| mapping=model_config.mapping, | ||
| tensor_parallel_mode=g_tp_mode, | ||
| quant_config=model_config.get_quant_config(), | ||
| quant_config=g_proj_quant_config(model_config, layer_idx), | ||
| ) | ||
|
|
||
| @staticmethod | ||
|
|
@@ -690,9 +725,13 @@ def handle_special_instance_module( | |
| .replace("up_proj", "w3") | ||
| .replace("down_proj", "w2") | ||
| ) | ||
| if use_fp8_block_rename: | ||
| if use_fp8_block_rename and "weight_scale_inv" not in new_wn: | ||
| # ModelOpt uses "weight_scale"; TRT-LLM DeepSeekFP8 | ||
| # quantization expects "weight_scale_inv" (same values). | ||
| # Skip names already using "weight_scale_inv" | ||
| # (compressed-tensors / DeepSeek style): a blanket replace | ||
| # turns it into "weight_scale_inv_inv", which silently | ||
| # drops the block scales and leaves them uninitialised. | ||
| new_wn = new_wn.replace("weight_scale", "weight_scale_inv") | ||
| # filter_weights strips module prefix leaving "experts.X.w1.weight"; | ||
| # VANILLA mode expects "X.w1.weight" (integer prefix only). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -605,6 +605,7 @@ def apply_quant_config_exclude_modules(self): | |
| if quant_config.exclude_modules is not None: | ||
| for name, module in self.named_modules(): | ||
| candidates = [name] | ||
| moe_expert_exclusions = None | ||
| if isinstance(module, Linear): | ||
| weight_mode = module.weights_loading_config.weight_mode | ||
| if weight_mode == WeightMode.FUSED_GATE_UP_LINEAR: | ||
|
|
@@ -622,9 +623,54 @@ def apply_quant_config_exclude_modules(self): | |
| name.replace('qkv_proj', 'k_proj'), | ||
| name.replace('qkv_proj', 'v_proj') | ||
| ] | ||
| elif isinstance(module, (MoE, VanillaMoE)): | ||
| # Fused MoE: a checkpoint's ignore list names the | ||
| # per-expert weights (re:...experts.[0-9]+.gate_proj), | ||
| # but the fused module has no per-expert child, so such | ||
| # a rule never matches and those experts get quantized | ||
| # despite being bf16 in the checkpoint. Probe every | ||
| # expert index so a rule targeting any single expert | ||
| # (not only expert 0) is honoured. | ||
| # | ||
| # Strip a trailing ".backend" for these synthesized | ||
| # candidates: ConfigurableMoE wraps the module that owns | ||
| # the weights, so a synthesized name must point at the | ||
| # backend (create_weights() delegates to it). A plain | ||
| # rule naming the wrapper already reaches the backend via | ||
| # is_module_excluded_from_quantization's ancestor walk, | ||
| # so the strip is only needed here. | ||
| base = (name[:-len('.backend')] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth noting in the comment that the |
||
| if name.endswith('.backend') else name) | ||
| num_experts = getattr(module, 'num_experts', None) | ||
| expert_ids = (range(num_experts) | ||
| if isinstance(num_experts, int) | ||
| and num_experts > 0 else [0]) | ||
| moe_expert_exclusions = [ | ||
| any( | ||
| quant_config. | ||
| is_module_excluded_from_quantization( | ||
| f'{base}.{e}.{proj}') | ||
| for proj in ('gate_proj', 'up_proj', | ||
| 'down_proj')) for e in expert_ids | ||
| ] | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| is_excluded = any( | ||
| quant_config.is_module_excluded_from_quantization(n) | ||
| for n in candidates) | ||
| if moe_expert_exclusions is not None: | ||
| n_excluded = sum(moe_expert_exclusions) | ||
| if 0 < n_excluded < len(moe_expert_exclusions): | ||
| # A fused MoE shares one quant config across all | ||
| # experts, so a partial ignore list cannot be | ||
| # honoured per-expert. Treat the module as excluded | ||
| # (never leave a bf16 expert quantized) but warn so | ||
| # the partial list is visible rather than silently | ||
| # rounded to all-or-nothing. | ||
| logger.warning( | ||
| f"Quantization ignore list excludes " | ||
| f"{n_excluded}/{len(moe_expert_exclusions)} " | ||
| f"experts of fused module '{base}'; treating " | ||
| f"the whole module as excluded.") | ||
| is_excluded = is_excluded or n_excluded > 0 | ||
| if is_excluded and getattr(module, "quant_config", | ||
| None) is not None: | ||
| module.quant_config = new_config | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| """A producer's per-expert ignore rule must de-quantize the fused MoE. | ||
|
|
||
| The fused module has no per-expert child, so a rule naming an expert's weights | ||
| (``...experts.<i>.up_proj``) only matches synthesized candidates. The pass must | ||
| probe every expert index (not just expert 0), strip a ``.backend`` wrapper, and | ||
| warn rather than silently round a partial list to all-or-nothing. | ||
| """ | ||
|
|
||
| from types import SimpleNamespace | ||
|
|
||
| import pytest | ||
| import torch.nn as nn | ||
|
|
||
| from tensorrt_llm._torch.models import modeling_utils | ||
| from tensorrt_llm._torch.modules.fused_moe import MoE | ||
| from tensorrt_llm.models.modeling_utils import QuantConfig | ||
| from tensorrt_llm.quantization.mode import QuantAlgo | ||
|
|
||
| pytestmark = pytest.mark.cpu_only | ||
|
|
||
| NUM_EXPERTS = 8 | ||
| BASE = "model.layers.3.mlp.experts" | ||
|
|
||
|
|
||
| class _FakeMoE(MoE): | ||
| """Minimal MoE stand-in exposing only what the exclusion pass reads.""" | ||
|
|
||
| def __init__(self, num_experts: int) -> None: | ||
| nn.Module.__init__(self) | ||
| self.num_experts = num_experts | ||
| self.quant_config = QuantConfig(quant_algo=QuantAlgo.FP8_BLOCK_SCALES) | ||
| self._weights_created = True | ||
|
|
||
|
|
||
| def _attach(root: nn.Module, path: str, leaf: nn.Module) -> None: | ||
| """Register ``leaf`` under ``root`` at a dotted path, so ``named_modules`` | ||
| yields ``path`` (plain nn.Modules stand in for the intermediate wrappers).""" | ||
| parent = root | ||
| parts = path.split(".") | ||
| for part in parts[:-1]: | ||
| child = parent._modules.get(part) | ||
| if child is None: | ||
| child = nn.Module() | ||
| parent.add_module(part, child) | ||
| parent = child | ||
| parent.add_module(parts[-1], leaf) | ||
|
|
||
|
|
||
| def _apply_exclusions(moe_path: str, exclude_modules: list[str]) -> _FakeMoE: | ||
| moe = _FakeMoE(NUM_EXPERTS) | ||
| model = nn.Module() | ||
| _attach(model, moe_path, moe) | ||
| model.model_config = SimpleNamespace( | ||
| quant_config=QuantConfig( | ||
| quant_algo=QuantAlgo.FP8_BLOCK_SCALES, exclude_modules=list(exclude_modules) | ||
| ) | ||
| ) | ||
| modeling_utils.DecoderModelForCausalLM.apply_quant_config_exclude_modules(model) | ||
| return moe | ||
|
|
||
|
|
||
| def test_exact_nonzero_expert_index_dequantizes() -> None: | ||
| """Regression: an exact rule for a nonzero expert must de-quantize the | ||
| module. The old code only probed expert 0, so such rules never matched.""" | ||
| last = NUM_EXPERTS - 1 | ||
| moe = _apply_exclusions(BASE, [f"{BASE}.{last}.up_proj", "lm_head"]) | ||
|
|
||
| assert moe.quant_config.quant_algo is None | ||
| assert moe._weights_created is False | ||
|
|
||
|
|
||
| def test_backend_wrapped_moe_is_dequantized() -> None: | ||
| """ConfigurableMoE wraps the weight-owning backend; the ``.backend`` suffix | ||
| must be stripped so the producer's expert rule reaches the backend.""" | ||
| moe = _apply_exclusions(f"{BASE}.backend", [f"{BASE}.5.down_proj", "lm_head"]) | ||
|
|
||
| assert moe.quant_config.quant_algo is None | ||
| assert moe._weights_created is False | ||
|
|
||
|
|
||
| def test_partial_expert_list_warns_and_dequantizes(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| """A fused module shares one quant config, so a partial list cannot be | ||
| honoured per-expert: warn, and treat the whole module as excluded.""" | ||
| warnings: list[str] = [] | ||
| monkeypatch.setattr(modeling_utils.logger, "warning", lambda msg, *a, **k: warnings.append(msg)) | ||
|
|
||
| exclude = [f"{BASE}.{e}.up_proj" for e in range(3)] + ["lm_head"] | ||
| moe = _apply_exclusions(BASE, exclude) | ||
|
|
||
| assert moe.quant_config.quant_algo is None | ||
| assert any("fused module" in w for w in warnings) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| """g_proj must not be built quantised when the checkpoint excludes it. | ||
|
|
||
| Only tp_size > 1 takes the sharding path, so this presents as a sharding | ||
| failure rather than a quantisation one. The existing Laguna accuracy tests are | ||
| all TP=1 and would not catch a regression. | ||
| """ | ||
|
|
||
| from types import SimpleNamespace | ||
|
|
||
| import pytest | ||
|
|
||
| from tensorrt_llm._torch.models.modeling_laguna import g_proj_quant_config | ||
| from tensorrt_llm.models.modeling_utils import QuantConfig | ||
| from tensorrt_llm.quantization.mode import QuantAlgo | ||
|
|
||
| pytestmark = pytest.mark.cpu_only | ||
|
|
||
| LAYER_IDX = 3 | ||
| G_PROJ = f"model.layers.{LAYER_IDX}.self_attn.g_proj" | ||
|
|
||
|
|
||
| def _model_config(exclude_modules: list[str]) -> SimpleNamespace: | ||
| quant_config = QuantConfig( | ||
| quant_algo=QuantAlgo.FP8_BLOCK_SCALES, | ||
| kv_cache_quant_algo=QuantAlgo.FP8, | ||
| exclude_modules=exclude_modules, | ||
| ) | ||
| return SimpleNamespace(get_quant_config=lambda name=None: quant_config) | ||
|
|
||
|
|
||
| # Exact name, ancestor walk, ".*" subtree, glob and "re:" regex. The fleet uses | ||
| # literal names for g_proj but regexes for the fused-MoE exclusions. | ||
| @pytest.mark.parametrize( | ||
| "pattern", | ||
| [ | ||
| G_PROJ, | ||
| f"model.layers.{LAYER_IDX}.self_attn", | ||
| f"model.layers.{LAYER_IDX}", | ||
| f"model.layers.{LAYER_IDX}.*", | ||
| "*g_proj*", | ||
| r"re:.*\.self_attn\.g_proj$", | ||
| ], | ||
| ) | ||
| def test_excluded_g_proj_is_not_quantised(pattern: str) -> None: | ||
| resolved = g_proj_quant_config(_model_config([pattern, "lm_head"]), LAYER_IDX) | ||
|
|
||
| assert resolved.quant_algo is None, f"{pattern!r} did not exclude g_proj" | ||
| # KV cache mode is derived from this, so it must survive the override. | ||
| assert resolved.kv_cache_quant_algo == QuantAlgo.FP8 | ||
|
|
||
|
|
||
| def test_unexcluded_g_proj_keeps_the_model_quant_config() -> None: | ||
| """Control: a checkpoint that does quantise g_proj is left alone.""" | ||
| model_config = _model_config(["lm_head", "model.layers.9.self_attn.g_proj"]) | ||
|
|
||
| resolved = g_proj_quant_config(model_config, LAYER_IDX) | ||
|
|
||
| assert resolved is model_config.get_quant_config() | ||
| assert resolved.quant_algo == QuantAlgo.FP8_BLOCK_SCALES | ||
|
|
||
|
|
||
| def test_unquantised_model_is_passed_through() -> None: | ||
| model_config = SimpleNamespace(get_quant_config=lambda name=None: None) | ||
|
|
||
| assert g_proj_quant_config(model_config, LAYER_IDX) is None | ||
|
|
||
|
|
||
| def test_missing_layer_idx_does_not_match_a_literal_none() -> None: | ||
| """layer_idx is Optional; without the guard the name would render as | ||
| "model.layers.None.self_attn.g_proj" and silently match nothing.""" | ||
| model_config = _model_config(["model.layers.None.self_attn.g_proj"]) | ||
|
|
||
| resolved = g_proj_quant_config(model_config, None) | ||
|
|
||
| assert resolved.quant_algo == QuantAlgo.FP8_BLOCK_SCALES | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment attributes
ignored_layersto modelopt, but modelopt configs never reach here — line 520 returns early via_build_modelopt_quant_config, andread_modelopt_quant_configrenames modelopt'signoretoexclude_modulesitself. Drop modelopt from the list so a future reader doesn't assume this block covers that path.