Skip to content

Add id/ tied_weight_names based export shared weight deduplication logic and remove legacy data_ptr approach - #2092

Open
juhi10071998 wants to merge 15 commits into
NVIDIA:mainfrom
juhi10071998:export_dedup_main
Open

Add id/ tied_weight_names based export shared weight deduplication logic and remove legacy data_ptr approach #2092
juhi10071998 wants to merge 15 commits into
NVIDIA:mainfrom
juhi10071998:export_dedup_main

Conversation

@juhi10071998

@juhi10071998 juhi10071998 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: Bug fix / robustness

Fixes NVBug 6525352 — MiniMax-M2.7
nvfp4_mlp_only-kv_fp8 failed at TensorRT-LLM load with assert w1_weight is not None and w3_weight is not None, because the previous data_ptr()-only postprocess dedup could falsely
drop an independent MoE expert weight.

Rework tied-weight dedup during unified HF checkpoint export so it no longer depends on tensor
addresses. Ties are detected by object identity before packing, recorded as names, and the
duplicate is dropped by name on the final state dict.

Why the old data_ptr() approach was wrong — it read the tie signal at the wrong time.

pack each module  ->  weight = new packed Parameter (shared object destroyed)  ->  postprocess: dedup by value.data_ptr()  ->  save

  x  false positive : a freed address is reused by an unrelated weight  ->  a real weight is dropped   (NVBug 6525352)
  x  false negative : FSDP gather / offload moves a tied weight to a new address  ->  tie missed, both copies written

root cause: the address is read AFTER packing severs it and gather/offload moves it, when it no longer reflects the real tie.

The new flow — observe identity once, at the only moment it is true, and carry it forward as a name.

build TiedWeightMap (pre-pack, resident) : group params by id()  ->  { alias_name : canonical_name }   (declaration only labels the canonical)
  ->  sync_tied_input_amax   : merge input amaxes across the tie
  ->  pack every module      : tie severed -> distinct, byte-identical tensors
  ->  postprocess (BY NAME)  : drop each tie's own exported keys (dense = weight + scales; MoE = per-expert keys of the tied projections; atomic)
  ->  storage backstop       : collapse undeclared same-storage shares
  ->  safetensors.save_file  ->  loader re-ties via _tied_weights_keys

key: id() is read ONCE pre-pack and kept as NAMES -> names survive packing / FSDP / offload, so the drop never needs the packed tensors to still be the same object.

Design in words.

  • Object identity detects the tie_build_tied_alias_map groups parameters by
    id(parameter) while the model is resident (before packing replaces shared Parameters with
    independent tensors). A declared-but-unapplied tie (e.g. lm_headembed_tokens with
    tie_word_embeddings=False) is two distinct objects, so it never groups and is never dropped.
  • The declaration only labels the canonical. _tied_weights_keys / tie_word_embeddings
    choose which member of a shared group to keep; they never create a tie. Only the alias
    pattern is matched — the canonical-side regex is never parsed, so parallel-pattern / backref
    declarations (DiffusionGemma) need no special handling.
  • The drop is by name, in postprocess_state_dict, atomically per module prefix (all of an
    alias's keys, or none, and only when every key has a canonical counterpart) — so tied sides with
    different quant state never orphan a weight_scale / input_scale, and an untied sibling under
    an alias prefix is never dropped.
  • data_ptr survives only as a backstop, keyed on safetensors' own shared-storage identity
    (device, storage_ptr, storage_size). It collapses undeclared same-storage shares that
    save_file would otherwise reject (e.g. a base tensor and a view of it), and only fires for
    unquantized/unpacked weights — quantized ties pack to distinct storage and are handled by name.
  • sync_tied_input_amax merges input amaxes so the kept side's single input_scale covers every
    side; it groups by name, plus an id(weight) fallback for undeclared shares the backstop will
    collapse (no silent activation clipping).

What changed

  • TiedWeightMap (model_utils.py) — a thin, immutable view over the id-derived
    {alias: canonical} map; the only stateful piece, built once in the driver and used by
    sync_tied_input_amax and postprocess_state_dict.
  • Both the per-module dense tied cache and the fused-MoE cache are removed; the name-drop is
    the single dedup authority. The FSDP/offload has_non_resident_weights guard (which existed only
    to disable those caches) is removed with them.

Tradeoff (honest): with the caches gone, each side of a tied-MoE is re-packed instead of
aliased, so peak save-time memory is higher and the informational total_parameters index
counts tied experts per side (DiffusionGemma-26B reports 25.8B vs 14.4B). On-disk weights are
unchanged and correct
— DiffusionGemma export is byte-identical to the known-good baseline.
Untied models and the streaming offload path are unaffected (streaming keeps its own
tie_word_embeddings-gated, exact-name drop).

Usage

No API change. Existing export just works — and now dedups tied weights correctly under FSDP /
offload as well as on a single GPU:

from modelopt.torch.export import export_hf_checkpoint

export_hf_checkpoint(model, export_dir="./exported")  # tied weights collapse correctly

Testing

Unit tests (CPU) cover: id-grouped alias map incl. per-layer backreference and DiffusionGemma
parallel-pattern; the FSDP case simulated on CPU (alias dropped by name across distinct
addresses); atomic per-expert MoE subtree drop; keep-both-sides when a canonical is absent, on a
bidirectional declaration, or when tied sides have mismatched quant state; declared-but-unapplied
tie is not dropped; name-based sync_tied_input_amax + id() fallback for undeclared shares;
storage-identity backstop collapsing a view/base share. tests/unit/torch/export/ +
plugins/test_fused_experts.py pass.

End-to-end verification (HSG):

  • MiniMax-M2.7 (nvfp4_mlp_only-kv_fp8, 4-GPU): 191211 tensors, 15872/15872 experts, 0 missing —
    the 6525352 w1_weight is None root cause is absent.
  • DiffusionGemma-26B (nvfp4_experts_only): tied encoder↔decoder experts + lm_head↔embeddings
    collapse to the canonical side — 47067 tensors, 0 leaked encoder-expert keys, byte-identical to
    the known-good baseline. Served in vLLM (vllm/vllm-openai:gemma) and generates correctly
    (e.g. 17 × 23 = 391 with a reasoning trace).

Review

Addressed two rounds of maintainer review (@cjluo-nv, @Edwardf0t1): atomic per-prefix drop for
mixed quant state, id() amax merge for undeclared shares, and the storage-identity backstop.
The detection core was then simplified to id-grouping (adopting the idea from #2151 while keeping
declared-only drop + the storage backstop), which let the interim guards — the object-identity
gate, the parallel-pattern heuristic and its re.fullmatch check, the container_group_key
fail-safe, and the postprocess bidirectional guard — all be removed as unreachable. Net ~120 fewer
lines than the pre-simplification revision.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ (on-disk output unchanged for declared ties / untied models)
  • If you copied code from any other sources or added a new PIP dependency…: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ✅ (0.47 → Bug Fixes)

Additional Information

Related bug: NVBug 6525352 (MiniMax-M2.7 TRT-LLM
load failure — root cause absent after this change). Related PR: #2151 (an id-only variant of the
same idea; this PR adds declared-only drop + the shared-storage backstop it lacks).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed Hugging Face checkpoint export for models with tied weights.
    • Tied parameters are now resolved by declared names, preserving canonical weights and removing duplicate aliases.
    • Improved handling of tied input/output embeddings and fused expert weights.
    • Added safer shared-storage fallback handling for undeclared or ambiguous ties.
    • Ensured tied weights are packed independently while duplicate aliases are removed during export.
  • Testing

    • Expanded coverage for alias resolution, expert deduplication, tensor views, distinct storage, bidirectional ties, and meta tensors.

@copy-pr-bot

copy-pr-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

HF checkpoint export now resolves declared tied weights by model names. A shared TiedGroupResolver coordinates amax synchronization, quantized export, and state-dict deduplication. Pointer-based matching remains a fallback for undeclared shared storage.

Changes

Tied-weight export

Layer / File(s) Summary
Tied-weight resolver contract
modelopt/torch/export/model_utils.py, tests/unit/torch/export/test_unified_export_hf.py
Added name-based alias parsing, regex and backreference handling, fused-expert grouping, canonical key rewriting, and resolver tests.
Shared resolver export pipeline
modelopt/torch/export/registry.py, modelopt/torch/export/unified_export_hf.py, modelopt/torch/export/hf_export_handlers.py, modelopt/torch/export/moe_utils.py, tests/unit/torch/export/test_export_registry.py, tests/unit/torch/export/test_offload_export.py, tests/unit/torch/quantization/plugins/test_fused_experts.py, modelopt/torch/export/unified_export_hf_streaming.py, modelopt/torch/quantization/utils/core_utils.py, tests/_test_utils/torch/quantization/tied_modules.py
Replaced dense and fused-MoE tied caches with independent packing and resolver-driven export coordination. Removed the unused non-resident-weight helper.
Canonical state-dict deduplication
modelopt/torch/export/quant_utils.py, tests/unit/torch/export/test_unified_export_hf.py, CHANGELOG.rst
Added name-based alias removal before constrained storage-identity fallback deduplication. Updated amax grouping and regression coverage for views, meta tensors, expert subtrees, and missing canonical keys.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant unified_export_hf
  participant TiedGroupResolver
  participant sync_tied_input_amax
  participant _process_quantized_modules
  participant postprocess_state_dict
  unified_export_hf->>TiedGroupResolver: resolve declared tied parameter names
  unified_export_hf->>sync_tied_input_amax: synchronize tied input amax values
  unified_export_hf->>_process_quantized_modules: process quantized modules with shared resolver
  unified_export_hf->>postprocess_state_dict: deduplicate state dict with shared resolver
  postprocess_state_dict-->>unified_export_hf: return canonicalized checkpoint
Loading

Suggested reviewers: sugunav14

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed The PR adds no forbidden torch.load, allow_pickle=True, trust_remote_code=True, eval/exec, or # nosec patterns, and adds no dependency changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: name-based shared-weight deduplication replaces the legacy data_ptr approach.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.79310% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.13%. Comparing base (a21173a) to head (e24c148).
⚠️ Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
modelopt/torch/export/model_utils.py 94.44% 4 Missing ⚠️
modelopt/torch/export/quant_utils.py 93.44% 4 Missing ⚠️
modelopt/torch/export/unified_export_hf.py 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2092      +/-   ##
==========================================
- Coverage   78.74%   77.13%   -1.62%     
==========================================
  Files         522      522              
  Lines       60368    61506    +1138     
==========================================
- Hits        47538    47440      -98     
- Misses      12830    14066    +1236     
Flag Coverage Δ
examples-diffusers 20.76% <12.41%> (-0.04%) ⬇️
examples-gpt-oss 13.27% <11.72%> (-0.01%) ⬇️
examples-hf_ptq 21.58% <81.37%> (+0.07%) ⬆️
examples-llm_distill 13.33% <11.72%> (-0.01%) ⬇️
examples-llm_eval 17.15% <46.89%> (+0.04%) ⬆️
examples-llm_qat 17.65% <46.89%> (+0.04%) ⬆️
examples-llm_sparsity 15.92% <11.72%> (-0.01%) ⬇️
examples-megatron_bridge 25.66% <11.72%> (-0.16%) ⬇️
examples-specdec_bench 13.01% <11.72%> (-0.01%) ⬇️
examples-speculative_decoding 17.58% <46.89%> (-0.03%) ⬇️
examples-torch_trt 15.09% <11.72%> (-0.01%) ⬇️
gpu 58.63% <54.48%> (-0.69%) ⬇️
regression 14.89% <11.72%> (+0.06%) ⬆️
unit 55.32% <81.37%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@juhi10071998
juhi10071998 marked this pull request as ready for review August 10, 2026 16:07
@juhi10071998
juhi10071998 requested review from a team as code owners August 10, 2026 16:07
@juhi10071998
juhi10071998 requested a review from sugunav14 August 10, 2026 16:07

@coderabbitai coderabbitai Bot left a comment

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.

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 6

🧹 Nitpick comments (6)
tests/unit/torch/export/test_export_registry.py (1)

306-312: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for resolver reuse.

This test covers the branch where ExportContext builds a resolver. It does not cover the branch where the caller supplies one. That branch carries the new resolver= plumbing from _process_quantized_modules, so a regression that overwrote the field would pass this test and silently rebuild the map.

💚 Proposed additional test
def test_export_context_reuses_caller_provided_resolver():
    model = nn.Linear(2, 2)
    resolver = TiedGroupResolver(model)
    ctx = ExportContext(model=model, dtype=torch.float16, resolver=resolver)
    # A caller-supplied resolver is kept, so the export builds the alias map once.
    assert ctx.resolver is resolver

Add the import at the top of the file:

from modelopt.torch.export.model_utils import TiedGroupResolver
🤖 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 `@tests/unit/torch/export/test_export_registry.py` around lines 306 - 312, Add
a unit test alongside test_export_context_builds_per_instance_resolver that
constructs a TiedGroupResolver, passes it through ExportContext(...,
resolver=resolver), and asserts ctx.resolver is the same instance. Import
TiedGroupResolver from model_utils so the caller-supplied resolver path is
covered without altering the existing per-instance resolver test.

