Skip to content

[OMNIML-6082] [2/3] Move each tensor of a sharded model whole to one owner rank - #3101

Merged
shengliangxu merged 6 commits into
mainfrom
shengliangx/hfep-12b2-writer-transport
Oct 29, 2026
Merged

shengliangxu merged 6 commits into
mainfrom
shengliangx/hfep-12b2-writer-transport

Conversation

@shengliangxu

@shengliangxu shengliangxu commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

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:

    • dim-0 shards travel once, to their owner, over batched point-to-point sends bucketed by (peer, dtype), so the operation count is O(world × dtypes), not O(keys × peers). Both sides build the buckets from the same sorted keys and owner map, so every send meets a receive of its length. Rounds of at most 4096 keys bound how many keys one transfer carries;
    • replicated and plain tensors are unwrapped by their owner and dropped elsewhere;
    • an empty tensor is created by its owner.

    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:nccl group 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:

    • sizes and the placement check;
    • every tensor whole and plain after ownership;
    • each unsupported layout refused, on every rank;
    • two ranks moving uneven and empty shards of two dtypes, with what a rank sent freed before it assembles.
  • tests/gpu/torch/export/test_distributed_writer_gpu.py runs 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 at b4baa594bdf (the transport code is unchanged since; only docstrings and tests changed):

    GPUs Result Time
    1 skipped (needs two GPUs) 0.5 s
    2 passed 21.1 s
    4 passed 23.2 s
  • 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's f577fc05af0, whose transport is this PR's. Rank 0 is shown; all ranks were near-identical.

    GPUs Input per rank Owned per rank Transport Peak device memory above the input
    4 (one node) 8.01 GiB 8.00 GiB 6.22 s 12.01 GiB
    8 (two nodes × 4) 4.00 GiB 4.00 GiB 14.15 s 7.01 GiB

    The peak is torch.cuda.max_memory_allocated() after reset_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):

    • 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. 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:

    GPUs Owned per rank Transport Peak device memory above the input (transport, and the write that follows)
    4 (one node) 8.00 GiB 6.83 s 14.01 GiB
    8 (two nodes × 4) 4.00 GiB 14.76 s 7.51 GiB

    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's b9fbb242300): the two-rank test checks that two merge groups sharing a key end on one owner, where byte balancing alone would split them; a _StridedShard on dim 0 is pinned as refused (in torch>=2.10 it is not a Shard, so it was already refused); the docstrings keep the constraints, and the cost derivations moved here. Pre-commit passes against main; test_distributed_writer.py passes (16).

  • Review round 2, 2026-10-29 (055b57bd7cf, cjluo-nv's review bot):

    • Dim-0 shards on more than one device type are refused on every rank before anything moves; so is a key whose ranks disagree on its dtype or device. A round's transfers are one batch, which cannot mix devices.
    • Every dense tensor's dtype and shape are compared across ranks, not only its byte count.
    • Each round of transfers runs in a helper, so its receive buffers are freed before the next round starts.
    • The refusal test gains a shard on another device on one rank, shards on two device types, and three whole-tensor disagreements at equal byte counts (dtype, shape, empty shapes). The transfer test runs rounds of two keys and checks that no earlier round's receive buffer is alive when the next starts. Each fails without its change.
    • test_distributed_writer.py passes (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 head ba9f1c5773b). GB200, torch 2.11.0a0 (NGC 26.02), transformers 5.9.0:

    tests/gpu/torch/export/test_distributed_writer_gpu.py (1 test) 1 GPU 2 GPUs 4 GPUs
    Result skipped (needs 2 or more) passed, 22.3 s passed, 24.0 s
  • 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):

    • Every rank initializes NCCL with a one-element all-reduce on the transport's device before the first transfer. A new GPU test, test_a_first_transfer_most_ranks_sit_out_finishes_on_a_fresh_group, spawns a fresh cpu:gloo,cuda:nccl group 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.
    • The docstring's memory bound names a rank's outgoing shard copies as well as what it receives and assembles; with skewed dim-0 shards, the outgoing copies can exceed the owned set.
    • Pre-commit passes against main; test_distributed_writer.py passes (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:

    Arm Result
    With the NCCL initialization (b73c052e45f) passed, 23.2 s
    Same head with only the initialization removed (84548cd6d4c) failed, 75.8 s: a rank exited non-zero through the 60 s watchdog

    The file's pool-based tests:

    test_distributed_writer_gpu.py 2 GPUs 4 GPUs
    Result 1 passed, 1 skipped (the fresh-group test needs 3 or more), 21.5 s 2 passed, 44.0 s
  • Review (juhi10071998), 2026-10-29 (764036cd64e): the docstring says ranks are weighed by bytes, not free device memory, so a large embedding or lm_head must fit on its owner. Docstring only; pre-commit passes against main.

Before your PR is "Ready for review"

Additional Information

Split out of #2751, part of the expert-parallel PTQ series for Hugging Face MoE models.

Summary by CodeRabbit

  • New Features
    • Distributed checkpoint materialization can assemble supported sharded tensors into full tensors without gathering all data onto one rank.
    • Dense tensors are assigned to a single rank, while replicated and ordinary tensors are handled locally. Supported dimension-0 shards, including uneven and empty shards, are combined in rank order.
    • Expert tensors remain on their existing ranks, and assembled tensors are retained by their assigned owner.
  • Bug Fixes
    • Unsupported layouts and inconsistent tensor metadata now produce errors instead of being assembled incorrectly.

@shengliangxu
shengliangxu requested review from a team as code owners October 29, 2026 00:14
@shengliangxu
shengliangxu requested review from sugunav14 and removed request for a team October 29, 2026 00:14
@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between b73c052 and 764036c.

Files selected for processing: 1
  • modelopt/torch/export/distributed_writer.py
Files skipped from review as they are similar to previous changes: 1
  • modelopt/torch/export/distributed_writer.py

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

Walkthrough

The pull request adds a distributed checkpoint materializer. It validates tensor metadata across ranks, assigns dense tensors and merge groups to owners, and transfers supported dimension-0 shards in bounded rounds. Unit and GPU tests cover materialization, ownership, transfer behavior, and invalid inputs.

Changes: Distributed tensor materialization

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: Normal

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

Change: Feature

Possibly related issues

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: Passed 5 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning 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. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 5 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed 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 objecti…
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 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_re…
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.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test b4baa59

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

pending.append((srcr, ks, buf))
ops.append(dist.P2POp(dist.irecv, buf, srcr))

_run_to_completion(ops)

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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_owned at 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

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] 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_set members 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@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 — [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_workers suite 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 same owner map and the same all_shapes emptiness 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. shard0 is derived from sorted dense_keys and its membership is verified across ranks, so the chunk = 4096 rounds are identical everywhere — no rank runs a transfer round its peer skipped.
  • Assembly order is shard order. _is_full_world_shard0 pins mesh.mesh == range(world), so global-rank order is mesh order and the torch.cat reproduces what full_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-element gshapes cannot 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 the min key (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 together union 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() == 0 and placements are all metadata; the one tensor read (mesh.mesh.tolist()) is CPU and memoized. Freeing the send buffers right after req.wait() is safe under NCCL because ProcessGroupNCCL::pointToPoint calls recordStream on them, so the caching allocator defers reuse.
  • torch.distributed.tensor is fine against the declared torch>=2.10; the module is intentionally absent from export/__init__.py's star imports (all-private, no caller yet), and the gloo_world_of_one move to tests/unit/torch/conftest.py is 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

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 46e42e1

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/claude review

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

Thanks. Here is what each item became; it is pushed as 46e42e1, rebased onto #3100's b9fbb24.

  • IMPORTANT, _StridedShard: already refused. In torch>=2.10, _StridedShard is bound on Placement, not on Shard, and is_shard(0) is False for it. The case you suggested is now in the refusal test. Details are in the thread.
  • Memory peak: the transport is unchanged. Its inputs are views of the model's parameters, so dropping them earlier frees nothing in the export. The docstring now says its bound is on top of those shards. A GB200 measurement is in the thread and the description.
  • together's happy path: now tested in the two-rank job, and mutation-checked.
  • Expert disjointness: the docstring now states it as the caller's precondition. [3/3] refuses a key held by two ranks before writing.
  • Docstring length: trimmed. The cost derivations moved to the description.

@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 — [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

  1. [SUGGESTION] distributed_writer.py:42 — make the placement gate exact (type(p) is Shard) instead of relying on is_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.
  2. [SUGGESTION] test_distributed_writer.py:33-34 — drop the safetensors/transformers importorskips. 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 the len(gshapes) != 1 test is harmless: the or short-circuits, so the arbitrarily-chosen element is never compared. The cpu_dist_workers suite covers all seven cases on both the agreeing and the disagreeing rank, under bounded_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 same owner map and the same all_shapes emptiness 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 = 4096 rounds are derived from the verified-identical sorted shard0 list, so no rank runs a round its peer skipped.
  • Assembly reproduces full_tensor(). _is_full_world_shard0 pins mesh.mesh == range(world), so global-rank order is mesh order; torch.chunk is what Shard._split_tensor uses, 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 the min key (loads[i], i) both break ties explicitly, over inputs (dense_sizes, expert_bytes, merged_units) that are all verified identical, and unit_of covers every dense key — so no tensor can be dropped everywhere or claimed twice. Asymmetric expert sets are fine by design: expert_bytes is read from the gathered metadata, not computed locally.
  • The together union pass is transitively correct. Each new unit is the union of every member's current unit and all of its members are rewritten, so unit_of stays a valid partition regardless of group order — [A,B], [C,D], [B,C] collapses to one unit either way round.
  • empty0 cannot desynchronize. DTensor.numel() is global, so ranks agree; a global shape disagreement surfaces as a shard0/empty0 classification split, which the all_shapes key-set check names.
  • No host syncs on the metadata path. _nbytes, numel() == 0 and placements are all metadata; the one tensor read (mesh.mesh.tolist()) is CPU and memoized, and id(mesh) cannot collide because local_sd pins every mesh alive through the classification loop. Freeing the send buffers right after req.wait() is safe under NCCL because ProcessGroupNCCL::pointToPoint calls recordStream on them, so the caching allocator defers reuse — and the two-rank test proves the free actually happens, via StorageWeakRefs sampled at each assembled store.
  • The module is intentionally absent from export/__init__.py (all-private, no caller yet — grep confirms nothing in modelopt/ references it), torch.distributed.tensor is fine against the declared torch>=2.10, and the gloo_world_of_one lift to tests/unit/torch/conftest.py keeps the meta-DCP tests working while adding the CPU mesh the new tests need, now with try/finally teardown.

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

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

Thanks for the approval. On the items the review lists as still open, and the two new ones:

  • Expert disjointness: _materialize_owned's docstring now states it as the caller's precondition ("experts must come whole, plain and disjoint per rank, as the caller splits them"). [3/3]'s _write_even_shards refuses, on every rank, a name held by more than one rank before it writes anything; this is in 46e42e1.
  • Prose volume: the three places you listed are trimmed in 46e42e1, and the cost derivations are in the description now.
  • Exact placement gate: kept as is_shard(0). In torch>=2.10, is_shard is itself the exact check (a C++ typeid comparison), and type(p) is Shard would refuse a Shard built on the C++ side. Details are in the thread.
  • The importorskips: kept, because [3/3]'s tests in this file import transformers at module level. Details are in the thread.

@cjluo-nv cjluo-nv left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bot review (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.

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

/ok to test 055b57b

@shengliangxu

Copy link
Copy Markdown
Collaborator Author

Thanks. The first three items are done in 055b57b, with details in their threads; the fourth needs no change.

  • Mixed devices: dim-0 shards on more than one device type, or a key whose ranks disagree on its dtype or device, are now refused on every rank before anything moves.
  • Whole-tensor metadata: every dense tensor's dtype and shape are compared across ranks, not just its byte count.
  • Round-local buffers: each round runs in a helper, and the transfer test checks across a round boundary.
  • NCCL first use with sparse participation: the batched transfer is never the group's first collective. _materialize_owned runs dist.all_gather_object(meta, my_meta) on the default group (distributed_writer.py:217) before it calls _gather_shard0_to_owners (line 356). Every rank reaches that collective, and the P2P ops use the same default group, so the communicator exists before any rank skips a send. The GPU test ran over NCCL on 2 and 4 GB200 GPUs, with uneven and empty shards, and passed.

shengliangxu added a commit that referenced this pull request Oct 29, 2026
…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>
Base automatically changed from shengliangx/hfep-12b1-conversion-hooks to main October 29, 2026 07:03
@juhi10071998

Copy link
Copy Markdown
Contributor

Reviewed distributed_writer.py: I found no blocking bug.

What I checked: I ran _materialize_owned on 2, 3 and 4 gloo ranks (torch 2.10, CPU) with:

  • uneven dim-0 shards (7 rows), and fewer rows than ranks (empty shards)
  • uint8, bf16, and fp8 viewed as uint8, side by side
  • replicated and plain tensors, plus per-rank expert tensors
  • overlapping merge groups chained transitively ((a, d), (d, g))

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 all_gather_object, so all ranks agree and none is left hanging.

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

Copy link
Copy Markdown
Collaborator Author

/ok to test 764036c

@shengliangxu

shengliangxu commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

@juhi10071998, thank you for the multi-rank checks. Running _materialize_owned on 2, 3 and 4 ranks, with empty shards, mixed dtypes and chained merge groups, covers layouts the unit tests take one at a time.

  • Owners balanced by bytes, not free memory: documented in 764036c. _materialize_owned's docstring now says ranks are weighed by bytes, not by free device memory, so a large embedding or lm_head must fit on its owner. I did not build a memory-aware bound. It would need each rank's free memory gathered in the metadata collective, and that number changes as the export runs.
  • The duplicate gloo_world_of_one fixture: agreed. [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099 and this PR add it in byte-identical text, so whichever merges second will be rebased onto main and drop its copy. That happens once one of them merges.

@kevalmorabia97 kevalmorabia97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@shengliangxu
shengliangxu merged commit 4bef852 into main Oct 29, 2026
50 checks passed
@shengliangxu
shengliangxu deleted the shengliangx/hfep-12b2-writer-transport branch October 29, 2026 19:02
shengliangxu added a commit that referenced this pull request Oct 29, 2026
[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>
shengliangxu added a commit that referenced this pull request Oct 29, 2026
…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>
shengliangxu added a commit that referenced this pull request Oct 30, 2026
[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>
shengliangxu added a commit that referenced this pull request Oct 30, 2026
…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>
shengliangxu added a commit that referenced this pull request Oct 30, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants