Skip to content

[OMNIML-6083] [16/21] Write a sharded model's HF checkpoint without gathering it - #2752

Open
shengliangxu wants to merge 19 commits into
shengliangx/hfep-12b-writer-piecesfrom
shengliangx/hfep-12c-nogather-export
Open

shengliangxu wants to merge 19 commits into
shengliangx/hfep-12b-writer-piecesfrom
shengliangx/hfep-12c-nogather-export

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Type of change: New feature, with a bug fix for models built from a config

distributed_save_hf_checkpoint writes an already-processed sharded model as HF safetensors with no rank-0 gather:

  • fused experts are split shard-locally ([14/21]);
  • every other tensor moves whole to one owner rank, which runs the reverse conversion ([15/21]);
  • each rank writes its own shards.

No rank ever holds the whole model. On top of its own shard, which the model keeps, a rank holds what it writes; only an inference tensor is copied up front, so an export under no_grad, as hf_ptq runs it, copies nothing. A model sharded by FSDP2 alone opts in with MODELOPT_FSDP2_EXPORT=nogather; the expert-parallel export routes to it unconditionally in [17/21].

Two gloo ranks exporting a 223 MiB bf16 Llama, the writer's peak RSS above each rank's 112 MiB shard:

Writer Peak RSS above the shard, per rank
copying every DTensor up front (previous revision) 278 / 246 MiB
copying only inference tensors 224 / 217 MiB
copying only inference tensors, with [15/21]'s send-copy fix (this revision) 162 / 166 MiB

export_hf_checkpoint on this path:

  • packs quantized weights with keep_fused_experts;
  • writes config.json, a sanitized generation_config.json and hf_quant_config.json on rank 0. hf_quant_config.json names modules as the weights do: when any rank cannot reverse the conversion mapping, every rank's weights keep their in-memory names, and the quant config keeps them too (distributed_save_hf_checkpoint returns whether the names were reverted);
  • copies the source's non-model files (tokenizer, processor, remote code, chat templates), which the writer never sees.

It rejects save_modelopt_state, which this path cannot honor because it never runs save_pretrained, rather than dropping it silently.

The export reads how the model is sharded from its distribution plan ([4/21]), not from an FSDP2 module in it: the FSDP2 routes, streaming or no-gather, and which rank reads the carried-over weights. A model FSDP2-sharded with fsdp2_wrap or loaded through load_pretrained carries its plan. One wrapped by hand records it with set_distribution_plan, and without it the export raises rather than treat it as whole. A diffusers pipeline is not a module and is exported without a plan.

The KV-cache format goes through _get_kv_cache_postprocess_config, so a mixed-precision KV cache exports as on the other paths, and which writer runs is rank 0's MODELOPT_FSDP2_EXPORT.

Usage

MODELOPT_FSDP2_EXPORT=nogather torchrun --nproc_per_node 8 examples/hf_ptq/hf_ptq.py --use_fsdp2 ...

Testing

  • tests/unit/torch/export/test_distributed_writer.py adds end-to-end cases on CPU under gloo:
    • One rank:
      • an FSDP2 Mixtral written byte-identical to save_pretrained, tied and untied;
      • inside inference mode;
      • extra_state_dict kept under its own names;
      • in each of these, every dense DTensor reaching the transfer as the model's own shard, not a copy (it fails with the unconditional copy, mutation-checked);
      • export_hf_checkpoint on the no-gather path, root-wrapped and layers-only: byte-identical to the single-process export, plus config files and the copied source sidecar, for FP8 and NVFP4;
      • a Qwen2-MoE's experts exported quantized, for FP8 and NVFP4, their inputs too, so the experts' input scales and their all-reduce run on CPU. Its shared expert is packed after the experts, so experts folded outside FSDP2's weight window ([14/21]) would be written unquantized; this replaces [14/21]'s stand-in test of that window;
      • a quantized Mixtral whose reverse conversion fails, keeping its in-memory names in exclude_modules as in the weights;
      • save_modelopt_state rejected.
    • Two ranks:
      • an uneven Mixtral matching its source, with one continuous file numbering;
      • an FP8 dense model matching the single-process export;
      • extras handed to every rank written exactly once, and an extra colliding with a model weight raising on both ranks;
      • each tensor with exactly one owner, both ranks owning some;
      • divergent dense keys, sharding and duplicate names across ranks raising instead of hanging;
      • ranks that calibrated the experts' shared input quantizer differently, every expert written with the largest scale.
    • The Mixtral-names and two-rank input-scale cases each fail without their fix (mutation-checked).
  • tests/unit/torch/export/test_unified_export_hf.py: a model built from a config, which main's Speed up CPU unit tests to stay under the 20 min CI limit #2823 gives no source checkpoint, copies nothing from the working directory at export.
  • The plan reads: reading none, taking no FSDP2 route, or dropping the pipeline guard each fails a test, the last main's test_export_diffusers_pipeline_config on a real tiny pipeline. Only rank 0 reading the carried weights is not unit-tested, since it needs two FSDP2 ranks; the GPU carry-over tests cover it.
  • Mutation-checked, across this PR and [14/21]-[15/21]: 76 of 77 single-point mutants killed. The survivor is a copy of plain inference tensors that nothing on CPU can reject.
  • The GPU results here were measured on earlier revisions of this branch; this revision's GPU tests run in CI only.
  • New tests/gpu/torch/export/test_distributed_hf_export.py: PTQs a tiny Qwen3 (dense, with tied embeddings, and MoE) under FSDP2 with FP8 and NVFP4, opting each export into the writer, and checks the checkpoint against the safetensors headers: every routed expert exported quantized, and the tied head written once. Its earlier version passed on a 4-GPU node, 8 tests, including checks of extras, ties, refusals and unquantized layers that now run on CPU above, where every PR runs them. On 1 GPU, all skip. Its docstring no longer describes a DCP staging step this writer never had, and an assertion on a sharded/ staging directory, which could not fail, is dropped; not re-run on GPU after that change.
  • The end-to-end cases fold into the hub-checkpoint test and the two-rank job, and tests a remaining test covers are dropped, this PR's test_export_diffusers.py test included. Each drop is checked by mutation.
  • The single-process comparison has an FP8 and NVFP4 KV-cache case, and the two-rank export sets the writer knob on rank 0 only.
  • Locally on this revision, the stack rebased on main 0638a27: pre-commit over the whole PR against main, at every PR of the stack; each fix's own tests at its PR; and at the stack's tip the unit tests of the changed areas (tests/unit/torch/utils, export, models, the fused-experts quantization tests and the hf_ptq example tests) on transformers 5.18 and 5.5 and on torch 2.8. They pass apart from the diffusers-export and GGML tests, which fail the same way on main in that environment. CI runs the full unit suite.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ⚠️ The no-gather writer is opt-in for FSDP2. A model FSDP2-sharded other than by load_pretrained, fsdp2_wrap included, must record its plan with set_distribution_plan before export_hf_checkpoint; without it the export raises.
  • 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?: ✅ The no-gather FSDP2 export opt-in, and, under Backward Breaking Changes, that the export and get_max_batch_size need a distributed model's plan recorded.
  • Did you get Claude approval on this PR?: ❌ Not yet.

