Skip to content
Merged
274 changes: 274 additions & 0 deletions modelopt/torch/export/distributed_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,59 @@
replicated across ranks.
"""

import json
import math
import os
import re
import warnings
from collections import Counter
from pathlib import Path

import torch
import torch.distributed as dist
import torch.nn as nn
from safetensors.torch import save_file
from torch.distributed.tensor import DTensor

from modelopt.torch.utils.distributed import all_reduce

from .quant_aware_conversion import (
_QUANT_STATE_SUFFIXES,
QuantConversionUnsupportedError,
_build_reverse_rules,
revert_weight_conversion_quant_aware,
)
from .unified_export_hf_streaming import _parse_shard_size

# A checkpoint's files: its weights, sharded as save_pretrained names them or in one file, and index.
_CHECKPOINT_FILES = re.compile(r"model(-\d{5}-of-\d{5})?\.safetensors(\.index\.json)?")


def _even_bins(keys: list, sizes: dict, n: int, max_bytes: int) -> list:
"""Partition ``keys`` into ~``n`` EVEN-sized bins (by bytes), each <= ``max_bytes`` where possible.

Longest-processing-time greedy (largest item -> lightest bin) balances the bins, so N files come out
~``total/N`` each rather than (N-1) full files + a small remainder -- ``max_shard_size`` is an UPPER
BOUND, not the target. ``n`` counts the bins for the tensors within the bound, and is bumped (up to
one file per tensor) if a lumpy item pushes a bin over it. A tensor larger than ``max_bytes`` gets
a file of its own on top, as in HF, and the rest still pack around it.
Returns the non-empty bins (``list[list[key]]``).
"""
oversized = [[k] for k in keys if sizes[k] > max_bytes]
fits = [k for k in keys if sizes[k] <= max_bytes]
n = max(1, min(n, len(fits))) if fits else 1
ordered = sorted(fits, key=lambda k: -sizes[k])
while True:
bins: list = [[] for _ in range(n)]
loads = [0] * n
for k in ordered:
j = min(range(n), key=lambda i: loads[i])
bins[j].append(k)
loads[j] += sizes[k]
if n >= len(fits) or max(loads) <= max_bytes:
return oversized + [b for b in bins if b]
n += 1


def _nbytes(v) -> int:
"""Byte size. DTensor.numel() is GLOBAL, so this is consistent across ranks."""
Expand Down Expand Up @@ -359,3 +408,228 @@ def _materialize_owned(
# collective above runs on Gloo where the group has it: every rank initializes NCCL here.
dist.all_reduce(torch.zeros(1, device=local_sd[shard0[0]].to_local().device))
_gather_shard0_to_owners(local_sd, shard0, owner, all_shapes, rank, world)


def _merge_groups(model: nn.Module, keys) -> list:
"""The keys the model's reverse conversion merges into one tensor, one list per merged tensor.

