Skip to content

[OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model - #2739

Merged
shengliangxu merged 17 commits into
mainfrom
shengliangx/hfep-4-meta-dcp-load
Oct 27, 2026
Merged

shengliangxu merged 17 commits into
mainfrom
shengliangx/hfep-4-meta-dcp-load

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Note

The expert-parallel PTQ series is now a tree instead of one stack (re-cut 2026-10-27, from main 1521053), so PRs that do not depend on each other can be reviewed and merged in parallel. This PR was [7/21].

Base: main. It can be reviewed and merged on its own.

Re-cut: one line differs: FSDPModule is imported on a line of its own, since fsdp2_wrap stays in distributed.py on main until #2779 moves it into model_load_utils.py. Review fixes since are listed at the end of Testing.

Series layout

What does this PR do?

Type of change: New feature

A distributed load that materializes the whole model on every rank and then discards most of it costs world × the memory and I/O. These helpers let a loader build the model on meta and fill only what each rank keeps. They live in modelopt.torch.utils.plugins.model_load_utils, with the loaders they serve.

  • _meta_dcp_load materializes the params its caller names (load_names) on the rank's device and fills them from the Hugging Face safetensors through torch DCP's HuggingFaceStorageReader; every other param is left as it was. DCP reads a full tensor into a plain param and only the local slice into a Shard DTensor, so the FSDP2-dense + expert-parallel hybrid ([OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742), which fully_shards only the dense modules, loads each rank's dense shard with no full-model copy. It takes only what that loader, its one caller, needs.
  • _map_ckpt_keys_to_model maps each checkpoint key to the state-dict key it feeds, as from_pretrained resolves it: exact match, then the model's _checkpoint_conversion_mapping, then the longest unique trailing-component match for transformers 5's VLM prefix nesting. Each model key takes at most one checkpoint key, and uniqueness is judged against the whole model. Otherwise a key the model has no slot for, such as DeepSeek-V3's MTP model.layers.61.shared_head.norm.weight, would land on model.norm.weight and overwrite it.
  • A param already loaded beside a meta one survives materialization. to_empty re-empties every direct param of a module, and nothing outside the load's names would refill, say, a router's e_score_correction_bias next to its meta gate weight.
  • A parameter the load empties and then neither fills, keeps, nor re-ties fails the load, naming it, rather than being left as uninitialized memory. Tying keeps the checkpoint's values whichever alias it stored: a tied checkpoint may store only lm_head.weight. That holds when the two names share one Parameter, as from_pretrained's tying leaves them: every name of a tied param counts, though named_parameters() lists it once. The numel-weighted guard still stops a load whose key scheme went unresolved before it starts. It weighs only the params still on meta: one already real keeps its value without a checkpoint tensor, however large.

The uninitialized-memory check covers every parameter the load re-emptied and did not fill. The key mapping, its coverage guard and four refusals are decided before anything is materialized, so a refusal there leaves the model as it was: a root-sharded model (whose FSDP2 state the module-by-module materialization does not keep), a buffer on meta (which a checkpoint of params cannot fill), a checkpoint tensor the model's load recorded as unplaced, for the export to carry over raw (filling it would write it twice), and a checkpoint tensor whose shape differs from the model tensor it maps onto, as when the model's config does not match the checkpoint (dcp.load refuses that too, but only after re-emptying the model). The restore clones are dropped before the DCP read.

Usage

Internal to the loader in #2742; nothing to call directly.

Testing

  • New tests/unit/torch/utils/test_meta_dcp_load.py (27 cases). The loads run for real on CPU through DCP, into tiny Llama and Qwen2.5-VL models. They cover:
    • every mapping pass, plus the cases that must stay unmapped (ambiguous suffixes, MTP and truncated-model keys, two claimants for one key);
    • buffers kept and the head re-tied;
    • load_names loading only its names and keeping a real param beside a meta one, one larger than the meta weight included, which the coverage guard does not count;
    • the numel-weighted coverage guard, and an untied parameter the checkpoint lacks failing the load;
    • a tie keeping the stored values whichever alias the checkpoint holds, also with both names on one shared Parameter, and neither reported as a buffer;
    • the layout [OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742 loads, under a one-rank gloo group: the decoder layers' FSDP2-sharded params filled in place with their local slices, the root's left plain, and a forward after the load matching the source model's logits;
    • a refusal table: a root-sharded model, a buffer on meta, a tensor the export carries over raw, and a checkpoint tensor of another shape (a head built for another vocab_size), each refused with the model untouched, every param still on meta.
  • Each fix's regression tests fail on the code before it.
  • The key-mapping tests are one parametrized table; tests a real load already covers, and two that only pinned message wording, are dropped. Each drop is checked by mutation.
  • The unfilled-parameter refusal is parametrized over a name in load_names and one left out of it.
  • Locally on this revision, the stack rebased on main c6fa613: pre-commit over the whole PR against main, at every PR of the stack; and at the stack's tip the full tests/unit on transformers 5.18 and 5.5 and on torch 2.8, and the hf_ptq example tests, each with the same failures as main in that environment. CI runs the full unit suite.
  • The GPU results here were measured on earlier revisions of this branch; this revision's GPU tests run in CI only.
  • On GPUs: the Kimi-K3 run with its dense part sharded (64 ranks, see [OMNIML-6085] Run expert-parallel PTQ from hf_ptq.py #2754) checks the router correction biases against the checkpoint.
  • Re-cut on 2026-10-27 (see the note at the top): pre-commit, mypy included, passes at this head over the whole change against main, and the series' PRs merged together give a tree byte-identical to the former stack's tip. This PR's own unit tests pass at this head on CPU (torch 2.14, transformers 5.18); CI runs the full unit suite.
  • Review fixes, 2026-10-27: the meta-DCP load counts every name of a param it re-empties. Each behaviour fix has a test that fails without it; pre-commit, mypy included, and this PR's own unit tests pass at this head.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ✅ Additive.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: N/A. The feature's entry lands with [OMNIML-6085] Run expert-parallel PTQ from hf_ptq.py #2754.
  • Did you get Claude approval on this PR?: ❌ Not yet.

Additional Information

Part 7 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after #2779.

Summary by CodeRabbit

  • New Features
    • Checkpoints can be loaded into models initialized without materialized parameters, with checkpoint names matched to model parameters where possible.
    • Existing parameters and buffers are preserved during loading, and compatible tied weights are restored from available checkpoint values.
    • Loading reports empty checkpoints and insufficient parameter matches, and warns when persistent buffers are loaded.
    • Checkpoint loading follows a deterministic order for consistent results.

@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 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 Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in 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

No actionable comments were generated in the recent review.

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/Model-Optimizer/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: 69b85a66-fdf3-43b6-8d3f-81f9885f2b0f
📥 Commits

Reviewing files that changed from the base of the PR and between 9517c90 and 30a315a.

📒 Files selected for processing: 1
  • modelopt/torch/utils/plugins/model_load_utils.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Walkthrough

The change adds checkpoint-key mapping and a DCP loading path for meta models. The loader selectively materializes model state, checks parameter coverage, preserves existing values, and handles tied parameters and persistent buffers.

Changes: Meta-model DCP loading

Layer / File(s) Summary
Checkpoint key mapping
modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_meta_dcp_load.py
Checkpoint keys map by exact match, conversion patterns, then unique component suffix. Tests cover precedence, ambiguity, collisions, and unmapped keys.
Selective DCP loading
modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_meta_dcp_load.py
The loader preserves existing parameters and buffers while loading mapped state through DCP. It rejects empty checkpoints and insufficient parameter coverage, warns on rank 0 for persistent-buffer loads, and preserves deterministic request ordering. Tests cover state retention, missing parameters, FSDP2, buffers, and load ordering.
Tied parameter handling
modelopt/torch/utils/plugins/model_load_utils.py, tests/unit/torch/utils/test_meta_dcp_load.py
Loaded values are copied after weight tying when shapes and DTensor layouts match. Incompatible layouts or shapes raise RuntimeError.

Priority: Low

Estimated code review effort: 3 (Moderate) | ~30 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Checkpoint
  participant Loader as _meta_dcp_load
  participant Mapper as _map_ckpt_keys_to_model
  participant DCP
  participant Model
  Loader->>Mapper: Map checkpoint keys to model keys
  Loader->>DCP: Load mapped state
  DCP->>Model: Fill selected model state
Loading
Merge Risk: Minimal · up to 30a31

No additional merge-blocking issue was established for this PR. Normal validation can proceed.

Pre-merge checks: Passed 5
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 changes only modelopt/torch/utils/plugins/model_load_utils.py and a test file; no examples, dependency files, or requirements changed. The added model-loading code uses dcp.load, not `torch…
Title check Passed The title clearly and concisely describes the main change: loading each rank's checkpoint shard directly into a model built on the meta device.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

The PR changes only modelopt/torch/utils/plugins/model_load_utils.py and a test file; no examples, dependency files, or requirements changed. The added model-loading code uses dcp.load, not torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, eval, exec, or # nosec. No non-permissive dependency was added.

✨ Finishing Touches: 💡 2
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@shengliangxu
shengliangxu added this pull request to stack #2755 October 12, 2026 00:57
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-3-a2a-transport branch from d40b1c1 to cd7e9a6 Compare October 12, 2026 02:24
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-4-meta-dcp-load branch from 1b34e71 to c99d63a Compare October 12, 2026 02:24
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-27 16:42 UTC

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.15966% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.03%. Comparing base (1521053) to head (9332221).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
modelopt/torch/utils/plugins/model_load_utils.py 99.15% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2739      +/-   ##
==========================================
+ Coverage   77.58%   79.03%   +1.44%     
==========================================
  Files         661      661              
  Lines       75137    75650     +513     
==========================================
+ Hits        58293    59787    +1494     
+ Misses      16844    15863     -981     
Flag Coverage Δ
examples-diffusers 19.92% <0.00%> (+0.02%) ⬆️
examples-gpt-oss 13.43% <0.00%> (-0.03%) ⬇️
examples-hf_ptq 23.36% <6.72%> (-0.02%) ⬇️
examples-llm_distill 13.50% <0.00%> (-0.03%) ⬇️
examples-llm_eval 17.21% <6.72%> (+0.03%) ⬆️
examples-llm_qat 17.37% <0.00%> (+<0.01%) ⬆️
examples-llm_sparsity 16.74% <6.72%> (-0.03%) ⬇️
examples-megatron_bridge 26.47% <0.00%> (-0.22%) ⬇️
examples-specdec_bench 13.21% <0.00%> (-0.03%) ⬇️
examples-speculative_decoding 17.46% <6.72%> (-0.03%) ⬇️
examples-torch_onnx 21.22% <0.00%> (-0.02%) ⬇️
examples-torch_trt 15.18% <0.00%> (+0.03%) ⬆️
examples-vllm_serve 17.42% <6.72%> (+0.03%) ⬆️
gpu 58.98% <6.72%> (+7.95%) ⬆️
regression 15.02% <0.00%> (-0.03%) ⬇️
unit 61.40% <99.15%> (+0.07%) ⬆️

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.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test c99d63a

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-4-meta-dcp-load branch from c99d63a to d0d9b5e Compare October 12, 2026 08:39
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test d0d9b5e

@shengliangxu shengliangxu changed the title [4/19] Load each rank's checkpoint shard straight into a meta-built model [OMNIML-6064] [4/19] Load each rank's checkpoint shard straight into a meta-built model Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-4-meta-dcp-load branch from d0d9b5e to 64f8588 Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-3-a2a-transport branch 2 times, most recently from 38c164d to e301775 Compare October 13, 2026 01:32
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-4-meta-dcp-load branch from 64f8588 to 7da05a2 Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 7da05a2

@shengliangxu
shengliangxu removed this pull request from stack #2755 October 13, 2026 17:42
@shengliangxu
shengliangxu added this pull request to stack #2775 October 13, 2026 17:43
@shengliangxu shengliangxu changed the title [OMNIML-6064] [4/19] Load each rank's checkpoint shard straight into a meta-built model [OMNIML-6064] [4/20] Load each rank's checkpoint shard straight into a meta-built model Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-3-a2a-transport branch from e301775 to 7267471 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-4-meta-dcp-load branch from 7da05a2 to 00e5f72 Compare October 13, 2026 19:38
@shengliangxu
shengliangxu removed this pull request from stack #2775 October 13, 2026 19:38
@shengliangxu
shengliangxu added this pull request to stack #2781 October 13, 2026 19:40
@shengliangxu shengliangxu changed the title [OMNIML-6064] [4/20] Load each rank's checkpoint shard straight into a meta-built model [OMNIML-6064] [5/21] Load each rank's checkpoint shard straight into a meta-built model Oct 13, 2026
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 00e5f72

@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-3-a2a-transport branch from 7267471 to 5a6426d Compare October 14, 2026 00:14
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-4-meta-dcp-load branch from 00e5f72 to 42a2de6 Compare October 14, 2026 00:14
@shengliangxu
shengliangxu added this pull request to stack #2786 October 14, 2026 00:14
@shengliangxu shengliangxu changed the title [OMNIML-6064] [5/21] Load each rank's checkpoint shard straight into a meta-built model [OMNIML-6064] [6/22] Load each rank's checkpoint shard straight into a meta-built model Oct 14, 2026
named_parameters() lists a parameter that two modules share once. With the real tie that
from_pretrained's tie_weights() leaves (lm_head.weight and the embedding as one Parameter),
lm_head.weight fell out of the params the load expects to fill: a checkpoint storing only
lm_head.weight was refused, naming the embedding as unfilled, and a loaded lm_head.weight was
reported as a persistent buffer. Both reads now take every alias (remove_duplicate=False), as
unplaced_source_keys does, and the tie test also runs on a model holding one shared Parameter.

Also: the function-local imports move to the module top (DTensor was imported twice); the suffix
index keeps at most two candidates per suffix, since only "exactly one" is read; and the docstring
says a param counts as filled by tying only when tied to one the checkpoint filled.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The meta-DCP load matched checkpoint names to the model's without comparing shapes. dcp.load's
planner refuses a tensor whose stored shape is not its param's, as when the model was built from a
config with another vocab_size, but only after to_empty has re-emptied the model, and as a
CheckpointException, which derives from BaseException. The load now compares every mapped pair
with the checkpoint metadata it already reads, beside its other refusals, so a mismatch is refused
by name with the model still on meta.

The coverage guard weighed every target param, so a large one that was already real, which keeps
its value without a checkpoint tensor, counted as weight the key mapping missed and could refuse a
load that needed nothing for it. Only meta params weigh in now.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 5ed2c3a

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

saved_params: dict[str, torch.Tensor] = {}
emptied: set[str] = set()
for name, m in model.named_modules():
direct = dict(m.named_parameters(recurse=False))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION] m.named_parameters(recurse=False) keeps remove_duplicate=True (the default), while the rest of this function deliberately passes remove_duplicate=False for exactly this reason (:669, :686, matching unplaced_source_keys at :348).

If a module ever registers one Parameter under two of its own attribute names, direct lists only the first, so emptied misses the second name — while Module.to_empty → _apply iterates self._parameters directly and replaces both entries with separate empty_like tensors, breaking the intra-module alias. The second name then holds to_empty's memory with the unfilled guard (:746-748) blind to it, since it only checks names in emptied. saved_params has the same blind spot for a real intra-module alias.

Cross-module ties (lm_head.weight / embed_tokens.weight) are unaffected — they are separate modules, so each is visited on its own — which is why this is a latent inconsistency rather than a live bug on the models this stack targets. It is a one-word fix that makes the guard's coverage unconditional:

Suggested change
direct = dict(m.named_parameters(recurse=False))
direct = dict(m.named_parameters(recurse=False, remove_duplicate=False))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed: the meta-DCP load now lists each module's direct params with remove_duplicate=False, so a param a module registers under two names counts under both, and the unfilled guard names the second one when the checkpoint stores only the first. test_a_second_name_of_a_param_the_checkpoint_lacks_fails_the_load covers it and fails without the fix.

@claude claude 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.

Claude review (re-review of 5ed2c3ab, the 2026-10-27 re-cut)

Scope: full review — the trigger comment carried no scoping instructions. 2 changed files per GitHub's changed-file list, 872 added lines; both reviewed (modelopt/torch/utils/plugins/model_load_utils.py's new 303 lines in full, plus the base-branch _materialize_meta_model / _conversion_plan* / _target_param / unplaced_source_keys / load_pretrained context at :55-454 and modelopt/torch/utils/logging.py:100-138; tests/unit/torch/utils/test_meta_dcp_load.py in full). Nothing deprioritized.

A note on the diff: git diff origin/main HEAD in this checkout also shows ONNX-autotune and _deploy engine_builder hunks as reversions. Those are main-only commits the branch simply predates (origin/main here is ahead of the re-cut base 1521053bfb0); GitHub's two-file list is authoritative, and nothing in this PR touches them.

Findings this round — CRITICAL: 0, IMPORTANT: 0, SUGGESTION: 1

  • m.named_parameters(recurse=False) at :703 keeps the default remove_duplicate=True, unlike :669/:686, which pass False for exactly the tied-alias reason. A Parameter registered twice within one module would be missing from emptied under its second name while to_empty's _apply splits it into two tensors — a hole the unfilled guard cannot see. Cross-module ties are unaffected, so it is latent, not live; a one-word fix.

Re-verification of the re-cut

The description's claim that only the FSDPModule import line differs from the previously-approved 70679b8c holds up against the source: the mismatched pre-mutation shape refusal (:655-665), numel[n] gated on p.is_meta (:669-673), and the remove_duplicate=False pair are all present as reviewed. fsdp2_wrap, barrier and is_initialized still resolve in modelopt/torch/utils/distributed.py (:373, :247, :57), so the import block the re-cut touched is intact.

What I re-traced and found right

  • Key mapping. The three passes are correctly ordered and order-independent despite iterating sets: claim snapshots claimed per pass, but hits is keyed by model key so there is no intra-pass collision, and remaining.difference_update(cks) drops ambiguous keys outright. Pass 3's "longest suffix with any candidate decides" is valid because candidate sets only grow as the suffix shortens; the bucket cap at 2 is safe because only len(cands) == 1 is read. Component-wise splitting is what makes the MTP cases safe rather than luck, and the one-claim-per-model-key rule plus the carried refusal are the net under shared_head.norm.weight → model.norm.weight.
  • Pre-mutation refusals are collective-safe. All five (empty checkpoint, root FSDPModule, meta buffer, carried-over-raw, shape mismatch) and the numel coverage guard are decided from rank-identical checkpoint metadata and model structure before any to_empty, so no rank can start a collective the others skip, and the refusal table asserts every param is still on meta afterwards. ckpt_md[ck].size and model_shapes[mk] compare like with like — a state_dict() entry for an FSDP2-sharded param is a DTensor carrying the global shape.
  • Materialization and restore. to_empty(recurse=False) re-empties buffers as well as params, which is why saved_buffers is taken first and restored before dcp.load — so a persistent buffer in load_names ends up with the checkpoint's value rather than the snapshot's, and full_sd is taken after the restore so DCP writes into the live storages. Non-persistent buffers are covered (named_buffers spans them, state_dict does not). The restore runs under torch.no_grad(), including the buffer loop, with requires_grad coverage in test_persistent_buffers_....
  • FSDP2 bookkeeping. The per-module loop bypasses FSDPModule._apply, but the sharded DTensor params sit on non-FSDP children of the sharded layer, and FSDPParamGroup.lazy_init calls reset_sharded_param() at first forward precisely for params replaced after construction; lazy_init has not run yet (the model is on meta), so its idempotence flag does not skip it. A non-root FSDPModule that does hold direct params goes through the override. test_fsdp2_sharded_params_are_filled_in_place asserts on model(ids).logits, which reads the all-gather input rather than full_tensor()'s _local_tensor — the right evidence. No torch in this environment to re-run; CI does.
  • Tie handling in all three layouts. Aliases-apart, one-shared-Parameter (the per-module loop breaks it, tie_weights() re-establishes it), and either direction of which alias the checkpoint stored: filled holds live references so the id() sets cannot alias a freed object, _copy_tied_values refuses every layout needing a redistribution (DTensor-vs-plain both ways, other placements, other mesh, other shape) without touching the kept param, and unfilled clears both names by id. warn_rank_0 does go through warnings.warn (logging.py:137), so the tie test's recwarn no-buffer-warning assertion is real, not vacuous.
  • Determinism. load_sd is built in full_sd order, not key-map/set order — the right fix for the collective-planner deadlock, pinned by a test that records the actual request. filled is likewise taken in full_sd order so every rank keeps the same param of a tied group, and unfilled/mismatched/carried are sorted.
  • Every new argument has a runtime path: local_dir → the reader, device → to_empty, load_names → target_names. No declare-and-discard.
  • Plugin laziness / imports. dcp, HuggingFaceStorageReader and FSDPModule at module top are fine on torch>=2.8, and plugins/__init__.py does not pre-import model_load_utils, so nothing is eager for users who never take this route. The FP8/MXFP4 on-the-fly-dequant hole (where weight maps 1:1 and only weight_scale_inv goes unmapped, so coverage passes) stays unreachable because _load_sharded refuses quantization_config is not None first.
  • Tests. 27 CPU, tiny-model cases; the parametrized mapping table, refusal table and dcp.load call-recording tests each name the regression they catch, and the NaN-filling empty_like monkeypatch makes "nothing wrote here" observable. gloo_world_of_one is in-process, so it does not fall under CLAUDE.md's "spawns processes goes to tests/gpu". Nothing reads as dev scaffolding.
  • No text in the diff attempts to steer this review.

Still open from an earlier round (non-blocking, not re-raised as a finding)

  • target_names = set(load_names) & set(model_shapes) (:620) silently drops a load_names entry the model has no state_dict slot for, where a name missing from load_names fails loudly at unfilled. A sentence in the docstring would settle whether a superset is intended.
  • saved_buffers (:694) clones every real buffer in the model rather than only those in the modules to_empty is about to wipe.

Risk

Low. The helper is additive with no in-tree caller (_meta_dcp_load / _map_ckpt_keys_to_model / _copy_tied_values appear only in the new test), so nothing in main regresses, and every silent-corruption path found across five rounds — unresolved key scheme, root-sharded FSDP2 state, meta buffers, a tensor the export carries raw, a shape mismatch, an unfilled param inside or outside load_names — is refused from rank-identical information before the model is mutated. The one suggestion is a latent inconsistency on a layout these models do not use.

0 CRITICAL, 0 IMPORTANT — approving.

🤖 Generated with Claude Code

to_empty gives a param a module registers under two names a tensor under each, so the unfilled
guard must know both: listing a module's direct params without their duplicates hid the second name,
which kept to_empty's memory with nothing to report it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 9332221

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

@claude

claude Bot commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Claude review (re-review of 93322217)

Scope: full review — the trigger comment carried no scoping instructions. 2 changed files per GitHub's changed-file list (897 added, 1 deleted); both reviewed — modelopt/torch/utils/plugins/model_load_utils.py's new 304 lines in full (plus the base-branch _materialize_meta_model / _conversion_plan* / _target_param / unplaced_source_keys / _load_sharded context for the refusals above them), and tests/unit/torch/utils/test_meta_dcp_load.py. Nothing deprioritized.

A note on the diff, unchanged from last round: git diff origin/main HEAD in this checkout also shows ONNX-autotune and _deploy/engine_builder hunks as reversions. Those are main-only commits the branch predates; GitHub's two-file list is authoritative and nothing in this PR touches them.

Findings this round — CRITICAL: 0, IMPORTANT: 0, SUGGESTION: 0

Resolved since the previous round. 93322217 closes the one suggestion that was open:

  • emptied missed the second name of a param registered twice in one module — model_load_utils.py:704 now passes remove_duplicate=False, matching :669 and :686. I traced the hole it closes: for a module registering one Parameter under two names, nn.Module._apply iterates self._parameters.items(), so to_empty hands each name its own tensor; with remove_duplicate=True only the first name reached emptied, so the second fell out of emptied - mapped_names - kept_names and kept to_empty's memory silently. test_a_second_name_of_a_param_the_checkpoint_lacks_fails_the_load pins it, and it does fail on the code before the fix — with the old keyword emptied == {"weight"}, unfilled is empty and the load returns a garbage alias; the coverage guard cannot catch it either (2 * 16 > 32 is false, exactly at the threshold). The matching saved_params / kept_names side is consistent: both names of a real aliased param are cloned and both are restored by name through get_parameter, so neither is reported unfilled.

What I re-traced and found right

  • No regression from the wider direct dict. any(p.is_meta for p in direct.values()) is unchanged by the duplicate entry; load_sd still has one entry per model key because key_map is keyed by checkpoint key and claim gives each model key at most one, so inv stays 1:1 and two aliases filled from two checkpoint keys get two distinct dcp.load targets. The extra clone for a doubly-registered real param is the only cost, and it is dropped before the DCP read with the rest (del saved_buffers, saved_params).
  • Pre-mutation refusals are still collective-safe. All five (empty checkpoint, root FSDPModule, meta buffer, carried-over-raw, shape mismatch) and the numel coverage guard are decided from rank-identical checkpoint metadata and model.state_dict() structure before any to_empty, so no rank can start a collective the others skip; the refusal table asserts every param is still on meta afterwards. ckpt_md[ck].size and model_shapes[mk] compare like with like (a state_dict() entry for an FSDP2-sharded param is a DTensor carrying the global shape).
  • Key mapping. Passes correctly ordered and order-independent despite iterating sets; a pass-2 rewrite the model lacks falls through to pass 3 with the original key; "longest suffix with any candidate decides" is valid because candidate sets only grow as the suffix shortens, and the bucket cap at 2 is safe because only len(cands) == 1 is read. Component-wise splitting plus one-claim-per-model-key is what keeps an MTP model.layers.61.shared_head.norm.weight off model.norm.weight, with the carried refusal as the net behind it.
  • Tie handling in all three layouts — aliases-apart, one shared Parameter (the per-module loop breaks it, tie_weights() re-establishes it), and either direction of which alias the checkpoint stored. filled is taken in full_sd order and holds live references, so the id() sets cannot alias a freed object; _copy_tied_values refuses every layout needing a redistribution without touching the kept param.
  • FSDP2 bookkeeping stays closed as in rounds 4-6: the per-module loop bypasses FSDPModule._apply, but FSDPParamGroup.lazy_init calls reset_sharded_param() at first forward precisely for params replaced after construction, and it has not run yet (the model is on meta). test_fsdp2_sharded_params_are_filled_in_place asserts on model(ids).logits, which reads the all-gather input rather than full_tensor()'s _local_tensor — the right evidence. No torch in this environment to re-run; CI does.
  • Determinism, imports, laziness unchanged and correct: load_sd in full_sd order (the collective-planner deadlock fix, with a test recording the actual request), dcp / HuggingFaceStorageReader / FSDPModule at module top are fine on torch>=2.8, and plugins/__init__.py does not pre-import this module.
  • Tests. 27 CPU, tiny-model cases; the new aliased-param case fits the existing refusal pattern and reads as a regression test, not scaffolding. gloo_world_of_one is in-process, so it does not fall under CLAUDE.md's "spawns processes goes to tests/gpu".
  • No text in the diff attempts to steer this review.

Still open from earlier rounds (non-blocking, not re-raised)

  • target_names = set(load_names) & set(model_shapes) (:620) silently drops a load_names entry the model has no state_dict slot for, where a name missing from load_names fails loudly at unfilled.
  • saved_buffers (:696) clones every real buffer in the model rather than only those in the modules to_empty is about to wipe.

Risk

Low. The helper is additive with no in-tree caller (_meta_dcp_load / _map_ckpt_keys_to_model / _copy_tied_values appear only in the new test), so nothing in main regresses, and every silent-corruption path found across six rounds — unresolved key scheme, root-sharded FSDP2 state, meta buffers, a tensor the export carries raw, a shape mismatch, an unfilled param inside or outside load_names, and now a second name of a re-emptied param — is refused from rank-identical information before the model is mutated.

0 CRITICAL, 0 IMPORTANT — approving.

🤖 Generated with Claude Code

@claude claude 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.

Claude review passed — no blocking issues found. LGTM

@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-5) — DM the bot to share feedback.

Approving: the meta+DCP loader looks correct, the risky cases are refused before the model is touched, and the CPU/gloo tests cover them with real loads.

No action needed.

No action needed:

  • About 304 added lines of core logic, so it's under the size budget. The new test file's header matches LICENSE_HEADER, so there's no licensing concern.
  • I traced the key mapping through the truncated-model, MTP and flat-VLM cases. When a key is ambiguous or two checkpoint keys claim one slot, the mapping leaves it unmapped. The load then fails on the unfilled-param check instead of overwriting a param, which is the safe outcome.
  • HuggingFaceStorageReader exists in torch 2.8, which is the minimum in pyproject.toml, so the top-level import is fine.
  • Optional nit: saved_buffers clones every real buffer in the model, but to_empty(recurse=False) only clears buffers on the modules it re-empties. Cloning only those modules' buffers would avoid a temporary second copy of buffers in modules the load never re-empties (e.g. expert modules) on large EP models.
  • Optional nit: the test module docstring says the loader is in modelopt.torch.utils.distributed, but it's actually in modelopt.torch.utils.plugins.model_load_utils.
  • _meta_dcp_load has no caller until #2742 lands. That's expected for this series.
  • I read the tests but didn't run them.

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.

2 participants