Additional Information

Part 16 of 21 of expert-parallel PTQ for Hugging Face MoE models; merges after [15/21].

Summary by CodeRabbit

  • New Features
    • Added an opt-in no-gather export for FSDP2-sharded models. With MODELOPT_FSDP2_EXPORT=nogather, ranks write checkpoint shards in parallel without gathering the full model on one host; MoE experts remain sharded.
    • Distributed exports include non-model files from the source checkpoint when available.
  • Bug Fixes
    • Export and batch-size checks now use the distribution plan recorded during model loading. Manually distributed models need a plan recorded before calibration or export; operations raise an error when a required plan is missing.

@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
Walkthrough

Adds an opt-in FSDP2 no-gather Hugging Face export path. The exporter uses the distribution plan and environment setting to select the path. Distributed ranks process and write checkpoint shards. Tests cover dense and MoE exports, quantization, metadata, and multi-rank writing.

Changes: FSDP2 No-Gather Export

Layer / File(s) Summary
Export routing, packing, and configuration
modelopt/torch/export/unified_export_hf.py, CHANGELOG.rst, examples/hf_ptq/README.md
The exporter uses the distribution plan and MODELOPT_FSDP2_EXPORT=nogather to select the no-gather path. Packing can retain fused experts. The path writes base and generation configuration, and rejects save_modelopt_state=True for non-offloaded exports. The changelog and README document the option and distribution-plan requirements.
Distributed shard processing and writing
modelopt/torch/export/distributed_writer.py
Adds a writer that processes sharded state, synchronizes expert input scales, processes dense and expert weights, assigns tensors to owner ranks, and writes checkpoint shards. Rank 0 adds non-overlapping extra state.
Writer and exporter validation
tests/unit/torch/export/test_distributed_writer.py, tests/gpu/torch/export/test_distributed_hf_export.py, tests/unit/torch/export/test_unified_export_hf.py, tests/unit/torch/export/test_fused_experts_keep_fused.py
Adds CPU, multi-rank, and GPU tests for tensor contents, quantization, checkpoint metadata, sidecar files, and shard assembly. Updates source-file-copy coverage and removes earlier helper-level writer checks and the fused-expert event-order test.

Priority: Normal

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

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant unified_export_hf
  participant distributed_save_hf_checkpoint
  participant FSDP2 ranks
  unified_export_hf->>distributed_save_hf_checkpoint: packed model and export options
  distributed_save_hf_checkpoint->>FSDP2 ranks: process sharded tensors and assign owners
  FSDP2 ranks->>distributed_save_hf_checkpoint: owned tensors for checkpoint writing
Loading
Merge Risk: Moderate · up to 12267

Ordinary unsharded exports in multi-rank jobs can fail before reaching their existing export path. Preserve that path before merging. CPU-only gloo exports no longer have the previously reported CUDA-device failure.

