Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
27bc896
Quantize Nemotron-H's experts under grouped_mm, with their wrapper in…
shengliangxu Oct 27, 2026
c012b7e
Drop the plugin-order test of the non-gated stand-in
shengliangxu Oct 27, 2026
81cd13c
Say Nemotron-H's experts wrapper relies on transformers' non-transpos…
shengliangxu Oct 27, 2026
c3534d0
Wrap Nemotron-H's experts docstring at 100 columns
shengliangxu Oct 27, 2026
dcda545
Pin that Nemotron-H's grouped_mm calibration goes through _grouped_li…
shengliangxu Oct 27, 2026
2b05bc0
Pin that the Nemotron-H export releases the fused up_proj and down_proj
shengliangxu Oct 27, 2026
6117dcb
Count already-converted experts as fused experts
shengliangxu Oct 28, 2026
90dc0f9
Compare up_proj's input amax exactly between grouped_mm and eager
shengliangxu Oct 28, 2026
a9a7fdb
Reuse _is_quant_fused_experts_module for the converted-experts check
shengliangxu Oct 28, 2026
d69c3b8
Drop the converted-experts case from _is_fused_experts_module
shengliangxu Oct 28, 2026
72b9298
Drop the import the moved non-gated tests no longer need
shengliangxu Oct 28, 2026
f8a663b
Test the calibration iterators and expert-index recovery on NemotronH…
shengliangxu Oct 28, 2026
961f609
Drop the on-the-fly fallback eager-only experts no longer need
shengliangxu Oct 28, 2026
680ae56
Refuse Nemotron-H experts transformers marks transposed
shengliangxu Oct 30, 2026
76fed0e
Refuse transposed experts in the fused-experts wrapper itself
shengliangxu Oct 31, 2026
cb83366
Name the Nemotron-H backend tests as comparisons, not matches to eager
shengliangxu Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modelopt/torch/export/moe_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ def _export_fused_experts(
"""Split fused MoE expert weights and export per-expert quantization scales.

Works with any module wrapped by ``_QuantFusedExperts`` (gated, with a fused
``gate_up_proj``) or ``_QuantNonGatedFusedExperts`` (non-gated, with a single
``gate_up_proj``) or by a non-gated subclass (``_is_gated = False``, with a single
``up_proj`` — e.g. NemotronH). Both store their projections as 3-D
``nn.Parameter`` tensors with per-expert quantizer ``nn.ModuleList`` s.

Expand Down
30 changes: 29 additions & 1 deletion modelopt/torch/models/nemotron_h/modeling_ptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

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.

Suggestion (non-blocking): the explicit registration skips the is_transposed and shape checks that register_fused_experts_on_the_fly applied, and the docstring says the wrapper relies on transformers' layout. A shape assert can't tell a consistently transposed pair, as you noted. But getattr(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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in 0da0591. _QuantNemotronHExperts._setup now raises when transformers marks the experts is_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_refused converts a real NemotronHExperts marked transposed and checks the conversion raises. It fails without the change.

"""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

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.

[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.

register_fused_experts_on_the_fly refuses a container whose first projection is stored transposed (huggingface.py:1531-1548: is_transposed, or getattr(module, wrapper._first_proj_attr).shape[-1] != module.down_proj.shape[1]) precisely because the generic machinery this wrapper reuses assumes (num_experts, out_dim, in_dim) — per-channel weight quantizers take axis=0 on each slice, and _export_fused_experts slices accordingly. Before this PR, Nemotron-H's experts went through that guard. Registered directly, they no longer do, and _QuantFusedExperts._setup asserts nothing about the layout.

Today that is safe: transformers' NemotronHExperts stores up_proj as (E, intermediate, hidden) and the pin is transformers>=5.5,<5.19. The risk is an upstream release that transposes it (as GptOssExperts already does) — calibration would collect per-input-channel amax and the export would write transposed per-expert weights, with no warning and no error, producing a checkpoint that loads and generates garbage.

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: _QuantFP8Experts is registered the same way and carries no such check either, so adding it here alone is slightly asymmetric. If you'd rather keep the diff minimal, a sentence in the class docstring recording that the layout assumption is now unguarded would also do.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in a772386 as the docstring line you offer: _QuantNemotronHExperts's docstring says the explicit registration skips register_fused_experts_on_the_fly's transposed-layout check and that the wrapper relies on transformers storing these weights (num_experts, out_dim, in_dim), as every supported release (5.5 to 5.18) does. No assert: no release stores them otherwise, and _QuantFP8Experts, registered the same way, carries none either.

Comment on lines +45 to +47

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.

[SUGGESTION] The explicit registration trades a loud failure for a silent one if the upstream layout ever changes.

register_fused_experts_on_the_fly refuses a container whose first projection is stored transposed and warns, precisely because the fused-experts export then slices the wrong axis and "writes silently wrong weights" (huggingface.py:1611-1624). Registering NemotronHExperts here bypasses that check — as the docstring states — and the non-gated export path has no split to catch it either: _export_fused_experts passes first_weight through whole (moe_utils.py:208-214), so a (num_experts, in_dim, out_dim) up_proj would export with transposed per-expert weights and an axis-0 weight scale computed over the input dim. Nothing fails; the checkpoint is just wrong at inference.

Since _setup already runs per module, a two-line invariant makes the assumption enforced rather than documented:

    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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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: up_proj (E, hidden, inter) with down_proj (E, inter, hidden) satisfies up_proj.shape[-1] == down_proj.shape[1] just as the stored pair does. What pins the layout is test_export_writes_up_and_down_proj_per_expert, which builds transformers' own NemotronHExperts with hidden 32 and intermediate 16 and asserts the exported per-expert shapes (16, 32) and (32, 16), so a release that stores them transposed fails it when the pin is raised.



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

Expand Down
83 changes: 34 additions & 49 deletions modelopt/torch/quantization/plugins/huggingface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

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.

[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 num_experts + 3-D down_proj + 3-D up_proj (no gate_proj/gate_up_proj) was claimed here and registered by register_fused_experts_on_the_fly. After this PR the only non-gated handler is the explicit QuantModuleRegistry.register({NemotronHExperts: ...}) in models/nemotron_h/modeling_ptq.py, which matches type(module) is transformers.models.nemotron_h.modeling_nemotron_h.NemotronHExperts exactly.

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. is_nemotron_h_model / get_nemotron_h_decoder_layers, 40 lines below the new registration in the same file, exists precisely because ModelOpt supports remote-code Nemotron-H checkpoints (model.backbone.layers). A remote-code or forked Nemotron-H whose experts are a fused non-gated container is a different class object, so it now gets no wrapper at all.

And the failure is quiet in a way the rest of this file deliberately avoids: register_fused_experts_on_the_fly just continues when wrapper is None (no warning, unlike the transposed-layout case right above), and assert_fused_experts_intercepted only scans isinstance(module, _QuantFusedExperts), so an unwrapped container is invisible to it too. The result is an otherwise-quantized checkpoint whose MoE weights — the bulk of the parameters — silently stay BF16, with no error and no warning at calibration or export time. That is the same class of silent fault assert_fused_experts_intercepted was written to turn into a loud one.

Suggested fix. Keep the removal (the generic wrapper really was unvalidated on grouped_mm), but make the gap loud. In register_fused_experts_on_the_fly, after wrapper = _fused_experts_wrapper(module) returns None, warn when the module still looks like a fused non-gated experts container:

        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."
                )
            continue

Either that, or widen the registration to also cover remote-code classes via CUSTOM_MODEL_PLUGINS (the pattern models/README.md prescribes for remote-code classes and that falcon/step3p5 already use) so the remote-code path keeps working rather than degrading quietly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 modeling_nemotron_h.py as an nn.ModuleList of NemotronHMLP with nn.Linear projections, which the per-module quantization covers and the fused detection never claimed, before or after this PR. The remote-code VL model, NVIDIA-Nemotron-3.5-Super-VL-120B-A12B-BF16, builds its language model with AutoModelForCausalLM.from_config(config.llm_config), i.e. transformers' NemotronHForCausalLM, whose NemotronHExperts this PR registers. A fused container the detection does not recognize is left as it is, as the base already does for the split-gated layout (gate_proj + up_proj), so a warning for this one layout would guard a class nothing ships.

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.

[SUGGESTION] Dropping the structural non-gated branch makes an unregistered non-gated fused container fall through silently.

A module with num_experts, a 3-D up_proj and a 3-D down_proj and no gate used to be claimed automatically (the capability shipped in a prior release — see the CHANGELOG.rst entry for "Support non-gated fused MoE experts in unified HuggingFace export"). Now only transformers' own NemotronHExperts is covered by the explicit registration in models/nemotron_h/modeling_ptq.py. A remote-code or vendored class with the same layout — e.g. transformers_modules.<repo>.modeling_nemotron_h.NemotronHExperts, a different class object from the registered one — gets neither a wrapper nor a message: register_fused_experts_on_the_fly just moves on, calibration leaves the experts unquantized, and the user only learns about it later from the export's NotImplementedError: MoE model with experts type '...' is not supported.

The transposed-layout case right above already warns rather than going quiet. Mirroring that here would make the narrowing diagnosable, e.g. in register_fused_experts_on_the_fly after wrapper is None:

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.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not taken. The non-gated fused layout is transformers' own NemotronHExperts, which is registered. Nemotron-H's remote code builds its routed experts as an nn.ModuleList of per-expert MLPs (checked in Nemotron-3-Nano-30B-A3B's modeling_nemotron_h.py), so a trust_remote_code load never presents a fused 3-D up_proj container, and no other transformers class has the layout. A warning no supported checkpoint can trigger is not worth its lines here.

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.

[SUGGESTION] Dropping the structural non-gated branch makes an unrecognized non-gated fused-experts container fail silently: _fused_experts_wrapper returns None, register_fused_experts_on_the_fly skips it without a message, _is_fused_experts_module is False, and PTQ produces a checkpoint whose MoE is simply unquantized. That is a worse failure mode than the transposed-layout case right below, which warns and says what is needed ("such a class needs its own quantized wrapper").

Registry resolution does cover a subclass of NemotronHExperts, but only one that inherits forward unchanged (_get_registered_nn_class requires nn_cls.forward is nn_cls_.forward), so a subclass or a remote-code class with its own non-gated forward lands in the silent path.

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 None

Non-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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not taken, as in the two earlier threads on this layout: transformers' NemotronHExperts is the only class with a fused 3-D up_proj, and it is registered, while Nemotron-H's remote code builds its experts as an nn.ModuleList of per-expert MLPs, which the per-module quantization covers. A warning no supported checkpoint can trigger is not worth its lines.



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):
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
)
Expand Down
7 changes: 7 additions & 0 deletions tests/gpu/torch/models/test_modeling_ptq_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@
else:
assert GptOssExperts in QuantModuleRegistry, "GptOssExperts not registered"

try:
from transformers.models.nemotron_h.modeling_nemotron_h import NemotronHExperts
except ImportError:
pass
else:
assert NemotronHExperts in QuantModuleRegistry, "NemotronHExperts not registered"

assert register_falcon_linears_on_the_fly in CUSTOM_MODEL_PLUGINS, "Falcon callback missing"
assert register_moe_linear_on_the_fly in CUSTOM_MODEL_PLUGINS, "Step callback missing"

Expand Down
Loading
Loading