A merge needs its whole group on one rank, so :func:`_materialize_owned` keeps each together.
A weight's quantization state goes with it: the merge refuses a quantized weight only where it
sees its scales, which a weight-specific pattern does not match. A mapping that cannot be
reversed has none: its reverse falls back to the in-memory names anyway. Groups are matched on
the names before the reverse splits run: a merge whose sources a split produces is whole on one
rank only because a split's outputs stay with the fused key they come from.
"""
Comment on lines +414 to +422

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] _merge_groups matches rename_source_key against the pre-split in-memory names, but apply_reverse_rules runs _apply_split_rule before the merge rules (quant_aware_conversion.py:359-363), and a split pops module + fused_suffix + leaf and inserts module + part + leaf for every leaf in _LEAF_SUFFIXES. So the keys _apply_merge_rule actually scans are not always the keys this function grouped:

  • a merge source that only exists after a split forms no group here, so it is not co-owned;
  • conversely, a fused key grouped here may no longer exist when the merge runs (harmless — co-owning a key that disappears costs nothing).

The first case is not a live bug today, because a split's outputs land on the owner of the fused key, so a merge over split outputs of the same module is still whole on one rank. It only bites for a merge whose sources are split outputs of different modules, and then the outcome is the safe one: _apply_merge_rule raises "incomplete merge" on the partial owner and all_reduce turns that into the global in-memory-name fallback — degraded, not wrong.

Worth one clause in the docstring so the next reader does not have to re-derive it, e.g. after "A mapping that cannot be reversed has none":

Groups are matched on the pre-split names; a merge whose sources a split produces is co-owned only because a split's outputs stay with the fused key they came from.

That way the ordering dependence between this function and apply_reverse_rules is recorded where it would be violated.

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.

Taken in 620537d: _merge_groups' docstring now says groups are matched on the names before the reverse splits run; a merge over split outputs stays whole on one rank only because a split's outputs stay with the fused key they come from.

try:
merge_rules = _build_reverse_rules(model)[1]
except QuantConversionUnsupportedError:
return []
key_set = set(keys)
groups: dict = {}
for i, rule in enumerate(merge_rules):
for k in keys:
target, source_pattern = rule.converter.rename_source_key(k)
if source_pattern is None:
continue
group = groups.setdefault((i, target), [])
group.append(k)
if k.endswith(".weight"):
module = k.removesuffix(".weight")
group += [
module + leaf for leaf in _QUANT_STATE_SUFFIXES if module + leaf in key_set
]
return list(groups.values())
Comment on lines +428 to +441

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.

[CRITICAL Export] A merge group's companion quant-state keys are not kept with it, so the quantized-merge refusal that the single-process path makes is silently bypassed and the export can ship a half-merged checkpoint.

_apply_merge_rule refuses a merge whose weight carries quantization state, and it decides that from the dict it is handed:

if key.endswith(".weight"):
    module = key.removesuffix(".weight")
    if any(module + leaf in state_dict for leaf in _QUANT_STATE_SUFFIXES):
        raise QuantConversionUnsupportedError(
            f"cannot merge quantization state for '{module}'; ..."
        )

_merge_groups collects only the keys for which rename_source_key returns a non-None source_pattern. Those patterns are weight-specific (attn.qkv.weight), which is exactly why _apply_rename_rules and the guard above have to derive a scale's module path from its weight — q_proj.weight_scale does not match, so it is not in any returned group. _materialize_owned therefore makes (q_proj.weight, k_proj.weight, v_proj.weight) one unit, while q_proj.weight_scale / k_proj.weight_scale / v_proj.weight_scale stay separate units and get load-balanced onto whichever ranks are lightest — scales are tiny and the weights are big, so they will normally land elsewhere.

Tracing the two paths for a quantized model with a Concatenate-reverse converter:

  • single process: all six keys are in state_dict → module + ".weight_scale" is present → QuantConversionUnsupportedError → _revert_whole_sd warns and the whole dict keeps its in-memory names. Consistent checkpoint.
  • distributed: the owner of the three weights has no *.weight_scale in local_sd → the guard never fires → torch.cat merges them into attn.qkv.weight. The ranks holding the scales see source_pattern is None for them, so they are skipped entirely and keep their in-memory names. all_reduce sees no failure, _write_even_shards's collision check sees no duplicate name, and the index gets blk.attn.qkv.weight alongside blk.attn.q_proj.weight_scale, k_proj.weight_scale, v_proj.weight_scale.

That is the one outcome the rest of this module's refusal paths are built to prevent: a checkpoint that loads and is quietly wrong (merged weight, three orphaned per-projection scales, no scale for the merged tensor). test_revert_whole_sd_merges_a_group_ownership_keeps_on_one_rank uses unquantized weights only, so it passes either way.

Minimal fix — pull each .weight source's quant-state siblings into the same group, so the guard sees them and the existing all_reduce turns its raise into the same fallback the single-process path takes:

    from .quant_aware_conversion import (
        _QUANT_STATE_SUFFIXES,
        QuantConversionUnsupportedError,
        _build_reverse_rules,
    )

    try:
        merge_rules = _build_reverse_rules(model)[1]
    except QuantConversionUnsupportedError:
        return []
    keys = set(keys)
    groups: dict = {}
    for i, rule in enumerate(merge_rules):
        for k in keys:
            target, source_pattern = rule.converter.rename_source_key(k)
            if source_pattern is None:
                continue
            group = groups.setdefault((i, target), [])
            group.append(k)
            # A merge of a quantized weight is refused, but only where the weight's scales are
            # visible: keep them with it so the owner refuses rather than merging the weight and
            # leaving the scales under their in-memory names on another rank.
            if k.endswith(".weight"):
                module = k.removesuffix(".weight")
                group.extend(module + leaf for leaf in _QUANT_STATE_SUFFIXES if module + leaf in keys)
    return list(groups.values())

Worth a test that would fail without it: the existing _merge_group_job with a weight_scale added next to each projection, asserting _revert_whole_sd returns False and the gathered names are the unmerged originals on every rank.

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.

Traced on two gloo ranks with each of q, k and v carrying a weight_scale: ownership puts the three weights on one rank and the scales on the other, and the scales' rank refuses on its own (cannot merge quantization state into 'blk.attn.qkv.weight_scale'), since the quant-aware reverse derives a scale's merge target from its weight's pattern. The reverse is atomic across the ranks, so every rank keeps its in-memory names and no half-merged checkpoint is written. The two-rank merge test now has this quantized case, pinning that the export falls back on every rank.

Comment on lines +413 to +441

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.

[IMPORTANT Correctness] A quantized merge group can slip past the refusal when the merge pattern does not match the scale keys, so the distributed path writes a merged quantized weight with its scales left un-merged — a layout the single-process path refuses.

What happens. _merge_groups puts into a group only the keys for which rename_source_key returns a non-None pattern. For a weight-specific pattern the companion quant-state keys are not among them — this file's own sibling module says so explicitly, with an anchored example: quant_aware_conversion.py:317 ("Weight-specific patterns (e.g. ^head\.weight$) need not match scale keys"), which is also why _apply_merge_rule carries the separate module + leaf in state_dict check at quant_aware_conversion.py:209-216 instead of relying on the scales showing up in groups.

So for such a rule the scales are not in together, each becomes its own unit, and ownership places them by size on whichever rank is lightest — generally not the weight's owner. Then neither guard fires:

  • the weight's owner runs any(module + leaf in state_dict ...) against its own slice, which no longer holds the scales → no refusal, and torch.cat merges the three projections;
  • the scales' holder has no key matching the rule for that target, so it never reaches the target_key.endswith(_QUANT_STATE_SUFFIXES) guard either (that guard only fires when the scales themselves were matched and formed a ...weight_scale target group).

The result is a checkpoint carrying attn.qkv.weight next to attn.q_proj.weight_scale / k_proj / v_proj — names that no longer describe the tensor they scale. _revert_whole_sd returns True, so nothing warns.

Why the quantized test does not catch it. In _merge_group_job the pattern is the unanchored "attn.qkv.weight", which does match ...q_proj.weight_scale, so the scales form their own attn.qkv.weight_scale target group and their holder raises from the _QUANT_STATE_SUFFIXES target check — exactly the "refused by the rank holding its scales" path the PR description describes. The invariant therefore rests on transformers' matching being unanchored for that one converter, not on anything _merge_groups does. Swap the pattern for an anchored one and the same case merges silently.

Suggested fix — make the co-ownership explicit, so the existing local sibling check is always evaluated on the rank that merges:

def _merge_groups(model: nn.Module, keys) -> list:
    """..."""
    from .quant_aware_conversion import (
        _QUANT_STATE_SUFFIXES,
        QuantConversionUnsupportedError,
        _build_reverse_rules,
    )

    try:
        merge_rules = _build_reverse_rules(model)[1]
    except QuantConversionUnsupportedError:
        return []
    key_set = set(keys)
    groups: dict = {}
    for i, rule in enumerate(merge_rules):
        for k in keys:
            target, source_pattern = rule.converter.rename_source_key(k)
            if source_pattern is None:
                continue
            group = groups.setdefault((i, target), [])
            group.append(k)
            # A weight-specific pattern need not match the module's quant state
            # (quant_aware_conversion._sub_scoped), and _apply_merge_rule refuses such a group
            # only from the rank that holds both: keep the scales with their weight.
            if k.endswith(".weight"):
                module = k.removesuffix(".weight")
                group.extend(module + leaf for leaf in _QUANT_STATE_SUFFIXES if module + leaf in key_set)
    return list(groups.values())

The extra keys only affect ownership — _apply_merge_rule still groups by pattern match — so the unquantized path is unchanged, and a quantized group is now refused on its owner whatever the pattern's anchoring. A test with an anchored source pattern (r"^blk\.attn\.qkv\.weight$", or whatever shape _build_reverse_rules emits for one) over the existing quantized case should fail before the fix and pass after.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. _merge_groups now puts each matched weight's quantization state (weight_scale, weight_scale_2, weight_scale_inv, input_scale, those present) in the weight's group, so ownership keeps the scales on the rank that merges, and _apply_merge_rule's sibling check refuses there whatever the pattern's anchoring. The two-rank merge test gains an anchored case: the same quantized q/k/v with the reverse's patterns ending in $, which do not match the scales. It also checks that the rank holding the group holds the weights' scales. Without the change the anchored case merges silently (no warning, True), and with it both quantized cases refuse on every rank.



def _revert_whole_sd(model: nn.Module, local_sd: dict) -> bool:
"""Reverse the transformers conversion mapping on this rank's whole tensors, in place.