Pre-merge checks: Passed 5 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 48 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed 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 PASS. The PR changes only modelopt/torch/export/distributed_writer.py and modelopt/torch/export/unified_export_hf.py in the package; their added lines contain no `torch.load(..., weights_only=Fals…
Title check Passed The title clearly and concisely describes the main change: exporting a sharded model's Hugging Face checkpoint without gathering it.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Full details: Security Anti-Patterns

Explanation

PASS. The PR changes only modelopt/torch/export/distributed_writer.py and modelopt/torch/export/unified_export_hf.py in the package; their added lines contain no torch.load(..., weights_only=False), pickle-enabled NumPy load, hardcoded trust_remote_code=True, eval, exec, or # nosec. The PR does not modify pyproject.toml or requirements files, and it adds no new PIP dependency.

✨ 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-12c-nogather-export branch 2 times, most recently from 4810e33 to 72eab4a Compare October 12, 2026 02:44
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://NVIDIA.github.io/Model-Optimizer/pr-preview/pr-2752/

Built to branch gh-pages at 2026-10-25 04:42 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.54545% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.13%. Comparing base (4fc05e4) to head (3b132ec).

Files with missing lines Patch % Lines
modelopt/torch/export/distributed_writer.py 96.15% 3 Missing ⚠️
modelopt/torch/export/unified_export_hf.py 90.62% 3 Missing ⚠️
Additional details and impacted files
@@                          Coverage Diff                           @@
##           shengliangx/hfep-12b-writer-pieces    #2752      +/-   ##
======================================================================
+ Coverage                               62.05%   62.13%   +0.08%     
======================================================================
  Files                                     656      656              
  Lines                                   73481    73584     +103     
======================================================================
+ Hits                                    45601    45725     +124     
+ Misses                                  27880    27859      -21     
Flag Coverage Δ
unit 62.13% <94.54%> (+0.08%) ⬆️

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 72eab4a

@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from 72eab4a to a30a335 Compare October 12, 2026 08:39
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test a30a335

@shengliangxu shengliangxu changed the title [17/19] Write a sharded model's HF checkpoint without gathering it [OMNIML-6083] [17/19] Write a sharded model's HF checkpoint without gathering it Oct 12, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from a30a335 to 751b1d1 Compare October 13, 2026 00:53
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from 751b1d1 to 717b73e Compare October 13, 2026 01:32
@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 717b73e

@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-6083] [17/19] Write a sharded model's HF checkpoint without gathering it [OMNIML-6083] [17/20] Write a sharded model's HF checkpoint without gathering it Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from 717b73e to 446370c 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-6083] [17/20] Write a sharded model's HF checkpoint without gathering it [OMNIML-6083] [19/21] Write a sharded model's HF checkpoint without gathering it Oct 13, 2026
@shengliangxu
shengliangxu force-pushed the shengliangx/hfep-12c-nogather-export branch from 446370c to 755ebf2 Compare October 13, 2026 21:12
@shengliangxu
shengliangxu removed this pull request from stack #2781 October 14, 2026 00:13

@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 — [16/21] no-gather distributed HF checkpoint writer (third pass)

Findings: CRITICAL 0 · IMPORTANT 1 · SUGGESTION 2

Scope: bare /claude review, so a full review. 8 files changed (991+/116−). Reviewed in full: modelopt/torch/export/distributed_writer.py, modelopt/torch/export/unified_export_hf.py, all three test diffs (tests/unit/torch/export/test_distributed_writer.py, tests/gpu/torch/export/test_distributed_hf_export.py, test_unified_export_hf.py / test_fused_experts_keep_fused.py), CHANGELOG.rst, examples/hf_ptq/README.md. Supporting context read outside the diff: DistributionPlan/distribution_plan/broadcast_from/all_reduce in modelopt/torch/utils/distributed.py, resolve_distribution_plan in model_load_utils.py, _materialize_owned/_revert_whole_sd/_write_even_shards, _split_local_fused_module and the consumed set in moe_utils.py, _prepare_model_for_export, save_non_weight_artifacts, _revert_quant_config_names_best_effort. No prompt-injection or review-steering text in the diff.

Resolved since the previous round

  • The reverse-conversion flag is threaded out and the quant-config revert is gated on it (if reverted:), so weight names and hf_quant_config.json can no longer disagree. test_nogather_export_names_modules_in_the_quant_config_as_the_weights_do asserts exclude_modules ⊆ the written module names.
  • The expert _input_scale reduce dropped its backend-string device branch and now reduces on the tensors own device, and test_two_ranks_write_every_expert_with_the_largest_input_scale covers it under gloo — the code/test contradiction is gone.
  • The isinstance(model, nn.Module) guard keeps a diffusers pipeline off distribution_plan().
  • The GPU test docstring no longer describes a DCP staging step, and the unfailable sharded/ assertion is gone.

Most impactful (new this round)

[IMPORTANT Performance] _to_normal copies every DTensor unconditionally, so peak is the sharded model twice (inline) — the plain branch is guarded by v.is_inference(), the DTensor branch is not, and the copy is additive rather than a replacement: get_model_state_dict returns detach()-ed views (as the wrapper docstring itself notes), so models parameters still own the originals and overwriting sharded_sd[_k] frees only the view. That makes the in-place-vs-comprehension rationale below it self-defeating — both forms are ~2x, and the copy this loop creates is the second one — and contradicts the functions "Peak memory per rank is its owned set, not the model". On the documented GPU usage a model sized to fit through calibration now needs 2x its shard at the first step of the export. Guarding the DTensor branch removes the cost outright for the non-inference-mode callers (every _nogather_export unit test here, and any QAT flow); for the inference-mode case most of the copy is redundant with the clones _split_local_fused_module and _gather_shard0_to_owners already make, and at minimum the docstring and comment should state the bound the code actually has.

Suggestions (non-blocking)

  • The route is selected on is_fsdp_dense alone, so an expert-parallel plan reaches a writer that declares EP out of scope (inline) — resolve_distribution_plan can return ep_size > 1 with dense="fsdp", and ep_rank/dp_idx are hardcoded to 0. It does fail rather than mis-write (the per-expert keys collide and _write_even_shards raises on every rank), but incidentally and with a message that names the writer rather than the plan.
  • Unused pi / _tied (inline).

Carried over, unaddressed — raise again only if you intend to act

  • _write_base_config still duplicates save_non_weight_artifacts minus the trailing copy; the two concrete deltas are the _patch_revert_weight_conversion wrapper and the can_generate() guard.
  • Dead EP bookkeeping (ep_rank, dp_idx = 0, 0, write_experts) — the inline suggestion above is the other half of this one.
  • The expert _input_scale all_reduce is still sized from per-rank keys behind if in_keys:, ahead of _materialize_owneds divergence check; unreachable under the is_fsdp_dense plan this writer targets.

What checked out

  • Mixed-precision KV cache goes through _get_kv_cache_postprocess_config and maxbound=448 matches kv_cache_max_bound on the gather and both streaming paths; _FP8_MIXED_KV_CFG covers it end to end.
  • _is_expert_keys suffix tuple matches moe_utils.py:672s consumed set exactly, for both the gated (gate_up_proj) and ungated (up_proj) first, so no fused tensor is written twice under its fused name and none is dropped. Keys outside both sets (e.g. a fused _bias) land in dense_sd under the fused name, which is what the single-process path does too.
  • The input-scale reduce precedes the fold-sensitive steps and only touches activation scales, so no weight is quantized against a scale the checkpoint does not carry. Dense scales get no reduce, but owner-takes-all for those is pre-existing to the FSDP2 streaming writer, not new here.
  • _assert_byte_identical compares dtype and shape as well as bytes, so the reshape(())/.float() in the reduce is covered against the single-process reference rather than slipping through a raw-byte comparison.
  • _revert_quant_config_names_best_effort reads only model.named_modules() — no collective — so calling it on rank 0 alone is safe, matching the streaming path.
  • extra_state_dict single ownership: _writes_extra matches the writers rank-0 merge, check_no_overlap runs inside all_reduce so a rank-0 collision raises everywhere, and the merge happens after the reverse so hub-named carried tensors are not renamed. _modelopt_carried_over_names is resolved on every rank, so get_quant_config still agrees.
  • broadcast_from is short-circuited by is_fsdp2_sharded, which comes from the recorded plan and is therefore identical on every rank — no rank enters a broadcast the others skip.
  • The save_modelopt_state refusal is raised before pack_quantized_weights mutates the model, and before the try, so a refusal leaves the model as it was and no rank is left in a collective.
  • File-write ordering is safe: copy_non_model_files skips _EXPORT_OWNED_FILES and files the export already wrote, so _copy_non_model_files_from_source cannot clobber the embedded quantization_config.
  • The GPU test reads safetensors headers directly (argument evaluation order in the length-then-header read is correct), expects per-expert shapes derived from the source, halves the last dim for packed U8, and asserts no lm_head.weight for the tied case via the unexpected check.
  • Dropping test_fused_experts_fold_in_a_weight_window... for the Qwen2-MoE CPU case is a net gain: the new test asserts the observable effect (experts written quantized with the shared expert packed after them) rather than the monkeypatched call sequence.
  • PR size within budget: 352 insertions outside tests/ and docs/. CHANGELOG entries are user-facing and filed under the right sub-sections, including the set_distribution_plan requirement under Backward Breaking Changes.

Risk

Moderate-low. The writer stays opt-in behind MODELOPT_FSDP2_EXPORT=nogather and the default export is untouched, and the structural failure modes (dropped key, mis-assembled shard, divergent ranks, duplicate name, missing index) are each asserted or explicitly refused — the correctness picture is in good shape after two rounds of fixes. The remaining IMPORTANT is not a wrong checkpoint but a memory bound: it doubles per-rank resident weights in the one path whose reason to exist is fitting a model that the gather path cannot, and the surrounding comments assert the opposite, so it is worth closing (or at least restating accurately) before merge.

🤖 Generated with Claude Code

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test cc5eba8

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 8b2d1df

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

Comment on lines +654 to +676
ep_rank, dp_idx = 0, 0
# Total config expert count, to tell a per-EP-group expert tensor (size < total) from one that
# spans all experts (classic DP x EP) when computing the per-expert global index offset.
_cfg = getattr(model, "config", None)

def _experts_of(c):
if c is None:
return 0
return int(
getattr(c, "num_experts", 0)
or getattr(c, "num_local_experts", 0)
or getattr(c, "n_routed_experts", 0) # nemotron_h
or 0
)

# VLMs nest the text-model fields under ``config.text_config`` (the quantized backbone is
# ``model.language_model``), so fall back to it when the top-level config has no expert count.
total_experts = _experts_of(_cfg) or _experts_of(getattr(_cfg, "text_config", None))
# DP x EP: experts are dp-REPLICATED across the EDP group (each holds a full EP-sharded copy). Only
# the first replica (dp_idx == 0) writes them; replica ranks (dp_idx > 0) skip the per-expert split.
# dp_idx is the EDP-group rank resolved above -- 0 when experts are not EDP-replicated (dense /
# FSDP2 / EP-without-DP), so every such rank writes.
write_experts = dp_idx == 0

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] This EP scaffolding has no runtime effect on this path, and one of its comments states something that is not true.

ep_rank and dp_idx are literals, so:

  • write_experts = dp_idx == 0 is always True — the if write_experts: guard below and the "Skipped on dp-replica ranks under DP x EP" note in the docstring describe a branch that cannot be taken here.
  • total_experts is never read. _split_local_fused_module only consults it inside add_ep_base = bool(ep_rank) and group_size > 0 and (total_experts == 0 or group_size < total_experts), and bool(0) short-circuits that to False unconditionally. So _cfg, _experts_of, the text_config VLM fallback and the two comment blocks explaining the per-EP-group vs all-experts distinction are all dead on this path.
  • The comment "dp_idx is the EDP-group rank resolved above" is wrong — nothing resolves it; it is the literal two lines up.

Why it matters: ~25 lines of code and comments describe an EP-hybrid routing that the function's own docstring says is out of scope, so a reader has to re-derive that ep_rank == 0 kills all of it. _experts_of is also a latent trap — int(getattr(c, "num_experts", 0) or ...) raises on a config where num_experts is a list, for a value nothing consumes.

Suggested fix: drop _cfg/_experts_of/total_experts/write_experts/dp_idx, call _split_local_fused_module(prefix, sharded_sd) with its defaults, and keep one sentence saying that this writer is FSDP2-only so the global expert index comes from the DTensor's own dim-0 offset. The EP-hybrid resolution can arrive with [17/21], where it is actually exercised.

Comment on lines +733 to +738
in_keys = sorted(k for k in sharded_sd if k.endswith("_input_scale") and _is_expert_key(k))
if in_keys:
stacked = torch.stack([sharded_sd[k].detach().reshape(()).float() for k in in_keys])
dist.all_reduce(stacked, op=dist.ReduceOp.MAX)
for j, k in enumerate(in_keys):
sharded_sd[k] = stacked[j].clone()

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] This is the only collective in the writer whose participation and payload size are decided from rank-local state, and it runs ahead of the divergence diagnostics that exist precisely to turn this class of mistake into an error.

in_keys is derived from this rank's own sharded_sd keys via _is_expert_key, which depends on prefix_set — itself built locally by _fused_experts_prefixes(sharded_sd). So:

  • a rank with an empty in_keys while its peers have some skips the all_reduce entirely and walks into _materialize_owned's all_gather_object, while the peers sit in the all_reduce — a hang with no message;
  • a rank whose in_keys has a different length reduces a differently-shaped stacked — again a hang or an opaque NCCL/gloo size error.

_materialize_owned already catches exactly this ("dense key sets differ across ranks, so the export would deadlock", "ranks disagree on which tensors are dim-0 sharded"), and because expert keys are the complement of dense keys, a divergent expert classification would be named by that check — but only after this all_reduce has already hung.

In practice get_model_state_dict(full_state_dict=False) gives every FSDP2 rank the same key set, so this is unlikely to fire today. It is still the one place in this file that can hang instead of diagnosing, which is the standard the rest of the module holds itself to (and CLAUDE.md's pre-push rule on rank-dependent work starting a collective).

Suggested fix: fold in_keys into the single all_gather_object metadata exchange that _materialize_owned already performs — or, if the reduce has to stay here, gather the key lists first, raise on disagreement, and then reduce over the agreed list unconditionally (an empty list on every rank skipping it is fine, since that decision is then global).

# How the model is distributed decides how it is exported. A diffusers pipeline is not a module,
# and nothing here distributes one.
plan = distribution_plan(model) if isinstance(model, nn.Module) else DistributionPlan()
_writes_extra = not plan.is_fsdp_dense or torch.distributed.get_rank() == 0

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] Switching from is_fsdp2_model(model) to plan.is_fsdp_dense drops the is_available() and is_initialized() guard that used to protect this get_rank() call (and the one at line 2015).

DistributionPlan.__post_init__ accepts DistributionPlan(dense="fsdp") with the default world_size=1, and set_distribution_plan records it without consulting the process group. A model carrying such a plan with no live default group makes export_hf_checkpoint die on this line with RuntimeError: Default process group has not been initialized, before it has done anything — rather than exporting as a single process.

Realistically unreachable today (FSDP2 fully_shard needs a mesh, so a genuinely sharded model has a group), but the old expression could not fail this way and the new one can. broadcast_from two lines down already handles the non-distributed case gracefully, so the asymmetry is easy to close:

_distributed = torch.distributed.is_available() and torch.distributed.is_initialized()
_writes_extra = not (plan.is_fsdp_dense and _distributed) or torch.distributed.get_rank() == 0

Alternatively, have DistributionPlan.__post_init__ (or set_distribution_plan) reject dense="fsdp" outside a distributed run, so an impossible plan is refused where it is recorded instead of surfacing at export.

@claude

claude Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Claude review — [16/21] No-gather sharded HF checkpoint write

Scope: full review (the trigger comment carried no scoping instructions). 8 changed files, +1011/−117. Reviewed modelopt/torch/export/distributed_writer.py, modelopt/torch/export/unified_export_hf.py, examples/hf_ptq/README.md and CHANGELOG.rst in full, plus the test diffs (test_distributed_writer.py, test_unified_export_hf.py, test_fused_experts_keep_fused.py) and tests/gpu/torch/export/test_distributed_hf_export.py at the structural level. Pulled in moe_utils._split_local_fused_module / split_fused_experts_state_dict, utils/distributed.DistributionPlan / broadcast_from / fsdp2_wrap, quant_utils.postprocess_state_dict, hf_checkpoint_utils.copy_non_model_files and the sibling export paths for context.

Findings — CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 3

  1. distributed_writer.py:654-676 — the ep_rank / dp_idx / total_experts / write_experts EP scaffolding is dead on this path (bool(ep_rank) short-circuits add_ep_base, so total_experts is never read; write_experts is always True), and the comment "dp_idx is the EDP-group rank resolved above" describes a resolution that does not happen.
  2. distributed_writer.py:733-738 — the expert input-scale all_reduce is the one collective gated on rank-local key classification, and it precedes the key-set divergence checks in _materialize_owned, so a divergence hangs there instead of being named.
  3. unified_export_hf.py:1968 — moving from is_fsdp2_model() to plan.is_fsdp_dense drops the is_initialized() guard on get_rank(); a plan recorded as dense="fsdp" without a live process group now raises at export.

All three are non-blocking.

What checked out

  • Collective ordering. Every other collective in _distributed_save_hf_checkpoint_impl is unconditional, and the two that look conditional are not in practice: the if shard0: in _materialize_owned is gated on a key set the preceding all_gather_object has already proven identical across ranks, and batch_isend_irecv is pairwise, with both sides deriving the same (peer, dtype) buckets from shared state. _revert_whole_sd and the extra_state_dict overlap check both run through modelopt.torch.utils.distributed.all_reduce, so a single-rank failure raises everywhere. The closing all_reduce("index written", ...) doubles as the barrier.
  • Writer/entry-point split. use_no_gather_writer is resolved through broadcast_from on rank 0, so ranks cannot read MODELOPT_FSDP2_EXPORT differently and land in different collective sequences — the two-rank test sets the knob on rank 0 only, which exercises exactly that.
  • Export completeness vs save_pretrained. _write_base_config covers config.json plus generation_config.json; the index comes from _write_even_shards, hf_quant_config.json from _write_hf_export_config, and remote code / tokenizer / chat templates from _copy_non_model_files_from_source. The ordering is right: copy_non_model_files skips any dst that already exists, so the copy cannot clobber the config files the writer just wrote. The guard not (_offloaded or (is_fsdp2_sharded and not use_no_gather_writer)) evaluates correctly for all four path combinations.
  • Dtype/shape fidelity of the reduced input scales. .reshape(()).float() is a no-op against what the export handlers register — get_activation_scaling_factor(...).squeeze() with _amax already cast to float32 — so the reduced scalars match the single-process path rather than silently re-typing or re-shaping them.
  • Quant config / weight name agreement. The reverted return threads correctly: _revert_quant_config_names_best_effort runs on rank 0 only when every rank reverted, and is itself a no-op when build_reverse_name_mapper returns None, so the config never names modules differently from the weights.
  • Expert key classification. The suffix set in _is_expert_key ("", _weight_scale, _weight_scale_2, _input_scale) matches what split_fused_experts_state_dict consumes, and the dim() == 3 test in _fused_experts_prefixes reads the DTensor global rank, so it agrees on every rank. Fused expert biases fall through to the dense path and are written whole under their fused names — the same as the single-process export.
  • Inference-tensor handling. _to_normal only copies tensors that actually carry the inference flag, and re-wraps a DTensor with the original global shape/stride rather than letting from_local re-infer local × world — which is what keeps uneven and zero-row shards from being classified differently per rank. The inference_mode(False) plus no_grad nesting in the public wrapper is the right pairing for empty_like / copy_ on an inference tensor.
  • The weakened _process_quantized_modules assertion. not is_fsdp2_model(model) or keep_fused_experts is a deliberate, narrow opening, and _prepare_model_for_export / requantize_resmooth_fused_llm_layers are already shared with the FSDP2 streaming path, so the no-gather branch adds no new sharded-model prep.
  • Test placement. The new unit tests use the session-scoped cpu_dist_workers fixture, which CLAUDE.md explicitly exempts from the no-subprocess rule for tests/unit; the multi-GPU PTQ work is in tests/gpu/. The deleted weight-window test is justified in the description and replaced by an end-to-end Qwen2-MoE case covering the dense="fsdp" branch it was standing in for (the dense="replicate" leg is now only covered indirectly, a fair trade given the window is a no-op there).
  • PR hygiene. 357 insertions under modelopt/ plus examples/, well inside the ~500-line budget; both the opt-in feature and the plan-required break are in CHANGELOG.rst, the latter under Backward Breaking Changes with the set_distribution_plan migration spelled out. No prompt-injection-style text in the diff.

One note out of scope

The plan requirement is a real break for anyone using the ModelOpt fsdp2_wrap helper together with export_hf_checkpoint. The decision that fsdp2_wrap records no plan lives in an earlier PR of the stack, and the shard_root=False case genuinely is not describable as dense="fsdp", so I am not asking for a change here — but having fsdp2_wrap(shard_root=True) record the plan it just carried out would remove the migration step for the common case, and may be worth revisiting before the stack lands.

Risk: low. The new path is opt-in behind MODELOPT_FSDP2_EXPORT=nogather and the default FSDP2 export is untouched; the one change to shared code (the _process_quantized_modules assertion and the new pack_quantized_weights keyword) is additive and defaults to the old behavior. The writer is unusually defensive about cross-rank divergence — it diagnoses rather than hangs in six distinct places — and the mutation-checked test matrix backs the claims in the description.

🤖 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

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 0b46535

distributed_save_hf_checkpoint writes an already-processed sharded model as
HF safetensors with no rank-0 gather: fused experts are split shard-locally,
every other tensor moves whole to one owner rank, the reverse conversion runs
there, and each rank writes its own shards. No rank holds the whole model. A
model sharded by FSDP2 alone opts in with MODELOPT_FSDP2_EXPORT=nogather; the
expert-parallel export routes to it in the next change.

On that path export_hf_checkpoint packs with keep_fused_experts; writes
config.json, a sanitized generation_config.json and hf_quant_config.json on
rank 0; and copies the source's non-model files (tokenizer, processor, remote
code, chat templates), which the writer never sees. It rejects
save_modelopt_state, which the path cannot honor without save_pretrained,
rather than drop it silently.

A model built from a config has an empty config._name_or_path, which as a
path is the working directory, so the export copied the working directory's
tokenizer, processor and code files as if they were the model's. An empty
name now means no source checkpoint.

The output matches the single-process export tensor for tensor (CPU tests:
FP8 and NVFP4, one and two ranks, root-wrapped and layers-only FSDP2). A GPU
test PTQs a tiny Qwen3 (dense and MoE) under FSDP2 with FP8 and NVFP4,
opting each export into the writer, and checks the checkpoint against the
safetensors headers.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
export_hf_checkpoint takes the FSDP2 routes, streaming or no-gather, when the model's recorded plan
FSDP2-shards it, and reads the carried-over weights on rank 0 alone for the same reason, rather than
looking for an FSDP2 module. A diffusers pipeline is not a module and is never distributed, so it is
exported without a plan, as a new test checks. The writer tests record the plan where they shard a
model with fully_shard themselves.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
fsdp2_wrap records no plan; load_pretrained does. These tests wrap the
model by hand, and the export picks its route from the plan, so each
records the FSDP2 plan it wrapped the model by.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Batch sizing and the HF export read how a model is distributed from the
plan load_pretrained records, and raise for an FSDP2-sharded or
multi-rank model without one, where they used to look at the model and
the process group. Users who distribute a model themselves, fsdp2_wrap
included, now record its plan.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
main opened 0.49.0 for what merges after 0.48.0's cut, which this PR does.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
It spawned two ranks, about 14 s of the linux unit job.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…iter test

The writer now raises it on every rank, so rank 0 no longer has to release
rank 1 from the closing barrier; the dedicated test bounds that case, so the
two-rank job no longer repeats it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The window around the fold was guarded only by a test of two stubs' call
order. The MoE no-gather export test used Qwen3-MoE, whose layers pack
nothing after their experts, so it exported them quantized with or
without the window. The window matters when a dense weight in the same
FSDP2 unit is packed after the fold: that weight's own window unshards
and reshards the unit, which puts FSDP2's original shards back over a
fold FSDP2 does not track. Qwen2-MoE's shared expert is such a weight,
so the test now exports Qwen2-MoE, which fails without the window, and
the stub test goes.

The GPU MoE export now requires every expert to be exported quantized,
not just some weight.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Every GPU test of the no-gather export needs two GPUs, which the PR
runner does not have. What they checked runs on CPU instead, through
the same writer and export. The two-rank write is now handed tensors
the model does not own on every rank, and must write them once. A
linear left unquantized already is checked: every no-gather
equivalence case exports the lm_head unquantized, byte for byte as the
single-process export does. The GPU completeness test ties the dense
model's embeddings, so its expected keys also check that the alias is
dropped. The GPU tests these cover go, with the GPU refusal of
save_modelopt_state, which the unit test of the same refusal covers.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…vered ones

Merged:
- The inference-mode and extra_state_dict writes become cases of
  test_distributed_save_writes_the_hub_checkpoint_of_a_sharded_moe.
- test_a_model_built_from_a_config_names_no_source_checkpoint folds into
  test_source_checkpoint_is_none_for_a_model_built_from_config, which now also
  checks that nothing is copied from the working directory.
- test_materialize_owned_leaves_every_tensor_whole_and_plain moves into the
  two-rank job, where ownership is real: its tensors cycle through dim-0 shards,
  replicated DTensors and plain tensors, plus a zero-row tensor. Dropping,
  duplicating or not unwrapping any of them, treating experts as dense, or
  writing the zero-row tensor as float32 fails the job.

Dropped, each caught by a remaining test when its code is broken:
- test_revert_whole_sd_keeps_a_state_dict_with_nothing_to_revert: wiping a dense
  state dict fails test_nogather_export_writes_what_the_single_process_export_writes.
- test_revert_whole_sd_renames_to_hub_names_in_place: discarding the reverse
  fails the hub-checkpoint test.
- test_a_pipeline_is_exported_without_a_distribution_plan: reading a pipeline's
  plan fails test_export_diffusers_pipeline_config on a real tiny pipeline.

The two-rank job checks the consistency errors with try/except inside
bounded_collectives rather than pytest.raises, which takes the shared worker
down when nothing is raised.

Kept, since no remaining test fails when their code is broken: the split's
storage, dense pass-through and prefix tests and the handler test in
test_fused_experts_keep_fused.py, the mesh-memo test, and
test_write_even_shards_writes_even_shards_and_an_index_of_every_tensor, the
only check that written tensors leave the rank's set, of the file count, and of
a non-contiguous tensor.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The no-gather writer took its device from torch.cuda.current_device() on
every call, though only the expert input-scale all-reduce needs it. On CPU
torch that raised, so every writer unit test patched current_device. Take
the device from the backend: the CPU under a gloo-only group, the current
CUDA device otherwise (distributed_run sets up cpu:gloo,cuda:nccl). The tests
drop the patch, and fail on CPU torch without the change.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
distributed_save_hf_checkpoint checked extra_state_dict against the model's
keys on rank 0 only, outside any collective. A collision raised on rank 0
while the other ranks went on into the write's first collective and waited
there until the timeout, then raised an unrelated timeout. Run the check inside
all_reduce, so every rank raises rank 0's error at once. The two-rank writer
job covers it, and now checks rank 0's checkpoint bytes after its last
collective, so a failed check cannot leave rank 1 waiting either.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
_write_hf_export_config rewrote generation_config.json, which every export
route has already written by then: save_pretrained on the plain path,
save_non_weight_artifacts on the streaming paths and _write_base_config,
behind can_generate(), on the no-gather path. The layerwise path returns
before it. Main's helper never wrote it.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…r not

The overlap check ran its all_reduce only where extra_state_dict was non-empty,
but a sharded export hands the tensors it carries over (an MTP head, an
auxiliary tower) to rank 0 alone. Rank 0 then entered the overlap collective
while the others went on to "shard counts", and every sharded export carrying
weights failed with "Ranks reduced out of step". Every rank joins now; only
rank 0, which holds the extras, checks and adds them. The two-rank writer job
hands its extras to rank 0 only, as the export does. Found by the epdp port.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The comment credited is_fsdp2_model with choosing the writer; the
recorded plan and the MODELOPT_FSDP2_EXPORT opt-in choose it. The
assertion stays: it checks the model is really sharded, so the test
exercises the shard writer.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…rank 0's writer choice

The no-gather export passed the raw kv_cache_quant_algo to the writer, so a mixed-precision KV
cache reached postprocess_state_dict as the "MIXED_PRECISION" sentinel and failed its format
assert. It now resolves the per-layer map through _get_kv_cache_postprocess_config, as the gather
and streaming exports do; the single-process comparison covers an FP8 and NVFP4 KV cache.

Which writer runs is rank 0's MODELOPT_FSDP2_EXPORT, broadcast: the two writers issue different
collectives. The two-rank export sets the knob on rank 0 only. The no-gather branch drops a
save_modelopt_state write it refused earlier, and _write_base_config says why a config save needs
no revert_weight_conversion patch.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The no-gather writer keeps the in-memory tensor names on every rank when any rank cannot reverse
the conversion mapping (a merge group split across ranks), but export_hf_checkpoint renamed the
quant config to hub names regardless, so exclude_modules named modules the checkpoint did not
hold and a loader would read an excluded layer as quantized. distributed_save_hf_checkpoint now
returns whether the names were reverted, and the config is renamed only when they were.

The experts' input-scale all-reduce runs on the scales' own device instead of one picked from the
backend name, and the CPU tests now cover it: the one-rank Qwen2-MoE export quantizes expert
inputs (its comment called the reduce CUDA-only), and a two-rank export whose ranks calibrated
different input amaxes must write the largest on every expert.

Also drops a comment that described no code, and the GPU test's description of a staging step
this writer never had, with the assertion that nothing left that staging directory behind.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The no-gather writer copied every DTensor's local shard up front to drop the inference flag,
though only an inference tensor carries it, and its comments called the copy free: the state
dict's values are views of the model's own parameters, so each copy adds to the rank's memory
instead of replacing a value. A DTensor is now copied only when its local shard is an inference
tensor, as a plain tensor already was, so a caller outside inference mode (hf_ptq exports under
no_grad) copies nothing. On two gloo ranks exporting a 223 MiB bf16 Llama, the writer's peak above
each rank's 112 MiB shard drops from 278 and 246 MiB to 224 and 217 MiB. The docstrings and
comments now state the bound the code has.

Also drops an unused loop index.

Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Since [8/21], get_max_batch_size measures a model with no recorded plan per rank, as it did before,
so only export_hf_checkpoint raises for a sharded or multi-rank model that carries no plan.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 3b132ec

This branch has not been deployed

No deployments
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.

1 participant