Repository navigation
[OMNIML-6082] [2/3] Move each tensor of a sharded model whole to one owner rank - #3101
Conversation
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Classification and collective validation modelopt/torch/export/distributed_writer.py, tests/unit/torch/export/test_distributed_writer.py, tests/unit/torch/conftest.py, tests/unit/torch/utils/test_meta_dcp_load.py |
The writer adds tensor and mesh helpers and checks rank agreement on keys, layouts, devices, shapes, dtypes, and merge groups. Unit tests cover helper behavior and invalid inputs. The one-rank Gloo fixture is defined in shared torch test configuration and removed from the meta DCP test module. |
Dense ownership and local materialization modelopt/torch/export/distributed_writer.py, tests/unit/torch/export/test_distributed_writer.py |
The writer assigns dense units to ranks by size and current load, including expert bytes in the initial load. Owners retain plain tensors, unwrap replicated tensors, or create empty tensors; other ranks drop their copies. |
Shard transfer and assembly modelopt/torch/export/distributed_writer.py, tests/unit/torch/export/test_distributed_writer.py, tests/gpu/torch/export/test_distributed_writer_gpu.py |
The writer transfers supported dimension-0 shards in batched flat buffers and concatenates them on owners in rank order. Unit and GPU tests check assembled values, unique ownership, sparse transfers, and buffer release. |
Priority:
Estimated code review effort: 4 (Complex) | ~45 minutes
Change: Feature
Possibly related issues
- [RFC] NVIDIA Model Optimizer — Product Roadmap #1699: This PR adds distributed checkpoint export ownership and shard transport without all-gathering tensors.
Merge Risk: 
Minimal · up to 76403
-
No actionable risk introduced by this PR remains. The existing export paths do not expose the previously reported companion-scale scenario.
Pre-merge checks: 
5 · 
1
-
Failed checks: 1 warning Passed checks: 5 passed-
Full details: Title check
-
Explanation
The title names the main change: moving each tensor of a sharded model whole to a single owner rank. It matches the distributed writer ownership and transport work described in the summary and objectives. It also carries the [OMNIML-6082] [2/3] tracking prefix, which is acceptable. The title is a single, specific sentence and does not use vague terms.
Full details: Security Anti-Patterns
-
Explanation
PASS. The PR adds one modelopt Python file and test-only Python changes. The added modelopt code contains no torch.load(..., weights_only=False), numpy.load(..., allow_pickle=True), hardcoded trust_remote_code=True, eval(), exec(), or # nosec. The diff adds no pyproject.toml or requirements.txt dependencies.
-
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 16 functions across 4 files.
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
-
Finishing Touches: 💡 1
-
📝 Generate docstrings: 💡
-
- Commit to this branch
- Create a new PR
🧪 Generate unit tests (beta)
-
- 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.
|
/ok to test b4baa59 |
|
/claude review |
| pending.append((srcr, ks, buf)) | ||
| ops.append(dist.P2POp(dist.irecv, buf, srcr)) | ||
|
|
||
| _run_to_completion(ops) |
There was a problem hiding this comment.
[SUGGESTION Performance] Non-owned local shards stay alive through the transfer, so the peak is higher than the docstring claims
torch.cat(...) at line 121 already copies every shard this rank owes a peer into the send buffer, so from that point the source local_sd[k] entries for owner[k] != rank are dead weight — but they are only dropped in the assembly loop at line 143, after the recv buffers are allocated and the transfer has run.
Peak therefore holds, concurrently: all local shards of the chunk (L), the send copies (≈ L·(world-1)/world), and the recv buffers (R). The docstring at line 169 says "A rank holds at most about twice its owned set: the shards it receives and the tensors assembled from them", which accounts for R + O but not the L + sends that coexist with R. Since L and the owned set are both ≈ model/world, the real peak is closer to 3× the per-rank share than 2×. For a feature whose whole value is the memory bound, I'd either correct the docstring or tighten the actual peak.
Tightening is cheap — after the send ops are built and before the recv buffers are allocated, nothing in the chunk needs the non-owned sources any more:
# Packed into the send ops above, so the sources are dead: drop them before the recv
# buffers are allocated, rather than after the transfer.
for k in part:
if owner[k] != rank:
del local_sd[k]
for srcr, dt in sorted(recv_groups, key=lambda kd: (kd[0], str(kd[1]))):
...with the if owner[k] != rank: del local_sd[k]; continue at lines 142-145 then reducing to if owner[k] != rank: continue. Note the recv-buffer device at line 127 is taken from local_sd[ks[0]], which is a key this rank owns, so it is unaffected by the earlier deletion.
The existing two-rank test already measures live send storages at each assembled store; extending it to assert the non-owned sources are gone before assembly would pin the tighter bound.
There was a problem hiding this comment.
I'm not changing the transport here.
The values _materialize_owned receives are views of the model's own parameters: #2752 takes them from get_model_state_dict, and the model keeps them. Deleting them from the dict earlier therefore frees nothing in the export. The docstring's bound counts what the transport holds on top of those shards, and 46e42e1 makes the docstring say so.
Measured on GB200 with a 32 GiB synthetic sharded state dict:
- 600 keys of shape (8741, 4096): BF16, FP8 and uint8.
- Uneven dim-0 shards.
- Timed
_materialize_ownedat the head before this round. The transport code is unchanged since.
| GPUs | Owned per rank | Transport | Peak device memory above the input |
|---|---|---|---|
| 4 (one node) | 8.0 GiB | 6.2 s | 12.0 GiB |
| 8 (two nodes) | 4.0 GiB | 14.2 s | 7.0 GiB |
During the transfer, a rank holds one send buffer and one receive buffer per (peer, dtype):
- The send buffers copy its shards of the tensors other ranks own, which is (w-1)/w of its input.
- The receive buffers hold the other ranks' shards of the tensors it owns, which is (w-1)/w of its owned set.
That is 6 + 6 GiB at 4 GPUs and 3.5 + 3.5 GiB at 8, as measured, and within the docstring's bound.
| ) -> None: | ||
| """Make every tensor in ``local_sd`` whole and owned by exactly one rank, in place. | ||
|
|
||
| Experts are already whole, plain and disjoint per rank; one still a DTensor is refused. Each |
There was a problem hiding this comment.
[SUGGESTION] Expert disjointness is assumed but, unlike every other cross-rank precondition here, not refused
The docstring states experts are "whole, plain and disjoint per rank", and the function does check two of those three from the metadata collective: still-a-DTensor is refused (m[6], line 232), and wholeness follows from plainness. Disjointness is the one that is only asserted in prose.
It is violable in a configuration this series targets. In an EP × FSDP hybrid with EP group size < world size — world 8, EP 4, FSDP 2 — each expert tensor lives on two ranks. _materialize_owned leaves every expert exactly where it is on every rank, so both ranks would carry that key into the write, and [3/3] would emit the same tensor into two shard files with one index.json entry. That is a silently duplicated/ambiguous checkpoint, in a function whose stated design is to refuse anything it cannot handle from the single gathered metadata rather than risk a bad write.
I am filing this as a suggestion rather than a blocker because nothing calls this yet and the caller arrives in [3/3] — a global key→file index built there has to gather every key anyway and would surface the duplicate. Two reasonable resolutions:
- Cheapest: reword the docstring from a description into an explicit precondition on the caller ("the caller must guarantee
expert_key_setmembers are held by exactly one rank; not checked here"), and note where it is enforced. - Or check it: the gather already carries
sum(_nbytes(...))over experts (m[1]). Adding a per-rank expert key count plus a stable digest of the sorted expert key list lets you refuse when the summed counts exceed the union — without paying the O(world × experts) string payload that gathering the full expert key sets would cost on a 128-expert MoE.
Either way the reader learns whether this is guarded or delegated; right now the sentence reads as a guarantee the code does not make.
There was a problem hiding this comment.
The docstring now states this as the caller's precondition (46e42e1): experts must come whole, plain and disjoint per rank, as the caller splits them.
The refusal does exist, in [3/3] (#2751). _write_even_shards gathers every rank's key list in its first collective and refuses a name held by more than one rank before any file is written. A duplicated expert fails there, on every rank, at no extra cost. That is the global key check you describe, and it covers dense keys as well.
There was a problem hiding this comment.
Claude review — [2/3] Move each tensor of a sharded model whole to one owner rank
Findings: CRITICAL 0 · IMPORTANT 1 · SUGGESTION 4
Scope. Full review (trigger comment carried no scoping instructions). 5 files / +695 −9; I read all of them — modelopt/torch/export/distributed_writer.py in full, plus both new test files and the two test-infra moves. No prior Claude review on this PR to dedupe against; CodeRabbit auto-review is disabled on this non-default base branch, so nothing was skipped as already-covered.
Most impactful finding
is_shard(0) accepts _StridedShard (line 45) — IMPORTANT. _StridedShard subclasses Shard, so is_shard(0) is true for it, and a strided placement on a 1-D full-world mesh passes the gate into shard0. Its chunks interleave by split_factor rather than sitting contiguously at rank offsets, but the tiling check only verifies the dim-0 extents sum to the global dim 0 — a strided layout tiles just as well. So assembling with torch.cat(pieces, dim=0) in rank order yields a silently row-permuted tensor, which is the one outcome the module's own docstring says it refuses to risk. Reachability is narrow (the 2-D-mesh and full-world-mesh checks already catch FSDP2+TP and FSDP sub-meshes), but the failure mode is unrecoverable corruption rather than an error, and the fix is to make the check exact instead of subclass-tolerant.
The four suggestions: the real memory peak is ~3× the per-rank share rather than the ~2× the docstring claims, and dropping non-owned sources right after torch.cat packs them would actually buy the tighter bound; together's happy path is untested (only its disagreement refusal is); expert disjointness is asserted in prose but, unlike the other preconditions, not refused; and a third of the file is prose, some of it cost derivations that CONTRIBUTING.md places in the PR description.
What I checked and found correct
The cross-rank reasoning is the risky part of this change and it holds up:
- Every refusal is symmetric. For the key-set, dim-0-classification, size and grouping checks, each rank compares the gathered values against its own. If the values are not all equal then every rank necessarily finds a differing peer, so all of them raise — no rank is left in a collective its peer never joins. The
cpu_dist_workerssuite covers this on both the agreeing and the disagreeing rank for all seven cases. - Send/recv buckets pair exactly. Sender
(owner[k], dtype)groups and the owner's(src, dtype)groups are built from the same sorted key list, the sameownermap and the sameall_shapesemptiness test, both sorted by(peer, str(dtype)). Element counts match by construction (sum(_numel(all_shapes[sender][k]))on both sides), the dtype equality the buffer sizing depends on is refused up front, and the(peer, dtype)bucket is one op per side so in-order p2p matching per pair is preserved even with two dtypes between the same ranks. - Chunk boundaries are aligned.
shard0is derived from sorteddense_keysand its membership is verified across ranks, so thechunk = 4096rounds are identical everywhere — no rank runs a transfer round its peer skipped. - Assembly order is shard order.
_is_full_world_shard0pinsmesh.mesh == range(world), so global-rank order is mesh order and thetorch.catreproduces whatfull_tensor()would have built. The padded-chunk hazard is caught by the tiling check (short-circuits correctly when ranks disagree on the global shape, so a multi-elementgshapescannot produce a false pass), and empty shards are skipped symmetrically on both sides rather than concatenated. - Owner assignment is deterministic. The unit sort
(-bytes, u)and theminkey(loads[i], i)both break ties explicitly, over inputs (dense_sizes,expert_bytes,merged_units) that are all verified identical — so no tensor can be dropped everywhere or claimed twice. - The
togetherunion pass is transitively correct. The new unit is the union of every member's old unit and all of its members are rewritten, so[A,B],[C,D],[B,C]collapses to one unit regardless of group order. - No host syncs on the metadata path.
_nbytes,numel() == 0andplacementsare all metadata; the one tensor read (mesh.mesh.tolist()) is CPU and memoized. Freeing the send buffers right afterreq.wait()is safe under NCCL becauseProcessGroupNCCL::pointToPointcallsrecordStreamon them, so the caching allocator defers reuse. torch.distributed.tensoris fine against the declaredtorch>=2.10; the module is intentionally absent fromexport/__init__.py's star imports (all-private, no caller yet), and thegloo_world_of_onemove totests/unit/torch/conftest.pyis a clean lift that keeps the meta-DCP tests working while adding the CPU mesh the new tests need.
Risk
Low-to-moderate. Additive and unreferenced, so nothing regresses today; the GPU test covers BF16/FP8/packed-uint8, uneven and empty shards, a no-row tensor, replicated and plain over NCCL, and the CPU suite covers the refusals on every rank. The residual risk is concentrated in the one placement gate above — this code's contract is "refuse anything not exactly Shard(0) on a full-world 1-D mesh", and that gate is currently one subclass wider than the contract. Worth closing before [3/3] starts depending on the transport.
🤖 Generated with Claude Code
b4baa59 to
46e42e1
Compare
|
/ok to test 46e42e1 |
|
/claude review |
|
Thanks. Here is what each item became; it is pushed as 46e42e1, rebased onto #3100's b9fbb24.
|
There was a problem hiding this comment.
Claude review — [2/3] Move each tensor of a sharded model whole to one owner rank
Claude review passed — no blocking issues found. LGTM.
Findings: CRITICAL 0 · IMPORTANT 0 · SUGGESTION 2
Scope. Full review (the trigger comment carried no scoping instructions). 5 files / +705 −9; I read all of them — modelopt/torch/export/distributed_writer.py in full, both new test files, and the two test-infra moves. CodeRabbit's auto-review is disabled on this non-default base branch, so nothing was skipped as already-covered.
Since the previous round (b4baa59 → 46e42e1)
The prior review's five findings, re-checked against the current code:
| Prior finding | Status now |
|---|---|
IMPORTANT — is_shard(0) accepts _StridedShard |
Addressed by test, downgraded. A strided-shard0 refusal case was added. The gate itself is unchanged, so the refusal still rests on torch's internal class hierarchy rather than on an explicit check — re-raised as a SUGGESTION, not a blocker, since CI decides whether the refusal fires. |
SUGGESTION — together's happy path untested |
Addressed. The two-rank test's third layout drives [["x","y"],["y","z"]], with sizes chosen so per-key balancing alone would split the group, and asserts all three land on one rank. |
| SUGGESTION — memory peak is ~3× not ~2× | Withdrawn — the docstring was right. _run_to_completion clears ops before the caller assembles, so the send copies are freed before the torch.cats allocate. Peak extra is max(send + recv, recv + assembled), and both terms are ~1× the owned set. No change needed here. |
| SUGGESTION — expert disjointness asserted in prose, not refused | Still open; non-blocking. |
| SUGGESTION — prose volume / cost derivations belong in the PR body | Still open; non-blocking. |
This round's findings
- [SUGGESTION]
distributed_writer.py:42— make the placement gate exact (type(p) is Shard) instead of relying onis_shard(0)excluding_StridedShard. The new test passes only under that torch-internal behaviour; stating the contract in code makes the refusal version-independent and turns the test into documentation of an intent. - [SUGGESTION]
test_distributed_writer.py:33-34— drop thesafetensors/transformersimportorskips. The module under test imports neither, so on a torch-only install they silently skip the seven cross-rank refusal cases, which reads as green.
What I checked and found correct
The cross-rank reasoning is the risky part of this change, and it holds up:
- Every refusal is symmetric. For the key-set, dim-0-classification, size, dtype, global-shape and grouping checks, each rank compares the gathered values against its own. If the values are not all equal, every rank necessarily finds a differing peer, so all of them raise — no rank is left in a collective its peer never joins.
gshape = next(iter(gshapes))being computed before thelen(gshapes) != 1test is harmless: theorshort-circuits, so the arbitrarily-chosen element is never compared. Thecpu_dist_workerssuite covers all seven cases on both the agreeing and the disagreeing rank, underbounded_collectives()so a hang fails fast rather than stalling CI. - Send/recv buckets pair exactly. Sender
(owner[k], dtype)groups and the owner's(src, dtype)groups are built from the same sorted key list, the sameownermap and the sameall_shapesemptiness test, both sorted by(peer, str(dtype)). Element counts match by construction, the dtype equality the buffer sizing depends on is refused up front, and one op per(peer, dtype)per side preserves in-order p2p matching per pair even with two dtypes between the same ranks.chunk = 4096rounds are derived from the verified-identical sortedshard0list, so no rank runs a round its peer skipped. - Assembly reproduces
full_tensor()._is_full_world_shard0pinsmesh.mesh == range(world), so global-rank order is mesh order;torch.chunkis whatShard._split_tensoruses, so the GPU test's hand-built shards match FSDP2's cut. Padded chunks are caught by the tiling check, and empty shards are skipped symmetrically on both sides rather than concatenated. - Owner assignment is deterministic and total. The unit sort
(-bytes, u)and theminkey(loads[i], i)both break ties explicitly, over inputs (dense_sizes,expert_bytes,merged_units) that are all verified identical, andunit_ofcovers every dense key — so no tensor can be dropped everywhere or claimed twice. Asymmetric expert sets are fine by design:expert_bytesis read from the gathered metadata, not computed locally. - The
togetherunion pass is transitively correct. Each new unit is the union of every member's current unit and all of its members are rewritten, sounit_ofstays a valid partition regardless of group order —[A,B],[C,D],[B,C]collapses to one unit either way round. empty0cannot desynchronize.DTensor.numel()is global, so ranks agree; a global shape disagreement surfaces as ashard0/empty0classification split, which theall_shapeskey-set check names.- No host syncs on the metadata path.
_nbytes,numel() == 0andplacementsare all metadata; the one tensor read (mesh.mesh.tolist()) is CPU and memoized, andid(mesh)cannot collide becauselocal_sdpins every mesh alive through the classification loop. Freeing the send buffers right afterreq.wait()is safe under NCCL becauseProcessGroupNCCL::pointToPointcallsrecordStreamon them, so the caching allocator defers reuse — and the two-rank test proves the free actually happens, viaStorageWeakRefs sampled at each assembled store. - The module is intentionally absent from
export/__init__.py(all-private, no caller yet — grep confirms nothing inmodelopt/references it),torch.distributed.tensoris fine against the declaredtorch>=2.10, and thegloo_world_of_onelift totests/unit/torch/conftest.pykeeps the meta-DCP tests working while adding the CPU mesh the new tests need, now withtry/finallyteardown.
Risk
Low. Additive and unreferenced, so nothing regresses today. The GPU test covers BF16/FP8/packed-uint8, uneven and empty shards, a no-row tensor, replicated and plain over NCCL; the CPU suite covers the refusals on every rank plus the merge-group and send-buffer-lifetime properties. Both suggestions are robustness and test hygiene, not behaviour. Fine to merge ahead of [3/3] once the strided gate is either made explicit or confirmed green in CI.
🤖 Generated with Claude Code
|
Thanks for the approval. On the items the review lists as still open, and the two new ones:
|
There was a problem hiding this comment.
Bot review (gpt-6.1-sol) — DM the bot to share feedback.
Changes requested: mixed-device shards can fail before their peers complete transport.
Needs action:
- Fix device-aware packing in
distributed_writer.py:90–121; add a mixed CPU-offloaded/CUDA shard test, or refuse that layout collectively before mutation. - Validate every dense tensor’s dtype and global shape in
distributed_writer.py:197–206; test disagreements that preserve byte counts, including empty tensors. - Release round-local receive-buffer references in
distributed_writer.py:126–147; extend the lifetime regression across the 4096-key boundary. - Confirm NCCL first-use works when some ranks have no P2P operations; add a cold-group sparse-participation test.
No action needed:
- Standard headers match
LICENSE_HEADER; the fixture relocation preserves existing assertions. sdk-gpt61-20261029T014604Z/PR-3101: static review only; pinned SHAs and reported test runs could not be independently verified.
|
/ok to test 055b57b |
|
Thanks. The first three items are done in 055b57b, with details in their threads; the fourth needs no change.
|
…ey matched (#3100) > [!NOTE] > **[1/3] of #2751's split** (2026-10-29), at its review's request: [1/3] this PR (the reverse conversion's hook), [2/3] #3101, the ownership and transport of the distributed checkpoint writer (stacked on this PR), and [3/3] #2751, the reverse conversion and shard writing (stacked on [2/3]). Merge in that order. > > **Base:** `main`. ### What does this PR do? Type of change: New feature (part of the no-gather distributed export) `revert_weight_conversion_quant_aware` refuses a merge rule that matches no key of the state dict, so that a later rename cannot leave a checkpoint that mixes the two namings. The distributed checkpoint writer reverts each rank's own share of the model. A rank can hold none of a merge rule's keys while another rank holds them all, so the conversion now takes an `unmatched_merges` list: - given one, a rule's refusal is recorded there instead of raised, and the rest of the conversion still applies; - the caller ([3/3]) refuses a rule that no rank matched; - without it, nothing changes. ### Usage Internal: `revert_weight_conversion_quant_aware(model, state_dict, unmatched_merges=[])`, called by the distributed writer ([3/3]). ### Testing - `test_quant_aware_conversion.py` gains `test_radio_merge_that_matches_no_keys_is_recorded_for_one_ranks_share` next to the existing raise case: the refusal is recorded instead of raised, and a matching merge rule after it still applies, its fused output checked, with the unrelated key kept. - The conversion tests pass (79), and pre-commit passes against main. - Review round, 2026-10-29 (`b9fbb242300`): `apply_reverse_rules`'s docstring says what `unmatched_merges` does. The recorded-refusal test now has a matching merge rule after the unmatched one and checks its fused output; it fails if the reverse stops after recording the refusal. A merge group split across ranks cannot reach this hook: [2/3] keeps each group on one owner, and [3/3] runs the reverse inside a collective that carries any rank's refusal to every rank. Pre-commit passes against main; the conversion tests pass (79 on transformers 5.18; on 5.5.4 the scoped-merge cases skip, as before). ### Before your PR is "*Ready for review*" - Is this change backward compatible?: ✅ The new argument is keyword-only and defaults to the old behaviour. - 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](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?: N/A. The no-gather export's entry lands with #2752. - Did you get Claude approval on this PR?: ❌ Not yet; its code was reviewed inside #2751. ### Additional Information Split out of #2751, part of the expert-parallel PTQ series for Hugging Face MoE models. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Unmatched merge rules can now be recorded for later handling instead of immediately raising an error when an optional collection is provided. * Without that option, unmatched rules continue to raise an error. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
Reviewed What I checked: I ran
In every case each tensor ended up whole on exactly one rank, byte-equal to the original, with no duplicates, and each merge group landed on a single owner. Send and receive grouping are built from the same gathered metadata, and every refusal is decided from the single Non-blocking:
|
A rank's share is balanced by the bytes it owns, so a large embedding or lm_head lands whole on one owner whatever its free memory, and must fit there. The docstring says so (a review's note). Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
/ok to test 764036c |
|
@juhi10071998, thank you for the multi-rank checks. Running
|
kevalmorabia97
left a comment
There was a problem hiding this comment.
The owner assignment and the batched send/receive pairing are consistent across ranks, the earlier cold-group NCCL finding is fixed, and the unit tests pass here (49 passed). Two description lines are stale: the Testing note that the transport is unchanged since the GB200 run at b4baa594, and the "Claude approval: not yet" checklist line.
[3/3] of #2751's split, on [2/3]'s transport (#3101): the reverse conversion on each rank's whole tensors, with each merge group and its quantization state kept on one owner (_merge_groups, _revert_whole_sd), and the shards' writing: even files numbered into one global range, staged as .tmp files and renamed into place once every rank has written, the index last (_even_bins, _write_even_shards). Every step a rank can fail alone runs in the failure-carrying all_reduce. This collects the branch's history from before the split, when it held the whole writer; the commits after the split follow on their own. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ws, and NVFP4 DTensor shards (#3099) > [!NOTE] > **[1/2] of #2750's split** (2026-10-28), made at its review's request because #2750 was over the 500-line budget. This PR packs weights that are still sharded, which the keep-fused fold needs. #2750, now [2/2] and stacked on this PR, keeps the fold. > > **Base:** `main`, now that #2736 has merged (2026-10-28). #2750 merges after this PR. <details><summary>Series layout</summary> - `main`, with #2736 (distribution plan, run group, collectives) merged - #3074 calibration on grouped_mm - #3067 Nemotron-H's experts under grouped_mm - #2740 calibration on per-rank data - **this PR: sharded-weight packing, [1/2] of #2750's split** - #2750 keep-fused fold, [2/2] - #2751 distributed-writer pieces - #2752 no-gather export (on #2751, #2750, #2779 and #2740) - #2753 expert-parallel export - #2737 plan decision - #2779 load routing - #2741 expert-parallel loader and the rest of the loader chain </details> ### What does this PR do? Type of change: New feature (part of the no-gather distributed export) The no-gather export (#2752) packs quantized weights while the model is still sharded across ranks: no rank gathers the whole model, though each FSDP2 unit is still all-gathered once, inside its weight-update window, while its weights are packed. This PR covers the two parts of that which do not depend on the fused-experts fold. - **NVFP4 packs DTensor shards.** Three steps broke on a DTensor: - The block scales' zero fill used a boolean-mask assignment. That lowers to `index_put_`, which DTensor cannot dispatch. It now uses `torch.where`. - The fp4 cast mixed the DTensor with plain constants ("got mixed torch.Tensor and DTensor"). It now runs on the local shard and rewraps it with the global shape and stride, so an uneven shard keeps its real size. `from_local` would infer local x world rows, which for 96 rows over 64 ranks gives each rank a different global shape, and the writer then fails far from the cause. - The nibble pack's `<<` is a silent no-op on a DTensor (torch 2.8 to 2.14), so it packed unrelated codes. It now uses `torch.bitwise_left_shift`. Plain tensors pack exactly as before. - **The other 4-bit packers refuse a DTensor shard.** INT4, NF4 and MXFP4, and the export's INT4 packer (`pack_int4_in_uint8`), shift with `torch.bitwise_left_shift` too, which gives the same bytes on a plain tensor. None of them packs a shard: each combines its input with plain tensors, which DTensor refuses deep inside, or, on INT4's CPU fallback, gathers it whole and then dropped the shift. So each now refuses a DTensor up front, and NVFP4 refuses one that comes with a plain per-block scale, such as a static quantizer's. On this PR's path the packers see plain tensors; the refusals keep a shard that reaches them from being packed wrong. - **FSDP2-owned weights pack inside weight-update windows.** `ExportContext.keep_fused_experts` marks the no-gather path. - On that path, a quantized linear whose dense part FSDP2 shards is packed inside its unit's `fsdp2_aware_weight_update` window, which hands FSDP2 the packed weights. Packed outside one, the unit's next reshard puts FSDP2's unquantized shard back, and the checkpoint is written unquantized. - Under `held_fsdp2_weight_windows`, a unit's window stays open from one module to the next, so a decoder layer is gathered once, not once per linear. A module joins the window once, however many of its weights it packs. `_process_quantized_modules` enters it around its dispatch loop, but no window opens there until the no-gather export (#2752) sets `keep_fused_experts` on that loop's context and lets a sharded model through. - Where expert parallelism leaves the root unwrapped, the window opens on the layer's own unit. - A unit that owns a weight directly, as a linear that is a unit of its own under an unwrapped root does, keys that weight by its bare name in the window's FSDPParam mapping (`core_utils._param_key`). `get_prefixed_param_names` used to name it after the weight itself, so the window skipped the packed weight and the reshard put the float shard back. A module the name map does not know keeps a key no parameter has, so the window skips it rather than take it for the root. - The unit is found by walking up the module's name through name maps built once per export (`ExportContext.module_names`), instead of scanning the model per module. Time to open the window around each of 10 linears per FSDP2 unit, in a synthetic model of about 1,000 modules per unit (CPU, one gloo rank, torch 2.14), measured before the split: | Units | Scanning the model per module | Walking up the name | |---|---|---| | 48 | 22.5 s | 4.8 s | | 96 | 79.8 s | 9.5 s | - **Test fixture:** the one-rank gloo fixture `gloo_world_of_one` moves into `tests/unit/torch/conftest.py`, in the same text #3101 adds. ### Usage Internal to the export: the no-gather writer (#2752) sets `keep_fused_experts`. Nothing changes for a single-process or gather export. ### Testing - `tests/unit/torch/export/test_sharded_weight_packing.py` (new) runs on a one-rank gloo world. It checks that: - NVFP4 packs a DTensor shard into the same codes and scales as the plain weight, an all-zero block included; - the fp4 cast keeps an uneven shard's global shape; - the quantized-linear handlers pack an FSDP2 unit in one held window, or in a window per module outside held windows, with the root sharded or not, and the sharded state dict carries the packed weights byte for byte. - `test_nvfp4_tensor.py`: an all-zero block gets a scale of 1 on both the dynamic and the static path. - These tests come from #2750's reviewed suite, moved here with their code. - Pre-commit passes against main. - `tests/unit/torch/export`, `test_nvfp4_tensor.py`, `test_meta_dcp_load.py` and `quantization/test_utils.py` pass (530), on transformers 5.18 and torch 2.14. The exceptions are 7 `test_export_diffusers.py` cases, which fail the same way in that environment without this change. - Review round, 2026-10-29: a module that asks for its unit's held window once per weight now joins it once; listed twice, it was re-registered with FSDP2 from its own shard, which FSDP2 rejects. The new `test_a_module_joins_its_units_held_window_once` fails without the fix. Comments are cut to their constraint, `DTensor` and `init_device_mesh` are imported at module scope, and `ExportContext.fsdp2_window` is typed. Pre-commit passes against main; `test_sharded_weight_packing.py`, `test_nvfp4_tensor.py`, `test_meta_dcp_load.py` and `test_export_registry.py` pass (66) on torch 2.14. - Review round 2, 2026-10-29 (`54f8632c78b`, cjluo-nv's review bot): - A linear that is an FSDP2 unit of its own under an unwrapped root now keeps its packed weight. It used to export as float32, reproduced on one gloo rank. The handler test gains that layout, which fails without the fix. - A two-rank job on the `cpu_dist_workers` pool packs an uneven NVFP4 shard (three rows over two ranks) into the plain pack's codes and scales. It also runs the handlers over two FSDP2 units, a 17-row weight among them, against an unsharded export. Dropping the global shape from `_cast_fp4`'s rewrap fails it. - `test_sharded_weight_packing.py` and `test_param_index.py` pass (17) on torch 2.14, and pre-commit passes against main. - GPU runs over NCCL: `tests/gpu/torch/export/test_sharded_weight_packing_gpu.py` (`51f187fb029`) runs the two-rank checks on every visible GPU and skips below two. Results are below. - GPU run, 2026-10-29, at `51f187fb029` (cjluo-nv's review bot asked for CUDA/NCCL validation). GB200, torch 2.11.0a0 (NGC 26.02), CUDA 13.1, transformers 5.9.0: | `test_sharded_weight_packing_gpu.py::test_sharded_packing_matches_the_unsharded_bytes_over_nccl` | 1 GPU | 2 GPUs | 4 GPUs | |---|---|---|---| | Result | skipped (needs 2 or more) | passed, 23.2 s | passed, 24.1 s | - GPU run, 2026-10-29, ran at `54f8632c78b`: main's `tests/gpu/torch/export/test_fsdp2_export.py`, which goes through `fsdp2_aware_weight_update` and so the parameter-key change in `core_utils`, on more GPUs than CI's one. GB200, torch 2.11.0a0 (NGC 26.02), transformers 5.9.0: | `tests/gpu/torch/export/test_fsdp2_export.py` | 2 GPUs | 4 GPUs | |---|---|---| | Result | 39 passed, 116.7 s | 39 passed, 85.3 s | - Review round 3, 2026-10-29 (`d568dacad39`, `6033bb8e2a8`; juhi10071998's review and Claude's): - The INT4, NF4 and MXFP4 packers and the export's INT4 packer shift with `torch.bitwise_left_shift` and refuse a DTensor shard, and NVFP4 refuses one with a static quantizer's plain per-block scale. `test_a_packer_that_packs_only_whole_tensors_refuses_a_shard` covers the five; each case fails without its refusal. On one and two gloo ranks, before the change, the INT4 export packer, NF4 and MXFP4 raised "mixed torch.Tensor and DTensor" deep inside, and `INT4QTensor`'s CPU fallback returned wrong codes. - A module the window's name map does not know no longer takes the key of a parameter the root owns. `test_a_module_the_window_cannot_name_never_takes_the_roots_own_weight` fails without the fix. - `keep_fused_experts` is documented in a docstring. - Pre-commit passes against main. `tests/unit/torch/export` and `tests/unit/torch/quantization` pass (2065) on torch 2.14 and transformers 5.18; 11 diffusers cases and one diffusers test module fail the same way on main in that environment. - Review round 4, 2026-10-29 (`46742ffd24e`, Claude's review), comments only: the held windows' call site says that no window opens there until the no-gather export sets `keep_fused_experts`, and the GPU test says what ends a rank left waiting in a window. Pre-commit passes against main. ### Before your PR is "*Ready for review*" - Is this change backward compatible?: ✅ Plain-tensor NVFP4 packing is unchanged, and the windows apply only on the no-gather path. - 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](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?: N/A. The no-gather export's entry lands with #2752. - Did you get Claude approval on this PR?: ✅ At `ebfb6a7b050`. Its reviews of `a48ce7aa55a` and `6033bb8e2a8` found issues that review rounds 3 and 4 address. ### Additional Information Split out of #2750, part of the expert-parallel PTQ series for Hugging Face MoE models. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Bug Fixes** * Improved quantized weight export for sharded models across distributed layouts, including models with fused experts retained. * Added support for NVFP4 quantization of distributed tensor shards while preserving their original shape. * Corrected NVFP4 scaling for blocks containing only zero values. * Improved weight handling during export and ensured packed weights and scales match unsharded results across tested configurations. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
[3/3] of #2751's split, on [2/3]'s transport (#3101): the reverse conversion on each rank's whole tensors, with each merge group and its quantization state kept on one owner (_merge_groups, _revert_whole_sd), and the shards' writing: even files numbered into one global range, staged as .tmp files and renamed into place once every rank has written, the index last (_even_bins, _write_even_shards). Every step a rank can fail alone runs in the failure-carrying all_reduce. This collects the branch's history from before the split, when it held the whole writer; the commits after the split follow on their own. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…on each rank's own experts (#3122) > [!NOTE] > **[1/2] of #3074's split.** A reviewer asked on #3074 for its expert-parallel correctness fix to land on its own, with a Bug Fixes changelog entry, apart from #3074's two backward-breaking changes (calibration on `grouped_mm` by default, and the new `calibrate()` error). This PR is that fix. #3074, now [2/2], sits on it and keeps the rest. > > **Base:** `main` (`2b900c8fa41`). ### What does this PR do? Type of change: Bug fix Under the expert parallelism of transformers 5.16 and later (the `grouped_gemm` plan style), a fused experts module keeps each expert weight as a DTensor sharded over the experts (`Shard(0)`) and sets `num_experts` to the rank's count. Its forward runs on local tensors, but ModelOpt also reads the expert weights outside the forward, by local expert index: - max calibration's weight pass and `fold_weight`, through `get_expert_weight`; - the weight-input hooks of local Hessian and imatrix (`on_weight` at registration, and the eager per-expert capture); - GPTQ's per-expert helpers; - the native FP8 wrapper's dequantization, which also indexes `*_scale_inv`. An index into the DTensor names a global expert, and each such read all-gathers every expert. (Before 5.16, transformers kept each rank's slice as a plain tensor, which these reads indexed correctly.) On two ranks with a 4-expert Qwen3-MoE experts module under weight-only INT8, main calibrates rank 1's two weight quantizers on rank 0's experts 0 and 1 instead of its own experts 2 and 3. `fold_weight` folds into the gathered copies, so no rank's own experts get folded, rank 0's included. `_QuantFusedExperts._local_experts` hands those readers the rank's own shard, in quantizer order. A DTensor holding every expert (FSDP2) is indexed as before. Two layouts are refused instead of read wrong: - a DTensor whose local shard does not hold the module's experts (one replicated over the ranks, say), which would otherwise be indexed as global experts; - native FP8 expert weights sharded over the experts, in `unpack_weight`, which would otherwise unpack into a buffer for every expert from this rank's experts. Both projections are checked before either is unpacked. ### Usage No API change. ### Testing - New tests in `tests/unit/torch/quantization/plugins/test_fused_experts.py` run on CPU over two gloo ranks, with experts sharded as transformers shards them. All six cases fail on main and pass here: - weight calibration and `fold_weight` use each rank's own experts: plain, and with the dense part FSDP2-sharded, on named and unnamed meshes; - a replicated expert DTensor, and native FP8 experts sharded over the experts, are refused; - registering the weight-input hooks hands `on_weight` each rank's first local expert, and runs no collective; - GPTQ builds its expert helpers from each rank's own experts. - `tests/unit/torch/quantization` on CPU (torch 2.14, transformers 5.18): the six new cases pass, and every other result matches main's. - pre-commit, mypy included, passes against main. - Review suggestion (Claude), 2026-10-29: `unpack_weight` checks both projections before unpacking either, so a refusal leaves the module unchanged; the refusal test shards `down_proj` alone and checks that `gate_up_proj` is still FP8, which fails with the per-projection check. The changelog entry and `_local_experts`' docstring name transformers 5.16, where expert-parallel weights became DTensors. pre-commit passes against main. - Rebased on 2026-10-30 onto main `99c3834f62c` (after #3093, #3108, #3101, #2737, #3099 and #2750 merged), with its own diff unchanged and no conflicts. pre-commit, now with main's ruff 0.17 and mypy 2.4 hooks, passes. ### Before your PR is "*Ready for review*" Make sure you read and follow [Contributor guidelines](https://github.com/NVIDIA/Model-Optimizer/blob/main/CONTRIBUTING.md) and your commits are signed (`git commit -s -S`). Make sure you read and follow the [Security Best Practices](https://github.com/NVIDIA/Model-Optimizer/blob/main/SECURITY.md#security-coding-practices-for-contributors) (e.g. avoiding hardcoded `trust_remote_code=True`, `torch.load(..., weights_only=False)`, `pickle`, etc.). - Is this change backward compatible?: ✅ <!--- A weight layout the quantizers cannot index now raises instead of calibrating the wrong experts. --> - 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](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?: ✅ (Bug Fixes) - Did you get Claude approval on this PR?: ❌ <!--- Requested. The same code was reviewed and approved as part of #3074. --> ### Additional Information Split out of #3074 at a reviewer's request. Jira: OMNIML-6073. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed weight calibration, GPTQ quantization, and weight folding for Hugging Face fused MoE models split across expert-parallel ranks. Each rank now uses its local experts’ weights. * Unsupported expert-sharding layouts now return a clear error when weights cannot be safely indexed or unpacked. * **Improvements** * Preserved native scaled FP8 expert weights through quantization and export, with exported weights checked against reconstructed BF16 references. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
… each tensor's owner rank (#2751) > [!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 [15/21]. Rebased onto main 7c3c868 later the same day, after #2739 merged, and onto main f03651e that night. > > **[3/3] of this PR's split** (2026-10-29), at its review's request: [1/3] #3100 (the reverse conversion's `unmatched_merges` hook), [2/3] #3101 (`_materialize_owned`, ownership and transport), and this PR (reverse conversion and shard writing). Merge in that order. [1/3] #3100 merged on 2026-10-29 (`8ab63386152`) and [2/3] #3101 the same day (`4bef85279e4`); this PR now targets `main`. > > **Base:** `main`, on #3101's squash `4bef85279e4`. The diff below is this PR's alone. > > **Re-cut:** the `gloo_world_of_one` fixture its tests use is in #3101 now, in the same text #3099 adds, so either order merges cleanly. Review fixes since are listed at the end of Testing. <details><summary>Series layout</summary> - `main` - #2745 fused experts under grouped_mm (merged) - #3074 calibration on grouped_mm - #3067 Nemotron-H's experts under grouped_mm - #2739 meta-DCP checkpoint load (merged) - #2736 distribution plan, run group, collectives (merged) - #2740 calibration on per-rank data - #2750 keep-fused fold - #3100 conversion hook, [1/3] (merged) - #3101 ownership and transport, [2/3] (merged) - **#2751 shard writing, [3/3] (this PR)** - #2752 no-gather export (on #2751, #2750, #2779 and #2740) - #2753 expert-parallel export - #2737 plan decision - #2779 load routing - #2741 expert-parallel loader (on #2779, #3067 and #2740) - #2742 dense sharding - #2743 block-FP8 dequantization - #2780 expert-parallel load routing - #2746 expert parallelism in the model specs - #2747 on-the-fly decompression - #2748 Kimi-K2.5 and Kimi-VL adapters - #2749 Kimi-K3 adapter - #2754 expert-parallel PTQ from hf_ptq.py (on #2746 and #2753) </details> ### What does this PR do? Type of change: New feature Main's FSDP2 export already writes each rank's share, but it first unshards each FSDP2 unit in turn, an all-gather of that unit to every rank. It also refuses conversion mappings that merge or split tensors, since one unit at a time cannot regroup them, and it does not handle expert-parallel shards. In this writer every tensor moves once, whole, to one owner rank and is written from there (`modelopt/torch/export/distributed_writer.py`). After the split this PR holds the last of its pieces: `_materialize_owned` is in #3101, and the conversion's `unmatched_merges` hook is in #3100. - **`_materialize_owned`** (now in #3101) gives each tensor exactly one owner rank and makes it whole there: - replicated and plain values need no transport; - dim-0 shards travel point-to-point, batched and bucketed per peer (one bucket per key deadlocked at 16 ranks); - each group of keys the reverse conversion merges into one tensor (`_merge_groups`) goes to one owner, with its weights' quantization state, so the merge, or its refusal of a quantized group, happens there. Placements it cannot assemble, and shards that do not tile the global shape, raise rather than risk a silently mis-assembled checkpoint; a zero-element tensor in such a layout is refused like a non-empty one. Experts arrive whole and plain, one disjoint set per rank: an expert that is still a DTensor is refused on every rank before anything moves. - **`_revert_whole_sd`** runs the reverse weight conversion on those whole tensors, so they go out under the checkpoint's names. transformers' conversion ops cannot express a placement. Ownership keeps each merge group whole on one rank, so no rank sees part of one. The reverse runs inside the failure-carrying `all_reduce`, so a refusal on any rank, such as a merge's refusal of a quantized or incomplete group, makes every rank keep its in-memory names together. - **`_write_even_shards`**: - bin-packs each rank's set into evenly sized files numbered into one global range; - refuses a name two ranks both hold, on every rank, in its first collective, before any file is written; - writes the files with safetensors, both names of a tensor two keys share included (one is copied, as `ShardedSafetensorsWriter` does), which `save_file` would otherwise refuse; - unions the index; - never overwrites a checkpoint: a directory already holding `model.safetensors`, its index, or `model-NNNNN-of-NNNNN.safetensors` files is refused on every rank in the first collective, before anything is written. Other files, auxiliary weights such as `mtp.safetensors` included, are left alone; - writes every file under a temporary name and renames them into place only once every rank has written all of its own, the index last, so no index names a shard that is not in place. - **Failing together.** The export directory's creation with the file counts and tensor names, the shard writes and rank 0's index write each run inside the failure-carrying `all_reduce` (#2736), so one rank's failure raises on every rank with that rank's error rather than leaving the others in an unmatched collective. A tensor over `max_shard_size` gets a file of its own, as in HF, and the rest still pack. The entry point that drives them arrives in #2752. **Why not a DCP writer, or main's streaming writer (`_StreamingShardWriter`, `name_shards_and_write_index`)?** - DCP writes its own format, not safetensors shards under the hub's names with an index. vLLM and TRT-LLM would need a consolidation pass over it, and that pass is the gather this writer avoids. - The streaming writer serves main's FSDP2 export, which unshards each unit, an all-gather of it to every rank, and refuses conversion mappings that merge or split tensors. This writer moves each tensor once, whole, to one owner, so a merge can run there; that part has nothing to extend. - The file writing itself could share code with `_StreamingShardWriter`, which already stages to temporary files, given three things this writer needs: - every step fails on every rank together, by running in the failure-carrying `all_reduce`, where the streaming path names its shards on rank 0 after a barrier and a `gather_object`; - a name two ranks both hold is refused before anything is written; - each rank's owned set is bin-packed into even files. That sharing is deferred to a follow-up. It would change main's streaming export, a path this series does not otherwise touch, and no checkpoint format, so it can land on its own once this writer is in, without growing a PR already split for size. The metadata collective carries each sharded tensor's dtype, every dense tensor's size and the merge units, which owners are assigned from; ranks that disagree on any of them are refused on every rank. The send buffers are freed once the transfers finish, before a rank assembles what it owns (a `P2POp` and its request each keep their tensor, so the transfer runs in a helper that drops both); on top of its own shards a rank holds at most about twice its owned set. On two gloo ranks with 128 MiB of shards each: | Send buffers | Peak RSS above the shards | |---|---| | kept while a rank assembles (previous revision) | 257 MiB | | freed before it assembles (this revision) | 193 MiB | ### Usage N/A: internal to the distributed export (#2752). ### Testing - New `tests/unit/torch/export/test_distributed_writer.py`, on CPU under gloo, drives each piece directly: - HF shard-size parsing, a unit in any case read as bytes, as the streaming export reads it (transformers reads a lowercase `b` as bits); - binning: balanced, bins added until each fits, an oversized tensor isolated, no empty bins; - DTensor sizes and the shard-0 placement check; - ownership making every tensor whole and plain, and rejecting placements it cannot assemble; - the reverse conversion renaming in place and keeping in-memory names when it fails; - shard files and an index covering every tensor once; - two ranks where one rank's directory or write fails, or two ranks hold one name: both raise, and the name collision leaves no shard file. Run on a 10 s gloo group, these fail in seconds on the code before the fix rather than waiting out the process group's timeout. - Mutation-checked: each test fails under at least one deliberate break of the code. - Each helper's tests are one table, and checks a remaining test covers are dropped; the two-rank jobs use the shared `bounded_collectives`. Each drop is checked by mutation. - On two ranks, `_materialize_owned` moves an uneven shard, an empty one and two dtypes to their owners, and a dtype disagreement, or an expert that only rank 1 still holds as a DTensor, is refused on every rank. An empty `Shard(1)` tensor is refused, and two names for one CPU tensor are both written. The expert, collision, empty-tensor and shared-tensor cases each fail without their fix (mutation-checked). - On two ranks, what a rank sent is freed before it stores an assembled tensor, a rank that received nothing included; a dense size that differs across ranks is refused on every rank; and `_write_even_shards` with uneven file counts numbers the files across ranks and indexes both ranks' tensors. Each fails without its fix (mutation-checked). - 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. - 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: each merge group goes to one owner rank, so the reverse conversion merges it; the shard size is read with the streaming export's parser; the mesh memo's comment gives its real reason. 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. - Review fixes, 2026-10-27, round 2: the merge groups are sorted before the units are built, so every rank derives the same owners whatever order its keys come in; a new case shows a quantized merge group, refused by the rank holding its scales, falling back on every rank. 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. - Review fixes, 2026-10-27, round 3: a merged weight's quantization state stays in its merge group, so the rank that merges a quantized group refuses it even when the merge pattern does not match the scales; groups that share a key form one unit whatever their order; the ranks check in the metadata collective that they formed the same units. 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. - Rebased on 2026-10-27 onto main 7c3c868, after #2739 merged: pre-commit, mypy included, and this PR's own unit tests pass at this head, and the series' PRs merged together add no unit-test failure over main (tests/unit/torch utils, export, quantization and models, on transformers 5.18 and 5.5.4 and on torch 2.8). - Review suggestions, 2026-10-27 evening: the refusal of ranks that group the keys differently lists each rank's first differing merge units. - Review fix, 2026-10-27 night: when the shard or index step fails, every rank removes the files it wrote, a partial one included, so a retry starts from the directory as it was. The one-rank-failure test checks the directory is left empty; its shard and index cases fail without the change. - Review suggestions, 2026-10-27, last round: a failed write's cleanup ignores an `unlink` error, so the error naming the failing rank is the one raised, and a comment says a retry rebuilds the state dict. The new test fails without the change. - Review suggestion, 2026-10-27, last round: files a failed write's cleanup cannot remove are named in one warning, and the write's own error is still the one raised. The extended test fails without the change. - The same `gloo_world_of_one` fix as #2750's, in this PR's copy of the fixture, so the two stay identical. - The same `test_meta_dcp_load.py` fixture deletion as #2750's, so the two stay identical. - Review suggestion, 2026-10-28, second round: each tensor is dropped right after its host copy, so a file's tensors are no longer held on the device while it is written; the new test fails without the change. - Review fix, 2026-10-28, seventh round: the comment by the drop now says it frees each tensor once its host copy exists. It does not lower the device peak, which is the allocation at entry with or without it, so a peak-memory measurement reads the same either way by construction and is no evidence against it; the memory still held when each file is written is what moves. Measured on 4 GPUs, in a distributed HF export of tiny FP8 and NVFP4 models that runs this writer on CUDA tensors (torch 2.15 pre-release, transformers 5.18; this PR's own writer test runs on CPU, where device memory does not move). The table gives the device bytes allocated (`torch.cuda.memory_allocated`) on each rank just before `save_file`. Each rank wrote one file, so a file was its whole owned set; a rank writing several files was not measured. | Model | Tensors per rank | Held before this change | Held with it | Released before the write | |---|---|---|---|---| | FP8, dense, tied embeddings | 5 to 20 | 167,424 to 167,936 | 109,056 | 58,368 to 58,880 (35%) | | FP8, MoE | 39 to 53 | 526,848 to 528,384 | 275,456 | 251,392 to 252,928 (48%) | | NVFP4, dense, tied embeddings | 6 to 25 | 131,072 to 131,584 | 97,280 | 33,792 to 34,304 (26%) | | NVFP4, MoE | 49 to 71 | 371,712 to 376,320 | 220,672 | 151,040 to 155,648 (41%) | - Rebased on 2026-10-28, late night, onto #2736 at `cf4597dcbeb`, on main `1ca8bd1fc4d` (after #3088 and #2745 merged), without conflicts; this PR's own diff is unchanged. Its tests pass (143), and pre-commit passes against main. - Rebased on 2026-10-28, late night, onto main `bdc45368d10` after #2736 merged; #2736's squash has the tree of its last head, so this PR's tree is unchanged. - Review (meenchen's review bot), 2026-10-29, all items: - **Split in three:** #3100, #3101 and this PR. This branch merges #3101, so its own commits stay; source lines are about 40, 347 and 254. - **Rollback:** a failed export no longer deletes a checkpoint already in the directory. Every file is written under a temporary name, and the ranks rename theirs into place only once every rank has written all of its own, the index last. A failure before then removes only the temporary files. The one-rank-failure test runs over an existing checkpoint too and checks it byte for byte; its shard and index cases fail without the change. A new test re-exports over a checkpoint. - **Imports:** the writer's, the fixture's and a test's imports moved to module scope. - **Docstrings:** the long ones keep their constraints; the narrative moved here. - **GPU tests:** `tests/gpu/torch/export/test_distributed_writer_gpu.py` runs from two GPUs. It moves BF16, FP8 and uint8 shards of uneven and empty extent over NCCL and writes several files per rank. The GB200 runs and measurements follow below. - **Results:** 148 tests pass, and pre-commit passes against main. - **Narrative moved from the docstrings:** bucketing the transfers by peer replaced one operation per (key, peer), which at 235B was about 12.7k operations and 660 s for a few GB of payload. - GPU runs on GB200 of `tests/gpu/torch/export/test_distributed_writer_gpu.py` (its two tests), ran at `f577fc05af0`. The code is unchanged at this head; only [2/3]'s docstrings and tests changed since. | GPUs | Result | Time | |---|---|---| | 1 | 2 skipped (need two GPUs) | 0.5 s | | 2 | 2 passed | 17.6 s | | 4 | 2 passed | 23.5 s | - Measured on GB200 at `f577fc05af0` with a synthetic sharded state dict: 600 keys of shape (8741, 4096), 360 BF16, 150 FP8 e4m3 and 90 uint8, 32.0 GiB in total. 8741 rows divide by neither 4 nor 8, so the dim-0 shards are uneven. Timed `_materialize_owned(sd, set(), rank, world)`, then `_write_even_shards(sd, out_dir, "5GB", rank, world)`. Rank 0 is shown; all ranks were near-identical. | GPUs | Input per rank | Owned per rank | Transport | Peak device memory in the transport, above its input | Write | Files per rank | Bytes written per rank | Peak host RSS (process lifetime) | |---|---|---|---|---|---|---|---|---| | 4 (one node) | 8.01 GiB | 8.00 GiB | 6.22 s | 12.01 GiB | 4.36 s | 2 | 8.00 GiB | 8.68 GiB | | 8 (two nodes × 4) | 4.00 GiB | 4.00 GiB | 14.15 s | 7.01 GiB | 2.57 s | 1 | 4.00 GiB | 8.49 GiB | - The device peak is `torch.cuda.max_memory_allocated()` after `reset_peak_memory_stats`, minus the allocation before the transport. During the transfer a rank holds one send buffer and one receive buffer per (peer, dtype). The send buffers copy its shards of the tensors other ranks own, (w-1)/w of its input; the receive buffers hold the other ranks' shards of the tensors it owns, (w-1)/w of its owned set. Their sum is the measured peak at both sizes. In this run the state dict held the only reference to each input shard, so a rank freed its inputs as it assembled. - Host RSS is `ru_maxrss`, the maximum over the process's life. It was 2.1 GiB before the write, so the write's own peak is at most the number shown. - The 8 GPUs span two nodes, so that transport time includes cross-node NCCL traffic and does not scale like-for-like against the one-node run. - Re-measured on GB200 at `239260c7d8d` with the input DTensors kept alive through the write, as the model keeps its parameters in a real export. The writer's code is the same as at `f577fc05af0` (docstrings differ) and is unchanged at `2470bc4a4f7`. Same 32.0 GiB state dict; rank 0 is shown, and all ranks were near-identical. | GPUs | Owned per rank | Transport | Peak device memory above the allocation before the transport (transport and write) | Write | Files per rank | Peak host RSS during the write | |---|---|---|---|---|---|---| | 4 (one node) | 8.00 GiB | 6.83 s | 14.01 GiB | 4.12 s | 2 | 8.65 GiB | | 8 (two nodes × 4) | 4.00 GiB | 14.76 s | 7.51 GiB | 2.52 s | 1 | 8.21 GiB | - With the inputs alive, the peak comes from the assembly: the receive buffers, (w-1)/w of the owned set, plus the assembled owned tensors. Their sum is the measured peak at both sizes, within `_materialize_owned`'s bound of about twice the owned set. - The write never raised the peak above the transport's: it copies each tensor to the host and drops it, so it adds time, not device memory. - Host RSS here is windowed to the write (the high-water mark is reset just before it); the first table's is the process-lifetime `ru_maxrss`. - 2026-10-29: merges [2/3]'s review round (`46e42e10a74`, rebased onto #3100's `b9fbb242300`). That brings [1/3]'s docstring and test, plus [2/3]'s merge-group and strided-shard tests and trimmed docstrings. No change of its own. Pre-commit passes against main; `test_distributed_writer.py`, `test_quant_aware_conversion.py` and `test_meta_dcp_load.py` pass (149). - 2026-10-29, later (`2470bc4a4f7`, test only): `test_the_index_goes_in_place_only_after_every_shard` pins the commit order. Rank 1 fails to rename its shard into place after every file is written, and the index must stay as it was: absent in an empty directory, byte for byte over an existing checkpoint. So an index in the directory never names missing shards. With the shard and index commits swapped, both cases fail, and every other writer test stays green. The writer's code is unchanged since `239260c7d8d`. `test_distributed_writer.py` passes (44), and pre-commit passes against main. - 2026-10-29 (meenchen's review bot): why the writer is neither a DCP writer nor main's streaming writer, and why sharing its file writing with the latter is deferred, is now in the description above, moved from the review reply. Description only. - Review (meenchen's review bot), 2026-10-29, second round (`285c30bf08e`): once every rank has committed, rank 0 removes an earlier checkpoint's `model-NNNNN-of-NNNNN.safetensors` files the new index does not name, and a `model.safetensors`, which transformers loads ahead of the index. The replacement test runs over an earlier one-shard, three-shard and single-file checkpoint, with a `config.json` and an off-index weight file that must stay; the three-shard and single-file cases fail without the removal. The failure tests' existing checkpoint gained a `model.safetensors`, so a removal on a failure path fails them (checked by running it in the failure handler). The docstring states the commit's limit: a rename failing partway through the final commit can leave a checkpoint already there mixed; it is documented rather than fixed, for the reasons in the review thread. `test_distributed_writer.py` passes (46), and pre-commit passes against main. Source lines: 272. - 2026-10-29, later: merges [2/3]'s second review round (`055b57bd7cf`): mixed-device shards and whole-tensor disagreements refused on every rank, each transfer round's buffers freed when it ends. No change of its own (`1feacd040c9`). The writer, conversion and meta-DCP tests pass (158), and pre-commit passes against main. - Review fix, 2026-10-29 (`03ae6f5d5ff`, meenchen's review bot): a reverse conversion that fails with an exception carrying no message now counts as a failure. Before, `RuntimeError()` read as success, so rank 0 committed hub names while rank 1 kept in-memory ones (reproduced on two gloo ranks with tiny Mixtral). The one-rank-failure test gains that case, which fails without the fix. `test_distributed_writer.py` passes (52), and pre-commit passes against main. - GPU run, 2026-10-29, ran at `1feacd040c9` (the same code as the current head). GB200, torch 2.11.0a0 (NGC 26.02), transformers 5.9.0: | `tests/gpu/torch/export/test_distributed_writer_gpu.py` (2 tests) | 1 GPU | 2 GPUs | 4 GPUs | |---|---|---|---| | Result | 2 skipped | 2 passed, 20.3 s | 2 passed, 22.5 s | - Review (meenchen's review bot), 2026-10-29, third round (`d9bf5aafbbb`, the user's call): the writer refuses, on every rank and before writing anything, a directory that already holds a checkpoint (`model.safetensors`, its index, or `model-NNNNN-of-NNNNN.safetensors`), so a commit failure can no longer mix one. The refusal is decided in the first collective, so one rank's view of the directory stops them all. This replaces the documented commit limit and the stale-file cleanup of `285c30bf08e`, which is removed with its tests; the staged `.tmp` writes stay. A two-rank test checks the refusal for a one-shard, three-shard and single-file earlier checkpoint, each left byte for byte, and fails without the refusal. Another exports beside a `config.json` and an auxiliary weights file, and fails if the pattern also matches the auxiliary file. `test_distributed_writer.py` passes (49), and pre-commit passes against main. Source lines: 270. - 2026-10-29, later: merges [2/3] rebased onto main `2b900c8fa41` with its third review round (`b73c052e45f`): every rank initializes NCCL before a transfer only some ranks take part in, with a fresh-group GPU test, and the transport's memory bound names what a rank sends. No change of its own (`8b6f3a275f7`). The writer and conversion tests pass (128), and pre-commit passes against main. - GPU run, 2026-10-29, at `8b6f3a275f7`. GB200, torch 2.11.0a0 (NGC 26.02), CUDA 13.1, NCCL 2.29.2, transformers 5.9.0: | `tests/gpu/torch/export/test_distributed_writer_gpu.py` | 2 GPUs | 4 GPUs | |---|---|---| | Result | 2 passed, 1 skipped (the fresh-group test needs 3 or more), 20.4 s | 3 passed, 46.1 s | - 2026-10-29, later: merges [2/3]'s docstring note that owners are weighed by bytes (`764036cd64e`). No change of its own (`2a65d9fa364`); pre-commit passes against main. - Rebased on 2026-10-29 onto #3101's squash `4bef85279e4` after it merged, with this PR's own diff unchanged. The history is now linear: one commit for the pieces as they stood when the split was made, then this PR's four commits since. #3100's and #3101's pre-squash commits are dropped. Pre-commit passes against main; the writer and conversion tests pass (128). - Claude review, 2026-10-29 (approved, two suggestions; `55b62f6fdf4`, docstring and message only): `_write_even_shards`' docstring says that a failure during the final renames leaves the shards already renamed, which a retry is refused over until they are removed, and the refusal's message names that cause. It also says the caller drops tied weights by name. Pre-commit passes against main; `test_distributed_writer.py` passes (49). - Rebased on 2026-10-30 onto main `99c3834f62c` (after #3099 and #2750 merged, and #2737), own diff unchanged and no conflicts: main's new commits touch none of this PR's files. Pre-commit passes against main. ### Before your PR is "*Ready for review*" - Is this change backward compatible?: ✅ Additive; nothing calls these yet. - 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](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?: N/A. The no-gather export's entry lands with #2752. - Did you get Claude approval on this PR?: ❌ Not yet. ### Additional Information Part 15 of 21 of expert-parallel PTQ for Hugging Face MoE models; its [1/3] #3100 and [2/3] #3101 have merged. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added distributed checkpoint export in Hugging Face safetensors format, writing tensors across ranks without gathering the full model on every rank. * Exported files are balanced by size, with oversized tensors placed in their own files and an index generated for the shards. * Export supports eligible sharded tensors and reverses supported quantization-related weight conversions. * **Bug Fixes** * Exports detect incompatible shards, tensor-name collisions, and write failures across ranks to help prevent incomplete or inconsistent checkpoints. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Note
[2/3] of #2751's split (2026-10-29), at its review's request: [1/3] #3100 (the reverse conversion's hook), [2/3] this PR (ownership and transport), and [3/3] #2751 (reverse conversion and shard writing). Merge in that order.
Base:
main. [1/3] #3100 merged on 2026-10-29 (8ab63386152), and this PR was rebased onto it with its own diff unchanged.What does this PR do?
Type of change: New feature (part of the no-gather distributed export)
This is the first part of a checkpoint writer for sharded models (FSDP2 and expert parallelism) in which every tensor moves once, whole, to one owner rank and is written from there (
modelopt/torch/export/distributed_writer.py).Main's FSDP2 export already writes each rank's share, but each FSDP2 unit is first unsharded in turn, an all-gather of that unit to every rank. It also refuses conversion mappings that merge or split tensors, since one unit at a time cannot regroup them, and it does not handle expert-parallel shards. Here no rank all-gathers anything, and the tensors a merge needs end up together, so the reverse conversion in [3/3] can run.
_materialize_owned:Owner assignment: gives each dense tensor one owner, largest first onto the lightest rank, seeded with each rank's expert bytes. Each group a merge needs (
together) goes to one owner as a unit.Transport:
The send buffers are freed before a rank assembles what it owns: the ops and their requests hold them, and they are dropped inside
_run_to_completion. On top of the shards it is handed, which the model keeps, a rank therefore holds in each round copies of its shards of keys other ranks own plus the shards it receives, and then those received shards plus the tensors assembled from them. With balanced shards that is about twice its owned set; a layout that puts most rows on one rank (four one-row tensors over four ranks) makes that rank's outgoing copies the larger part. The mesh check is memoized per mesh: FSDP2 gives every parameter the same mesh, so a few hundred keys would otherwise each redo it.NCCL initialization: before the first transfer, every rank runs a one-element all-reduce on the transport's device. A batched P2P that is NCCL's first call on a group needs every rank of the group, and on the
cpu:gloo,cuda:ncclgroup the setup creates, the metadata collective runs on Gloo, so a transfer only some ranks take part in could otherwise be that first call.Refusals: an unsupported placement, shards that do not tile their global shape, an expert still a DTensor, dim-0 shards on more than one device type (a CPU-offloaded shard beside one on the accelerator), and ranks that disagree on keys, on a shard's dtype or device, on a whole tensor's dtype or shape, or on groups are all refused. Every refusal is decided from one gathered metadata collective, so it is the same on every rank.
Also here, the one-rank gloo world the meta-DCP load tests used moves to the shared
tests/unit/torch/conftest.py, in the same text #3099 adds.Usage
Internal to the no-gather export (#2752).
Testing
tests/unit/torch/export/test_distributed_writer.py, on CPU under gloo:tests/gpu/torch/export/test_distributed_writer_gpu.pyruns on every visible GPU, from two. It moves BF16, FP8 and packed-NVFP4 uint8 shards of uneven and empty extent, a tensor with no rows, a replicated and a plain tensor over NCCL, and checks each ends whole, byte for byte, on exactly one rank. The same logic passes on two gloo ranks locally; GPU runs are requested.These tests come from [OMNIML-6082] [3/3] Write a sharded model's HF checkpoint shards from each tensor's owner rank #2751's reviewed suite, moved here with their code. 43 pass locally, and pre-commit passes against main.
GPU runs on GB200 of
test_distributed_writer_gpu.py, ran atb4baa594bdf(the transport code is unchanged since; only docstrings and tests changed):Transport measured on GB200 with a synthetic sharded state dict: 600 keys of shape (8741, 4096), 360 BF16, 150 FP8 e4m3 and 90 uint8, 32.0 GiB in total. 8741 rows divide by neither 4 nor 8, so the dim-0 shards are uneven.
_materialize_owned(sd, set(), rank, world)ran at [OMNIML-6082] [3/3] Write a sharded model's HF checkpoint shards from each tensor's owner rank #2751'sf577fc05af0, whose transport is this PR's. Rank 0 is shown; all ranks were near-identical.The peak is
torch.cuda.max_memory_allocated()afterreset_peak_memory_stats, minus the allocation before the transport. During the transfer a rank holds a send buffer and a receive buffer per (peer, dtype):Their sum is the measured peak at both sizes. In this run the state dict held the only reference to each input shard, so a rank freed its inputs as it assembled. The 8 GPUs span two nodes, so that time includes cross-node traffic and does not scale like-for-like against the one-node run.
Re-measured at [OMNIML-6082] [3/3] Write a sharded model's HF checkpoint shards from each tensor's owner rank #2751's
239260c7d8d(same transport code) with the input DTensors kept alive, as the model keeps its parameters in a real export. Same state dict and hardware:The peak now comes from the assembly: the receive buffers, (w-1)/w of the owned set, plus the assembled owned tensors. Their sum is the measured peak at both sizes, within the docstring's bound of about twice the owned set.
Review round, 2026-10-29 (
46e42e10a74, rebased onto [OMNIML-6082] [1/3] Let a reverse conversion record a merge rule no key matched #3100'sb9fbb242300): the two-rank test checks that two merge groups sharing a key end on one owner, where byte balancing alone would split them; a_StridedShardon dim 0 is pinned as refused (in torch>=2.10 it is not aShard, so it was already refused); the docstrings keep the constraints, and the cost derivations moved here. Pre-commit passes against main;test_distributed_writer.pypasses (16).Review round 2, 2026-10-29 (
055b57bd7cf, cjluo-nv's review bot):test_distributed_writer.pypasses (21), and pre-commit passes against main. The transport's GPU test was re-run at this round's head; see below.GPU run, 2026-10-29, ran at
055b57bd7cf(the same code as the rebased headba9f1c5773b). GB200, torch 2.11.0a0 (NGC 26.02), transformers 5.9.0:tests/gpu/torch/export/test_distributed_writer_gpu.py(1 test)Rebased on 2026-10-29 onto main
2b900c8fa41(the [hf] floor bump and the QAT Diffusers-attention fix), own diff unchanged.Review round 3, 2026-10-29 (
bb80474fe8f,b73c052e45f; cjluo-nv's review bot):test_a_first_transfer_most_ranks_sit_out_finishes_on_a_fresh_group, spawns a freshcpu:gloo,cuda:ncclgroup on 3 or more GPUs, runs no collective on it first, and transfers a two-row tensor that only ranks 0 and 1 hold. A rank stuck in NCCL's initialization exits through a watchdog, so the test fails instead of hanging. Its logic passes on three gloo CPU ranks; its GPU run is below.test_distributed_writer.pypasses (21).GPU runs, 2026-10-29, at
b73c052e45f(cjluo-nv's review bot asked for the fresh-group test with and without the NCCL initialization). GB200, torch 2.11.0a0 (NGC 26.02), CUDA 13.1, NCCL 2.29.2, transformers 5.9.0.test_a_first_transfer_most_ranks_sit_out_finishes_on_a_fresh_group, on 4 GPUs:b73c052e45f)84548cd6d4c)The file's pool-based tests:
test_distributed_writer_gpu.pyReview (juhi10071998), 2026-10-29 (
764036cd64e): the docstring says ranks are weighed by bytes, not free device memory, so a large embedding orlm_headmust fit on its owner. Docstring only; pre-commit passes against main.Before your PR is "Ready for review"
CONTRIBUTING.md: N/AAdditional Information
Split out of #2751, part of the expert-parallel PTQ series for Hugging Face MoE models.
Summary by CodeRabbit