Each rank reverts the disjoint subset it owns. Every value is a whole plain tensor by now, so no
placement handling is needed. Renames and splits act on one tensor at a time, so a subset is
all they need. A merge needs its whole group, which ownership keeps on one rank
(:func:`_merge_groups`); a rank holding none of a rule's groups skips the rule, and a rule no
rank matches is refused, as the single-process path refuses one. Best-effort and atomic across
the ranks, mirroring the single-process path, which keeps the in-memory names for the whole dict
on any failure. If the reverse fails on any rank, every rank keeps them, rather than writing a
checkpoint that mixes the two. Collective.
"""
Comment on lines +444 to +455

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.

[IMPORTANT Export] For any model whose conversion mapping carries a merge rule, this reverse pass will fail essentially every time — not occasionally — and the failure silently downgrades the whole checkpoint's names.

The chain: apply_reverse_rules (quant_aware_conversion.py:349) iterates for merge_rule in merge_rules or [] and calls _apply_merge_rule on every rank's subset. _apply_merge_rule raises QuantConversionUnsupportedError both when the rank holds no key the rule matches (if not groups, L218) and when any matched group is incomplete (missing, L226). Owner assignment above (L381-385) is largest-first onto the lightest rank, so a merge group's sources land on different ranks by construction: with GQA, q_proj / k_proj / v_proj differ in size and go to different ranks; equal-sized siblings (gate_proj / up_proj) go to successive lightest ranks, which is also different ranks. So missing fires on at least one rank for world > 1, all_reduce turns that into a failure on every rank, and the whole distributed checkpoint ships with post-conversion in-memory names behind a single warnings.warn.

Why it matters: the gather-based export succeeds at the same merge, so the no-gather path produces a checkpoint with different tensor names for the same model — and for unquantized merge groups (norms, embeddings, excluded BF16 layers in a PTQ run, a hub-fused QKV left in BF16) there is nothing intrinsically unsupported about the merge; it is purely an artifact of ownership splitting the group. One such group anywhere degrades all names, since the fallback is global. (Quantized merge groups already raise in both paths — L207-210 — so the divergence is specifically the unquantized ones.)

Suggestion: keep a merge group on one rank rather than accept the fallback. The merge target key is derivable from each rule's converter, so the owner loop can assign by group key (all sources of one group → one owner) before falling back to per-key assignment. If that is deliberately deferred to a later PR, say so here and make the degradation loud and specific — e.g. detect merge_rules up front and name the group that split, instead of a generic "names may not match" warning that is indistinguishable from a genuinely unreversible mapping.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed: ownership now places each merge group on one rank (_merge_groups, passed to _materialize_owned). A rank's reverse records a merge rule it matches nothing of instead of refusing it, and a rule that no rank matches is still refused, as the gather export refuses it. A two-rank gloo test with a fused-qkv Chunk converter, whose q/k/v would otherwise split over the ranks key by key, now merges to attn.qkv.weight on one rank (test_revert_whole_sd_merges_a_group_ownership_keeps_on_one_rank).

reverted = local_sd

def revert() -> tuple[str | None, list]:
nonlocal reverted
unmatched: list = []
try:
reverted = revert_weight_conversion_quant_aware(
model, local_sd, unmatched_merges=unmatched
)
except Exception as exc:
# An exception may carry no message: name its type, so the warning says something.
return str(exc) or type(exc).__name__, []
return None, unmatched

def combine(results: list) -> list:

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

failures = [
f"rank {r}: {error}" for r, (error, _) in enumerate(results) if error is not None
]
return failures or sorted(set.intersection(*(set(unmatched) for _, unmatched in results)))

failures = all_reduce("reverse weight conversion", revert, combine)
if failures:
warnings.warn(
f"Quant-aware reverse weight conversion skipped ({failures[0]}); exported tensor "
"names may not match the original HF hub checkpoint."
)
return False
# revert_weight_conversion_quant_aware returns the INPUT dict itself when the model has no
# reverse rules (a dense model with no conversion mapping). Clearing then updating would then
# read from the dict just emptied and wipe the state dict, so only rebuild on a fresh object.
if reverted is not local_sd:
local_sd.clear()
local_sd.update(reverted)
return True
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


def _in_rank_order(values: list[list]) -> list:
"""``all_reduce``'s combine that keeps every rank's value: each rank's own is wrapped in a list."""
return [value for (value,) in values]