Source: Path instructions

modelopt/torch/export/unified_export_hf.py (1)

1028-1032: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

_reorder_canonical_first is now redundant for declared ties.

The comment states the reorder feeds "postprocess_state_dict's first-wins data_ptr dedup". The name-based pass now selects the canonical side from the declaration, so iteration order no longer decides which key survives for a declared tie.

The address pass still runs first-wins, but _reorder_canonical_first derives its patterns from _tied_weights_keys, and every tie declared there is already handled by name. The reorder therefore has no remaining effect on the keys it can match.

Consider removing the call and the helper, or at minimum correct the comment so a later reader does not treat the address pass as authoritative. The helper is gated to DiffusionGemma, so confirm that export before removing it.

Run the following script to find every user of the reorder helper before removing it:

#!/bin/bash
# Description: Locate all references to the canonical-first reorder helpers.
set -euo pipefail

rg -nP -C4 '_reorder_canonical_first|_collect_canonical_tied_patterns' --type=py
🤖 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/unified_export_hf.py` around lines 1028 - 1032, Remove
the now-redundant _reorder_canonical_first call and its
_collect_canonical_tied_patterns helper after confirming the repository search
shows no other required users and the DiffusionGemma export remains covered by
name-based tie handling. Also remove any imports or comments that only support
this reorder, while preserving the existing quantized_state_dict processing
flow.
tests/unit/torch/export/test_unified_export_hf.py (1)

274-314: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for a key marked by both dedup passes.

postprocess_state_dict uses dict.fromkeys(keys_to_delete) at modelopt/torch/export/quant_utils.py Line 1181 to tolerate a key that both the name pass and an earlier pass mark. No test drives that path, so a regression that reintroduces a double pop on a missing key would pass.

A declared alias key that also matches the LoRA filter covers it in one case.

💚 Proposed test for the double-marked key path
def test_postprocess_double_marked_alias_is_deleted_once():
    """A key marked by both the LoRA filter and the name-based tie pass deletes cleanly."""
    enc, dec = make_tied_linear_pair()
    parent = wrap_in_parent_with_tied_keys(enc, dec, decoder_canonical=True)
    resolver = TiedGroupResolver(parent)

    sd = {
        "encoder.weight": torch.randn(4, 4),  # declared alias
        "decoder.weight": torch.randn(4, 4),  # canonical
    }
    # is_modelopt_qlora routes "lora" keys into keys_to_delete as well.
    out = postprocess_state_dict(
        sd, maxbound=448, quantization=None, is_modelopt_qlora=False, resolver=resolver
    )

    assert "decoder.weight" in out
    assert "encoder.weight" not in out
🤖 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 `@tests/unit/torch/export/test_unified_export_hf.py` around lines 274 - 314,
Add a unit test near
test_postprocess_name_based_drops_tied_expert_subtree_by_name that creates a
declared encoder/decoder tied pair, includes the alias and canonical weight
keys, and runs postprocess_state_dict with the resolver and LoRA filtering
enabled as required to mark the alias in both deletion passes. Assert the
canonical decoder key remains and the encoder alias is removed without error,
covering single deletion of a double-marked key.

Source: Path instructions

tests/unit/torch/quantization/plugins/test_fused_experts.py (1)

654-659: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The added _tied_weights_keys declaration does not affect either test.

Both tests call _export_fused_experts(container, dtype) directly. That function no longer takes a resolver and no longer dedups, so nothing in this file reads _tied_weights_keys. The tie=True and tie=False cases now differ only by whether the 3-D source Parameters are shared, and the torch.equal assertion follows from that sharing alone.

The class is named TestExportFusedExpertsTiedDedup, which implies dedup coverage. Dedup coverage lives in tests/unit/torch/export/test_unified_export_hf.py.

Make the declaration load-bearing with a resolver assertion, or remove it so the fixture states only what it exercises.

💚 Proposed assertion that makes the declaration load-bearing
     def test_tied_fused_experts_pack_independently_to_equal_values(self):
         """Tied FusedExperts pack independently (distinct storage), byte-identical values.
 
         There is no per-module dedup cache: each container splits and packs the shared
         source itself, so per-expert buffers have DIFFERENT data_ptrs but EQUAL bytes --
         the duplicate keys are then dropped by name in postprocess_state_dict.
         """
         parent = _build_two_moe_blocks(tie=True)
+        # The declaration in _build_two_moe_blocks is what postprocess_state_dict later
+        # uses to drop the encoder subtree, so assert the resolver reads it.
+        resolver = TiedGroupResolver(parent)
+        assert (
+            resolver.container_group_key("encoder.experts", "gate_up_proj")
+            == "decoder.experts"
+        )
         expert_type = type(parent.encoder.experts)

Add the import at the top of the file:

from modelopt.torch.export.model_utils import TiedGroupResolver
🤖 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 `@tests/unit/torch/quantization/plugins/test_fused_experts.py` around lines 654
- 659, Remove the unused parent._tied_weights_keys declaration from the tie=True
branch, since _export_fused_experts does not consume it and these tests only
exercise shared 3-D Parameters. Keep tie_fused_experts_3d_params and the
existing assertions unchanged.

Source: Coding guidelines

modelopt/torch/export/quant_utils.py (2)

1055-1055: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Annotate the resolver parameter in both public functions. Both functions accept resolver=None with no type annotation, while every other parameter in the same signatures is annotated. The shared cause is that quant_utils.py has no TYPE_CHECKING import for TiedGroupResolver, which a runtime import would make circular. registry.py already solves this with a TYPE_CHECKING block plus a quoted annotation.

  • modelopt/torch/export/quant_utils.py#L1055-L1055: annotate as resolver: "TiedGroupResolver | None" = None in postprocess_state_dict.
  • modelopt/torch/export/quant_utils.py#L1630-L1630: annotate as resolver: "TiedGroupResolver | None" = None in sync_tied_input_amax.

Add the guarded import once at module scope:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .model_utils import TiedGroupResolver
🤖 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` at line 1055, Update
modelopt/torch/export/quant_utils.py at lines 1055-1055 and 1630-1630 to
annotate the resolver parameters in postprocess_state_dict and
sync_tied_input_amax as "TiedGroupResolver | None", preserving the default None.
Add a single module-scope TYPE_CHECKING guard importing TiedGroupResolver from
.model_utils to avoid a runtime circular import.

1653-1658: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the defaultdict import to module scope and justify the local TiedGroupResolver import.

from collections import defaultdict is a stdlib import inside the function. It is not a circular dependency, an optional dependency, or an unusually heavy import, so it belongs at module scope.

The TiedGroupResolver import is a justified circular-import workaround, but it carries no explanatory comment.

♻️ Proposed import cleanup
-    from collections import defaultdict
-
-    from .model_utils import TiedGroupResolver
+    # Imported here to avoid a circular import: model_utils imports from this module.
+    from .model_utils import TiedGroupResolver

Add the stdlib import at the top of the file:

from collections import defaultdict

As per coding guidelines: "Keep imports at the top of Python source and test files; use local imports only for justified circular dependencies, optional dependencies, or unusually heavy imports, with a brief explanatory comment."

🤖 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 1653 - 1658, Move the
defaultdict import from the function to module scope, and retain the local
TiedGroupResolver import only as a documented circular-dependency workaround by
adding a brief explanatory comment next to it. Keep the resolver initialization
behavior unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In `@CHANGELOG.rst`:
- Line 24: Update the CHANGELOG entry to remove claims that a fused-MoE cache
survives or provides a resident-path optimization. State that fused-MoE
in-memory tied caching and per-module dedup caching were removed, while
preserving the description of name-based tied-weight resolution and
authoritative dedup.

In `@modelopt/torch/export/model_utils.py`:
- Around line 407-440: Restrict alias-prefix deduplication in alias_prefix_pairs
and postprocess_state_dict to declared aliased parameters and their exported
companion keys, rather than every descendant under the alias module prefix.
Preserve untied sibling parameters by ensuring canonical_state_dict_key only
rewrites eligible exported keys. Add a regression test covering an alias module
with an untied sibling and verify the sibling remains in the state dict.

In `@modelopt/torch/export/quant_utils.py`:
- Around line 1132-1142: Update the name-alias pass around
resolver.canonical_state_dict_key so it does not mark key when its canonical_key
is already present in keys_to_delete; maintain a set for marked keys and skip
such counterparts, ensuring bidirectional aliases retain at least one state-dict
entry.

In `@modelopt/torch/export/registry.py`:
- Around line 64-70: Create a single TiedGroupResolver before export preparation
and reuse it throughout the export flow, including streaming. Pass that resolver
into every ExportContext used by _prepare_moe_inputs() and
_process_quantized_modules(), and reuse it for final post-processing instead of
constructing additional resolvers; keep ExportContext.__post_init__ as the
fallback only when no resolver is supplied.

In `@modelopt/torch/export/unified_export_hf.py`:
- Around line 991-995: Update the comments in
modelopt/torch/export/unified_export_hf.py at lines 991-995 and 914-916: remove
the per-module MoE cache from the resolver consumer list near
TiedGroupResolver(model), and replace the obsolete per-call MoE dedup-cache
description with wording that ExportContext builds a resolver when the caller
does not provide one.

In `@tests/unit/torch/export/test_offload_export.py`:
- Around line 302-318: Update the docstring of
test_tied_weights_exported_independently_without_cache to describe only the
independent packing and byte-identical weight behavior it actually verifies;
remove the claim that it guards or covers the offload path.

---

Nitpick comments:
In `@modelopt/torch/export/quant_utils.py`:
- Line 1055: Update modelopt/torch/export/quant_utils.py at lines 1055-1055 and
1630-1630 to annotate the resolver parameters in postprocess_state_dict and
sync_tied_input_amax as "TiedGroupResolver | None", preserving the default None.
Add a single module-scope TYPE_CHECKING guard importing TiedGroupResolver from
.model_utils to avoid a runtime circular import.
- Around line 1653-1658: Move the defaultdict import from the function to module
scope, and retain the local TiedGroupResolver import only as a documented
circular-dependency workaround by adding a brief explanatory comment next to it.
Keep the resolver initialization behavior unchanged.

In `@modelopt/torch/export/unified_export_hf.py`:
- Around line 1028-1032: Remove the now-redundant _reorder_canonical_first call
and its _collect_canonical_tied_patterns helper after confirming the repository
search shows no other required users and the DiffusionGemma export remains
covered by name-based tie handling. Also remove any imports or comments that
only support this reorder, while preserving the existing quantized_state_dict
processing flow.

In `@tests/unit/torch/export/test_export_registry.py`:
- Around line 306-312: Add a unit test alongside
test_export_context_builds_per_instance_resolver that constructs a
TiedGroupResolver, passes it through ExportContext(..., resolver=resolver), and
asserts ctx.resolver is the same instance. Import TiedGroupResolver from
model_utils so the caller-supplied resolver path is covered without altering the
existing per-instance resolver test.

In `@tests/unit/torch/export/test_unified_export_hf.py`:
- Around line 274-314: Add a unit test near
test_postprocess_name_based_drops_tied_expert_subtree_by_name that creates a
declared encoder/decoder tied pair, includes the alias and canonical weight
keys, and runs postprocess_state_dict with the resolver and LoRA filtering
enabled as required to mark the alias in both deletion passes. Assert the
canonical decoder key remains and the encoder alias is removed without error,
covering single deletion of a double-marked key.

In `@tests/unit/torch/quantization/plugins/test_fused_experts.py`:
- Around line 654-659: Remove the unused parent._tied_weights_keys declaration
from the tie=True branch, since _export_fused_experts does not consume it and
these tests only exercise shared 3-D Parameters. Keep
tie_fused_experts_3d_params and the existing assertions unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 2243e3ff-b346-4c7e-990e-640cf314822e

📥 Commits

Reviewing files that changed from the base of the PR and between bd3798a and 3c8cbcb.

📒 Files selected for processing (11)
  • CHANGELOG.rst
  • modelopt/torch/export/hf_export_handlers.py
  • modelopt/torch/export/model_utils.py
  • modelopt/torch/export/moe_utils.py
  • modelopt/torch/export/quant_utils.py
  • modelopt/torch/export/registry.py
  • modelopt/torch/export/unified_export_hf.py
  • tests/unit/torch/export/test_export_registry.py
  • tests/unit/torch/export/test_offload_export.py
  • tests/unit/torch/export/test_unified_export_hf.py
  • tests/unit/torch/quantization/plugins/test_fused_experts.py

Comment thread CHANGELOG.rst Outdated
Comment thread modelopt/torch/export/model_utils.py Outdated
Comment thread modelopt/torch/export/quant_utils.py Outdated
Comment thread modelopt/torch/export/registry.py Outdated
Comment thread modelopt/torch/export/unified_export_hf.py Outdated
Comment thread tests/unit/torch/export/test_offload_export.py
@juhi10071998
juhi10071998 force-pushed the export_dedup_main branch 2 times, most recently from aa4511d to 1f5f360 Compare August 10, 2026 20:21
@juhi10071998

