Files
Model-Optimizer/tests/unit/torch/quantization/plugins/test_sparse_moe.py
T
Chenjie Luo ac7c985d96 [NVBUG: 5804406] Auto detect MOE layers (#900)
## What does this PR do?

**Type of change:** New feature, new tests

**Overview:** Replace hardcoded per-model MoE class registrations
(Mixtral, Qwen2Moe, Qwen3Moe, Qwen3Next, Llama4TextMoe, Qwen3VLMoe,
MiniMaxM2, etc.) with a single generic auto-detection mechanism
(`register_sparse_moe_on_the_fly`) that walks the model tree and
identifies MoE blocks by their structural attributes (`gate` + `experts`
with `top_k`/`num_experts`). This makes MoE quantization
forward-compatible with new HuggingFace MoE architectures without
requiring explicit registration for each model family.

Additionally, this PR:
- Tracks per-expert token routing counts during calibration via a gate
forward hook, enabling visibility into expert utilization.
- Saves an HTML report of expert token counts during export
(`save_expert_token_count_table`), highlighting under-utilized experts.
- Fixes the `topk` -> `top_k` attribute name for transformers >= 5.0
compatibility.
- Also move the ptq summary prints to a file in hf_ptq.py to reduce the
prints

## Usage

Auto-detection is transparent -- no user-facing API changes are needed.
Any HuggingFace MoE model with the standard `gate`/`experts` pattern is
automatically detected and quantized:

import modelopt.torch.quantization as mtq

# Any HuggingFace MoE model (Mixtral, Qwen3Moe, DeepSeek, etc.)
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-30B-A3B")

mtq.quantize(model, mtq.INT8_DEFAULT_CFG, forward_loop)

# During export, an .moe.html report with per-expert token counts is
saved automatically

## Testing
unittest, also test exporting qwen MOE

## Before your PR is "*Ready for review*"
<!-- If you haven't finished some of the above items you can still open
`Draft` PR. -->

- **Make sure you read and follow [Contributor
guidelines](https://github.com/NVIDIA/Model-Optimizer/blob/main/CONTRIBUTING.md)**
and your commits are signed.
- **Is this change backward compatible?**: Yes/No <!--- If No, explain
why. -->
- **Did you write any new necessary tests?**: Yes/No
- **Did you add or update any necessary documentation?**: Yes/No
- **Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?**:
Yes/No <!--- Only for new features, API changes, critical bug fixes or
bw breaking changes. -->

## Additional Information
<!-- E.g. related issue. -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added expert token count visualization for Mixture of Experts models,
exported as HTML reports during model export.
* Enhanced sparse MoE quantization with improved calibration-aware
routing and automatic model block detection.

* **Tests**
* Added comprehensive test suite for sparse MoE quantization validation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
2026-02-19 19:46:58 +00:00

327 lines
12 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2024 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.
"""Tests for _is_sparse_moe_block and _QuantSparseMoe."""
import pytest
import torch
import torch.nn as nn
pytest.importorskip("transformers")
from _test_utils.torch.transformers_models import get_tiny_qwen3_moe
import modelopt.torch.quantization as mtq
from modelopt.torch.quantization.nn import QuantModuleRegistry
from modelopt.torch.quantization.plugins.huggingface import (
TRANSFORMERS_VERSION_GE_5_0,
_is_sparse_moe_block,
register_sparse_moe_on_the_fly,
)
# ---------------------------------------------------------------------------
# Helpers: lightweight mock modules for _is_sparse_moe_block
# ---------------------------------------------------------------------------
class _FakeGateWithRouter(nn.Module):
"""Mimics a v5.x TopKRouter gate with top_k and num_experts."""
def __init__(self, top_k=2, num_experts=4):
super().__init__()
self.top_k = top_k
self.num_experts = num_experts
self.linear = nn.Linear(8, num_experts)
def forward(self, x):
return self.linear(x)
class _FakeExperts(nn.ModuleList):
def __init__(self, n=4):
super().__init__([nn.Linear(8, 8) for _ in range(n)])
self.num_experts = n
class _MoEBlockWithGateRouter(nn.Module):
"""Matches the primary detection path: gate.top_k + gate.num_experts."""
def __init__(self, num_experts=4, top_k=2):
super().__init__()
self.gate = _FakeGateWithRouter(top_k=top_k, num_experts=num_experts)
self.experts = _FakeExperts(num_experts)
def forward(self, hidden_states):
logits = self.gate(hidden_states)
routing_weights, selected = torch.topk(logits, self.gate.top_k, dim=-1)
out = torch.zeros_like(hidden_states)
for i in range(self.gate.num_experts):
mask = (selected == i).any(dim=-1)
if mask.any():
out[mask] += self.experts[i](hidden_states[mask])
return out
class _MoEBlockFallback(nn.Module):
"""Matches the fallback path: top_k + num_experts on the block itself."""
def __init__(self, num_experts=4, top_k=2):
super().__init__()
self.num_experts = num_experts
self.top_k = top_k
self.gate = nn.Linear(8, num_experts)
self.experts = _FakeExperts(num_experts)
def forward(self, hidden_states):
logits = self.gate(hidden_states)
routing_weights, selected = torch.topk(logits, self.top_k, dim=-1)
out = torch.zeros_like(hidden_states)
for i in range(self.num_experts):
mask = (selected == i).any(dim=-1)
if mask.any():
out[mask] += self.experts[i](hidden_states[mask])
return out
# ---------------------------------------------------------------------------
# Tests for _is_sparse_moe_block
# ---------------------------------------------------------------------------
class TestIsSparseBlock:
def test_no_experts_returns_false(self):
module = nn.Linear(8, 8)
assert _is_sparse_moe_block(module) is False
def test_experts_but_no_gate_or_topk_returns_false(self):
module = nn.Module()
module.experts = nn.ModuleList([nn.Linear(8, 8)])
assert _is_sparse_moe_block(module) is False
def test_gate_with_router_attrs_returns_true(self):
block = _MoEBlockWithGateRouter(num_experts=4, top_k=2)
assert _is_sparse_moe_block(block) is True
def test_fallback_block_level_attrs_returns_true(self):
block = _MoEBlockFallback(num_experts=4, top_k=2)
assert _is_sparse_moe_block(block) is True
def test_gate_missing_num_experts_returns_false(self):
"""gate.top_k present but gate.num_experts absent -> primary path fails."""
module = nn.Module()
module.experts = nn.ModuleList([nn.Linear(8, 8)])
gate = nn.Module()
gate.top_k = 2
module.gate = gate
assert _is_sparse_moe_block(module) is False
def test_gate_missing_top_k_returns_false(self):
"""gate.num_experts present but gate.top_k absent -> primary path fails."""
module = nn.Module()
module.experts = nn.ModuleList([nn.Linear(8, 8)])
gate = nn.Module()
gate.num_experts = 4
module.gate = gate
assert _is_sparse_moe_block(module) is False
def test_block_level_only_top_k_returns_false(self):
"""Only top_k on block (no num_experts) -> fallback fails."""
module = nn.Module()
module.experts = nn.ModuleList([nn.Linear(8, 8)])
module.top_k = 2
assert _is_sparse_moe_block(module) is False
def test_block_level_only_num_experts_returns_false(self):
"""Only num_experts on block (no top_k) -> fallback fails."""
module = nn.Module()
module.experts = nn.ModuleList([nn.Linear(8, 8)])
module.num_experts = 4
assert _is_sparse_moe_block(module) is False
def test_glm4_like_block_rejected(self):
"""A module with n_routed_experts instead of num_experts should be rejected."""
module = nn.Module()
module.experts = nn.ModuleList([nn.Linear(8, 8)])
gate = nn.Module()
gate.top_k = 2
gate.n_routed_experts = 4 # different attr name
module.gate = gate
assert _is_sparse_moe_block(module) is False
# ---------------------------------------------------------------------------
# Tests for _QuantSparseMoe
# ---------------------------------------------------------------------------
class TestQuantSparseMoe:
"""Tests for _QuantSparseMoe using a real tiny Qwen3Moe model."""
@staticmethod
def _get_moe_block(model):
"""Return the first MoE block from the model."""
for module in model.modules():
if _is_sparse_moe_block(module):
return module
raise RuntimeError("No MoE block found in model")
def test_register_sparse_moe_on_the_fly(self):
model = get_tiny_qwen3_moe()
moe_block = self._get_moe_block(model)
moe_type = type(moe_block)
if QuantModuleRegistry.get(moe_type) is not None:
pytest.skip("MoE type already registered (upstream change)")
register_sparse_moe_on_the_fly(model)
assert QuantModuleRegistry.get(moe_type) is not None
def test_setup_creates_expert_token_count(self):
model = get_tiny_qwen3_moe()
moe_block = self._get_moe_block(model)
moe_type = type(moe_block)
if QuantModuleRegistry.get(moe_type) is None:
register_sparse_moe_on_the_fly(model)
converted = QuantModuleRegistry.convert(moe_block)
assert hasattr(converted, "expert_token_count")
if hasattr(moe_block, "gate") and hasattr(moe_block.gate, "num_experts"):
expected_num_experts = moe_block.gate.num_experts
elif hasattr(moe_block, "num_experts"):
expected_num_experts = moe_block.num_experts
elif hasattr(moe_block, "experts") and hasattr(moe_block.experts, "num_experts"):
expected_num_experts = moe_block.experts.num_experts
else:
expected_num_experts = 0
assert converted.expert_token_count.shape == (expected_num_experts,)
assert converted.expert_token_count.dtype == torch.long
assert (converted.expert_token_count == 0).all()
def test_setup_count_expert_tokens_default_false(self):
model = get_tiny_qwen3_moe()
moe_block = self._get_moe_block(model)
moe_type = type(moe_block)
if QuantModuleRegistry.get(moe_type) is None:
register_sparse_moe_on_the_fly(model)
converted = QuantModuleRegistry.convert(moe_block)
assert converted._count_expert_tokens is False
def test_forward_no_calib_matches_original(self):
"""When calibration is off, _QuantSparseMoe should produce the same output as the original."""
model = get_tiny_qwen3_moe()
moe_block = self._get_moe_block(model)
moe_type = type(moe_block)
if QuantModuleRegistry.get(moe_type) is None:
register_sparse_moe_on_the_fly(model)
ref_block = self._get_moe_block(get_tiny_qwen3_moe())
ref_block.load_state_dict(moe_block.state_dict())
converted = QuantModuleRegistry.convert(moe_block)
torch.manual_seed(42)
x = torch.randn(1, 4, 32)
with torch.no_grad():
out_ref = ref_block(x)
out_test = converted(x)
if isinstance(out_ref, tuple):
out_ref = out_ref[0]
if isinstance(out_test, tuple):
out_test = out_test[0]
assert torch.allclose(out_ref, out_test, atol=1e-5)
def test_forward_calib_sends_all_tokens_to_all_experts(self):
"""During calibration, all experts should see tokens (expert_token_count all > 0)."""
model = get_tiny_qwen3_moe()
register_sparse_moe_on_the_fly(model)
def calib_fn(model):
x = model.dummy_inputs["input_ids"]
model(x)
mtq.quantize(model, mtq.INT8_DEFAULT_CFG, calib_fn)
for name, module in model.named_modules():
if hasattr(module, "expert_token_count") and module.expert_token_count.numel() > 0:
assert (module.expert_token_count > 0).all(), (
f"Not all experts received tokens in {name}: {module.expert_token_count}"
)
def test_forward_calib_restores_top_k(self):
"""After calibration forward, top_k should be restored to its original value."""
model = get_tiny_qwen3_moe()
moe_block = self._get_moe_block(model)
moe_type = type(moe_block)
if QuantModuleRegistry.get(moe_type) is None:
register_sparse_moe_on_the_fly(model)
if TRANSFORMERS_VERSION_GE_5_0:
original_top_k = moe_block.gate.top_k
else:
original_top_k = moe_block.top_k
converted = QuantModuleRegistry.convert(moe_block)
# Simulate calibration mode: set _if_calib on a child TensorQuantizer
for m in converted.experts.modules():
if hasattr(m, "_if_calib"):
m._if_calib = True
break
x = torch.randn(1, 4, 32)
with torch.no_grad():
converted(x)
if TRANSFORMERS_VERSION_GE_5_0:
assert converted.gate.top_k == original_top_k
else:
assert converted.top_k == original_top_k
def test_gate_forward_hook_counts_tokens(self):
"""Verify the gate forward hook correctly counts expert token assignments."""
model = get_tiny_qwen3_moe()
moe_block = self._get_moe_block(model)
moe_type = type(moe_block)
if QuantModuleRegistry.get(moe_type) is None:
register_sparse_moe_on_the_fly(model)
converted = QuantModuleRegistry.convert(moe_block)
# Reset counts and enable counting
converted.expert_token_count.zero_()
converted._count_expert_tokens = True
if TRANSFORMERS_VERSION_GE_5_0:
hidden_size = converted.gate.weight.shape[1]
top_k = converted.gate.top_k
else:
hidden_size = converted.gate.in_features
top_k = converted.top_k if hasattr(converted, "top_k") else converted.gate.top_k
x = torch.randn(8, hidden_size)
with torch.no_grad():
converted.gate(x)
total_assigned = converted.expert_token_count.sum().item()
assert total_assigned == 8 * top_k
# Disable counting and verify counts don't change
converted._count_expert_tokens = False
prev_counts = converted.expert_token_count.clone()
with torch.no_grad():
converted.gate(x)
assert torch.equal(converted.expert_token_count, prev_counts)