Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 48 additions & 11 deletions modelopt/torch/export/hf_export_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from .layer_utils import get_expert_linear_names, is_quantlinear, set_expert_quantizer_amax
from .model_config import QUANTIZATION_NONE
from .moe_utils import _export_fused_experts
from .moe_utils import _delete_fused_moe_source_attrs, _export_fused_experts
from .quant_utils import get_quantization_format
from .registry import ExportContext, ExportModuleRegistry, PrepareMoEInputsRegistry

Expand All @@ -36,16 +36,39 @@ def _has_fused_experts_quantizers(module: nn.Module) -> bool:
return hasattr(module, f"{first_proj_attr}_weight_quantizers")


def _full_weight_name(module_name: str, weight_name: str) -> str:
return f"{module_name}.{weight_name}" if module_name else weight_name


def _is_duplicate_with_same_format(
module_name: str,
ctx: ExportContext,
weight_name: str,
) -> bool:
"""Whether this source weight can be omitted in favor of its canonical tied name."""
full_name = _full_weight_name(module_name, weight_name)
canonical_name = ctx.duplicate_of(full_name)
if canonical_name is None:
return False
return ctx.weight_formats[canonical_name] == ctx.weight_formats[full_name]
Comment on lines +43 to +53

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against an unpopulated weight_formats.

ExportContext.__post_init__ fills duplicate_weight_map and weight_locations, but weight_formats is filled later, inside _process_quantized_modules (modelopt/torch/export/unified_export_hf.py, Lines 814-816). Any handler invoked with a context that has not passed through _process_quantized_modules therefore sees a populated duplicate_weight_map and an empty weight_formats. Line 53 then raises KeyError instead of returning False.

_export_transformers_checkpoint already passes export_ctx to the PrepareMoEInputsRegistry handlers at Line 923, before weight_formats exists. Confirm that no handler on that path reaches this helper. The durable fix is to populate weight_formats in __post_init__ next to the other fields, so the context is fully initialized at construction.

#!/bin/bash
# Description: Find every handler that receives an ExportContext and check which reach _is_duplicate_with_same_format.
set -euo pipefail

rg -nP --type=py -C3 '_is_duplicate_with_same_format|PrepareMoEInputsRegistry\.register|ExportModuleRegistry\.register'
rg -nP --type=py -C4 'def .*\(.*ctx: ExportContext'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelopt/torch/export/hf_export_handlers.py` around lines 43 - 53, Initialize
weight_formats in ExportContext.__post_init__ alongside duplicate_weight_map and
weight_locations, using the existing weight-format population logic so contexts
are fully usable at construction. Keep _is_duplicate_with_same_format unchanged
and verify handlers invoked through _export_transformers_checkpoint and
PrepareMoEInputsRegistry are safe with the initialized context.



def _export_weight(
module_name: str,
module: nn.Module,
ctx: ExportContext,
weight_name: str = "weight",
) -> None:
full_name = _full_weight_name(module_name, weight_name)
if _is_duplicate_with_same_format(module_name, ctx, weight_name):
ctx.mark_skipped(full_name)
return

# Imported lazily to avoid a cycle: unified_export_hf imports this module to
# install the built-in handlers while retaining this legacy helper's import path.
from .unified_export_hf import _export_quantized_weight

_export_quantized_weight(module, ctx.dtype, weight_name, _tied_cache=ctx.tied_cache)
_export_quantized_weight(module, ctx.dtype, weight_name)


# Preparation handlers are registered in the same precedence as the legacy MoE prepass.
Expand Down Expand Up @@ -129,13 +152,27 @@ def _export_moe_linear(name: str, module: nn.Module, ctx: ExportContext) -> None
@ExportModuleRegistry.register(predicate=_has_fused_experts_quantizers)
def _export_fused_experts_module(name: str, module: nn.Module, ctx: ExportContext) -> None:
"""Split and quantize a fused-experts module with plural weight quantizers."""
first_proj_attr = getattr(module, "_first_proj_attr", "gate_up_proj")
first_name = _full_weight_name(name, first_proj_attr)
down_name = _full_weight_name(name, "down_proj")
first_canonical = ctx.duplicate_of(first_name)
down_canonical = ctx.duplicate_of(down_name)

# Omit the entire tied subtree when both source projections point to one canonical
# fused-experts module and use the same quantization representation.
if (
first_canonical is not None
and down_canonical is not None
and first_canonical.rsplit(".", 1)[0] == down_canonical.rsplit(".", 1)[0]
and _is_duplicate_with_same_format(name, ctx, first_proj_attr)
and _is_duplicate_with_same_format(name, ctx, "down_proj")
):
ctx.mark_skipped(first_name, down_name)
_delete_fused_moe_source_attrs(module)
return

with fsdp2_aware_weight_update(ctx.model, module, reshard=False):
_export_fused_experts(
module,
ctx.dtype,
_moe_tied_cache=ctx.moe_tied_cache,
_tied_cache=ctx.tied_cache,
)
_export_fused_experts(module, ctx.dtype)


@ExportModuleRegistry.register(predicate=is_quantlinear)
Expand All @@ -145,7 +182,7 @@ def _export_quant_linear(name: str, module: nn.Module, ctx: ExportContext) -> No
return
try:
with fsdp2_aware_weight_update(ctx.model, module, reshard=False):
_export_weight(module, ctx)
_export_weight(name, module, ctx)
except AssertionError as e:
raise AssertionError(
f"Failed to export module '{name}' (type={type(module).__name__}): {e}"
Expand Down Expand Up @@ -176,7 +213,7 @@ def _export_quant_embedding(name: str, module: nn.Module, ctx: ExportContext) ->
return
try:
with fsdp2_aware_weight_update(ctx.model, module, reshard=False):
_export_weight(module, ctx)
_export_weight(name, module, ctx)
except AssertionError as e:
raise AssertionError(
f"Failed to export embedding '{name}' (type={type(module).__name__}): {e}"
Expand All @@ -199,4 +236,4 @@ def _export_bmm_experts(name: str, module: nn.Module, ctx: ExportContext) -> Non
)
with fsdp2_aware_weight_update(ctx.model, module, reshard=False):
for weight_name in ["gate_up_proj", "down_proj"]:
_export_weight(module, ctx, weight_name)
_export_weight(name, module, ctx, weight_name)
78 changes: 2 additions & 76 deletions modelopt/torch/export/moe_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,35 +23,6 @@
import torch.nn as nn


def _alias_per_expert_subtree_from_prior(module: nn.Module, prior: nn.Module, n: int) -> None:
"""Build per-expert subtree on ``module`` by aliasing ``prior``'s packed buffers.

For each expert ``idx`` in ``0..n-1``, creates ``module.{idx}.{gate,up,down}_proj``
sub-modules whose ``weight`` / ``weight_scale`` / ``weight_scale_2`` /
``input_scale`` are aliased to the prior side's already-packed tensors.
data_ptr equality is preserved so the downstream
``postprocess_state_dict`` dedup collapses the duplicates at write time.
Called by ``_export_fused_experts`` on the tied-experts cache-hit fast path.
"""
for _idx in range(n):
_prior_expert = getattr(prior, str(_idx), None)
if _prior_expert is None:
continue
_cur_expert = nn.Module()
for _proj_name in ("gate_proj", "up_proj", "down_proj"):
_prior_proj = getattr(_prior_expert, _proj_name, None)
if _prior_proj is None:
continue
_cur_proj = nn.Module()
if hasattr(_prior_proj, "weight"):
_cur_proj.weight = _prior_proj.weight
for _attr in ("weight_scale", "weight_scale_2", "input_scale"):
if hasattr(_prior_proj, _attr):
_cur_proj.register_buffer(_attr, getattr(_prior_proj, _attr))
_cur_expert.add_module(_proj_name, _cur_proj)
module.add_module(str(_idx), _cur_expert)


def _delete_fused_moe_source_attrs(module: nn.Module) -> None:
"""Remove the 3-D fused source params and per-expert quantizer ModuleLists.

Expand All @@ -74,12 +45,7 @@ def _delete_fused_moe_source_attrs(module: nn.Module) -> None:
delattr(module, attr)


def _export_fused_experts(
module: nn.Module,
dtype: torch.dtype,
_moe_tied_cache: dict[tuple[int, int], nn.Module] | None = None,
_tied_cache: dict[int, nn.Module] | None = None,
) -> None:
def _export_fused_experts(module: nn.Module, dtype: torch.dtype) -> None:
"""Split fused MoE expert weights and export per-expert quantization scales.

Works with any module wrapped by ``_QuantFusedExperts`` (gated, with a fused
Expand All @@ -99,20 +65,6 @@ def _export_fused_experts(
{E}.gate_proj.weight, {E}.gate_proj.weight_scale, ... # gated only
{E}.up_proj.weight, {E}.up_proj.weight_scale, ...
{E}.down_proj.weight, {E}.down_proj.weight_scale, ...

Tied-experts dedup is opt-in via ``_moe_tied_cache``: when multiple
fused-expert modules share their 3-D source params via HF
``_tied_weights_keys``, the unpacking creates fresh per-expert tensors
that break the tie. With ``_moe_tied_cache`` provided (tuple-keyed by
``(<first_proj>.data_ptr(), down_proj.data_ptr())``), the alias step
at the end re-points the per-expert ``weight`` / ``weight_scale`` /
``weight_scale_2`` / ``input_scale`` buffers at a previously-processed
module sharing the same source memory. ``_tied_cache`` (int-keyed) is
threaded through to the per-projection ``_export_quantized_weight``
calls so wrapper-level dedup uses the same scope as standalone Linears.
Both caches are owned by the caller (typically
``_export_transformers_checkpoint``) and scoped to one export
invocation; when ``None`` the corresponding alias step is skipped.
"""
from modelopt.torch.export.unified_export_hf import _export_quantized_weight
from modelopt.torch.quantization.plugins.huggingface import _get_fused_expert_intermediate_dim
Expand All @@ -124,25 +76,6 @@ def _export_fused_experts(
# Only the gated split needs the per-expert intermediate dim (gate|up boundary).
expert_dim = _get_fused_expert_intermediate_dim(module) if is_gated else None

# Capture source tensor identities BEFORE unpacking (the source
# attrs are deleted at the end of this function).
_source_key = (
getattr(module, first_proj_attr).data_ptr(),
module.down_proj.data_ptr(),
)

# Tied-experts fast path: if this exact (first_proj, down) source-tensor pair
# has been processed before, alias all per-expert buffers directly from the
# prior module — no unpacking, no per-expert packing, no transient buffers
# thrown away. Cache miss falls through to the full unpack/pack below and
# registers this module as the prior for any later tied module.
if _moe_tied_cache is not None:
_prior = _moe_tied_cache.get(_source_key)
if _prior is not None and _prior is not module:
_alias_per_expert_subtree_from_prior(module, _prior, n)
_delete_fused_moe_source_attrs(module)
return

# 1. Shared input quantizers — one per projection type, shared across all experts.
first_proj_input_q = getattr(module, f"{first_proj_attr}_input_quantizer")
first_proj_weight_quantizers = getattr(module, f"{first_proj_attr}_weight_quantizers")
Expand Down Expand Up @@ -271,7 +204,7 @@ def _export_fused_experts(
wrapper.weight_quantizer = w_quantizer
wrapper.input_quantizer = i_quantizer

_export_quantized_weight(wrapper, dtype, _tied_cache=_tied_cache)
_export_quantized_weight(wrapper, dtype)

proj = nn.Module()
proj.weight = wrapper.weight
Expand All @@ -286,13 +219,6 @@ def _export_fused_experts(
# 4. Remove fused params and quantizer lists — replaced by per-expert submodules
_delete_fused_moe_source_attrs(module)

# 5. Register this module in the dedup cache so any later tied module
# (same source data_ptr pair) takes the fast path at the top of this
# function. Reached only on cache miss; cache-hit modules early-exited
# above before any unpack work.
if _moe_tied_cache is not None:
_moe_tied_cache[_source_key] = module


def save_expert_token_count_table(model: nn.Module, output_dir: str | Path | None = None):
"""Collect expert_token_count from all quantized MoE layers and save as an HTML table.
Expand Down
9 changes: 4 additions & 5 deletions modelopt/torch/export/quant_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1063,13 +1063,12 @@ def postprocess_state_dict(
# Check for tied weights and remove duplicates
seen_tensors = {}

# Remove any tied weights if found.
# Remove any tied weights if found. Device and size distinguish independent tensors whose
# allocator addresses happen to match. Zero-pointer tensors are left for serialization to reject.
for key, value in post_state_dict.items():
if isinstance(value, torch.Tensor):
# Use tensor data pointer to identify tied weights
tensor_id = value.data_ptr()
if isinstance(value, torch.Tensor) and value.data_ptr() != 0:
tensor_id = (value.device, value.data_ptr(), value.numel() * value.element_size())
Comment on lines +1066 to +1070

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use complete tensor metadata for deduplication.

value.numel() * value.element_size() is only the logical byte count. It does not identify dtype, shape, or stride. It is also not the storage span for a non-contiguous view. Two state-dict entries can therefore share the current key while representing different tensors. The loop then deletes the later key at Line 1072. The preceding squeeze(0) can also change shape without changing the pointer or byte count.

Include dtype, shape, and stride, or require exact view metadata before removing a duplicate key. Add a regression for same-start views with different shape or stride.

Based on the PR objective, deduplication must prevent false-positive removal without dropping distinct exported tensors.

Suggested key
-            tensor_id = (value.device, value.data_ptr(), value.numel() * value.element_size())
+            tensor_id = (
+                value.device,
+                value.data_ptr(),
+                value.dtype,
+                tuple(value.shape),
+                tuple(value.stride()),
+            )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Remove any tied weights if found. Device and size distinguish independent tensors whose
# allocator addresses happen to match. Zero-pointer tensors are left for serialization to reject.
for key, value in post_state_dict.items():
if isinstance(value, torch.Tensor):
# Use tensor data pointer to identify tied weights
tensor_id = value.data_ptr()
if isinstance(value, torch.Tensor) and value.data_ptr() != 0:
tensor_id = (value.device, value.data_ptr(), value.numel() * value.element_size())
# Remove any tied weights if found. Device and size distinguish independent tensors whose
# allocator addresses happen to match. Zero-pointer tensors are left for serialization to reject.
for key, value in post_state_dict.items():
if isinstance(value, torch.Tensor) and value.data_ptr() != 0:
tensor_id = (
value.device,
value.data_ptr(),
value.dtype,
tuple(value.shape),
tuple(value.stride()),
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelopt/torch/export/quant_utils.py` around lines 1066 - 1070, Update the
deduplication key in the loop over post_state_dict within the tied-weight
removal logic to include complete view metadata: device, data pointer, dtype,
shape, and stride, rather than only the logical byte count. Ensure entries are
removed only when their tensor views are exactly equivalent, preserving distinct
tensors such as same-start views with different shapes or strides, and add a
regression covering that case.

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.

why do we need data_ptr still?

if tensor_id in seen_tensors:
# This is a tied weight, mark for deletion and warn
keys_to_delete.append(key)
logger.warning(
f"Found tied weight: '{key}' is tied to '{seen_tensors[tensor_id]}'. "
Expand Down
50 changes: 37 additions & 13 deletions modelopt/torch/export/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@

from modelopt.torch.utils.distributed import is_fsdp2_model

from .model_utils import _reorder_canonical_first

__all__ = [
"ExportContext",
"ExportHandler",
Expand All @@ -46,27 +48,49 @@
class ExportContext:
"""Shared state for a single export invocation, passed to every handler call.

The tied-weight dedup caches must be scoped to one export invocation: a
process-global cache would carry stale entries whose ``data_ptr`` keys can be
recycled by PyTorch's allocator across exports, causing silent false-positive
aliasing. ``tied_cache`` (int keys) holds dense Linear / per-expert wrapper
dedup; ``moe_tied_cache`` (tuple keys) holds MoE fused-experts module dedup.
``duplicate_weight_map`` maps each duplicate source-parameter name to the canonical
name that should survive export. It is snapshotted before export mutates Parameters.
"""

model: nn.Module
dtype: torch.dtype
is_modelopt_qlora: bool = False
tied_cache: dict[int, nn.Module] | None = field(default_factory=dict)
moe_tied_cache: dict[tuple[int, int], nn.Module] | None = field(default_factory=dict)
duplicate_weight_map: dict[str, str] = field(init=False, default_factory=dict)
weight_locations: dict[str, tuple[nn.Module, str]] = field(init=False, default_factory=dict)
weight_formats: dict[str, str | None] = field(init=False, default_factory=dict)
skipped_weight_names: set[str] = field(init=False, default_factory=set)

def __post_init__(self) -> None:
# FSDP2 may recycle data_ptr() values as modules are resharded, so pointer-keyed dedup can
# falsely alias distinct weights. Disable it for FSDP2; consequently, legitimately tied
# packed weights and scale buffers are not re-aliased and may be stored as duplicates.
# TODO: replace this with stable, name-based tied-group deduplication.
# FSDP2 replaces Parameters while resharding. Keep its existing behavior of writing
# tied packed weights independently instead of relying on pre-reshard identities.
if is_fsdp2_model(self.model):
self.tied_cache = None
self.moe_tied_cache = None
return

names_by_source_id: dict[int, list[str]] = {}
for module_name, module in self.model.named_modules(remove_duplicate=False):
for weight_name, parameter in module._parameters.items():
if parameter is None:
continue
full_name = f"{module_name}.{weight_name}" if module_name else weight_name
self.weight_locations[full_name] = (module, weight_name)
names_by_source_id.setdefault(id(parameter), []).append(full_name)

for names in names_by_source_id.values():
if len(names) < 2:
continue
# Reuse the existing HF canonical-name policy. For models without an explicit
# canonical side this preserves traversal order and keeps the first name.
ordered = list(_reorder_canonical_first(dict.fromkeys(names), self.model))
canonical = ordered[0]
self.duplicate_weight_map.update(dict.fromkeys(ordered[1:], canonical))

def duplicate_of(self, weight_name: str) -> str | None:
"""Return the canonical source weight name, or ``None`` if it is not duplicated."""
return self.duplicate_weight_map.get(weight_name)

def mark_skipped(self, *weight_names: str) -> None:
"""Record duplicate source weights omitted from packing and the state dict."""
self.skipped_weight_names.update(weight_names)


ExportHandler = Callable[[str, nn.Module, ExportContext], None]
Expand Down
Loading
Loading