def _write_even_shards(
local_sd: dict, export_dir: "str | Path", max_shard_size: "str | int", rank: int, world: int
) -> None:
"""Write this rank's whole tensors as HF safetensors shards; rank 0 writes the index.

Each rank splits its tensors into even shards, numbered into one global range. Runs after the
reverse conversion, whose renames and splits fix the names and sizes packed here.

Every file is written under a temporary name and renamed into place only once every rank has
written all of its own, the index last. It never overwrites a checkpoint: a directory that already
holds one is refused on every rank before anything is written. A failure removes the temporary
files, but one during the final renames leaves the shards already renamed, which a retry into the
directory is refused over until they are removed. Each step a rank can fail alone runs in
:func:`~modelopt.torch.utils.distributed.all_reduce`, which raises on every rank when one fails.

Every name in ``local_sd`` is written, two names for one tensor included: the caller drops tied
weights by name beforehand, as the streaming export drops ``_tied_weights_keys``.
"""
export_dir = Path(export_dir)
max_bytes = _parse_shard_size(max_shard_size)

write_keys = sorted(local_sd)
sizes = {k: _nbytes(local_sd[k]) for k in write_keys}
# Tensors over the bound get files of their own, so only the rest decide how many to aim for.
fits = sum(size for size in sizes.values() if size <= max_bytes)
my_files = _even_bins(write_keys, sizes, max(1, math.ceil(fits / max_bytes)), max_bytes)

