Repository navigation
[OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067
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?
[OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067
Changes from all commits
2a0ca69
4e60820
2e50352
e14171f
4cc1f84
3613641
bf789a7
ec7f814
017ff89
e3e1ab2
1a27021
7cae593
49ddf63
adb9757
8d4a9b6
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 |
|---|---|---|
|
|
@@ -16,13 +16,41 @@ | |
| """Nemotron-H PTQ modeling (HF model type ``nemotron_h``).""" | ||
|
|
||
| import torch.nn as nn | ||
| from transformers.models.nemotron_h.modeling_nemotron_h import NemotronHExperts | ||
|
|
||
| from modelopt.torch.quantization.plugins.huggingface import _is_supported_hf_model | ||
| from modelopt.torch.quantization.nn import QuantModuleRegistry | ||
| from modelopt.torch.quantization.plugins.huggingface import ( | ||
| _is_supported_hf_model, | ||
| _QuantFusedExperts, | ||
| ) | ||
| from modelopt.torch.quantization.utils.layerwise_calib import LayerActivationCollector | ||
|
|
||
| __all__: list[str] = [] | ||
|
|
||
|
|
||
| class _QuantNemotronHExperts(_QuantFusedExperts): | ||
| """Quantized ``NemotronHExperts``: non-gated fused experts, ``down_proj(act_fn(up_proj(x)))``. | ||
|
|
||
| The container holds 3-D ``up_proj`` and ``down_proj`` parameters instead of the gated | ||
| ``gate_up_proj``, and its forward runs ``up_proj`` then ``down_proj`` once per expert on the | ||
| eager path, or once per expert-sorted batch on ``grouped_mm``. So only the first projection's | ||
| name differs from :class:`_QuantFusedExperts`: its per-expert weight quantizers and shared input | ||
| quantizer live under ``up_proj_*``. Registered explicitly, these experts skip the | ||
| transposed-layout check of :func:`register_fused_experts_on_the_fly`: the wrapper relies on | ||
| transformers storing their weights ``(num_experts, out_dim, in_dim)``, as every supported | ||
| release does, and :class:`_QuantFusedExperts` refuses experts transformers marks | ||
| ``is_transposed``. | ||
| """ | ||
|
|
||
| _first_proj_attr = "up_proj" | ||
| # Read by the export: a non-gated up_proj is exported whole, not split into gate|up halves. | ||
| _is_gated = False | ||
|
Contributor
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. [SUGGESTION] The explicit registration bypasses the layout guard the on-the-fly path applies, with nothing left to catch a silent mis-export if the upstream layout ever flips.
Today that is safe: transformers' One cheap line turns that into a loud failure at conversion: def _setup(self):
# The generic fused-experts machinery this reuses -- axis-0 per-expert weight quantizers
# and the per-expert export split -- assumes (num_experts, out_dim, in_dim). The explicit
# registration skips register_fused_experts_on_the_fly's transposed-layout guard.
assert self.up_proj.shape[-1] == self.down_proj.shape[1], (
f"NemotronHExperts weights are not stored (num_experts, out_dim, in_dim): "
f"up_proj {tuple(self.up_proj.shape)}, down_proj {tuple(self.down_proj.shape)}."
)
super()._setup()Non-blocking, and reasonable to skip:
Collaborator
Author
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. Taken in a772386 as the docstring line you offer:
Comment on lines
+45
to
+47
Contributor
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. [SUGGESTION] The explicit registration trades a loud failure for a silent one if the upstream layout ever changes.
Since def _setup(self):
# Registered explicitly, so `register_fused_experts_on_the_fly`'s transposed-layout
# guard never runs: pin the `(num_experts, out_dim, in_dim)` layout the export assumes.
assert self.up_proj.shape[-1] == self.down_proj.shape[1], (
f"NemotronHExperts weights are not stored (num_experts, out_dim, in_dim): "
f"up_proj {tuple(self.up_proj.shape)}, down_proj {tuple(self.down_proj.shape)}"
)
super()._setup()Same shape relation the generic guard uses, so it stays consistent with it.
Collaborator
Author
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. Not taken. For non-gated experts the proposed relation cannot tell the stored layout from a transposed one: |
||
|
|
||
|
|
||
| if NemotronHExperts not in QuantModuleRegistry: | ||
| QuantModuleRegistry.register({NemotronHExperts: "hf.NemotronHExperts"})(_QuantNemotronHExperts) | ||
|
|
||
|
|
||
| def is_nemotron_h_model(model: nn.Module) -> bool: | ||
| return get_nemotron_h_decoder_layers(model) is not None | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -824,15 +824,14 @@ class _QuantFusedExperts(_QuantFunctionalMixin): | |
| kernel) backends are not intercepted; ``normalize_experts_impl_on_the_fly`` | ||
| redirects them to ``grouped_mm`` before calibration. | ||
|
|
||
| The non-gated variant (``up_proj`` instead of ``gate_up_proj``, used by | ||
| NemotronH) is handled by :class:`_QuantNonGatedFusedExperts` via the | ||
| A non-gated layout (a single ``up_proj``) is handled by a subclass that overrides the | ||
| ``_first_proj_attr`` / ``_is_gated`` hooks below; each layout names the | ||
| first-projection quantizers after its backing parameter. | ||
| """ | ||
|
|
||
| # Name of the 3-D weight parameter feeding the first ``F.linear`` per expert. | ||
| # Gated experts fuse gate+up into ``gate_up_proj``; non-gated experts use a | ||
| # single ``up_proj`` (see _QuantNonGatedFusedExperts). | ||
| # single ``up_proj``, which their subclass names here. | ||
| _first_proj_attr = "gate_up_proj" | ||
| # Whether the first projection packs a gate half that must be split on export. | ||
| _is_gated = True | ||
|
|
@@ -887,6 +886,14 @@ def _get_expert_idx_from_first_proj(self, weight: torch.Tensor) -> int: | |
| return idx | ||
|
|
||
| def _setup(self): | ||
| # Each expert's weight is sliced as (out_dim, in_dim), and a transposed container would be | ||
| # sliced wrong with nothing in the shapes to catch it. register_fused_experts_on_the_fly skips | ||
| # such containers; this refuses them for the wrappers registered explicitly (FP8, Nemotron-H). | ||
| if getattr(self, "is_transposed", False): | ||
| raise NotImplementedError( | ||
| f"{type(self).__name__} quantizes expert weights stored (num_experts, out_dim, " | ||
| "in_dim), but these experts are marked is_transposed." | ||
| ) | ||
| n = self.num_experts | ||
| setattr(self, self._first_proj_input_quantizer_attr, TensorQuantizer()) | ||
| setattr( | ||
|
|
@@ -1210,28 +1217,13 @@ def fold_weight(self, keep_attrs: bool = False): | |
| self._fold_weight_quantizer(q, (weight_slice,), keep_attrs) | ||
|
|
||
|
|
||
| class _QuantNonGatedFusedExperts(_QuantFusedExperts): | ||
| """Quantized wrapper for non-gated fused MoE experts. | ||
|
|
||
| Used by NemotronH (transformers 5.5+ ``NemotronHExperts``), whose experts | ||
| are a *non-gated* MLP: a single ``up_proj`` (no gate half) and a ``down_proj``, | ||
| both stored as 3-D ``nn.Parameter`` s indexed per expert. | ||
| """ | ||
|
|
||
| _first_proj_attr = "up_proj" | ||
| _is_gated = False | ||
| # The grouped_mm interception is validated for the gated layout only, so these keep | ||
| # calibrating on the eager forward. | ||
| _eager_only = True | ||
|
|
||
|
|
||
| class _QuantFP8Experts(_QuantFusedExperts): | ||
| """Scale-aware PTQ for native Transformers FP8 experts, dequantized per expert.""" | ||
|
|
||
| # The block scales apply in get_expert_weight, which only the eager forward reaches: transformers' | ||
| # FP8 grouped_mm forward runs its own kernels, which nothing here intercepts. So, unlike for the | ||
| # non-gated wrapper, the grouped interceptor's refusal never fires for these: what keeps them on | ||
| # eager is normalize_experts_impl_on_the_fly, which runs on convert and on restore. | ||
| # FP8 grouped_mm forward runs its own kernels, which nothing here intercepts. So the grouped | ||
| # interceptor's refusal never fires for these: what keeps them on eager is | ||
| # normalize_experts_impl_on_the_fly, which runs on convert and on restore. | ||
| _eager_only = True | ||
|
|
||
| @property | ||
|
|
@@ -1617,17 +1609,13 @@ def register_sparse_moe_on_the_fly(model): | |
|
|
||
|
|
||
| def _fused_experts_wrapper(module): | ||
| """Return the ``_QuantFusedExperts`` subclass for a fused-MoE-experts module, else None. | ||
| """Return :class:`_QuantFusedExperts` for a fused-MoE-experts module, else None. | ||
|
|
||
| Detects the standardized HuggingFace transformers 5.0+ fused expert pattern: ``num_experts``, | ||
| a 3-D ``down_proj`` and a 3-D first projection, either | ||
|
|
||
| * gated (:class:`_QuantFusedExperts`): a 3-D ``gate_up_proj`` fusing gate+up. Matches | ||
| ``MixtralExperts``, ``Qwen2MoeExperts``, ``Qwen3MoeExperts``, | ||
| ``Qwen3_5MoeExperts``, ``DeepseekV3NaiveMoe``, ``JambaExperts``, | ||
| ``OlmoeExperts``, ``MiniMaxM2Experts``, ``MiniMaxM3VLExperts``, etc. | ||
| * non-gated (``_QuantNonGatedFusedExperts``): a 3-D ``up_proj`` with no | ||
| ``gate_proj`` and no ``gate_up_proj``. Matches NemotronH ``NemotronHExperts``. | ||
| a 3-D ``down_proj`` and a 3-D ``gate_up_proj`` fusing gate+up. Matches ``MixtralExperts``, | ||
| ``Qwen2MoeExperts``, ``Qwen3MoeExperts``, ``Qwen3_5MoeExperts``, ``DeepseekV3NaiveMoe``, | ||
| ``JambaExperts``, ``OlmoeExperts``, ``MiniMaxM2Experts``, ``MiniMaxM3VLExperts``, etc. Experts | ||
| of another layout register a wrapper of their own, as Nemotron-H's non-gated ones do. | ||
|
|
||
| Matching is by shape alone, so GptOss and Llama4TextExperts -- transposed layouts with their own | ||
| explicit registrations -- match too; :func:`register_fused_experts_on_the_fly` never reaches | ||
|
|
@@ -1645,24 +1633,23 @@ def _fused_experts_wrapper(module): | |
| gate_up = getattr(module, "gate_up_proj", None) | ||
| if isinstance(gate_up, (nn.Parameter, Tensor)) and gate_up.dim() == 3: | ||
| return _QuantFusedExperts | ||
| up = getattr(module, "up_proj", None) | ||
| if isinstance(up, (nn.Parameter, Tensor)) and up.dim() == 3: | ||
| # Only claim non-gated experts that alternate up_proj then down_proj. | ||
| if getattr(module, "gate_proj", None) is None and gate_up is None: | ||
| return _QuantNonGatedFusedExperts | ||
| return None | ||
|
Contributor
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. [IMPORTANT Compatibility] Dropping the non-gated structural branch makes coverage of that layout keyed on the transformers class name, and an unclaimed non-gated fused container is now skipped silently. What changes. On the base branch, any module with Why it matters. The PR argues no other transformers experts class has this layout, which I agree with for the classes shipped today — but the keying also excludes classes that aren't the transformers one. And the failure is quiet in a way the rest of this file deliberately avoids: Suggested fix. Keep the removal (the generic wrapper really was unvalidated on wrapper = _fused_experts_wrapper(module)
if wrapper is None:
# A fused container of a layout nothing claims: a non-gated `up_proj` from a class
# other than the one its model folder registers (e.g. a remote-code Nemotron-H).
# Leaving it unwrapped silently exports its experts unquantized, so say so.
up = getattr(module, "up_proj", None)
down = getattr(module, "down_proj", None)
if (
hasattr(module, "num_experts")
and isinstance(up, (nn.Parameter, Tensor))
and up.dim() == 3
and isinstance(down, (nn.Parameter, Tensor))
and down.dim() == 3
):
warnings.warn(
f"Fused MoE experts '{name}' ({mod_type.__name__}) store a single 3-D "
"`up_proj` (non-gated), a layout the generic detection does not claim. "
"Their weights will be exported UNQUANTIZED. A non-gated class needs its "
"own wrapper, as `_QuantNemotronHExperts` is for transformers' "
"`NemotronHExperts`; register it on this class too."
)
continueEither that, or widen the registration to also cover remote-code classes via
Collaborator
Author
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. Not adding the warning: no released checkpoint reaches this gap. The remote-code Nemotron-H MoE checkpoints, NVIDIA-Nemotron-3-Nano-30B-A3B-BF16 and NVIDIA-Nemotron-3-Super-120B-A12B-BF16, build their experts in
Contributor
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. [SUGGESTION] Dropping the structural non-gated branch makes an unregistered non-gated fused container fall through silently. A module with The transposed-layout case right above already warns rather than going quiet. Mirroring that here would make the narrowing diagnosable, e.g. in up = getattr(module, "up_proj", None)
if (
wrapper is None
and isinstance(up, (nn.Parameter, Tensor))
and up.dim() == 3
and getattr(module, "gate_proj", None) is None
):
warnings.warn(
f"Fused MoE experts '{name}' ({mod_type.__name__}) use a non-gated layout (a single "
"3-D `up_proj`), which the generic detection no longer claims. Such a class needs its "
"own quantized wrapper registered from `modelopt/torch/models/<model_type>/"
"modeling_ptq.py`, as transformers' `NemotronHExperts` has. Leaving them unquantized."
)(Optional — if the layout is considered out of support, saying so loudly still beats the opaque export failure.)
Collaborator
Author
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. Not taken. The non-gated fused layout is transformers' own
Contributor
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. [SUGGESTION] Dropping the structural non-gated branch makes an unrecognized non-gated fused-experts container fail silently: Registry resolution does cover a subclass of Consider keeping a detection-only arm that warns instead of claiming the module, mirroring the transposed warning: up = getattr(module, "up_proj", None)
if (
isinstance(up, (nn.Parameter, Tensor))
and up.dim() == 3
and getattr(module, "gate_proj", None) is None
):
warnings.warn(
f"Fused MoE experts of type {type(module).__name__} have a non-gated layout "
"(a single 3-D `up_proj`), which the generic detection does not claim. Leaving them "
"unquantized; such a class needs its own quantized wrapper, as NemotronHExperts has."
)
return NoneNon-blocking — the claim that no other transformers class has this layout looks right today; the value is in telling a user with a custom one why their experts came out in BF16.
Collaborator
Author
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. Not taken, as in the two earlier threads on this layout: transformers' |
||
|
|
||
|
|
||
| def _is_fused_experts_module(module): | ||
| """Check if a module is a fused MoE expert container compatible with _QuantFusedExperts. | ||
|
|
||
| See :func:`_fused_experts_wrapper` for the recognized layouts (gated | ||
| ``gate_up_proj`` and non-gated ``up_proj``). Transposed containers match too, though | ||
| registration skips them: :func:`normalize_experts_impl_on_the_fly` must still find them, to keep | ||
| GPT-OSS's own wrapper on eager, the only forward on which it quantizes ``down_proj``'s input (it | ||
| intercepts the eager forward's ``bmm``/``matmul``). | ||
| That is one :func:`_fused_experts_wrapper` detects, or one whose class registered a subclass of | ||
| it, as Nemotron-H's non-gated experts and transformers' FP8 experts do. | ||
| Transposed containers match too, though registration skips them: | ||
| :func:`normalize_experts_impl_on_the_fly` must still find them, to keep GPT-OSS's own wrapper on | ||
| eager, the only forward on which it quantizes ``down_proj``'s input (it intercepts the eager | ||
| forward's ``bmm``/``matmul``). | ||
| """ | ||
| return _fused_experts_wrapper(module) is not None | ||
| if _fused_experts_wrapper(module) is not None: | ||
| return True | ||
| wrapper = QuantModuleRegistry.get(type(module)) if hasattr(module, "num_experts") else None | ||
| return wrapper is not None and issubclass(wrapper, _QuantFusedExperts) | ||
|
|
||
|
|
||
| def register_fused_experts_on_the_fly(model): | ||
|
|
@@ -1688,9 +1675,8 @@ def register_fused_experts_on_the_fly(model): | |
| if wrapper is not None and ( | ||
| getattr(module, "is_transposed", False) | ||
| # Stored `(num_experts, out_dim, in_dim)`, the first projection takes the dim down_proj | ||
| # puts out. That separates the layouts for a gated container, whose first projection is | ||
| # `2 * intermediate`, whether it says so or not; a non-gated one's two layouts have the | ||
| # same shapes, so only its own `is_transposed` gives it away. | ||
| # puts out. A gated container's first projection is `2 * intermediate`, so its shapes | ||
| # give a transposed layout away whether it says so or not. | ||
| or getattr(module, wrapper._first_proj_attr).shape[-1] != module.down_proj.shape[1] | ||
| ): | ||
| # `_fused_experts_wrapper` matches on parameter SHAPE only, so it also claims a | ||
|
|
@@ -1755,15 +1741,14 @@ def normalize_experts_impl_on_the_fly(model): | |
| fallback_impl = "grouped_mm" if _HAS_GROUPED_LINEAR else "eager" | ||
| # A class with its own quantized wrapper -- GPT-OSS's quantizes down_proj's input by intercepting | ||
| # the eager forward's bmm/matmul -- only works on the eager forward, and so does a fused-experts | ||
| # wrapper that says so (FP8's), so keep main's forced eager for such a model. The plugins run in | ||
| # no set order, so a class register_fused_experts_on_the_fly registers may not be registered yet: | ||
| # the wrapper it will get decides then. | ||
| # wrapper that says so (FP8's), so keep main's forced eager for such a model. Those register | ||
| # when their module is imported; register_fused_experts_on_the_fly, which may run after this, | ||
| # registers only the generic wrapper, which is not eager-only. | ||
| eager_only = sorted( | ||
| { | ||
| type(module).__name__ | ||
| for module in fused_experts | ||
| if (wrapper := QuantModuleRegistry.get(type(module)) or _fused_experts_wrapper(module)) | ||
| is not None | ||
| if (wrapper := QuantModuleRegistry.get(type(module))) is not None | ||
| and (not issubclass(wrapper, _QuantFusedExperts) or wrapper._eager_only) | ||
| } | ||
| ) | ||
|
|
||
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.
Suggestion (non-blocking): the explicit registration skips the
is_transposedand shape checks thatregister_fused_experts_on_the_flyapplied, and the docstring says the wrapper relies on transformers' layout. A shape assert can't tell a consistently transposed pair, as you noted. Butgetattr(module, "is_transposed", False)needs no shape inference. Could setup raise when it is true, so a future layout flip fails loudly instead of slicing the experts wrong?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.
Taken in 0da0591.
_QuantNemotronHExperts._setupnow raises when transformers marks the expertsis_transposed, before any quantizer is created, so a future layout flip fails at conversion instead of slicing every expert wrong. The docstring says so.test_experts_marked_transposed_are_refusedconverts a realNemotronHExpertsmarked transposed and checks the conversion raises. It fails without the change.