Skip to content

Commit dca6ecd

Browse files
authored
Registry-based module dispatch for unified_export_hf.py (#1939)
### What does this PR do? Type of change: ? refactor Use registry-based module dispatch for unified_export_hf.py export handlers do not replace classes, need structural matching, and preparation/export require independent ordering ### Usage same as before ### Testing old and new unittest ### Before your PR is "*Ready for review*" Make sure you read and follow [Contributor guidelines](https://github.com/NVIDIA/Model-Optimizer/blob/main/CONTRIBUTING.md) and your commits are signed (`git commit -s -S`). Make sure you read and follow the [Security Best Practices](https://github.com/NVIDIA/Model-Optimizer/blob/main/SECURITY.md#security-coding-practices-for-contributors) (e.g. avoiding hardcoded `trust_remote_code=True`, `torch.load(..., weights_only=False)`, `pickle`, etc.). - Is this change backward compatible?: ✅ - If you copied code from any other sources or added a new PIP dependency, did you follow guidance in `CONTRIBUTING.md`:N/A - Did you write any new necessary tests?: ✅ - Did you update [Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)? N/A - Did you get Claude approval on this PR?: ✅ / ❌ / N/A <!--- Run `/claude review`. NVIDIA org members can self-trigger for complex changes; orthogonal to CodeRabbit. --> ### Additional Information <!-- E.g. related issue. --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary * **New Features** * Added registry-driven export dispatch for Hugging Face quantized models, improving coverage across standard layers and multiple MoE/expert variants. * Introduced a shared export context to manage per-export state such as tied-weight handling. * **Bug Fixes** * Improved quantized/tied-weight export behavior to prevent duplicated or incorrectly packed weights. * Enhanced MoE expert “amax” preparation to support more expert structures. * **Tests** * Added unit tests for registry matching, precedence/replacement behavior, and quantized export paths. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Shiyang Chen <shiychen@nvidia.com>
1 parent 92296ae commit dca6ecd

5 files changed

Lines changed: 687 additions & 162 deletions

File tree

modelopt/torch/export/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .model_utils import *
2222
from .moe_utils import *
2323
from .plugins import *
24+
from .registry import *
2425
from .transformer_engine import *
2526
from .unified_export_hf import *
2627
from .unified_export_megatron import *
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Built-in module handlers for unified Hugging Face export."""
17+
18+
import collections.abc
19+
import warnings
20+
21+
import torch.nn as nn
22+
23+
from modelopt.torch.quantization.utils import fsdp2_aware_weight_update
24+
25+
from .layer_utils import get_expert_linear_names, is_quantlinear, set_expert_quantizer_amax
26+
from .model_config import QUANTIZATION_NONE
27+
from .moe_utils import _export_fused_experts
28+
from .quant_utils import get_quantization_format
29+
from .registry import ExportContext, ExportModuleRegistry, PrepareMoEInputsRegistry
30+
31+
__all__: list[str] = []
32+
33+
34+
def _has_fused_experts_quantizers(module: nn.Module) -> bool:
35+
first_proj_attr = getattr(module, "_first_proj_attr", "gate_up_proj")
36+
return hasattr(module, f"{first_proj_attr}_weight_quantizers")
37+
38+
39+
def _export_weight(
40+
module: nn.Module,
41+
ctx: ExportContext,
42+
weight_name: str = "weight",
43+
) -> None:
44+
# Imported lazily to avoid a cycle: unified_export_hf imports this module to
45+
# install the built-in handlers while retaining this legacy helper's import path.
46+
from .unified_export_hf import _export_quantized_weight
47+
48+
_export_quantized_weight(module, ctx.dtype, weight_name, _tied_cache=ctx.tied_cache)
49+
50+
51+
# Preparation handlers are registered in the same precedence as the legacy MoE prepass.
52+
53+
54+
# Keyed on the mixin class name too: the generated class is normally named
55+
# "QuantDbrxExperts", but _DMRegistryCls falls back to a module-prefixed name on
56+
# collision, while "_QuantDbrxExperts" remains in the generated class's MRO.
57+
@PrepareMoEInputsRegistry.register("QuantDbrxExperts", "_QuantDbrxExperts")
58+
def _prepare_dbrx_experts(name: str, moe_module: nn.Module, ctx: ExportContext) -> None:
59+
"""Fill missing input amax values for DBRX per-expert ModuleLists."""
60+
experts_mlp = moe_module.experts.mlp
61+
for linear_name in get_expert_linear_names(moe_module):
62+
if hasattr(experts_mlp, linear_name):
63+
linear_modulelist = getattr(experts_mlp, linear_name)
64+
if hasattr(linear_modulelist, "__iter__"):
65+
set_expert_quantizer_amax(
66+
modules=list(linear_modulelist),
67+
quantizer_attrs=["input_quantizer"],
68+
)
69+
70+
71+
@PrepareMoEInputsRegistry.register(predicate=_has_fused_experts_quantizers)
72+
def _prepare_fused_experts(name: str, moe_module: nn.Module, ctx: ExportContext) -> None:
73+
"""Mark fused experts handled; their missing amax fallback occurs during export."""
74+
75+
76+
@PrepareMoEInputsRegistry.register("Llama4TextExperts", "GptOssExperts")
77+
def _prepare_bmm_experts(name: str, moe_module: nn.Module, ctx: ExportContext) -> None:
78+
"""Fill missing input amax values for fused BMM-style experts."""
79+
# Both use gate_up_proj and down_proj with singular input quantizers
80+
# (gate_up_proj_input_quantizer/down_proj_input_quantizer); the weight-side
81+
# amax fallback and weight export happen in _export_bmm_experts.
82+
for linear_name in ["gate_up_proj", "down_proj"]:
83+
if hasattr(moe_module.experts, linear_name):
84+
linear_module = getattr(moe_module.experts, linear_name)
85+
if hasattr(linear_module, "input_quantizer"):
86+
set_expert_quantizer_amax(
87+
modules=[linear_module],
88+
quantizer_attrs=["input_quantizer"],
89+
)
90+
91+
92+
@PrepareMoEInputsRegistry.register(
93+
predicate=lambda module: isinstance(module, collections.abc.Iterable)
94+
)
95+
def _prepare_iterable_experts(name: str, moe_module: nn.Module, ctx: ExportContext) -> None:
96+
"""Fill missing input amax values for iterable per-expert submodules."""
97+
expert_linear_names = get_expert_linear_names(moe_module)
98+
linear_name = None
99+
try:
100+
for linear_name in expert_linear_names:
101+
set_expert_quantizer_amax(
102+
modules=[getattr(expert, linear_name) for expert in moe_module.experts],
103+
quantizer_attrs=["input_quantizer"],
104+
)
105+
except AttributeError as e:
106+
expert_types = [type(expert).__name__ for expert in moe_module.experts]
107+
raise AttributeError(
108+
f"Failed to access attribute '{linear_name}' on experts. "
109+
f"MoE module type: {type(moe_module).__name__}, "
110+
f"Expert types: {expert_types}, "
111+
f"Expected linear names: {expert_linear_names}. "
112+
f"This suggests the get_expert_linear_names function may need "
113+
f"to be updated for this model architecture. "
114+
f"Original error: {e}"
115+
) from e
116+
117+
118+
# Export handlers are registered in the same precedence as the legacy model walk.
119+
120+
121+
@ExportModuleRegistry.register(
122+
"QuantMoELinear", predicate=lambda module: hasattr(module, "experts")
123+
)
124+
def _export_moe_linear(name: str, module: nn.Module, ctx: ExportContext) -> None:
125+
"""Fill missing input amax before child expert QuantLinears are exported."""
126+
set_expert_quantizer_amax(list(module.experts), quantizer_attrs="input_quantizer")
127+
128+
129+
@ExportModuleRegistry.register(predicate=_has_fused_experts_quantizers)
130+
def _export_fused_experts_module(name: str, module: nn.Module, ctx: ExportContext) -> None:
131+
"""Split and quantize a fused-experts module with plural weight quantizers."""
132+
with fsdp2_aware_weight_update(ctx.model, module, reshard=False):
133+
_export_fused_experts(
134+
module,
135+
ctx.dtype,
136+
_moe_tied_cache=ctx.moe_tied_cache,
137+
_tied_cache=ctx.tied_cache,
138+
)
139+
140+
141+
@ExportModuleRegistry.register(predicate=is_quantlinear)
142+
def _export_quant_linear(name: str, module: nn.Module, ctx: ExportContext) -> None:
143+
"""Export a standard quantized linear layer."""
144+
if get_quantization_format(module) == QUANTIZATION_NONE:
145+
return
146+
try:
147+
with fsdp2_aware_weight_update(ctx.model, module, reshard=False):
148+
_export_weight(module, ctx)
149+
except AssertionError as e:
150+
raise AssertionError(
151+
f"Failed to export module '{name}' (type={type(module).__name__}): {e}"
152+
) from e
153+
154+
155+
@ExportModuleRegistry.register(
156+
nn.Embedding, predicate=lambda module: hasattr(module, "weight_quantizer")
157+
)
158+
def _export_quant_embedding(name: str, module: nn.Module, ctx: ExportContext) -> None:
159+
"""Export a quantized embedding table unless its weight is tied."""
160+
if get_quantization_format(module) == QUANTIZATION_NONE:
161+
return
162+
# Packing replaces .weight, which would sever any Python-level weight tie and
163+
# leave the other module pointing at a stale float Parameter.
164+
tied_to = [
165+
other_name
166+
for other_name, other_module in ctx.model.named_modules()
167+
if other_module is not module and getattr(other_module, "weight", None) is module.weight
168+
]
169+
if tied_to:
170+
warnings.warn(
171+
f"Skipping quantized weight packing for embedding '{name}': its "
172+
f"weight Parameter is shared with {tied_to} (weight tying). Packing "
173+
"would break the tie and produce stale weights in the tied module(s). "
174+
"The embedding will be exported as its fake-quantized float weight."
175+
)
176+
return
177+
try:
178+
with fsdp2_aware_weight_update(ctx.model, module, reshard=False):
179+
_export_weight(module, ctx)
180+
except AssertionError as e:
181+
raise AssertionError(
182+
f"Failed to export embedding '{name}' (type={type(module).__name__}): {e}"
183+
) from e
184+
185+
186+
@ExportModuleRegistry.register("Llama4TextExperts", "GptOssExperts")
187+
def _export_bmm_experts(name: str, module: nn.Module, ctx: ExportContext) -> None:
188+
"""Export fused BMM-style expert weights and quantization metadata."""
189+
if get_quantization_format(module) == QUANTIZATION_NONE:
190+
return
191+
# TODO: consolidate uncalibrated experts handling logic
192+
set_expert_quantizer_amax(
193+
modules=module,
194+
quantizer_attrs=["gate_up_proj_weight_quantizer", "down_proj_weight_quantizer"],
195+
)
196+
set_expert_quantizer_amax(
197+
modules=module,
198+
quantizer_attrs=["gate_up_proj_input_quantizer", "down_proj_input_quantizer"],
199+
)
200+
with fsdp2_aware_weight_update(ctx.model, module, reshard=False):
201+
for weight_name in ["gate_up_proj", "down_proj"]:
202+
_export_weight(module, ctx, weight_name)

modelopt/torch/export/registry.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Registries dispatching per-module logic for the unified HF export path.
17+
18+
This mirrors the registration-and-dispatch idiom of
19+
:class:`QuantModuleRegistry <modelopt.torch.quantization.nn.modules.quant_module.QuantModuleRegistry>`,
20+
but not its mechanism: quantization registers replacement classes and converts modules
21+
in place, whereas export registers functions that emit compressed weights and scale
22+
buffers for a module without changing its class.
23+
24+
Preparation and export use separate registries because they have independent matching
25+
precedence. Registering a handler for a new module type replaces what previously required
26+
editing if/elif chains inside ``unified_export_hf.py``.
27+
"""
28+
29+
from collections.abc import Callable
30+
from dataclasses import dataclass, field
31+
32+
import torch
33+
import torch.nn as nn
34+
35+
__all__ = [
36+
"ExportContext",
37+
"ExportHandler",
38+
"ExportModuleRegistry",
39+
"PrepareMoEInputsRegistry",
40+
]
41+
42+
43+
@dataclass
44+
class ExportContext:
45+
"""Shared state for a single export invocation, passed to every handler call.
46+
47+
The tied-weight dedup caches must be scoped to one export invocation: a
48+
process-global cache would carry stale entries whose ``data_ptr`` keys can be
49+
recycled by PyTorch's allocator across exports, causing silent false-positive
50+
aliasing. ``tied_cache`` (int keys) holds dense Linear / per-expert wrapper
51+
dedup; ``moe_tied_cache`` (tuple keys) holds MoE fused-experts module dedup.
52+
"""
53+
54+
model: nn.Module
55+
dtype: torch.dtype
56+
is_modelopt_qlora: bool = False
57+
tied_cache: dict[int, nn.Module] = field(default_factory=dict)
58+
moe_tied_cache: dict[tuple[int, int], nn.Module] = field(default_factory=dict)
59+
60+
61+
ExportHandler = Callable[[str, nn.Module, ExportContext], None]
62+
63+
64+
class _ExportHandlerRegistryCls:
65+
"""Ordered, first-match-wins registry mapping modules to handler functions.
66+
67+
An entry can match a module by any combination of:
68+
69+
- a class key: the registered class appears in ``type(module).__mro__``, so
70+
dynamically generated quantized classes (e.g. ``QuantLinear``) match through
71+
their original base class;
72+
- a class-name string key: the string equals the ``__name__`` of a class in the
73+
MRO — for classes that cannot be imported statically (trust_remote_code models
74+
or on-the-fly generated quantized classes);
75+
- a predicate on the module instance, for structural detection.
76+
77+
When keys and a predicate are both given, both must match. Entries are tried in
78+
registration order and the first match wins, so more specific handlers must be
79+
registered before generic ones. External handlers can use ``prepend=True`` to take
80+
precedence over built-in entries.
81+
"""
82+
83+
def __init__(self) -> None:
84+
self._entries: list[
85+
tuple[
86+
tuple[type | str, ...],
87+
Callable[[nn.Module], bool] | None,
88+
ExportHandler,
89+
]
90+
] = []
91+
92+
def register(
93+
self,
94+
*keys: type | str,
95+
predicate: Callable[[nn.Module], bool] | None = None,
96+
prepend: bool = False,
97+
) -> Callable[[ExportHandler], ExportHandler]:
98+
"""Return a decorator registering a handler function.
99+
100+
Re-registering the same handler (e.g. on module reload) replaces its existing
101+
entry in place instead of appending a duplicate.
102+
103+
Usage::
104+
105+
@ExportModuleRegistry.register(
106+
"Llama4TextExperts",
107+
"GptOssExperts",
108+
)
109+
def _export_bmm_experts(name, module, ctx): ...
110+
"""
111+
assert keys or predicate is not None, "register() requires at least one key or a predicate"
112+
113+
def decorator(handler: ExportHandler) -> ExportHandler:
114+
entry = (keys, predicate, handler)
115+
identity = (handler.__module__, handler.__qualname__)
116+
for i, (_, _, existing) in enumerate(self._entries):
117+
if (existing.__module__, existing.__qualname__) == identity:
118+
self._entries[i] = entry
119+
return handler
120+
if prepend:
121+
self._entries.insert(0, entry)
122+
else:
123+
self._entries.append(entry)
124+
return handler
125+
126+
return decorator
127+
128+
def match(self, module: nn.Module) -> ExportHandler | None:
129+
"""Return the first registered handler matching ``module``, or ``None``."""
130+
mro = type(module).__mro__
131+
mro_names = {cls.__name__ for cls in mro}
132+
for keys, predicate, handler in self._entries:
133+
if keys and not any(
134+
key in mro_names if isinstance(key, str) else key in mro for key in keys
135+
):
136+
continue
137+
if predicate is not None and not predicate(module):
138+
continue
139+
return handler
140+
return None
141+
142+
143+
# Matches an MoE block's ``.experts`` container; the handler receives the enclosing block.
144+
PrepareMoEInputsRegistry = _ExportHandlerRegistryCls()
145+
# Matches and passes the same module to the handler during the whole-model export walk.
146+
ExportModuleRegistry = _ExportHandlerRegistryCls()

0 commit comments

Comments
 (0)