def plan_mine() -> list:
# In the first collective, which carries one rank's failure to every rank: its filesystem can
# fail to create the directory, or show a checkpoint there, which the export never overwrites.
export_dir.mkdir(parents=True, exist_ok=True)
existing = sorted(
p.name for p in export_dir.iterdir() if _CHECKPOINT_FILES.fullmatch(p.name)
)
if existing:
raise FileExistsError(
f"{export_dir} already holds a checkpoint ({', '.join(existing[:3])}), which the "
"distributed export does not overwrite; remove those files (an export that failed "
"during its final renames can leave some behind) or export to another directory."
)
return [(len(my_files), write_keys)]

planned = all_reduce("shard plan", plan_mine, _in_rank_order)
# The reverse conversion checks for rename collisions, but only within one rank's slice. A
# collision ACROSS ranks would be silently swallowed by the index's dict update and ship a
# checkpoint quietly missing a tensor, so every rank refuses it here, before any file is written.
names = Counter(k for _, keys in planned for k in keys)
collisions = sorted(k for k, n in names.items() if n > 1)
if collisions:
raise RuntimeError(
f"tensor name collision across ranks: {len(collisions)} name(s) held by more than one "
f"rank; first few: {collisions[:5]}"
)
counts = [n for n, _ in planned]
n_total = sum(counts)
if n_total == 0:
raise RuntimeError(
"distributed export produced no shards on any rank: every tensor was dropped "
"before the write (state dict emptied upstream)"
)
base = sum(counts[:rank])
staged: list = [] # this rank's files, each written as <name>.tmp until the commit
index_name = "model.safetensors.index.json"

def stage(name: str) -> Path:
# Recorded before the write: one that fails midway leaves a partial file behind.
staged.append(name)
return export_dir / f"{name}.tmp"

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
def write_mine() -> tuple[dict, int]:
weight_map: dict = {}
my_bytes = 0
for i, keys in enumerate(my_files):
fname = f"model-{base + i + 1:05d}-of-{n_total:05d}.safetensors"
path = stage(fname)
tensors: dict = {}
ptrs: set = set()
for k in keys:
t = local_sd[k].detach().to("cpu").contiguous()
# Once copied this rank has no further use for its own tensor. The writer never
# allocates on the device, so dropping it here does not lower the peak (the owned
# set at entry); it frees the tensor now instead of after the whole file is written.
del local_sd[k]
# 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())
Comment on lines +580 to +585

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

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.

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.

tensors[k] = t
Comment on lines +575 to +586

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] The alias guard keys on the tensor pointer, but save_file groups by the storage pointer, so two different-offset views of one storage still reach it and still get refused.

safetensors.torch._find_shared_tensors buckets on (device, storage_ptr(v), storage_size(v)) — v.untyped_storage().data_ptr(), not v.data_ptr() — and _filter_shared_not_shared only drops a name from a bucket when _is_complete(v) (offset 0 and covering the whole storage). Two halves of one buffer are each incomplete, so both stay in the bucket and _flatten raises Some tensors share memory.

The guard here misses that case: for a, b = buf.chunk(2) both are contiguous and already on CPU, so .to("cpu").contiguous() is a no-op pair, a.data_ptr() != b.data_ptr(), nothing is cloned, and the save_file call on line 586 raises.

That layout is specifically what this writer is documented to receive: _materialize_owned's docstring says experts arrive plain "each rank split its own shard", and a chunk/narrow/slice split returns views into the fused storage unless the splitter clones. plain dense keys pass through untouched too. Everything that goes through _gather_shard0_to_owners or the replicated/empty0 branches is a fresh allocation, so only these two inputs are exposed.

One line covers both, and subsumes the exact-pointer case:

                t = local_sd[k].detach().to("cpu").contiguous()
                # save_file refuses tensors sharing a STORAGE, not just a data_ptr: it buckets on
                # untyped_storage().data_ptr() and keeps any tensor that does not cover its whole
                # storage. Neither call above copies a CPU-contiguous tensor, so two names for one
                # tensor, and two views of one buffer, both arrive sharing storage. Copy rather
                # than drop one: every name has to reach the index.
                storage = t.untyped_storage()
                if storage.data_ptr() in ptrs or t.data_ptr() != storage.data_ptr() or (
                    t.numel() * t.element_size() != storage.size()
                ):
                    t = t.clone()
                ptrs.add(t.untyped_storage().data_ptr())

unified_export_hf_streaming._StreamingShardWriter carries the same data_ptr() convention, so this is a pre-existing gap rather than a regression — but that writer buffers whole tensors it allocated itself, whereas this one is handed whatever the expert split produced, which is where the gap turns into a failure. Non-blocking (nothing calls this yet, and it fails loudly rather than corrupting), but worth closing before #2752 wires in the expert path.

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.

save_file does not refuse non-overlapping views of one storage: _find_shared_tensors buckets the tensors by storage, then _filter_shared_not_shared splits each bucket by byte range, so only overlapping tensors stay together and fail with Some tensors share memory; _is_complete belongs to save_model's _remove_duplicate_names, which save_file does not call. Checked with safetensors 0.8.0 (our test environments), and in the source of 0.4.3, the floor transformers 5.5 requires: the halves of buf.chunk(2), and the four row views w[i] of one (4, 6) buffer, save and load back unchanged; only overlapping views such as buf[:5] and buf[3:] are refused. Two names for one tensor overlap, and that is the case the guard copies.

save_file(tensors, str(path), metadata={"format": "pt"})
for k in keys:
weight_map[k] = fname
my_bytes += sum(_nbytes(t) for t in tensors.values())
del tensors
return weight_map, my_bytes

def write_index() -> bool:
if rank == 0:
index = {"metadata": {"total_size": total_bytes}, "weight_map": full_map}
with open(stage(index_name), "w") as f:
json.dump(index, f, indent=2)
return True

def commit(names: list) -> bool:
for name in names:
os.replace(export_dir / f"{name}.tmp", export_dir / name)
return True

# A failed export removes the temporary files it wrote, and only those. What was written is gone
# from ``local_sd``, so a retry needs the state dict built again.
try:
gathered = all_reduce("shards written", lambda: [write_mine()], _in_rank_order)
full_map: dict = {}
total_bytes = 0
for m, b in gathered:
full_map.update(m)
total_bytes += b
all_reduce("index written", write_index, all)
# Every file is written: only now does the directory change, the index last, so no index
# names a shard that is not in place. Also the closing barrier.
all_reduce("shards committed", lambda: commit([n for n in staged if n != index_name]), all)

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.

Bot comment.

[P1] Preserve the previous checkpoint through commit failures, not just staging failures. commit() overwrites final shard paths with os.replace, but the exception handler removes only .tmp files. In the newly added _shard_commit_fails_job, rank 0 replaces model-00001-of-00002.safetensors with a file containing rank0.weight, while rank 1's rename fails. The unchanged old index still requests old.a from rank 0's shard, where that tensor no longer exists. test_the_index_goes_in_place_only_after_every_shard passes because it checks only the index bytes. An index-rename failure likewise leaves old metadata pointing at replaced shards.

Could publication use generation-unique shard filenames and an index swap, or preserve/restore overwritten files? Extend the existing failure table through shard and index commit failures and assert the entire previous checkpoint survives byte-for-byte. Ordering the index last does not by itself make overwriting its referenced shards safe.

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.

You're right that a rename failing partway through the final commit can leave an existing checkpoint mixed, and that my new test checks only the index. I'm not changing the code for it. Instead, _write_even_shards's docstring now states the exact guarantee (285c30b): a failure while writing leaves a checkpoint already there untouched, but a rename failing partway through that final commit can leave it mixed.

Why it stops there:

  • The final step is a series of renames within one directory (os.replace), and it runs only after every rank has written all of its files. A failure there is a filesystem error, not a write error.
  • Main's single-process export, save_pretrained, deletes the old shards before it writes the new ones, so it gives no such guarantee at all.
  • Full safety would need per-export shard names, so that the old shards stay intact until the index is swapped. That gives up HF's standard model-NNNNN-of-NNNNN naming.

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.

Changed in d9bf5aa: the writer now refuses to write over a checkpoint, which replaces the documented limit.

  • What is refused. Before anything is written, _write_even_shards refuses on every rank a directory that holds model.safetensors, model.safetensors.index.json or model-NNNNN-of-NNNNN.safetensors files. The error names the files and says to remove them or export to another directory.
  • Decided collectively. The check runs in the first collective (shard plan), so a rank whose own view of the directory shows a checkpoint fails the other ranks too.
  • What is left alone. Other files are not touched and don't stop the export: a config.json, or an auxiliary weights file such as mtp.safetensors.
  • The guarantee. The docstring now says the writer never overwrites a checkpoint. A commit failure can no longer mix one, because there is none in the directory to mix.

Tests:

  • Two ranks, with an earlier checkpoint as one shard, three shards or a single model.safetensors, visible to rank 1 only. Both ranks refuse, and the checkpoint is left byte for byte. All three cases fail without the refusal.
  • An export into a directory holding only a config.json and an auxiliary weights file succeeds and leaves both unchanged. This test fails if the pattern also matches the auxiliary file.