Copy link
Copy Markdown
Contributor Author

Why tied_cache and moe_tied_cache were removed

The two caches only existed to make the old address-based dedup survive the destructive packing step. Once ties are resolved by declared name, they are unnecessary — and they were actually the source of the bug.

What they did. Quantized export packs each weight in place — setattr(module, "weight", nn.Parameter(packed)) — which replaces the Parameter and severs any Python-level tie (the two tied modules no longer share one object). tied_cache (dense, keyed on the pre-pack weight.data_ptr()) and moe_tied_cache (fused MoE, keyed on (first_proj.data_ptr(), down_proj.data_ptr())) captured the source address before packing and, after packing, re-aliased the second module's packed weight/scales back to the first — restoring a shared address so the downstream data_ptr drop in postprocess_state_dict could collapse them.

Why that was fragile — address is the wrong identity primitive:

  • False positive (free-then-reuse): _export_fused_experts deletes its 3-D source tensors; the allocator can hand that freed address to a later, unrelated container → false cache hit → wrong aliasing (the NVBug 6525352 class, where the data_ptr-only postprocess drop deleted an independent expert weight → assert w1_weight is not None at load).
  • False negative (FSDP / offload): the FSDP full_state_dict gather (and CPU/disk offload) clones tied params to distinct addresses → cache miss → the tie is lost and both copies are written. That is why the caches had to be disabled whenever weights weren't resident (has_non_resident_weights), making tied-weight export resident-path only.

Why they're now unnecessary. This PR resolves ties by declared name (TiedGroupResolver from _tied_weights_keys / tie_word_embeddings). postprocess_state_dict drops a declared alias key by name, which works even when the two packed tensors sit at different addresses — exactly the FSDP/offload case the caches failed on. So there is nothing left for a cache to re-glue: both sides pack independently to byte-identical tensors, and the alias key is dropped by name.

Net effect of removing them:

  • One dedup authority (the name-based postprocess drop) instead of "address caches to survive packing + an address drop".
  • Correct under multi-GPU FSDP and offload — the residency guard is gone.
  • Drops _alias_per_expert_subtree_from_prior, a delicate per-expert buffer-surgery routine that could silently mis-alias a scale.
  • A (device, data_ptr, size) pass is kept only as a backstop for undeclared same-storage shares (which safetensors.save_file would otherwise refuse to serialize; it fires only for unquantized/unpacked shared weights).

Verified end-to-end on 4×GB200: DiffusionGemma-26B (tied encoder↔decoder experts + lm_head↔embeddings collapse to the canonical side, 0 leaked keys) and MiniMax-M2.7 (15872/15872 experts, no weight dropped — 6525352 root cause absent); the DiffusionGemma export also serves correctly in vLLM.

@coderabbitai coderabbitai Bot left a comment

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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
modelopt/torch/export/quant_utils.py (2)

1132-1154: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Restrict name-based removal to entries derived from declared tied parameters.

A declaration for encoder.weight -> decoder.weight creates an encoder -> decoder prefix mapping. The current pass also removes encoder.bias when decoder.bias exists, even when only the weights are tied. The exported checkpoint then loses an untied parameter.

Only rewrite packed weights and scale entries that derive from the declared parameter. Keep unrelated parameters and buffers. Add a regression with tied weights and distinct biases.

🤖 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 1132 - 1154, Restrict the
name-based deduplication loop around resolver.canonical_state_dict_key to keys
derived from the explicitly declared tied parameter, rather than every key
sharing its alias prefix. Ensure tied packed-weight and scale entries are still
deduplicated, while unrelated parameters such as distinct encoder.bias and
decoder.bias remain in post_state_dict. Add a regression covering tied weights
with separate biases.

1177-1189: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use complete view metadata before storage-based removal.

The current identity does not include dtype, shape, or stride. A tensor and its transpose can have the same data_ptr() and byte count but serialize to different logical values. This loop removes one required state-dict key.

Deduplicate only tensors with equivalent serialization metadata. Preserve or clone non-equivalent shared views. Add a regression for equal-span transposed views.

🤖 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 1177 - 1189, Update the
tensor identity in the shared-storage deduplication loop to include dtype,
shape, and stride alongside device, data_ptr, and byte count. Only remove a key
when the complete serialization metadata matches; preserve or clone
non-equivalent shared views, including equal-span transposed tensors, and add a
regression covering that case.
🤖 Prompt for all review comments with 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.

Outside diff comments:
In `@modelopt/torch/export/quant_utils.py`:
- Around line 1132-1154: Restrict the name-based deduplication loop around
resolver.canonical_state_dict_key to keys derived from the explicitly declared
tied parameter, rather than every key sharing its alias prefix. Ensure tied
packed-weight and scale entries are still deduplicated, while unrelated
parameters such as distinct encoder.bias and decoder.bias remain in
post_state_dict. Add a regression covering tied weights with separate biases.
- Around line 1177-1189: Update the tensor identity in the shared-storage
deduplication loop to include dtype, shape, and stride alongside device,
data_ptr, and byte count. Only remove a key when the complete serialization
metadata matches; preserve or clone non-equivalent shared views, including
equal-span transposed tensors, and add a regression covering that case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 8282bfac-b764-4551-9beb-7d3be78deb0a

📥 Commits

Reviewing files that changed from the base of the PR and between 1f5f360 and aa4511d.

📒 Files selected for processing (5)
  • modelopt/torch/export/model_utils.py
  • modelopt/torch/export/quant_utils.py
  • modelopt/torch/export/unified_export_hf.py
  • tests/unit/torch/export/test_offload_export.py
  • tests/unit/torch/export/test_unified_export_hf.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • modelopt/torch/export/model_utils.py
  • tests/unit/torch/export/test_offload_export.py
  • modelopt/torch/export/unified_export_hf.py

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot review (bedrock-claude-opus-5) — DM the bot to share feedback.

Replaces the data_ptr()-keyed tied-weight dedup in HF export with a name-based TiedGroupResolver built from _tied_weights_keys / tie_word_embeddings, deletes the dense and fused-MoE pack-time dedup caches, and makes postprocess_state_dict the single dedup authority. The motivation (address identity is wrong under FSDP gather/offload and can be recycled) is well argued and the CPU unit tests are a genuine improvement over the old address-based tests. However I think there are correctness risks that should be settled before merge:

  1. Declared-but-not-actually-tied is not verified (highest risk). _build_tied_alias_map records an alias purely because a name matches a dict-style _tied_weights_keys pattern; it never checks that the alias and canonical Parameters are the same object. Recent transformers declares the dict form on the class independently of config.tie_word_embeddings, so a model with tying disabled could have lm_head.weight dropped from the exported checkpoint. The streaming exporter in this very repo already guards this exact case ("_tied_weights_keys can list keys whose weights are not actually shared … which would incorrectly drop lm_head.weight"); the new path has no equivalent guard and no test.
  2. Prefix rewriting is broader than the declaration. alias_prefix_pairs() + canonical_state_dict_key() turn a per-parameter tie into a whole-subtree prefix substitution, so untied siblings under an alias module prefix are dropped whenever a same-named canonical key exists. Given the bug being fixed is "a false dedup dropped an independent expert weight", a cheap shape/dtype (or value) check before dropping would be a good backstop.
  3. Partial-quantization ties become inconsistent — the deleted test_export_quantized_weight_skips_alias_when_one_tied_side_is_unquantized scenario now yields orphan weight_scale/input_scale keys on the alias side with its weight dropped; the drop is per-key rather than per-group and that coverage wasn't replaced.
  4. Design question not addressed in the PR body: pre-pack nn.Parameter object identity (group named_parameters(remove_duplicate=False) by id) is stable under FSDP resharding/offload and immune to allocator reuse, and would give exact tie groups without _canonical_via_pattern_pair, backreference templates, or prefix rewriting — with declarations used only to pick the canonical name. Please explain why regex name resolution was preferred.
  5. Smaller items: ExportContext.resolver is no longer read by any handler yet is built eagerly (streaming path builds two unused resolvers over a large model); sync_tied_input_amax silently loses amax merging for undeclared shares that the address backstop still drops; resolver params are untyped; the PR body claims the fused-MoE cache is "kept purely as a resident-path optimization" while the diff (and CHANGELOG) removes it entirely, and the Testing section says e2e is "in progress" while Additional Information reports it complete. Also the Slack description cites NVBug 6530966 while the body cites 6525352 — worth reconciling. The CHANGELOG entry is a 15-line paragraph where neighbours are one-liners.

No licensing concerns (no license files or third-party code touched), size is reasonable (+644/-311), and no prompt-injection content was observed.

if not isinstance(tied, dict) or not tied:
continue
prefix = f"{mod_name}." if mod_name else ""
plen = len(prefix)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

param_names is only used to match names — nothing here verifies that the alias and canonical parameters are actually the same object, so a declared-but-unapplied tie becomes a real dedup entry and postprocess_state_dict will drop the alias key.

That is a live scenario: recent transformers declares the dict form of _tied_weights_keys (e.g. {"lm_head.weight": "model.embed_tokens.weight"}) on the class regardless of config.tie_word_embeddings, and unified_export_hf_streaming.py already documents exactly this hazard:

Only apply when tie_word_embeddings=True: _tied_weights_keys can list keys whose weights are not actually shared … which would incorrectly drop lm_head.weight.

Since the resolver is built before packing, you can settle it exactly: keep the {name: param} mapping from named_parameters(remove_duplicate=False) and only record alias -> canonical when params[alias] is params[canonical] (object identity survives FSDP resharding and offload, unlike data_ptr). Please add a unit test with a dict-style declaration over untied parameters asserting neither key is dropped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 7d5155a9f: _build_tied_alias_map now confirms each declared dict-style tie is actually applied — alias and canonical must resolve to the same live nn.Parameter. That check is done pre-pack while every parameter is resident, so no freed-then-reused address can forge it. A class-level _tied_weights_keys with tie_word_embeddings=False therefore no longer drops an independent lm_head.weight. New test test_build_tied_alias_map_skips_declared_but_unapplied_tie.

Comment thread modelopt/torch/export/model_utils.py Outdated
return head


def _canonical_via_pattern_pair(alias_pat: str, canonical_pat: str, name: str) -> str | None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

This heuristic (longest common suffix of two pattern strings, plus a "literal head" check that permits .) can cut a regex mid-token and silently produce a canonical name that the canonical pattern would never match. Consider validating the result before accepting it — e.g. re.fullmatch(canonical_pat, canonical_rel) (or re.search) and falling back to alias_re.sub(...) otherwise. Right now the only coverage is the happy-path DiffusionGemma shape; a declaration whose trailing structures are not actually parallel would resolve to a wrong canonical name and drop a real weight.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ad17a5a95: _canonical_via_pattern_pair now validates its result with re.fullmatch(canonical_pat, candidate) and returns None (caller falls back to alias_re.sub) when the character-based common-suffix split lands mid-token, so a name the pattern would never produce is never emitted as canonical.

Comment thread modelopt/torch/export/model_utils.py Outdated
``encoder…experts.gate_up_proj``); stripping the trailing parameter component
yields the owning-module prefix. State-dict dedup rewrites any exported key
under an alias prefix — packed weight, ``weight_scale`` / ``weight_scale_2`` /
``input_scale``, and per-expert splits like ``…experts.3.gate_proj.weight`` —

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

Widening a per-parameter declaration into a module-prefix rewrite is broader than what the model declared: every exported key under the alias prefix is rewritten, so untied siblings get dropped whenever a same-named canonical key exists (e.g. a tie declared only on …experts.gate_up_proj/down_proj also collapses any other key in that container; lm_head.weight -> …embed_tokens.weight makes every lm_head.* scale collide with …embed_tokens.*).

Given the bug this PR fixes is precisely "dedup falsely dropped an independent weight", please either restrict the rewrite to keys derived from the declared parameter (the weight plus its known scale suffixes and the per-expert split pattern) or add a cheap shape/dtype equality check on the two tensors before appending to keys_to_delete.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed by the atomic drop in 7d5155a9f: postprocess_state_dict now drops an alias module-prefix's keys all-or-nothing, and only when every key has a canonical counterpart. An untied sibling under an alias prefix (no canonical twin) makes the group incomplete, so nothing is dropped. The declared-but-unapplied object-identity gate (see the model_utils.py:320 thread) further keeps a falsely-declared prefix out of the map entirely.

Comment thread modelopt/torch/export/quant_utils.py Outdated
if resolver is not None:
alias_prefixes = resolver.alias_prefix_pairs()
if alias_prefixes:
for key in post_state_dict:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

The drop is decided per key, so a tie whose two sides have different quantization states is left inconsistent: if the alias side is quantized and the canonical is not, encoder.weight is dropped (canonical weight exists) while encoder.weight_scale / encoder.input_scale survive (no canonical counterpart) — the checkpoint then carries scales for a weight that is gone. This is the scenario the now-deleted test_export_quantized_weight_skips_alias_when_one_tied_side_is_unquantized covered; there is no replacement test. Suggest treating the alias key group atomically (only drop when the canonical side has the matching key set, otherwise keep both and warn) and re-adding coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 7d5155a9f: the drop is now atomic per alias module prefix — if any key in the group lacks a canonical counterpart (e.g. the alias is quantized but the canonical is not), none are dropped, so weight_scale/input_scale are never orphaned. New test test_postprocess_keeps_both_sides_when_tied_quant_state_differs replaces the deleted one.

Comment thread modelopt/torch/export/quant_utils.py Outdated
maxbound: float,
quantization: str | None,
is_modelopt_qlora: bool = False,
resolver=None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

resolver=None is untyped in this public helper (same in sync_tied_input_amax). Please annotate resolver: "TiedGroupResolver | None" = None with a TYPE_CHECKING import, matching how registry.py types the same field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: annotated resolver: "TiedGroupResolver | None" = None on both postprocess_state_dict and sync_tied_input_amax (7d5155a9f), and in ad17a5a95 the TiedGroupResolver import was hoisted to module level (model_utils imports only stdlib + torch.nn, so there is no cycle).

key = ("moe", first_proj.data_ptr(), m.down_proj.data_ptr())
by_dp[key].append(m)
gk = resolver.container_group_key(name, first_proj_attr)
if gk is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

Behavioural regression for undeclared ties: the previous grouping keyed on weight.data_ptr() and so merged amaxes for genuinely shared Parameters even when the model never declared the tie. Now only declared ties are grouped — but the address backstop in postprocess_state_dict still drops one side of an undeclared share, so the retained side's input_scale no longer covers the dropped side's activation range (silent clipping at inference, exactly the failure mode this helper exists to prevent). Grouping by nn.Parameter object identity here (id(m.weight)) would restore that coverage without reintroducing data_ptr fragility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 7d5155a9f: sync_tied_input_amax gains an id(weight) fallback so an undeclared physical share — which the address backstop still collapses — has its input amaxes max-merged too, so the surviving input_scale covers both sides (no silent clipping). Grouping by object id (pre-pack, all resident) can't collide the way data_ptr can. New test test_sync_tied_input_amax_merges_undeclared_shared_weight.

Comment thread modelopt/torch/export/registry.py Outdated
self.tied_cache = None
self.moe_tied_cache = None
# Import here to avoid a circular import at module load time.
from .model_utils import TiedGroupResolver

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

