Resolve tied weights by declared name in HF export (FSDP/offload-correct) - #2092
Resolve tied weights by declared name in HF export (FSDP/offload-correct)#2092juhi10071998 wants to merge 4 commits into
Conversation
…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>
|
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. |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
…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>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #2092 +/- ##
==========================================
- Coverage 78.60% 77.40% -1.20%
==========================================
Files 522 522
Lines 60167 62504 +2337
==========================================
+ Hits 47294 48383 +1089
- Misses 12873 14121 +1248
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
a6a6f60 to
b190aa3
Compare
…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>
What does this PR do?
Type of change: Bug fix / robustness
Fixes NVBug 6525352 — MiniMax-M2.7
nvfp4_mlp_only-kv_fp8failed at TensorRT-LLM load withassert w1_weight is not None and w3_weight is not None, because the previousdata_ptr()-only postprocess dedup (no sizecheck) could falsely drop an independent MoE expert weight.
Resolve tied weights during unified HF checkpoint export by their declared name
(from the model's
_tied_weights_keys/tie_word_embeddings) instead of by tensordata_ptr(). This implements theTODOalready noted onExportContext("dedup by tied-group name instead… which survives weight moves").
Why address identity is wrong here.
data_ptr()answers "do these bytes start at thesame location?", not "are these the same declared parameter?". Those diverge:
allocator can recycle a freed address for a later, unrelated module — falsely aliasing it.
full_state_dictgather (and CPU/disk offload), tied weights are materialized at distinct addresses, so
the address-based dedup misses a genuine tie and writes both copies. This is why tied-weight
export was resident-path only. Name-based dedup runs on the final gathered state dict and is
correct on 1 GPU and under multi-GPU FSDP (e.g. a 4-GPU MiniMax export that doesn't fit
on one device).
What changed
TiedGroupResolver(model_utils.py): builds{alias → canonical}from dict-style_tied_weights_keys(with per-layer regex backreferences and the container-level fused-MoEtie) plus
tie_word_embeddings. Usesnamed_parameters(remove_duplicate=False)so agenuinely shared Parameter is seen under both names even when the canonical side is
registered first.
postprocess_state_dictis the single authoritative dedup: drops each declared alias keywhose canonical is present (address-independent). A
(device, data_ptr, size)pass is keptas a backstop for undeclared / coincidental shares; deletion is idempotent.
sync_tied_input_amax(name-based grouping), theExportContextMoE cache, andpostprocess.sync_tied_input_amaxstill runs beforepacking so the retained weight's single
input_scalecovers every side's activation range.duplicate is dropped by name. The fused-MoE cache is kept purely as a resident-path
compute/memory optimization (skips re-unpacking 128 experts), name-keyed, and still disabled
under
has_non_resident_weights(FSDP2 / offload).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 via
_tied_weights_keysandis unaffected.
Usage
No API change. Existing export just works — and now dedups tied weights correctly under FSDP /
offload as well as on a single GPU:
Testing
Unit tests (CPU): resolver alias-map + per-layer backreference; the FSDP case simulated on
CPU — declared alias dropped by name across distinct addresses; per-expert MoE subtree
drop; keep-alias-when-canonical-absent; name-based
sync_tied_input_amax; MoE tied/untiedcache on the declared-tie + name-key contract; offload
ExportContextresidency guards.tests/unit/torch/export/+test_fused_experts.py+test_quant_embedding.py: 220 passed(4 pre-existing
test_quant_aware_conversion.pyscoped-rule failures onmainare unrelated).End-to-end DiffusionGemma (single-GPU) and MiniMax (4-GPU FSDP) verification is in progress.
Before your PR is "Ready for review"
Additional Information
Related bug: NVBug 6525352 (MiniMax-M2.7 TRT-LLM
load failure — root cause absent after this change).
Dedup happens in a single place — the name-based drop in
postprocess_state_dict. A size-aware(device, data_ptr, size)address pass is retained only as a backstop for undeclaredsame-storage shares (which
safetensors.save_filewould otherwise reject); it only ever firesfor unquantized/unpacked shared weights, since quantized ties pack to distinct storage and are
collapsed by name.
End-to-end verification (4× GB200):
nvfp4_mlp_only-kv_fp8): 191211 tensors, 15872/15872 experts, 0 missing — the6525352
w1_weight is Noneroot cause is absent.nvfp4_experts_only): tied encoder↔decoder experts +lm_head↔embeddingscollapse to the canonical side (0 leaked keys); matches the known-good baseline. Served in vLLM
(
vllm/vllm-openai:gemma) and generates correctly (e.g.17 × 23 = 391with a reasoning trace).