Repository navigation
[OMNIML-6082] [3/3] Write a sharded model's HF checkpoint shards from each tensor's owner rank #2751
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[OMNIML-6082] [3/3] Write a sharded model's HF checkpoint shards from each tensor's owner rank #2751
Changes from all commits
1ce245a
1cd9e40
ecd92b6
edbe49c
c666ea5
f796006
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.""" | ||
|
|
@@ -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. | ||
| """ | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
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}'; ..."
)
Tracing the two paths for a quantized model with a
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). Minimal fix — pull each 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Comment on lines
+413
to
+441
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. So for such a rule the scales are not in
The result is a checkpoint carrying Why the 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 —
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. |
||
|
|
||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: ownership now places each merge group 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[P2] Detect reverse failures independently of their message.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 03ae6f5; you were right.
|
||
| 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 | ||
|
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" | ||
|
|
||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggestion: no code change here, just make the constraint explicit so the entry point owns it — e.g. extend the comment to
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 55b62f6. The no-gather entry point in #2752 already does this. It builds a |
||
| tensors[k] = t | ||
|
Comment on lines
+575
to
+586
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] The alias guard keys on the tensor pointer, but
The guard here misses that case: for That layout is specifically what this writer is documented to receive: 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())
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[P1] Preserve the previous checkpoint through commit failures, not just staging failures. 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Why it stops there:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Tests:
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 |
||
| all_reduce("index committed", lambda: commit([n for n in staged if n == index_name]), all) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
[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 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] The cleanup unlinks only Walk the path Why it matters: those leftovers match Unlike the stale-file removal dropped in 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Your reasoning is right: |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] If an Best-effort cleanup keeps the original error: except BaseException:
for fname in written:
with contextlib.suppress(OSError):
(export_dir / fname).unlink(missing_ok=True)
raisewith 🤖 Generated with Claude Code
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Taken in 559dd88: the cleanup suppresses an
Comment on lines
+620
to
+635
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] The The reasoning in the comment is sound: a cleanup 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] This Under warnings-as-errors ( The 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keeping the warning as is. Under |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[SUGGESTION]
_merge_groupsmatchesrename_source_keyagainst the pre-split in-memory names, butapply_reverse_rulesruns_apply_split_rulebefore the merge rules (quant_aware_conversion.py:359-363), and a split popsmodule + fused_suffix + leafand insertsmodule + part + leaffor every leaf in_LEAF_SUFFIXES. So the keys_apply_merge_ruleactually scans are not always the keys this function grouped: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_ruleraises "incomplete merge" on the partial owner andall_reduceturns 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":
That way the ordering dependence between this function and
apply_reverse_rulesis recorded where it would be violated.There was a problem hiding this comment.
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.