After this change no handler reads ctx.resolver (both hf_export_handlers.py call sites now pass nothing), so this field is unused state that is nevertheless built eagerly. _export_transformers_checkpoint_streaming constructs two contexts (its own plus the one inside _prepare_moe_inputs), each building a full alias map that nobody reads — and _build_tied_alias_map is O(#declaring modules × #patterns × #params) regex searches, which is not free on a MiniMax-scale model. Either drop resolver from ExportContext or make it lazily computed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 7d5155a9f: dropped the ExportContext.resolver field and its __post_init__ build. No handler read it; the driver builds one resolver and passes it directly to sync_tied_input_amax / postprocess_state_dict (its only consumers). This removes the redundant per-context alias-map build, including the streaming path's two contexts.

@@ -77,8 +48,6 @@ def _delete_fused_moe_source_attrs(module: nn.Module) -> None:
def _export_fused_experts(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

Removing the fused-MoE cache means each tied experts container now re-unpacks and re-packs all experts, and both packed copies stay resident until postprocess_state_dict drops one — a compute and peak-memory increase on the resident path for tied-expert models (DiffusionGemma). The PR body states the fused-MoE cache is "kept purely as a resident-path compute/memory optimization … name-keyed", which contradicts this diff and the CHANGELOG; please update the body and confirm the DiffusionGemma-26B export peak memory did not regress. Note the correctness goal doesn't require deleting the cache — it can be re-keyed on the resolver's container group key.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The fused-MoE cache is removed (not kept) — the PR body has been corrected to match the diff/CHANGELOG. Both the dense and fused-MoE caches are gone; the name-based drop is the single authority, and the has_non_resident_weights guard is removed with them. Peak save-memory does rise for tied-MoE (each side is re-packed rather than aliased) and the informational total_parameters index counts tied experts per side (25.8B vs 14.4B on DiffusionGemma-26B) — on-disk weights are byte-identical to the known-good baseline. Re-verified on HSG: DiffusionGemma 47067 tensors / 0 leaked encoder-expert keys, MiniMax 191211 / 15872 experts / 0 missing.

@juhi10071998

Copy link
Copy Markdown
Contributor Author

Design note: why tied-weight dedup uses both declared names and object identity

A few review comments (thanks @cjluo-nv) touch the same question — why introduce object-identity checks alongside the name-based resolver, isn't one signal enough? Writing the rationale down here since it motivates the resolver, the is-gate in _build_tied_alias_map, and the id() fallback in sync_tied_input_amax.

Dedup asks two independent questions, and no single signal answers both. To safely drop alias B and keep canonical A:

  1. Physical samenessB really is the same tensor as A right now, so dropping it loses nothing. → object id answers this.
  2. Re-tiability — the loader can reconstruct B from A on load, so B reappears. → only a declaration (_tied_weights_keys / tie_word_embeddings) answers this.

Each single-signal approach has a blind spot:

  • id / data_ptr alone proves physical sameness but not re-tiability. An undeclared physical share dropped by identity has no declaration for the loader to re-tie from → missing weight on load. (data_ptr additionally false-positives on allocator reuse and false-negatives under the FSDP full_state_dict gather — the original NVBug 6525352.)
  • names alone prove re-tiability and survive FSDP, but not that the tie is applied. Modern transformers ships a class-level _tied_weights_keys (e.g. {"lm_head.weight": "…embed_tokens.weight"}) even when tie_word_embeddings=False, so a name-only match drops an independent lm_head.weight.

So the two are combined, each covering the other's blind spot:

Failure mode id-alone names-alone id + names
Undeclared share dropped → loader can't re-tie ✅ (name gate blocks drop)
Declared-but-unapplied → real weight dropped ✅ (id gate blocks drop)
FSDP moves tied weight to a new address/object ✅ (name drives the drop)

Concretely, the resulting rules:

  • Name is the drop authority. postprocess_state_dict drops an alias key only when its canonical counterpart is present — the only signal that guarantees the loader re-ties, and it's stable across FSDP gather / offload / packing (it runs on the materialized state dict).
  • Object identity is a build-time confirmation, never the key. _build_tied_alias_map adds a declared tie only when alias and canonical resolve to the same live nn.Parameter. This is safe precisely because it runs before any packing/frees, while every Parameter is resident — so no freed-then-reused address can forge a match the way data_ptr can. It is not consulted on the exported tensors.
  • data_ptr survives only as the postprocess backstop for undeclared unquantized shares that still physically share storage (safetensors refuses to save shared storage). Quantized ties pack into distinct storage and are handled solely by the name pass.
  • sync_tied_input_amax merges input amaxes across a tie so one surviving input_scale covers all sides. It groups by declared name, plus an id(weight) fallback for undeclared physical shares the backstop will collapse — otherwise the surviving scale wouldn't cover the dropped side (silent activation clipping).

In short: physical sameness (id) and re-tiability (name) together are the actual definition of "safe to dedup" — dropping either check re-introduces one of the failure modes above.

@Edwardf0t1 Edwardf0t1 left a comment

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.

Follow-up review pass. Skipping everything already raised in the existing threads (declared-but-not-tied / tie_word_embeddings, prefix rewriting broader than the declaration, per-key drop leaving orphaned scales, the _canonical_via_pattern_pair mid-token cut, the removed fused-MoE cache, the bidirectional map, and the stale MoE-cache comments) — the 5 inline comments below are points not covered by any existing thread.

Comment thread modelopt/torch/export/quant_utils.py Outdated
if key in already_marked:
continue
if isinstance(value, torch.Tensor) and value.data_ptr() != 0:
tensor_id = (value.device, value.data_ptr(), value.numel() * value.element_size())

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.

Bot comment.

Adding the size component makes this backstop disagree with the writer it exists to protect. safetensors groups by storage identity, not by tensor extent — _find_shared_tensors keys on (v.device, storage_ptr(v), storage_size(v)) — so a base tensor and a shorter view of it land in the same group and save_file raises. This key uses value.data_ptr() + numel * element_size, so that pair gets two distinct ids, both survive postprocess_state_dict, and the export dies at write time with RuntimeError: Some tensors share memory after all the packing work is done.

Reproduced on the exact shape the new test pins:

base = torch.arange(4, dtype=torch.float32)
sd = {"short": base[:2], "long": base}
# tensor_id here: {'short': (ptr, 8), 'long': (ptr, 16)}  -> not collapsed
_find_shared_tensors(sd)  # [{'long', 'short'}]  -> save_file RAISES

So test_postprocess_state_dict_preserves_tensors_with_different_byte_ranges asserts precisely the state that makes the subsequent save_file fail. The false-positive it was added to fix is real (two independent tensors whose addresses coincide), but the discriminator should match safetensors' notion of sharing — e.g. key on (device, storage_ptr, storage_size) via safetensors.torch.storage_ptr/storage_size, or on value.untyped_storage().data_ptr() — rather than on tensor length.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ad17a5a95: the backstop now keys on safetensors' own shared-storage identity via safetensors.torch.storage_ptr / storage_size(device, storage_ptr, storage_size). A base tensor and a view of it share storage, so they now collapse here instead of both surviving and crashing save_file. Since two distinct live tensors cannot share a data_ptr without sharing storage, storage identity never false-collapses independent weights. The ..._preserves_tensors_with_different_byte_ranges test is replaced by ..._collapses_view_and_base_sharing_storage.

Comment thread modelopt/torch/export/model_utils.py Outdated
if gk is None:
return None
suffix = f".{first_proj_attr}"
return gk.removesuffix(suffix)

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.

Bot comment.

removesuffix is a silent no-op when the resolved canonical does not end with .{first_proj_attr} — e.g. a declaration that maps …experts.gate_up_proj onto a differently-named canonical projection, or a re.sub fallback that produced a mangled canonical. The method then returns a full parameter name as a container key, which will not equal the key computed from the container's other projection, so sync_tied_input_amax quietly puts the two tied containers in different groups, skips the merge (len(modules) < 2), and the retained side's input_scale under-covers the dropped side — the exact clipping this function exists to prevent, with no warning.

Suggest returning None (or warning) when not gk.endswith(suffix), so a misresolved canonical degrades to "untied" instead of to a wrong group.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ad17a5a95: container_group_key returns None when the resolved canonical does not end in .{first_proj_attr} (the case where removesuffix was a silent no-op), so a misresolved canonical degrades to 'untied' instead of forming a wrong group and skipping the amax merge.

@@ -1053,7 +1038,11 @@ def _export_transformers_checkpoint(
quantized_state_dict = _reorder_canonical_first(quantized_state_dict, model)

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.

Bot comment.

This call is obsolete under the new scheme and the comment above it is now inaccurate. _reorder_canonical_first's own docstring states its purpose — "Lets the downstream first-wins data_ptr dedup keep canonical names" — but the name-based pass now always keeps the canonical side regardless of iteration order, and the data_ptr pass is a backstop for undeclared shares only (which no reordering can help). For every declared tie the reorder is a no-op on the outcome.

What remains is ~80 lines in model_utils.py (_collect_canonical_tied_patterns + _reorder_canonical_first, a regex-token heuristic behind a DiffusionGemma class-name allowlist) that parse the same _tied_weights_keys structure as _build_tied_alias_map — two parsers of one declaration format that can drift apart, plus a model-specific special case layered on the new shared infrastructure. Since the PR's stated goal is to make postprocess_state_dict the single dedup authority, dropping this path (and its helper + import) would finish that consolidation; if there is a DiffusionGemma case the resolver still cannot express, that is worth a comment here, because right now it reads as leftover scaffolding.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in ad17a5a95: removed _reorder_canonical_first and _collect_canonical_tied_patterns, the call site, the import, and their tests. Confirmed it is a no-op under the name-based drop (the canonical is kept regardless of iteration order; declared aliases are pre-marked so the address backstop skips them; undeclared shares the reorder never sees), and it was a second parser of _tied_weights_keys — removing it finishes the single-dedup-authority consolidation. DiffusionGemma dedup is still 0-leaked without it (HSG re-run).

Comment thread modelopt/torch/export/registry.py Outdated
self.tied_cache = None
self.moe_tied_cache = None
# Import here to avoid a circular import at module load time.
from .model_utils import TiedGroupResolver

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.

Bot comment.

The stated justification does not hold: model_utils.py imports only re, warnings and torch.nn — nothing from this package — so there is no cycle to break and this can be a module-level import (the file already has a TYPE_CHECKING import of the same name at line 37). quant_utils.py:1667 adds a second function-level import of TiedGroupResolver with no stated reason at all.

CONTRIBUTING.md asks for imports at module top, with a function-level import only for a concrete reason such as an unavoidable cycle. Beyond style, the inaccurate comment is the kind of thing that gets copied, and it defers any import error to export time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: the registry __post_init__ and its inaccurate 'avoid circular import' comment are gone (7d5155a9f removed the resolver from ExportContext). The quant_utils function-level import is hoisted to module level in ad17a5a95model_utils imports only re, warnings, and torch.nn, so there is no cycle.

Comment thread modelopt/torch/export/registry.py Outdated

# Reuse a caller-provided resolver when given (built once per export), else build.
if self.resolver is None:
self.resolver = TiedGroupResolver(self.model)

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.

Bot comment.

Separate from the "nobody reads ctx.resolver" thread above: the offload/streaming path — the motivating case for moving off data_ptr() — is not actually covered by this change, so the two paths now produce different key sets for the same model.

_export_transformers_checkpoint_streaming builds an ExportContext (paying a full named_parameters(remove_duplicate=False) walk to construct a resolver it never reads), never calls postprocess_state_dict, and keeps its own tie-drop logic:

raw_tied_keys = set(getattr(model, "_tied_weights_keys", None) or []) if config.tie_word_embeddings else set()

On transformers>=5 the dict form makes set(...) the pattern keys (e.g. lm_head\.weight), which never equal a state-dict key, so the drop silently no-ops and the offload export emits both sides of every declared tie while the resident export emits one. Either thread the resolver into the streaming key filter, or say explicitly in the docstring that the streaming path keeps its own (tie_word_embeddings-gated, exact-name) rule — but the current state builds the resolver there for nothing.

While in here: has_non_resident_weights in modelopt/torch/quantization/utils/core_utils.py:667 now has zero callers repo-wide (this PR removed its only import) and can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Partly fixed, partly documented. ExportContext no longer builds a resolver (7d5155a9f), so the streaming path no longer pays a named_parameters walk for one it never read. The streaming path does keep its own tie_word_embeddings-gated, exact-name drop and does not dedup dict-style / MoE ties — this is now stated explicitly in the streaming exporter's docstring (ad17a5a95), with resolver-threading left as a tracked follow-up (so such models should use the resident path meanwhile). The now-dead has_non_resident_weights is removed in ad17a5a95.

@juhi10071998
juhi10071998 requested a review from a team as a code owner August 11, 2026 03:53

@coderabbitai coderabbitai Bot left a comment

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.

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 2

🧹 Nitpick comments (4)
modelopt/torch/export/quant_utils.py (2)

1177-1180: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use logger.info for the expected deduplication outcome.

Line 1177 logs at warning level for the normal success path. Every declared tie produces a warning on every export. A per-expert MoE declaration produces one warning per group. The three log calls in this block use the same level, so a reader cannot separate the two skip conditions (which are anomalies) from the successful drop (which is the intended behavior).

Lower the success message to logger.info and keep logger.warning for the two skip branches at Lines 1161 and 1169.

♻️ Proposed change
                 for k, _ in members:
                     keys_to_delete.append(k)
-                logger.warning(
+                logger.info(
                     f"Tied weight (declared): dropping {len(members)} alias key(s) under "
                     f"'{a_base}'; canonical '{alias_prefixes[a_base]}' kept."
                 )
🤖 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 1177 - 1180, Change the
tied-weight deduplication log in the successful drop path to use logger.info
instead of logger.warning. Keep logger.warning unchanged for the two skip
branches near the same block, so anomaly conditions remain warnings while
expected alias removal is informational.

1203-1211: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the safetensors import to module scope.

safetensors is a direct dependency, and storage_ptr and storage_size are public safetensors.torch utilities. The dependency is not version-pinned.

🤖 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 1203 - 1211, Move the
safetensors.torch import for storage_ptr and storage_size from the local scope
to module scope in quant_utils.py, while preserving the existing tensor
deduplication logic around already_marked, seen_tensors, and post_state_dict.

Source: Coding guidelines

modelopt/torch/export/unified_export_hf.py (1)

956-961: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider building the resolver after the offload rejection.

TiedGroupResolver(model) walks named_parameters(remove_duplicate=False) and runs a regex match per declared pattern per parameter. Line 969 then raises NotImplementedError for offloaded models. For that case the whole alias-map build is discarded. Moving the construction below the has_accelerate_offload(model) check avoids the work on the error path.

Note that the resolver must still be built before _process_quantized_modules, because the object-identity gate requires unpacked Parameters.

🤖 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/unified_export_hf.py` around lines 956 - 961, Move
TiedGroupResolver(model) construction below the has_accelerate_offload(model)
rejection so offloaded models fail before building the unused alias map. Keep
resolver creation before _process_quantized_modules to preserve the
object-identity gate’s access to unpacked Parameters, and retain its existing
use by amax synchronization and postprocess_state_dict deduplication.
modelopt/torch/export/model_utils.py (1)

400-427: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider one helper that returns both the alias prefix and the canonical key.

matched_alias_prefix repeats the prefix walk of canonical_state_dict_key. The two walks also apply different accept conditions: canonical_state_dict_key stops at the longest matching prefix and returns None when the rewrite equals the key, while matched_alias_prefix continues to shorter prefixes in that case. The caller in modelopt/torch/export/quant_utils.py then records a member with canonical_key=None, which keeps the whole group. That fallback is safe, so this is not a defect.

A single method returning (alias_prefix, canonical_key) removes the duplication and the divergence.

🤖 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/model_utils.py` around lines 400 - 427, Consolidate
canonical_state_dict_key and matched_alias_prefix into one helper that performs
the prefix walk once and returns both the matched alias prefix and canonical
key, using consistent longest-prefix and unchanged-rewrite handling. Update
callers to consume the tuple and preserve the existing safe None fallback
behavior when no rewrite applies.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@modelopt/torch/export/quant_utils.py`:
- Around line 1697-1704: Restrict or remove the object-identity-based
dense_shared fallback in sync_tied_input_amax, since _process_quantized_modules
replaces undeclared shared weights and postprocess_state_dict retains both
entries. Only merge amax values for weights that remain aliased after
processing, or add matching deduplication for packed undeclared shares; update
the related test and comments to reflect the preserved separate input scales.

In `@modelopt/torch/export/unified_export_hf_streaming.py`:
- Around line 206-213: Correct the streaming export docstring near the
tied-weight handling to state that only list-style _tied_weights_keys
declarations are supported and dict-style declarations are not deduplicated,
including the common embedding-tie case. Keep the implementation unchanged
unless explicitly choosing to add regex-pattern handling for dict declarations.

---

Nitpick comments:
In `@modelopt/torch/export/model_utils.py`:
- Around line 400-427: Consolidate canonical_state_dict_key and
matched_alias_prefix into one helper that performs the prefix walk once and
returns both the matched alias prefix and canonical key, using consistent
longest-prefix and unchanged-rewrite handling. Update callers to consume the
tuple and preserve the existing safe None fallback behavior when no rewrite
applies.

In `@modelopt/torch/export/quant_utils.py`:
- Around line 1177-1180: Change the tied-weight deduplication log in the
successful drop path to use logger.info instead of logger.warning. Keep
logger.warning unchanged for the two skip branches near the same block, so
anomaly conditions remain warnings while expected alias removal is
informational.
- Around line 1203-1211: Move the safetensors.torch import for storage_ptr and
storage_size from the local scope to module scope in quant_utils.py, while
preserving the existing tensor deduplication logic around already_marked,
seen_tensors, and post_state_dict.

In `@modelopt/torch/export/unified_export_hf.py`:
- Around line 956-961: Move TiedGroupResolver(model) construction below the
has_accelerate_offload(model) rejection so offloaded models fail before building
the unused alias map. Keep resolver creation before _process_quantized_modules
to preserve the object-identity gate’s access to unpacked Parameters, and retain
its existing use by amax synchronization and postprocess_state_dict
deduplication.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: adf93f39-4626-4a32-9eea-70b31f66aa18

📥 Commits

Reviewing files that changed from the base of the PR and between aa4511d and ad17a5a.

📒 Files selected for processing (9)
  • modelopt/torch/export/model_utils.py
  • modelopt/torch/export/quant_utils.py
  • modelopt/torch/export/registry.py
  • modelopt/torch/export/unified_export_hf.py
  • modelopt/torch/export/unified_export_hf_streaming.py
  • modelopt/torch/quantization/utils/core_utils.py
  • tests/_test_utils/torch/quantization/tied_modules.py
  • tests/unit/torch/export/test_export_registry.py
  • tests/unit/torch/export/test_unified_export_hf.py
💤 Files with no reviewable changes (1)
  • modelopt/torch/quantization/utils/core_utils.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/unit/torch/export/test_export_registry.py
  • tests/unit/torch/export/test_unified_export_hf.py

Comment thread modelopt/torch/export/quant_utils.py Outdated
Comment thread modelopt/torch/export/unified_export_hf_streaming.py Outdated
@chadvoegele

Copy link
Copy Markdown
Contributor

Directionally the change is right to me.

Before we had a data_ptr-based dedupe in post processing.

Now, we build a tied weights map based on name, and dedupe with that in the data_ptr in post processing.

This fixes a false-positive issue in dedupe in the pointer becomes invalid during the export flow (quant weight packing, moe expansion, etc.)

The logic in the PR is becoming a bit hard to understand with consecutive agent patches. It seems the agent is very worried about inconsistencies between name-based tie config and the actual weights.

I'd propose to have an agent re-write the PR from scratch, using id(parameter) as the signal to build the tied name map. Potentially also remove the data_ptr in post processing since it should be covered by the tied name map already.

@chadvoegele

Copy link
Copy Markdown
Contributor

Here is a re-write from agent based on my suggested prompt above: https://github.com/NVIDIA/Model-Optimizer/pull/2151/changes

juhi10071998 and others added 8 commits August 12, 2026 01:21
…ect)

Replace the data_ptr-based tied-weight dedup in the unified HF export with a
name-based scheme driven by the model's own _tied_weights_keys /
tie_word_embeddings declarations. Address identity misfires in several ways: a
freed address recycled by the allocator can falsely alias two unrelated
weights, and the FSDP full-state-dict gather (and offload) materializes tied
weights at distinct addresses so a genuine tie is missed and both copies are
written. Names are stable across packing, FSDP resharding, and offload -- this
implements the TODO already noted on ExportContext.

- Add TiedGroupResolver (model_utils): builds {alias -> canonical} from
  dict-style _tied_weights_keys (with per-layer regex backreferences and the
  container-level fused-experts tie) plus tie_word_embeddings. Enumerates
  named_parameters(remove_duplicate=False) so a genuinely shared Parameter is
  seen under both names even when the canonical side is registered first.
- postprocess_state_dict is the authoritative dedup: drop each declared alias
  key whose canonical is present (address-independent -> correct under the FSDP
  gather / offload). A (device, data_ptr, size) pass is kept as a backstop for
  undeclared/coincidental shares; deletion is idempotent.
- One resolver is built per export and threaded through sync_tied_input_amax
  (name-based grouping), the ExportContext MoE cache, and postprocess.
- sync_tied_input_amax still runs before packing: the tied group collapses to
  one retained weight whose single input_scale must cover every side's range.
- Remove the per-module dense tied cache: both sides pack identically (sync
  equalizes scales) and the duplicate is dropped by name; the fused-MoE cache
  is retained as a resident-path compute/memory optimization, keyed by the
  name-based container group key and disabled under has_non_resident_weights
  (FSDP2 / offload), as before.

On-disk output is unchanged for every declared tie and untied model; only the
memory-identity heuristic is replaced. The streaming offload path already
deduped by _tied_weights_keys and is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
…mma)

DiffusionGemma declares ties as {alias_regex: canonical_regex} where the value
is a second regex structurally identical to the alias except for a leading
literal head (e.g. "encoder.language_model.layers\.(?:[^.]+\.)*gate_up_proj" ->
"decoder.layers\.(?:[^.]+\.)*gate_up_proj"), NOT a re.sub backreference template.
_build_tied_alias_map treated the value as a re.sub replacement, emitting the raw
pattern string as the "canonical" name; that name never exists in the state dict,
so postprocess dropped nothing and the encoder experts were written to disk
(under-dedup: ~1536 extra keys on a DiffusionGemma nvfp4_experts_only export).

Add _canonical_via_pattern_pair: when the alias and canonical declarations are
parallel patterns, derive the differing literal head via longest-common-suffix
and swap it, copying the shared trailing structure from the concrete name. The
loop tries this first and falls back to re.sub for genuine backreference
templates (and plain-name canonicals). Regression test covers the exact
DiffusionGemma format, including the post-export per-expert split key rewriting
to the decoder canonical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
…ority)

Prefer fewer moving parts and one dedup authority over an in-memory optimization.
The moe_tied_cache aliased a tied experts container's already-packed per-expert
buffers to skip re-packing the second side. It was never load-bearing for
correctness -- postprocess_state_dict's name-based drop yields the exact same
on-disk checkpoint with or without it -- and it relied on
_alias_per_expert_subtree_from_prior, a delicate hand-rolled routine that rebuilds
each expert's weight / weight_scale / weight_scale_2 / input_scale aliases and
could silently mis-alias a buffer. Removing it makes both dense and fused-MoE tied
weights follow one auditable path: pack each side independently to byte-identical
tensors, then drop the duplicate keys by name in postprocess. Fewer variables, a
smaller surface for silent corruption, and no residency guard to reason about.

On-disk output is unchanged: postprocess drops the same declared-alias keys, so the
exported checkpoint is byte-identical (verified on DiffusionGemma -- same 47067
keys and total_size as the with-cache run).

Accepted tradeoff (does not affect the stored weights or loading): without the
in-memory aliasing a tied experts container is re-packed rather than shared, so
during save its experts exist as separate tensors until postprocess drops the
keys. This raises peak save memory for tied-MoE and inflates the informational
`total_parameters` index field (tied experts counted per-side -- e.g. 25.8B vs
14.4B on DiffusionGemma-26B). The simpler single-authority path is worth that cost.

- moe_utils: drop _moe_tied_cache/_tied_group_key params, the fast-path
  alias+return, the cache register, and _alias_per_expert_subtree_from_prior.
- ExportContext: drop the moe_tied_cache field and the has_non_resident_weights
  guard (nothing left to disable; the name-based postprocess drop is
  FSDP/offload-safe). container_group_key stays on the resolver -- sync_tied_input_amax
  still groups amax by it.
- Tests: tied fused experts now assert independent storage + byte-identical values
  (dropped later by name); offload/registry cache tests removed or repointed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
…ionable warning

Clarify (comment) that the (device,data_ptr,size) backstop only ever fires for
unquantized/unpacked shared weights, which keep the single original shared
Parameter and thus two keys on one storage. Quantized tied weights cannot reach
it: each side packs into its own fresh Parameter (distinct storage, byte-identical),
so they are collapsed by the name-based pass, which is the sole authority for
quantized ties. The backstop remains because safetensors save_file raises on any
two keys sharing storage, so a residual undeclared share must be collapsed here or
the export fails at write time.

Make the warning actionable: a quantized weight reaching the backstop means its tie
was not declared in _tied_weights_keys / tie_word_embeddings and was missed by the
name-based dedup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
The changelog entry was written in the first commit, before the per-module
caches were removed. Update it to the shipped design: one TiedGroupResolver
drives the input-amax sync and the postprocess name-drop; both the dense and
fused-MoE data_ptr caches are removed; the (device,data_ptr,size) pass is only
a backstop for undeclared unquantized same-storage shares.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
…collision, cleanups)

- postprocess_state_dict: guard the name-drop against a (pathological) bidirectional
  alias map (A<->B) that would otherwise mark both sides and leave the loader with a
  missing tensor. Only drop an alias whose canonical is a terminal canonical (not itself
  an alias); warn and keep both otherwise. Regression test added.
- TiedGroupResolver.alias_prefix_pairs: warn (instead of silently overwriting) when one
  alias module prefix maps to two different canonical prefixes, so declared per-key
  rewrites are not misrouted.
- Build the name-based resolver once per export and thread it into _prepare_moe_inputs
  (was built twice: prepare context + driver).
- Fix stale comments still referencing the removed per-module MoE dedup cache
  (_process_quantized_modules and the driver).
- Narrow test_tied_weights_exported_independently_without_cache docstring: it checks
  packing behavior, not the offload/streaming path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
…drop, id sync)

Follow-up to the name-based tied-weight dedup, addressing cjluo-nv review:

- _build_tied_alias_map now confirms a declared dict-style tie is actually
  applied (alias and canonical resolve to the SAME live Parameter) before
  trusting it. A class-level _tied_weights_keys dict is declared regardless of
  config (e.g. lm_head<->embed_tokens with tie_word_embeddings=False), so a
  name-only match would drop an independent weight. Object identity is safe
  here: the map is built pre-packing while all params are resident, so no
  freed-then-reused address can forge a match (unlike data_ptr).

- postprocess_state_dict drops an alias's keys atomically per module prefix:
  only when EVERY key has a canonical counterpart present, else keep all. Fixes
  orphaned weight_scale/input_scale when tied sides have mismatched quant state,
  and stops an untied sibling under an alias prefix from being dropped.

- sync_tied_input_amax gains an id(weight) fallback so undeclared physical
  shares (which the address backstop still collapses) get their input amaxes
  max-merged too, avoiding silent activation clipping on the surviving side.

- _canonical_via_pattern_pair validates its rewrite with re.fullmatch and falls
  back to re.sub when the common-suffix split lands mid-token.

- Drop the dead ExportContext.resolver field: no handler read it; the driver
  owns the one resolver and feeds it to sync_tied_input_amax /
  postprocess_state_dict. Removes an avoidable per-context alias-map build.

- Type-annotate resolver params via TYPE_CHECKING import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
…ode cleanup)

- postprocess_state_dict address backstop keys on safetensors' own shared-storage
  identity (device, storage_ptr, storage_size) instead of tensor extent. A base
  tensor and a shorter view share storage and safetensors save_file rejects them,
  but the numel*element_size key split them into two ids so both survived and the
  export crashed at write time. Two distinct live tensors cannot share a data_ptr
  without sharing storage, so storage identity never false-collapses independents.

- TiedGroupResolver.container_group_key returns None when the resolved canonical
  does not end in the projection suffix (removesuffix would otherwise be a silent
  no-op, mis-grouping tied containers and skipping the amax merge).

- Remove _reorder_canonical_first + _collect_canonical_tied_patterns and their call
  site: a no-op under the name-based drop (which keeps the canonical regardless of
  iteration order) and a second parser of _tied_weights_keys that could drift from
  _build_tied_alias_map. Finishes the single-dedup-authority consolidation.

- Hoist TiedGroupResolver import in quant_utils to module level (model_utils has no
  local imports, so no cycle) and drop the function-level import.

- Remove has_non_resident_weights (zero callers after the cache removal).

- Document that the streaming exporter keeps its own tie_word_embeddings-gated,
  exact-name tie-drop and does not dedup dict-style / MoE ties (follow-up tracked).

112 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
Simplify the tied-weight path (inspired by the id-grouping in PR NVIDIA#2151) while
keeping both safety properties the identity-only approach lacks: declared-only
drop and the shared-storage backstop.

- _build_tied_alias_map now detects ties by shared object identity: group params
  by id(parameter) pre-pack (resident), and use _tied_weights_keys /
  tie_word_embeddings ONLY to label which member of a shared group is canonical.
  The canonical-side regex is never parsed. Delete _canonical_via_pattern_pair.

  id is observed once at build time and recorded as NAMES; names survive packing /
  FSDP gather / offload, so the drop (in postprocess) never needs the packed tensors
  to still be the same object -- which they aren't.

- Several guards become impossible and are removed: the declared-but-unapplied
  is-gate and the re.fullmatch check (with the regex derivation), the
  container_group_key removesuffix fail-safe, and the postprocess bidirectional
  guard (chains can't form when ties are id-groups).

- Keep declared-only drop (undeclared shares are not ours to drop) and the
  storage-identity backstop; postprocess / sync logic otherwise unchanged.

- Rename TiedGroupResolver -> TiedWeightMap: a thin immutable view over the
  id-derived {alias: canonical} map, no longer a regex resolver.

- Condense the docstrings/comments added across the review rounds.

Net ~120 fewer lines. 112 unit tests pass; DiffusionGemma (47067 tensors,
0 leaked encoder-expert keys) and MiniMax (191211 tensors, 15872/15872 experts)
exports verified unchanged on HSG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
@juhi10071998 juhi10071998 changed the title Resolve tied weights by declared name in HF export (FSDP/offload-correct) Update legacy data_ptr based export path deduplication and add id/ tied_weight_names based logic Aug 12, 2026
@juhi10071998 juhi10071998 changed the title Update legacy data_ptr based export path deduplication and add id/ tied_weight_names based logic Add id/ tied_weight_names based export shared weight deduplication logic and remove legacy data_ptr approach Aug 12, 2026
@juhi10071998

Copy link
Copy Markdown
Contributor Author

Worked example: what TiedWeightMap computes and answers

A short walkthrough for reviewers, since the class is the query layer the export sites lean on.

The model. encoder and decoder each own a fused-experts container. The container's
gate_up_proj and down_proj are the same nn.Parameter object on both sides (a genuine tie),
declared dict-style. decoder is registered first.

_build_tied_alias_map(model) produces the raw {alias: canonical} dict that TiedWeightMap wraps:

encoder.experts.gate_up_proj -> decoder.experts.gate_up_proj
encoder.experts.down_proj    -> decoder.experts.down_proj
canonical_names: {decoder.experts.gate_up_proj, decoder.experts.down_proj}

Note the encoder side became the alias (it matched the alias pattern) and decoder the
canonical (the non-alias member) — even though decoder was registered first. Detection is by
object identity; the declaration only labels which side is canonical.

The five queries (real I/O on this model):

1. group_key(name) — canonical for either side, None if untied.

group_key('encoder.experts.gate_up_proj') = 'decoder.experts.gate_up_proj'   # alias -> canonical
group_key('decoder.experts.gate_up_proj') = 'decoder.experts.gate_up_proj'   # canonical -> itself
group_key('unrelated.weight')             = None                              # untied

Both sides return the same key, so callers group them without caring which side the export walk
visits first. Consumer: sync_tied_input_amax (dense Linears).

2. container_group_key(container, first_proj_attr) — one key per fused container.

container_group_key('encoder.experts', 'gate_up_proj') = 'decoder.experts'
container_group_key('decoder.experts', 'gate_up_proj') = 'decoder.experts'

group_key on the container's projection, then strip the suffix. Consumer: sync_tied_input_amax
(fused MoE).

3. alias_prefix_pairs(){alias module prefix : canonical module prefix}.

{'encoder.experts': 'decoder.experts'}

Strips the trailing param component off each map entry. This is the bridge to the per-expert
export keys — see (4).

4. canonical_state_dict_key(key, prefixes) — rewrite an exported key to its canonical.

encoder.experts.0.gate_proj.weight       -> decoder.experts.0.gate_proj.weight
encoder.experts.1.down_proj.weight_scale -> decoder.experts.1.down_proj.weight_scale
decoder.experts.0.gate_proj.weight       -> None      # already canonical, nothing to drop

The crux: the tie is declared on the 3-D container gate_up_proj, but export splits it into
per-expert keys (experts.0.gate_proj.weight, …weight_scale, …) that were never in the
declaration. The prefix rewrite catches all of them by falling under encoder.experts, so each
per-expert alias finds its decoder… twin. Consumer: postprocess_state_dict.

5. matched_alias_prefix(key, prefixes) — which alias prefix a key belongs to.

matched_alias_prefix('encoder.experts.0.gate_proj.weight') = 'encoder.experts'
matched_alias_prefix('decoder.experts.0.gate_proj.weight') = None

Lets postprocess_state_dict group all keys of one tie and drop them atomically (all-or-nothing).

End to end

sync_tied_input_amax   -> group_key / container_group_key            (merge input amaxes across the tie)
postprocess_state_dict -> alias_prefix_pairs (once)
                          + matched_alias_prefix   (group a tie's keys)
                          + canonical_state_dict_key (find the twin to keep)
                          => drop every encoder.experts.* key, keep decoder.experts.*

So on this model the export drops every encoder.experts.* key (its decoder.experts.* twin is
present) and keeps the decoder side — i.e. the "0 leaked encoder-expert keys" from the DiffusionGemma
run, at per-expert granularity.

The parameter and driver variable were still named `resolver`, but the type is
`TiedWeightMap` (no longer a regex resolver). Rename to `tied_map` in
postprocess_state_dict / sync_tied_input_amax, the driver, and the tests, plus two
stale comments. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot review (bedrock-claude-opus-5) — DM the bot to share feedback.

Re-review of the tied-weight dedup rework (14 files, +668/-457 ≈ 1125 lines; design-review gate fired).

Design review. Problem: HF-export dedup keyed on data_ptr(), which is read after packing/gather and so both false-positives (allocator reuse → dropped an independent MoE expert, NVBug 6525352) and false-negatives (FSDP gather → tie missed). Alternatives in-repo/deps: (a) keep the data_ptr caches and re-key them on a group name; (b) id-only grouping as in PR #2151; (c) the streaming exporter's existing exact-name _tied_weights_keys filter; (d) let save_pretrained/safetensors' own shared-storage handling do it. The PR body now explicitly compares against #2151 (id-only) and against the old cache approach, and explains why declaration + identity are combined (physical sameness vs. re-tiability) — the design question is addressed, so I moved on to correctness.

Previously-raised comments — status. Addressed: declared-but-unapplied tie (now gated by id(parameter) grouping, with test_build_tied_alias_map_skips_declared_but_unapplied_tie); orphaned weight_scale/input_scale on mixed quant state (atomic per-prefix drop + replacement test); ExportContext.resolver dead field removed and one map built in the driver; storage-identity backstop now matches safetensors' (device, storage_ptr, storage_size) grouping with a view/base test; _reorder_canonical_first/_collect_canonical_tied_patterns and has_non_resident_weights deleted; resolvertied_map typed; CHANGELOG and stale MoE-cache/offload docstrings corrected; bidirectional declaration now yields an empty map with a regression test.

Still unresolved (see inline): the prefix-wide rewrite can still drop an untied sibling under an alias module prefix whenever a same-named key exists under the canonical prefix (partial-container ties, or a tied weight with a distinct sibling parameter). The atomic-completeness rule only saves the case where the counterpart is absent, so the PR-body claim "an untied sibling under an alias prefix is never dropped" is not accurate, and there is no regression test for it. Given the bug being fixed is exactly "dedup dropped an independent weight", a cheap shape/dtype (or byte) check before dropping, or restricting the rewrite to keys derived from the declared parameter, would close it.

Smaller items inline: the newly added function-local safetensors.torch import; the FSDP2 identity assumption now underpinning detection (no wrapped-model test); container_group_key's removesuffix silently no-ops if the canonical projection is registered under a different attribute name, which silently skips the amax merge (the earlier None guard was dropped in the simplification).

No licensing changes and no prompt-injection content observed. Size is on the large side but cohesive.


Additional comments (outside the PR diff):

  • modelopt/torch/export/model_utils.py:420 — > Bot comment.

The docstring here ("State-dict dedup rewrites any exported key under an alias prefix") is an accurate description of the implementation, but it is exactly the behaviour that widens a per-parameter declaration into a whole-subtree substitution — see the comment on postprocess_state_dict. If you keep the prefix approach, please note in the docstring that correctness relies on every exported key under an alias prefix genuinely belonging to the tie, and reconcile the PR-body claim that "an untied sibling under an alias prefix is never dropped".

Comment thread modelopt/torch/export/quant_utils.py Outdated

# No bidirectional/chain guard is needed: ties are id-groups, so if A<->B and
# B<->C were both shared, A, B and C would be one group -- a canonical is never
# also an alias of another group, so alias->canonical->alias chains cannot form.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

💬 Author replied that the atomic per-prefix drop makes untied siblings safe — that only holds when the sibling has no canonical counterpart. When it does have one, it is still dropped.

Concretely: a tie declared/applied on encoder.experts.gate_up_proj only (with down_proj an independent Parameter) yields alias_prefixes = {"encoder.experts": "decoder.experts"}. Every encoder.experts.<e>.down_proj.* key then matches matched_alias_prefix, its decoder.experts.<e>.down_proj.* twin is present, so the completeness check passes and the whole group — including the independent down_proj tensors — is deleted. Same shape for a declared weight tie between two Linears with distinct biases: A.bias is dropped because B.bias exists.

That is the same failure class as NVBug 6525352 (an independent weight silently dropped), just reached by name instead of by address. Two cheap options:

  1. restrict the rewrite to keys derived from the declared parameter (packed weight + the known weight_scale/weight_scale_2/input_scale suffixes + the per-expert split pattern), or
  2. keep the prefix rewrite but require shape/dtype (ideally torch.equal) agreement between alias key and canonical key before appending to keys_to_delete, warning otherwise.

Either way please add a regression test: an alias prefix containing an untied sibling whose canonical twin is present, asserting the sibling survives.

@juhi10071998 juhi10071998 Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8568a86ea — thanks, this was a real one. I reproduced it first (an independent A.bias was being dropped just because B.bias existed), then took your option 1.

The dedup no longer rewrites by module prefix. Each tie now expands to only the tied parameter's own exported keys:

  • Dense X.weight tie → weight + weight_scale / weight_scale_2 / input_scale. A sibling bias isn't in that set, so it can't be swept.
  • Fused-MoE → only the per-expert keys of the tied projections, matched by projection name (gate_proj / up_proj / down_proj). A partially-tied container dedups exactly what's tied, and an untied down_proj or a router under the same container survives.

Removed the now-unused alias_prefix_pairs / matched_alias_prefix / canonical_state_dict_key. Added regression tests for both cases (tied weight + independent bias; partially-tied container + router). Re-verified on HSG: DiffusionGemma 47067 tensors / 0 leaked, MiniMax 191211 / 15872 experts / 0 missing.

f"tie_word_embeddings and was not caught by the name-based dedup."
)
else:
seen_tensors[tensor_id] = key

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

from safetensors.torch import storage_ptr, storage_size is a function-local import of a hard dependency (safetensors is already imported at module scope elsewhere in the export package) and there is no circular-import or heavy/optional-dependency reason given. Please move it to the top of the file per the repo convention; deferring it only delays an import error to export time.

@juhi10071998 juhi10071998 Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8568a86ea — moved from safetensors.torch import storage_ptr, storage_size to the top of the file.

# resolves ties from the model's declarations (stable across FSDP resharding / offload
# / packing).
tied_map = TiedWeightMap(model)
_prepare_moe_inputs(model, dtype, is_modelopt_qlora)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

Detection now rests on id(parameter) observed here, i.e. after FSDP2 wrapping. If fully_shard hands the two tied modules distinct sharded parameter objects (e.g. when they land in different FSDP parameter groups), the group never forms, no alias is recorded, and both sides are written — the exact false-negative this PR sets out to fix, and the name-based map can no longer recover it because it was never populated.

The old name-only detection was immune to this. The PR body claims 4-GPU verification; could you state which of those runs actually had applied ties under FSDP2 (MiniMax has none), and add a CPU/2-proc test that builds the map on a wrapped model (or at least an assertion/warning when a declared alias matches a name but no id-group formed) so a future torch change can't silently disable dedup?

@juhi10071998 juhi10071998 Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8568a86ea, and thanks — this is the real trade-off of detecting ties by id().

To be straight about what the runs covered: MiniMax has no applied ties, and the DiffusionGemma run used accelerate device_map (resident), not fully_shard — so the specific 'fully_shard hands back distinct objects' case is genuinely untested. (The same applies to the id-based detection in #2151.)

What this does is make it fail loud instead of silent: when a declared tie doesn't form a shared id-group and the model is under FSDP2/offload, _build_tied_alias_map now warns that dedup may be incomplete and suggests gathering/unsharding before export. It's scoped to that context so it doesn't cry wolf on the normal declared-but-unapplied case off FSDP. There's a test for both directions (silent off FSDP, warns under it).

… warning

Address the latest cjluo-nv review.

- (bug) postprocess no longer rewrites by module PREFIX, which swept up untied
  siblings sharing the prefix -- an independent bias next to a tied weight, or an
  untied projection of a partially-tied experts container -- when a canonical twin
  happened to exist (NVBug 6525352's failure class, reached by name). Now each
  {alias: canonical} tie expands to the tied parameter's OWN exported keys: for a
  dense .weight tie, weight + weight_scale/weight_scale_2/input_scale; for a fused
  MoE container, the per-expert keys only when the container is FULLY tied (a
  gate/up projection AND down_proj declared). Atomic drop unchanged. Removes the
  now-unused alias_prefix_pairs / matched_alias_prefix / canonical_state_dict_key.
  Regression tests: tied weight + independent bias -> bias survives; partially-tied
  container -> untied projection survives.

- Hoist `from safetensors.torch import storage_ptr, storage_size` to module top.

- Detection rests on id(parameter) read at export time, which under FSDP2 may be
  after fully_shard has split a tied param into distinct sharded objects (id-group
  never forms -> silent under-dedup). Add a scoped warning: when a declared alias
  did not form a shared id-group AND the model is FSDP2/offloaded, warn. Test both
  directions (quiet off FSDP, warns under it).

115 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
@Edwardf0t1
Edwardf0t1 requested a review from Fridah-nv August 12, 2026 14:54

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot review (bedrock-claude-opus-5) — DM the bot to share feedback.

Re-review of the tied-weight dedup rework (14 files, +782/-456 ≈ 1238 lines; design-review gate fired). Design question is settled: the PR body compares against #2151 (id-only), the old data_ptr caches, the streaming exporter's exact-name filter and safetensors' own shared-storage handling, and explains why declaration + identity are combined. No licensing changes and no prompt-injection content observed.

Previously-raised comments now addressed. The big one — prefix-wide rewrite dropping an untied sibling — is genuinely fixed: alias_prefix_pairs/matched_alias_prefix/canonical_state_dict_key are gone and the drop now expands a tied parameter into its own exported keys (weight_suffixes) or the tied projections' per-expert keys, with two new regression tests (independent bias survives; partially-tied container keeps down_proj and the router). Also fixed: safetensors.torch import hoisted to module scope; declared-but-unapplied tie gated by id(); atomic per-prefix drop for mixed quant state; bidirectional declaration yields an empty map; dead ExportContext.resolver / has_non_resident_weights / _reorder_canonical_first removed; CHANGELOG and stale docstrings corrected.

Still unresolved / new (inline):

  1. Storage-identity backstop can now silently drop a distinct tensor (this replaces the earlier data_ptr+extent key and supersedes the earlier "use complete view metadata" comment, which was resolved in the opposite direction). Any two exported tensors that merely share a storage — different offsets, shapes, or dtypes — are now collapsed keeping whichever is iterated first. test_postprocess_state_dict_collapses_view_and_base_sharing_storage codifies dropping the 4-element base and keeping the 2-element view, i.e. losing data. There is a concrete reachable path: _export_fused_experts_module has no QUANTIZATION_NONE early-return, and per-expert slices in _export_fused_experts are contiguous, so when an expert weight quantizer is disabled _export_quantized_weight early-returns and every per-expert weight stays a view into the one fused storage. Pre-PR those had distinct data_ptrs and save_file raised loudly; now all but one per storage are silently deleted — exactly the NVBug 6525352 failure class this PR exists to remove.
  2. weight_suffixes omits pre_quant_scale, so an AWQ/SVDQuant-style tied dense pair drops the alias weight/scales but leaves <alias>.pre_quant_scale orphaned (the atomicity guarantee only covers enumerated suffixes). The MoE branch is fine because it matches by projection path component.
  3. New function-local imports (has_accelerate_offload, is_fsdp2_model) in _build_tied_alias_map with no stated reason; sync_tied_input_amax still imports defaultdict locally.

Smaller, non-blocking: the FSDP2 identity assumption is still only covered by a warning-path test (author acknowledges no wrapped-model repro); the ("dense_shared", id(weight)) amax merge still applies to quantized undeclared shares that both survive packing, widening both sides' input_scale unnecessarily; a tie declared on a fused projection whose alias/canonical last components differ falls through to a silent no-dedup with no warning, which sits oddly with the "fail loud" philosophy elsewhere in the diff.

Comment thread modelopt/torch/export/quant_utils.py Outdated
# Use tensor data pointer to identify tied weights
tensor_id = value.data_ptr()
if key in already_marked:
continue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

Keying the backstop on (device, storage_ptr, storage_size) matches safetensors' grouping, but not its action: save_file raises on a shared-storage group, whereas this drops every member but the first — even when the members are non-identical views (different storage_offset, shape, stride, or dtype). The new test enshrines that: {"short": base[:2], "long": base} keeps the 2-element view and deletes the 4-element base, i.e. loses data.

This is reachable, not hypothetical: _export_fused_experts_module has no get_quantization_format(...) == QUANTIZATION_NONE early-return (unlike _export_quant_linear), and in _export_fused_experts first_proj[idx, :expert_dim, :] / down[idx] are contiguous, so .contiguous() is a no-op and wrapper.weight aliases the fused storage. If the per-expert weight quantizers are disabled, _export_quantized_weight early-returns and every per-expert weight is a view into one storage → this loop keeps one key and silently deletes the rest. Pre-PR each view had a distinct data_ptr, so they all survived and save_file failed loudly.

Suggest only dropping when the two tensors are genuinely the same logical tensor (equal dtype/shape/stride/storage_offset, or torch.equal), and for a non-equivalent share either clone() the later tensor to break the sharing (what safetensors' own save_model/_remove_duplicate_names effectively does) or raise — silent deletion of a distinct weight is the failure class this PR is fixing. A regression test with two non-overlapping slices of one storage would pin it.

@juhi10071998 juhi10071998 Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in e185b01fb — good catch, this was a real one. Keying on (device, storage_ptr, storage_size) matched how safetensors groups shared storage, but not what it does: I was dropping all-but-first, which loses data when two tensors share a buffer but are different views of it (different offset/shape) — exactly the failure class this PR is fixing.

It now behaves like safetensors' own save_model:

  • if a later key is the same view (data_ptr, shape, stride, storage_offset, dtype all equal) → it's a true duplicate → drop it;
  • if it's a distinct view of the same buffer → clone() it to break the share.

So the unquantized fused-experts case (each per-expert first_proj[idx] is a view into one buffer) now clones each expert rather than silently deleting it — no crash and no data loss. Added tests for distinct views (both survive), two non-overlapping slices, and a true duplicate; the old short/long test was updated to assert the non-lossy behavior.

Comment thread modelopt/torch/export/quant_utils.py Outdated
# `weight_suffixes` / `proj_splits` HERE -- nothing else needs to change.
#
# A dense quantized weight exports as these keys; a `X.weight` tie covers exactly them.
weight_suffixes = ("weight", "weight_scale", "weight_scale_2", "input_scale")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

weight_suffixes misses pre_quant_scale. postprocess_state_dict renames <module>.input_quantizer._pre_quant_scale<module>.pre_quant_scale (see _KV_CACHE_REPLACEMENTS), so for an AWQ / NVFP4_AWQ / SVDQuant tied dense pair the alias's weight + weight_scale* + input_scale are dropped while <alias>.pre_quant_scale stays behind — an orphaned tensor for a weight that no longer exists, which is the same class of problem the atomic-drop rule was added to prevent (the atomicity check only ranges over the enumerated suffixes, so an unenumerated companion is invisible to it).

Adding "pre_quant_scale" to the tuple covers the known case; a cheap extra safety net would be to warn if, after dropping a dense group, any key still remains under the alias module prefix that is not a plain parameter (e.g. bias), so a future new companion key surfaces instead of silently leaking.

@juhi10071998 juhi10071998 Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in e185b01fb — thanks. Added pre_quant_scale to the suffix list, so an AWQ / NVFP4_AWQ / SVDQuant dense tie drops it along with the weight instead of leaving <alias>.pre_quant_scale orphaned.

I also took your safety-net idea: after a dense tie is dropped, if any non-bias key is still sitting under the alias's module prefix, it now warns — "likely an un-enumerated quantizer companion, add its suffix." That way a future new companion shows up as a warning instead of leaking silently. There's a regression test that a tied pre_quant_scale is dropped with the weight.

Comment thread modelopt/torch/export/model_utils.py Outdated
unrealized = declared_aliases - set(alias_to_canonical)
if unrealized:
from modelopt.torch.quantization.utils.core_utils import has_accelerate_offload
from modelopt.torch.utils.distributed import is_fsdp2_model

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

These two imports are function-local with no stated justification. modelopt/torch/export/quant_utils.py already imports modelopt.torch.quantization.model_calib at module scope and unified_export_hf.py imports both has_accelerate_offload and is_fsdp2_model at module scope, so a cycle looks unlikely — please move them to the top of model_utils.py, or add a one-line comment naming the concrete cycle if there is one. (Same for the from collections import defaultdict still inside sync_tied_input_amax in quant_utils.py, which this PR touches.)

@juhi10071998 juhi10071998 Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in e185b01fb — moved has_accelerate_offload / is_fsdp2_model to the top of model_utils.py and defaultdict to the top of quant_utils.py (no cycle, as you noted). One small consequence: since is_fsdp2_model is now imported into model_utils, the FSDP-warning test patches it there rather than at its source module.

…st imports

Address the latest cjluo-nv review.

- (1149) weight_suffixes missed `pre_quant_scale` (the AWQ/NVFP4_AWQ/SVDQuant
  companion, renamed from input_quantizer._pre_quant_scale). A dense tie dropped
  weight + weight_scale* + input_scale but orphaned `<alias>.pre_quant_scale`.
  Add it to the tuple, and warn after a dense drop if a non-`bias` key still
  remains under the alias prefix, so a future un-enumerated companion surfaces
  instead of silently leaking.

- (1230, bug) the storage backstop keyed on (device, storage_ptr, storage_size)
  matched safetensors' grouping but not its action: it DROPPED all-but-first,
  which loses data for two DISTINCT views of one storage (e.g. unquantized
  fused-expert slices at different offsets). Now it drops only a genuine
  duplicate (same data_ptr/shape/stride/offset/dtype) and CLONES a distinct view
  to break the share -- mirroring safetensors' save_model (no crash, no data
  loss). Tests: distinct views cloned, non-overlapping slices both kept, true
  duplicate dropped.

- (244) hoist the FSDP2-warning imports in model_utils and the defaultdict
  import in sync_tied_input_amax to module top (no cycle).

These do not change NVFP4 export output (pre_quant_scale absent; quantized
experts pack to distinct storage so never hit the view path). 118 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
@juhi10071998

Copy link
Copy Markdown
Contributor Author

Why two mechanisms — tied_map (name pass) vs. the address backstop

A recurring question, so writing down the division of labor.

tied_map  = DECLARED parameter ties (quantized OR unquantized) -> handled by the NAME pass
backstop  = physical STORAGE shares the map can't / doesn't see:
              - undeclared parameter shares (not in _tied_weights_keys / tie_word_embeddings)
              - shared buffers (the map is built from named_parameters() only)
              - distinct views of one buffer (not ties at all, e.g. unquantized fused-expert slices)

Why the backstop is required (not an optimization). safetensors.save_file raises
(RuntimeError: Some tensors share memory) on any two keys that share storage. The name pass
only removes declared parameter ties, so if anything else still aliases a buffer at save time, the
export crashes at write time. The backstop is what turns that crash into a clean save:

  • an unquantized fused-experts model leaves each per-expert weight as a view into one buffer
    (distinct objects, undeclared) — without the backstop save_file raises and the model cannot be
    exported at all; the backstop clones the distinct views so both serialize;
  • an undeclared structural share (self.b.weight = self.a.weight in __init__, not declared)
    or two buffers sharing storage would likewise crash the write.

Why it does not overlap the name pass. A declared tie's alias key is already dropped by name
before the backstop runs (and declared aliases are skipped there), so the backstop never
re-processes a declared tie. tied_map reasons about declared identity (names the loader can
re-tie); the backstop reasons about physical storage (what safetensors will reject).

Action, not just grouping. The backstop keys on safetensors' own storage identity
(device, storage_ptr, storage_size), and matches safetensors' action: drop a genuine duplicate
(same view: data_ptr/shape/stride/offset/dtype), clone a distinct view — so it can never
silently delete a weight that merely happens to share a buffer.

Frequency. In a normal quantized export the backstop is dormant: declared ties are name-dropped
and quantized weights pack into distinct storage, so nothing shares a buffer. It only fires for the
edge configs above — which is exactly why it must be correct when it does.

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot review (bedrock-claude-opus-5) — DM the bot to share feedback.

Re-review #5 of the tied-weight dedup rework (14 files, +877/-463 ≈ 1340 changed lines; design-review gate fired). Design question stays settled — the PR body compares against #2151 (id-only), the removed data_ptr caches, the streaming exporter's exact-name filter and safetensors' own shared-storage handling. No licensing changes, no prompt-injection content observed.

Previously-flagged items now resolved. (1) The storage backstop no longer silently deletes a distinct tensor: it drops only a true duplicate (equal shape/stride/storage_offset/dtype) and clone()s a distinct view, which makes the reachable unquantized fused-experts path (_export_fused_experts slices alias one storage; .contiguous() is a no-op there) export correctly instead of losing per-expert data, with three new regression tests. (2) pre_quant_scale is now in weight_suffixes, plus a leftover-companion warning and a test. (3) has_accelerate_offload, is_fsdp2_model, defaultdict and safetensors.torch imports are all hoisted to module scope. Earlier rounds' fixes (per-parameter expansion instead of prefix rewrite, atomic drop, id() gate for declared-but-unapplied ties, dead ExportContext.resolver/has_non_resident_weights/_reorder_canonical_first removal, CHANGELOG) still hold; has_non_resident_weights has no remaining callers.

New finding (inline, worth fixing before merge). _build_tied_alias_map maps every non-canonical member of a shared id-group to the canonical, not just the declared ones. In a ≥3-member group that contains at least one declared alias, an undeclared co-share is also recorded and then dropped by name — with nothing in _tied_weights_keys for the loader to re-tie from, so the checkpoint is missing that key. Once weights are packed the two are distinct storage, so the backstop won't have dropped it pre-PR; this contradicts the module docstring ("an undeclared share … is not dropped") and the design note ("names alone prove re-tiability"). Restricting the alias set to declared_aliases (plus the tie_word_embeddings output-embedding name) is a one-line fix and is safe, because a genuinely storage-sharing undeclared member is still handled by the backstop. No test covers a 3-member group.

Smaller items (non-blocking): alias_groups is keyed by a_pre alone in the MoE branch, so one alias container tied to two different canonical containers silently loses one group (inline); a tie declared on fused projections whose alias/canonical last components differ still falls through to elif alias in post_state_dict and silently no-ops with no warning; the dropped_dense_prefixes leftover scan is O(#dense ties × #keys) (fine at 1 tie, worth noting if many dense ties appear); clone() preserves strides, so a hypothetical non-contiguous shared view would still be rejected by save_fileclone(memory_format=torch.contiguous_format) would close that; the FSDP2 identity assumption is still only covered by a monkeypatched warning-path test (author acknowledged no wrapped-model repro), and the streaming path's divergent tie rule remains documented-but-unfixed.

continue
canonical = non_aliases[0]
for name in names:
if name != canonical:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

This drops every non-canonical member of the id-group, not just the declared ones. If a group has ≥3 names and at least one of them matched a declared alias pattern (so non_aliases is non-empty and the continue above doesn't fire), an undeclared co-share also gets alias_to_canonical[name] = canonical and is then deleted by name in postprocess_state_dict — but there is no declaration for the loader to re-tie it from, so the checkpoint is missing that tensor.

This is not covered by the storage backstop: after packing, the two sides have distinct storage, so pre-PR that undeclared name survived. It also contradicts this function's own docstring ("an undeclared share … has no canonical the loader could re-tie from, so it is not dropped") and the design note in the PR body ("names alone prove re-tiability").

Suggest restricting the emitted aliases to names the model actually declared:

for name in names:
    if name != canonical and (
        name in declared_aliases or embedding_canonical.get(parameter_id) == canonical
    ):
        alias_to_canonical[name] = canonical

(for the tie_word_embeddings branch the output-embedding name is the declared side, so gate on that rather than on declared_aliases). A genuinely storage-sharing undeclared member is still collapsed by the backstop, so nothing regresses at save time. Please add a regression test for a 3-member group {declared alias, canonical, undeclared share} asserting the undeclared name survives.

and any(part in tied_proj_names for part in key[len(prefix) :].split("."))
]
if members:
alias_groups[a_pre] = members

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot comment.

alias_groups is keyed by a_pre only, while moe_containers is keyed by (a_pre, c_pre). If the same alias container has two projections tied to different canonical containers, the second iteration overwrites the first group and those alias keys are never dropped (silent partial dedup, duplicate keys left in the checkpoint). The earlier revision at least warned on that collision before alias_prefix_pairs was removed.

Cheapest fix is to key the group by the pair, e.g. alias_groups[f"{a_pre}->{c_pre}"] = members (group keys are only used for the log message and the dense_prefixes lookup), or warn when a_pre is already present.

juhi10071998 and others added 2 commits August 12, 2026 19:32
Per human review, revert the address backstop to the original value.data_ptr()
drop-on-collision pass rather than the storage-key/clone variant. It now runs
after the name-based drop is applied, on the reduced dict, so it is byte-for-byte
the pre-existing postprocess dedup -- declared ties are handled by name and never
reach it. 6525352 is fixed by the tied_cache removal + name drop, not by this pass.

Keeps pre_quant_scale + the leftover-companion warning (name pass). Tests updated
to the raw-data_ptr behavior. No change for fully quantized exports (dormant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
…t_hf_checkpoint(tied_map=...)

Add a public build_tied_weight_map(model) that snapshots the tied-weight map
while the model is resident, and a tied_map kwarg on export_hf_checkpoint that
consumes it. FSDP2 shard / accelerate offload split the shared tied parameter's
id-group, so a map built at export entry misses the tie; capturing pre-shard
records it by name, which survives. Default (tied_map=None) is unchanged.

Wire it in examples/hf_ptq/hf_ptq.py: capture on the resident model in load_model
and pass it to export_hf_checkpoint. Tests: FSDP2 (GPU) and offload (CPU) prove
the id-group is lost post-wrap and the pre-capture map still resolves the tie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
@juhi10071998
juhi10071998 requested a review from a team as a code owner August 13, 2026 00:47
@juhi10071998

Copy link
Copy Markdown
Contributor Author

Added optional load-time tie capture so the dedup is robust under FSDP2/offload.

Why: the tie map groups params by id(parameter). FSDP2 fully_shard and accelerate offload split the shared tied nn.Parameter into distinct per-module params, so a map built at export entry sees no id-group and misses the tie (warning-only today). Captured before sharding, the tie is recorded by name, which survives shard/offload/packing.

What:

  • build_tied_weight_map(model) — snapshot the map while resident (call right after load).
  • export_hf_checkpoint(..., tied_map=...) — consume it; None (default) rebuilds at entry, so existing behavior is unchanged.
  • Wired into examples/hf_ptq/hf_ptq.py (capture in load_model, pass at export).

Tests: FSDP2 (GPU) and offload (CPU) prove the id-group is lost post-wrap and the pre-captured map still resolves the tie.

Verified: DiffusionGemma (encoder+expert keys leaked: 0, 60 tied groups deduped) and MiniMax-M2 (15872 experts, 0 missing) exported through the tied_map path.

Note: this is the caller-supplied (opt-in) mechanism. An alternative is auto-capturing inside mtq.quantize (no caller change, but too late for FSDP-first flows) — happy to discuss which we prefer.

Merge the two negative tied-alias-map cases (list-style + unapplied-tie) into
one, merge the two offload characterization tests into the Option B contract
test, and drop test_postprocess_backstop_drops_true_duplicate (a strict subset
of ..._collapses_keys_sharing_a_dataptr). No coverage lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Juhi Mittal <juhim@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants