Repository navigation
[OMNIML-6082] [3/3] Write a sharded model's HF checkpoint shards from each tensor's owner rank - #2751
Conversation
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
65d7878 to
e832c67
Compare
8aef85d to
6994370
Compare
e832c67 to
52b4592
Compare
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2751 +/- ##
==========================================
+ Coverage 71.55% 79.95% +8.39%
==========================================
Files 664 664
Lines 76659 76804 +145
==========================================
+ Hits 54856 61406 +6550
+ Misses 21803 15398 -6405
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
/ok to test 6994370 |
52b4592 to
d314e75
Compare
6994370 to
5b2ee4a
Compare
|
/ok to test 5b2ee4a |
5b2ee4a to
f738cd0
Compare
636aea0 to
dbb570a
Compare
f738cd0 to
812e4e1
Compare
|
/ok to test 812e4e1 |
dbb570a to
e85bc68
Compare
812e4e1 to
6c1cb0e
Compare
e85bc68 to
be5d536
Compare
6c1cb0e to
a8e0c27
Compare
a8e0c27 to
a3be04a
Compare
|
Thanks. Here is what each item became; it is pushed as 285c30b.
|
meenchen
left a comment
There was a problem hiding this comment.
Bot review (gpt-6.1-sol) — DM the bot to share feedback.
Changes requested: commit-failure corruption remains unresolved, and message-less reverse exceptions can publish mixed tensor names.
Needs action:
- 💬 Author documented the mixed-checkpoint limit — still preserve prior files or collectively refuse overwriting in
_write_even_shards; extend shard/index-rename failure coverage to verify every prior file byte-for-byte. - Fix
_revert_whole_sd.combineto checkerror is not None; extend the two-rank failure test withRuntimeError(). See inline.
No action needed:
- ✔️ Resolved since the last review: obsolete-weight cleanup and the design rationale. The split, staged write-time preservation, module-level imports, and GPU coverage remain intact.
| return str(exc), [] | ||
| return None, unmatched | ||
|
|
||
| def combine(results: list) -> list: |
There was a problem hiding this comment.
Bot comment.
[P2] Detect reverse failures independently of their message. RuntimeError() has an empty string, so revert() returns ("", []), but if error discards that failure. With rank 1 raising this exception and rank 0 successfully reversing its Mixtral subset, combine returns no failures, both ranks report True, and rank 0 commits hub names while rank 1 keeps in-memory names. This bypasses the all-rank fallback without a warning. Check error is not None instead, and extend _one_rank_cannot_revert_job with a message-less exception that verifies both ranks retain their original names.
There was a problem hiding this comment.
Fixed in 03ae6f5; you were right. combine now counts any rank's error as a failure (error is not None), however empty its message. The warning names the exception's type when there is no message.
_one_rank_cannot_revert_job now also runs with a message-less RuntimeError() on rank 1. Both ranks must return False and keep their original names. That case fails without the fix: rank 0 committed its hub names.
|
/ok to test 1feacd0 |
|
/ok to test 03ae6f5 |
|
/ok to test c28c038 |
There was a problem hiding this comment.
Bot review (gpt-6.1-sol) — DM the bot to share feedback.
Changes requested: message-less reverse failures are fixed, but commit failures can still corrupt a previously valid checkpoint.
Needs action:
- 💬 Author documented the commit-failure limit — still preserve prior files or collectively refuse overwriting in
_write_even_shards; extend shard/index-rename failure coverage to verify the entire prior checkpoint byte-for-byte. See the existing commit-failure inline.
No action needed:
- ✔️ 7 earlier concerns addressed, including message-less reverse failures and write-time preservation; obsolete-weight cleanup, design rationale, split, module-level imports and GPU coverage remain intact.
|
/ok to test d9bf5aa |
|
Thanks. The remaining item is done in d9bf5aa, by collectively refusing to overwrite.
|
|
/ok to test 8b6f3a2 |
|
/ok to test 2a65d9f |
|
/ok to test bb9dd1f |
|
/claude review |
| all_reduce("index committed", lambda: commit([n for n in staged if n == index_name]), all) | ||
| except BaseException: | ||
| left = [] | ||
| for name in staged: | ||
| # Best effort: the filesystem that failed the write can fail this too, and its error | ||
| # would replace the one naming the rank that failed, so the files it keeps are named in | ||
| # a warning instead. A file already renamed into place has no temporary file left. | ||
| try: | ||
| (export_dir / f"{name}.tmp").unlink(missing_ok=True) | ||
| except OSError: |
There was a problem hiding this comment.
[SUGGESTION] The cleanup unlinks only <name>.tmp, so a failure during the final commit leaves the shards that were already renamed into place — and the new refusal then permanently blocks a retry into that directory.
Walk the path test_the_index_goes_in_place_only_after_every_shard exercises: every rank has written all its .tmp files, all_reduce("shards committed", ...) runs, rank 0 renames its shards successfully, rank 1's os.replace raises. Every rank raises, and each one's cleanup finds no .tmp left for a name it already renamed ("A file already renamed into place has no temporary file left"). The directory is left holding model-00001-of-00003.safetensors with no index. That test only asserts the index is absent, so the leftover shards are not pinned either way.
Why it matters: those leftovers match _CHECKPOINT_FILES, so the next run of this writer refuses with "<dir> already holds a checkpoint (model-00001-of-00003.safetensors), which the distributed export does not overwrite" — which reads as "someone else's checkpoint is here" when in fact it is debris from the export that just failed. The user has to work out which files to delete by hand before any retry, and the docstring's "never overwrites a checkpoint" now also means "a failed commit needs manual cleanup".
Unlike the stale-file removal dropped in 285c30bf08e, removing these is unambiguously safe: the refusal in plan_mine guarantees the directory held no model*.safetensors before this export started, so every name in this rank's staged that is now in place was written by this export and by this rank. Suggested change — unlink both the temporary and the committed name, per rank:
except BaseException:
left = []
for name in staged:
# Best effort: the filesystem that failed the write can fail this too, and its error
# would replace the one naming the rank that failed, so the files it keeps are named in
# a warning instead. The commit renames a staged file, so remove both names: the
# directory held no checkpoint file when the export started (plan_mine refuses one), so
# a ``model*.safetensors`` under one of this rank's names is this export's own.
for leftover in (export_dir / f"{name}.tmp", export_dir / name):
try:
leftover.unlink(missing_ok=True)
except OSError:
left.append(leftover.name)
if left:
warnings.warn(
f"rank {rank}: could not remove {len(left)} file(s) the failed export "
f"wrote to {export_dir}: {left[:5]}"
)
raiseThat makes "a failure leaves the directory as it was" hold on the commit path too, and test_the_index_goes_in_place_only_after_every_shard can then assert the stronger not list(tmp_path.iterdir()) the other failure test already asserts (it fails today on the shard-commit case). If you'd rather keep the commit non-reverting, the alternative is to say so in _write_even_shards' docstring and to have the refusal name the possibility, e.g. "... does not overwrite; remove those files (a previous export that failed during its final rename can leave some behind) or export to another directory."
There was a problem hiding this comment.
I took the alternative you offered, in 55b62f6, and kept the commit non-reverting.
- Docstring:
_write_even_shards's docstring now says that a failure removes the temporary files, but one during the final renames leaves the shards already renamed, and a retry into the directory is refused over them until they are removed. - Error message: the refusal now names that cause: "remove those files (an export that failed during its final renames can leave some behind) or export to another directory".
Your reasoning is right: plan_mine's refusal means any model*.safetensors under one of this rank's staged names was written by this export, so deleting them on failure would be safe. I didn't make that change here, because the failure-path cleanup was decided explicitly for this PR; I've raised your version as an option instead.
| # save_file refuses tensors sharing memory, and neither call copies a tensor that is | ||
| # already on the CPU and contiguous, so two names for one tensor arrive aliased. Copy | ||
| # rather than drop one: every name has to reach the index. | ||
| if t.data_ptr() in ptrs: | ||
| t = t.clone() | ||
| ptrs.add(t.data_ptr()) |
There was a problem hiding this comment.
[SUGGESTION] "every name has to reach the index" is the right call for this helper, but it makes the distributed export write a tied lm_head.weight as a full byte-for-byte duplicate, where both save_pretrained and main's streaming export drop it by name. Worth a pointer here so #2752 does not inherit it silently.
unified_export_hf_streaming.py:325 / :373 spell out the reason main's path uses names rather than pointers: "Tied weights are dropped by name from _tied_weights_keys (data_ptr is meaningless …)". This writer has no equivalent filter, and it cannot have one — it is handed a state dict, not a model — so for a tied-embedding model it emits model.embed_tokens.weight and lm_head.weight as two independent tensors. The checkpoint still loads (transformers re-ties after load), but it costs an extra vocab × hidden tensor on disk and in the index's total_size: ~0.5 GB in BF16 for a 1B-class tied model, and the PR description's own 4-GPU measurement table has two "tied embeddings" rows, so this is a layout the writer already meets.
Suggestion: no code change here, just make the constraint explicit so the entry point owns it — e.g. extend the comment to "... Copy rather than drop one: every name has to reach the index. Tied weights must therefore be dropped by name upstream, as unified_export_hf_streaming does from _tied_weights_keys; this writer cannot tell a tie from an alias." and add the same sentence to _write_even_shards' docstring, which is where #2752 will look.
There was a problem hiding this comment.
Done in 55b62f6. _write_even_shards's docstring now states the contract: every name in local_sd is written, two names for one tensor included, so the caller drops tied weights by name beforehand, as the streaming export drops _tied_weights_keys.
The no-gather entry point in #2752 already does this. It builds a TiedWeightMap from the model and passes it to postprocess_state_dict, for both the expert and the dense state dicts, so a tied lm_head.weight is dropped by name before the writer sees it.
There was a problem hiding this comment.
Claude review — [3/3] shard writing from each tensors owner rank
Scope: full review (no scoping instructions in the trigger). 3 files changed (+741/-3), all read: distributed_writer.py (+270), tests/unit/torch/export/test_distributed_writer.py (+443/-2), tests/gpu/torch/export/test_distributed_writer_gpu.py (+28/-1). Read from the base for context: _materialize_owned / _is_full_world_shard0 / _nbytes and the unit_of construction in the same module; _apply_merge_rule / _apply_split_rule / _split_leaf_tensor / _build_reverse_rules / _QUANT_STATE_SUFFIXES / rename_source_key; unified_export_hf_streaming._parse_shard_size and its shared-tensor and tied-weight handling; utils/distributed.all_reduce. Grepped modelopt/ and examples/ for importers — none, as the description says (entry point is #2752). The checkout is shallow, so I re-derived the write-path arguments against the base rather than reviewing only the delta, with extra attention on what is new since the 2026-10-28 approval: the checkpoint refusal that replaced 285c30bf08es stale-file cleanup, the two-phase commit, and the empty-message fix in _revert_whole_sd.
Findings — CRITICAL: 0 · IMPORTANT: 0 · SUGGESTION: 2
Neither blocks.
- The cleanup unlinks only
<name>.tmp, so a rename failure partway through the final commit leaves shards already renamed into place, and_CHECKPOINT_FILESthen blocks a retry into that directory with a message reading as "someone elses checkpoint is here". Sinceplan_mineguarantees the directory held no checkpoint file at entry, also removing this ranks committed names is safe and restores "a failure leaves the directory as it was". - Tied weights are written as full duplicates, where
save_pretrainedand mains streaming export drop them by_tied_weights_keysname. This helper cannot tell a tie from an alias, so the constraint is worth stating where the alias copy is already explained, for #2752.
Verified this round
- The refusal is rank-identical and cannot be pre-empted.
plan_minesmkdir/iterdir/FileExistsErrorruns inside the firstall_reduce, which runsfnon every rank and gathers before any proceeds, so a directory only one rank can see still stops them all — what_refuses_a_checkpoint_jobpins. The collisionCounter,n_total == 0andbase = sum(counts[:rank])all read the rank-identicalplanned, so those raises are identical too. _CHECKPOINT_FILESmatches what it should and nothing else. Underfullmatch:model.safetensors, its index, andmodel-NNNNN-of-NNNNN.safetensors. Not*.safetensors.tmp(so a previous runs staged files do not block a retry and are simply replaced byos.replace), notconfig.json/mtp.safetensors/adapter_model.safetensors. 4-digit numbering and other prefixes are out, matching transformers own%05d.- Commit ordering holds.
all_reducegathers after runningfn, so the index can only land once every rank has committed every shard — no index ever names a missing shard, and both cases oftest_the_index_goes_in_place_only_after_every_shardfail with the two commits swapped. The index commit is a pure barrier on non-zero ranks, which is what lets the GPU test read the checkpoint back without its own barrier. _even_binsterminates and emits no empty or over-bound file.nis bumped at most tolen(fits), where LPT places one item per bin;max(loads)never sees an empty sequence. Empty key set returns[](a rank owning nothing writes no file and still joins every collective, leavingbase/n_totalcontiguous); all-oversized floorsnto 1 with a bin each;min(range(n), ...)takes the first minimum, so the plan is deterministic.sizes/fits/my_filesare computed beforewrite_minedrains the dict.- Every key reaches one file and the index.
my_filespartitionssorted(local_sd)by the> max_bytespredicate;base/n_totalgive disjoint, contiguous, correctly totalled numbers across ranks, pinned with uneven per-rank counts (1 + 2). - The alias guard survives the per-key drop.
del local_sd[k1]drops only the dict entry whilet1holds the storage, sok2s copy still reports the samedata_ptr()and is cloned — and the deleted key is the first, so the test exercises the order that matters. Address reuse cannot false-positive, since every pointer inptrsbelongs to a tensor still intensors. Resettingptrsper file is right (save_fileonly refuses sharing within one file). I checked the one case wheredata_ptr()and safetensors storage-identity predicate diverge — two contiguous views into one storage at different offsets — and the reverse conversion cannot produce it:_split_leaf_tensorends in.clone()on both branches, andshard0/empty0come out oftorch.cat/torch.empty. Empty tensors are skipped by safetensorsstorage_size != 0guard. The guard also matches_StreamingShardWriters existingdata_ptr()approach, so it is not a new divergence. _revert_whole_sdis atomic across ranks and the no-message fix is right.revert()catchesExceptionitself, so the decision is made incombine, wherefailures or ...short-circuits before an errored ranks emptyunmatchedcould empty the intersection.str(exc) or type(exc).__name__closes the case where a bareRuntimeError()read as success.apply_reverse_rulescopies before mutating andrevertedkeeps its initial binding on failure, so thereverted is not local_sdguard means no checkpoint can mix hub and in-memory names._merge_groupsmatches what the merge demands.(rule_index, target)is the same granularity_apply_merge_rulegroups by. Pulling each.weights_QUANT_STATE_SUFFIXESsiblings in is what makes the guard atquant_aware_conversion.py:210-216fire on the owning rank for a weight-anchored pattern that never matches a scale — theanchoredcase. Duplicate keys in a group are harmless (unit_ofunions sets); expert keys are filtered byk in unit_of;regroupedcatches a rank that formed different units before a byte moves. Theunit_ofwrite-back is order-independent (walked[a,b],[c,d],[b,c]and its reverse), so the dict-insertion order of_merge_groupscannot make ranks disagree.- Failure propagation. Every write step runs inside
all_reduce, which re-raises on the failing rank and raises a rank-namingRuntimeErroron its peers;staged.appendprecedes each write so a partial file is recorded before it exists; theexcept OSErroraround theunlinkkeeps the rank-naming error as the one raised. The three pre-commit failure cases assertnot list(tmp_path.iterdir()); the commit case is the gap suggestion 1 covers. - Serialization and API surface. Every payload round-trips under
weights_only;_in_rank_orders unwrap is correct in both the distributed and the single-processcombine([fn()])branch. No mode registration, config schema,modelopt_statekey or public__init__.pyexport is touched, and nothing imports this module yet — though the new module-scopesafetensors/quant_aware_conversion/unified_export_hf_streamingimports mean #2752 should keep its own import of it lazy ifexport/__init__.pyis to stay importable without transformers 5.
Tests are proportionate: CPU-only unit file, every multi-process case on the shared cpu_dist_workers as CLAUDE.md requires, _even_bins as one parametrized table, and the GPU addition reuses the existing helpers and asserts byte identity plus no leftover .tmp.
Already raised, not re-filed. The raw all_gather_object in _materialize_owned outside the failure-carrying all_reduce; the receive-buffer device taken from one key per bucket; _is_full_world_shard0 still refusing the HSDP (Replicate(), Shard(0)) layout, which belongs with #2742/#2753. A max_shard_size of "0" divides by zero in math.ceil(fits / max_bytes) — pathological, not worth a guard.
Risk: low, and additive. Nothing dispatches to these helpers yet, so the blast radius before #2752 is zero. Every invariant whose violation would ship a checkpoint that loads and is quietly wrong — a name two ranks both write, a file number landing on another ranks file, a merge split across ranks, a half-reverted name space, an index naming a missing shard, an overwritten checkpoint — is either refused from the single gathered payload on every rank or covered by a world > 1 test the author reports as mutation-checked. 270 added source lines is within the sizing budget, and the split into #3100 / #3101 / this PR answers the earlier oversize note.
🤖 Generated with Claude Code
|
/ok to test 55b62f6 |
meenchen
left a comment
There was a problem hiding this comment.
Bot review (gpt-6.1-sol) — DM the bot to share feedback.
Approved: collective refusal before writes resolves the remaining checkpoint-preservation concern, and reverse-conversion failures retain consistent names across ranks.
No action needed:
- ✔️ Resolved since the last review: prior-checkpoint corruption and message-less reverse failures.
Complex PR: 2 existing test files modified or removed. Looping in a human for approval.
[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>
Swapping the shard and index commits kept every writer test green. A rank that fails to rename its shard into place must leave the index as it was, so an index in the directory never names shards that are missing. The new test fails with the two commits swapped. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A re-export into a directory replaced the shards it wrote under the same names and left the rest: an earlier export's shards beyond the new count, and a single-file model.safetensors, which transformers loads ahead of the new index. Once every rank has committed, rank 0 removes the model-NNNNN-of-NNNNN.safetensors files the new index does not name, as save_pretrained does, and a model.safetensors. Nothing else is touched, and nothing is removed on a failure. The docstring states the commit's guarantee: a failure while writing leaves a checkpoint already there untouched, but a rename failing partway through the final commit can leave it mixed. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The ranks' reverse verdicts told a failure by its message, and an exception with none, as RuntimeError() is, read as success: the other ranks then committed hub names while that rank kept its in-memory ones. A failure is now any rank's error, whatever it says, and the warning names the type of one with no message. The one-rank-failure test gains that case, which fails without the change. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
A rename failing partway through the final commit could leave a checkpoint already in the directory mixed: some of its shards replaced, its index naming tensors they no longer hold. The writer now refuses, on every rank and before anything is written, a directory holding model.safetensors, its index, or model-NNNNN-of-NNNNN.safetensors shards. The check runs in the first collective, so a rank whose view of the directory alone shows them fails the others too. Other files, auxiliary weights included, are left alone and do not stop the export. With no checkpoint ever written over, the removal of an earlier checkpoint's stale weight files (285c30b) goes, with its tests. The staged .tmp writes stay. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…ghts are the caller's to drop A failure removes the writer's temporary files, but one during the final renames leaves the shards already renamed, and the refusal of a directory holding a checkpoint then refuses a retry over them. The docstring says so, and the refusal's message names that cause. The docstring also says that every name is written, two names for one tensor included, so tied weights are dropped by name before the writer, as the streaming export does. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
/ok to test f796006 |
There was a problem hiding this comment.
Bot review (gpt-6.1-sol) — DM the bot to share feedback.
LGTM: the prior correctness concerns are resolved; a human should sign off on the expanded CPU/GPU test coverage.
Needs action:
- Sign off on the test expansion in
tests/unit/torch/export/test_distributed_writer.pyandtests/gpu/torch/export/test_distributed_writer_gpu.py; existing coverage remains intact.
No action needed:
- ✔️ Resolved since the last review: checkpoint preservation and message-less reverse failures; the earlier split, import and merge-group fixes remain intact.
kevalmorabia97
left a comment
There was a problem hiding this comment.
Each tensor is written once from its owner, the shard plan and failures go through the #2736 collectives so all ranks agree, and the tests fail when key logic is broken (49 unit tests pass, plus the 2-GPU test).
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_mergeshook), [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 targetsmain.Base:
main, on #3101's squash4bef85279e4. The diff below is this PR's alone.Re-cut: the
gloo_world_of_onefixture 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.Series layout
mainWhat 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_ownedis in #3101, and the conversion'sunmatched_mergeshook is in #3100._materialize_owned(now in [OMNIML-6082] [2/3] Move each tensor of a sharded model whole to one owner rank #3101) gives each tensor exactly one owner rank and makes it whole there:_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_sdruns 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-carryingall_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:ShardedSafetensorsWriterdoes), whichsave_filewould otherwise refuse;model.safetensors, its index, ormodel-NNNNN-of-NNNNN.safetensorsfiles is refused on every rank in the first collective, before anything is written. Other files, auxiliary weights such asmtp.safetensorsincluded, are left alone;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([OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #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_sizegets 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:all_reduce, where the streaming path names its shards on rank 0 after a barrier and agather_object;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
P2POpand 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:Usage
N/A: internal to the distributed export (#2752).
Testing
tests/unit/torch/export/test_distributed_writer.py, on CPU under gloo, drives each piece directly:bas bits);bounded_collectives. Each drop is checked by mutation._materialize_ownedmoves 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 emptyShard(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)._write_even_shardswith uneven file counts numbers the files across ranks and indexes both ranks' tensors. Each fails without its fix (mutation-checked).tests/uniton 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.unlinkerror, 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.gloo_world_of_onefix as [OMNIML-6081] [2/2] Fold fused experts' quantizers into their weights shard-locally #2750's, in this PR's copy of the fixture, so the two stay identical.test_meta_dcp_load.pyfixture deletion as [OMNIML-6081] [2/2] Fold fused experts' quantizers into their weights shard-locally #2750's, so the two stay identical.torch.cuda.memory_allocated) on each rank just beforesave_file. Each rank wrote one file, so a file was its whole owned set; a rank writing several files was not measured.Rebased on 2026-10-28, late night, onto [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736 at
cf4597dcbeb, on main1ca8bd1fc4d(after [OMNIML-6073] [1/3] Gather only FSDP2's own parameters in its weight-access window #3088 and [OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #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
bdc45368d10after [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736 merged; [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #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:
tests/gpu/torch/export/test_distributed_writer_gpu.pyruns 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.GPU runs on GB200 of
tests/gpu/torch/export/test_distributed_writer_gpu.py(its two tests), ran atf577fc05af0. The code is unchanged at this head; only [2/3]'s docstrings and tests changed since.Measured on GB200 at
f577fc05af0with 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.torch.cuda.max_memory_allocated()afterreset_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.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.Re-measured on GB200 at
239260c7d8dwith 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 atf577fc05af0(docstrings differ) and is unchanged at2470bc4a4f7. Same 32.0 GiB state dict; rank 0 is shown, and all ranks were near-identical._materialize_owned's bound of about twice the owned set.ru_maxrss.2026-10-29: merges [2/3]'s review round (
46e42e10a74, rebased onto [OMNIML-6082] [1/3] Let a reverse conversion record a merge rule no key matched #3100'sb9fbb242300). 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.pyandtest_meta_dcp_load.pypass (149).2026-10-29, later (
2470bc4a4f7, test only):test_the_index_goes_in_place_only_after_every_shardpins 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 since239260c7d8d.test_distributed_writer.pypasses (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'smodel-NNNNN-of-NNNNN.safetensorsfiles the new index does not name, and amodel.safetensors, which transformers loads ahead of the index. The replacement test runs over an earlier one-shard, three-shard and single-file checkpoint, with aconfig.jsonand 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 amodel.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.pypasses (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.pypasses (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)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, ormodel-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 of285c30bf08e, which is removed with its tests; the staged.tmpwrites 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 aconfig.jsonand an auxiliary weights file, and fails if the pattern also matches the auxiliary file.test_distributed_writer.pypasses (49), and pre-commit passes against main. Source lines: 270.2026-10-29, later: merges [2/3] rebased onto main
2b900c8fa41with 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.py2026-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 [OMNIML-6082] [2/3] Move each tensor of a sharded model whole to one owner rank #3101's squash
4bef85279e4after 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. [OMNIML-6082] [1/3] Let a reverse conversion record a merge rule no key matched #3100's and [OMNIML-6082] [2/3] Move each tensor of a sharded model whole to one owner rank #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.pypasses (49).Rebased on 2026-10-30 onto main
99c3834f62c(after [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099 and [OMNIML-6081] [2/2] Fold fused experts' quantizers into their weights shard-locally #2750 merged, and [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #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"
CONTRIBUTING.md: N/AAdditional Information
Part 15 of 21 of expert-parallel PTQ for Hugging Face MoE models; its [1/3] #3100 and [2/3] #3101 have merged.
Summary by CodeRabbit