The stale-file cleanup from 285c30b is gone, along with its tests: the earlier cleanup thread is superseded, since an export never writes over an earlier checkpoint now. The staged .tmp writes stay.

all_reduce("index committed", lambda: commit([n for n in staged if n == index_name]), all)

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.

Bot comment.

[P2] A successful re-export does not retire the previous checkpoint's other weight files. The replacement test covers only the same one-shard filename. Re-export with a different shard count leaves every old model-*-of-N.safetensors behind; even this test module's _load_checkpoint then fails its index/file agreement assertion. More importantly, re-export over a normal single-file checkpoint leaves model.safetensors untouched alongside the new index. The repo's hf_checkpoint_utils.load_multimodal_components explicitly prefers that single file over the index, so it reloads old tensors after this writer reports success. _StreamingShardWriter.finalize can produce exactly that single-file starting layout.

Retire obsolete main-checkpoint artifacts as part of a successful, preservation-safe publication (without deleting genuine off-index auxiliary weights). Extend replacement coverage to different shard counts and a pre-existing model.safetensors, and verify consumers see only the newly exported weights.

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.

Done in 285c30b. Once every rank has committed, which means "index committed" succeeded everywhere, rank 0 removes two kinds of file:

  • the model-NNNNN-of-NNNNN.safetensors files that the new index does not name, which is the pattern save_pretrained removes;
  • a model.safetensors, which transformers loads ahead of the index.

Nothing else is touched: other files and off-index weight files stay. Nothing is removed on a failure path, because the removal runs only after the commit, as a step of its own. A failure in that step raises on every rank, like the writer's other steps.

Tests:

  • The replacement test now runs over an earlier one-shard, three-shard and single-file checkpoint, with a config.json and an off-index mtp.safetensors beside it.
  • It checks that the index-based load returns only the new tensors, and that no other weight file of the checkpoint is left, model.safetensors included. It also checks that the directory then holds just the new checkpoint and those two files, unchanged.
  • The three-shard and single-file cases fail without the removal.
  • The failure tests' existing checkpoint now includes a model.safetensors, so a removal on a failure path would fail them. I checked this by running the removal in the failure handler.

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.

Superseded by d9bf5aa. The writer now 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). Since no earlier checkpoint is ever written over, it leaves nothing stale behind, and the post-commit removal from 285c30b is gone with its tests. Details are in the commit-failure thread.

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:
Comment on lines +619 to +628

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] 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]}"
            )
        raise

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

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

left.append(f"{name}.tmp")
if left:
warnings.warn(
f"rank {rank}: could not remove {len(left)} temporary file(s) the failed export "
f"wrote to {export_dir}: {left[:5]}"
)
raise
Comment on lines +620 to +635

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] If an unlink in the cleanup loop raises, it replaces the error being propagated. missing_ok=True covers the common case, but an OSError from the filesystem that just failed the write (read-only remount, EIO, a stale NFS handle, permissions) is exactly the plausible one here — and the error it would displace is the whole point of the all_reduce machinery: the one that names the failing rank and its message, identically on every rank. A peer would then report OSError: [Errno 30] Read-only file system: 'model-00002-of-00003.safetensors' for its own cleanup rather than "rank 1: disk full", losing the diagnosis the three _one_rank_fails_job cases are built around.

Best-effort cleanup keeps the original error:

    except BaseException:
        for fname in written:
            with contextlib.suppress(OSError):
                (export_dir / fname).unlink(missing_ok=True)
        raise

with import contextlib at module scope.

🤖 Generated with Claude Code

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.

Taken in 559dd88: the cleanup suppresses an OSError from unlink, so the error naming the failing rank is the one raised. A one-rank test fails the write with "disk full" and the cleanup with "read-only file system"; without the change it raises the cleanup's error and fails.

Comment on lines +620 to +635

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] The suppress(OSError) is the right call for which error propagates, but it also makes a failed cleanup completely invisible — and a surviving shard is exactly what this block exists to prevent.

The reasoning in the comment is sound: a cleanup OSError must not displace the RuntimeError that names the rank which actually failed, and test_a_failed_write_keeps_its_error_when_the_cleanup_fails_too pins that. But the two failures are not the same event. A read-only mount, an NFS stale handle, or a concurrent rm -rf can fail the unlink while the write failed for an unrelated reason on another rank — and then the directory keeps model-000NN-of-000MM.safetensors with no index. Per the comment at 605-607, the point of the removal is that "a retry into the directory would otherwise inherit shards no index names, numbered for a split that may not recur"; when the unlink silently fails, the user retries into exactly that state with nothing having told them the directory is not clean, and the inherited shard's name is one a retry with a different n_total will not overwrite.

Collecting the paths that survived and warning once keeps the raised error intact while making the dirty directory visible:

    except BaseException:
        left: list = []
        for fname in written:
            # 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 record it, do not raise it.
            try:
                (export_dir / fname).unlink(missing_ok=True)
            except OSError:
                left.append(fname)
        if left:
            warnings.warn(
                f"rank {rank}: could not remove {len(left)} file(s) of the failed export; "
                f"a retry into {export_dir} would inherit them -- remove the directory first. "
                f"First few: {left[:5]}"
            )
        raise

warnings is already imported, and this needs no extra collective (the per-rank written lists are disjoint, so each rank reports only its own). Non-blocking — it needs the cleanup to fail as well as the write.

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.

Taken in 3e402e9: the cleanup collects the files it could not remove and names them in one warning (how many, the directory, the first five), and the write's error is still the one raised. The two-failure test now also requires the warning, and fails without the change.

Comment on lines +630 to +635

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION] This warnings.warn can itself raise, and then it displaces the error the trailing raise exists to deliver — the exact property last round's contextlib.suppress(OSError) around the unlink was added to protect.

Under warnings-as-errors (python -W error, -W error::UserWarning, or a filterwarnings = error pytest config in a downstream repo — this repo sets none, so nothing in CI catches it), warnings.warn raises UserWarning from inside the except block. That propagates in place of the pending exception, so a peer rank's RuntimeError: rank 1: ... disk full becomes a bare UserWarning about leftover files, and the rank that actually failed is no longer named. The naming is the whole point of routing the write through the failure-carrying all_reduce.

The unlink is already guarded for the same reason; the warn deserves the same treatment:

        if left:
            # The warning must not displace the pending error either (-W error).
            with contextlib.suppress(Exception):
                warnings.warn(
                    f"rank {rank}: could not remove {len(left)} file(s) the failed export wrote "
                    f"to {export_dir}; remove the directory before retrying into it: {left[:5]}"
                )
        raise

(needs import contextlib at module scope). Non-blocking — the default warning filter makes this a no-op — but it closes the last path by which the cleanup can swallow the rank-naming error.

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.

Keeping the warning as is. Under -W error the UserWarning raised in the except block carries the write's error as its __context__, and the traceback prints that error first, naming the failed rank, then 'During handling of the above exception, another exception occurred' and the warning (checked with python -W error: a RuntimeError: rank 1 failed to compute 'shards written': ... followed by the cleanup's warnings.warn prints both, the RuntimeError first). -W error asks for that warning to fail loudly; suppressing it would hide the files a retry has to remove. It also takes a second failure, an unlink the filesystem refuses, on top of the first.

29 changes: 28 additions & 1 deletion tests/gpu/torch/export/test_distributed_writer_gpu.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@

"""The no-gather writer's transport and shard writing over NCCL, on every visible GPU."""

import json
import os
import threading

import pytest
import torch
from _test_utils.torch.distributed.utils import spawn_multiprocess_job
from safetensors.torch import load_file
from torch.distributed.device_mesh import init_device_mesh
from torch.distributed.tensor import DTensor, Replicate, Shard

from modelopt.torch.export.distributed_writer import _materialize_owned
from modelopt.torch.export.distributed_writer import _materialize_owned, _write_even_shards


def _raw_bytes(tensor):
Expand Down Expand Up @@ -94,6 +96,31 @@ def test_materialize_owned_moves_uneven_and_empty_shards_over_nccl(need_2_gpus,
dist_workers.run(_materialize_job)


def _materialize_and_write_job(rank, world_size, tmp_dir):
full = _full_tensors(world_size)
sd = _sharded(full, rank, world_size)

_materialize_owned(sd, set(), rank, world_size)
# 256 bytes fits two of the 4x16 BF16 layers: an owner of several writes several files.
_write_even_shards(sd, tmp_dir, "256", rank, world_size)

assert not sd
index = json.loads((tmp_dir / "model.safetensors.index.json").read_text())
loaded = {}
for shard in sorted(set(index["weight_map"].values())):
loaded.update(load_file(str(tmp_dir / shard)))
assert sorted(loaded) == sorted(full)
for key, tensor in full.items():
assert torch.equal(_raw_bytes(loaded[key]), _raw_bytes(tensor)), key
assert not list(tmp_dir.glob("*.tmp"))


def test_write_even_shards_writes_every_rank_s_files_over_nccl(need_2_gpus, dist_workers, tmp_path):
"""Moved to their owners and written there, several files per rank, the checkpoint holds every
tensor byte for byte, and no temporary file is left."""
dist_workers.run(_materialize_and_write_job, tmp_dir=tmp_path)


def _sparse_first_transfer_job(rank, world_size):
# A rank stuck in NCCL's initialization exits, so the test fails instead of hanging.
watchdog = threading.Timer(60, os._exit, args=(3,))
Expand Down
Loading
Loading