Fix RamTorch prefetch-order save race - #2988
Conversation
|
but is_main_process wouldn't happen to save it on each node in multi-node training runs. can you verify that? |
There was a problem hiding this comment.
Pull request overview
Fixes a distributed checkpoint-time race by ensuring the shared RamTorch prefetch-order file is written only by the main process, after is_main_process is determined inside SaveHookManager.save_model_hook.
Changes:
- Move
StateTracker.save_ramtorch_prefetch_orders(output_dir)to afteris_main_processresolution and guard it withif is_main_process:. - Add a unit test covering
is_main_process=True/Falsebehavior for the RamTorch prefetch-order save call.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
simpletuner/helpers/training/save_hooks.py |
Guards the shared RamTorch prefetch-order write so only the main process performs it, eliminating the multi-rank temp-file rename race. |
tests/test_save_hooks.py |
Adds a regression test asserting the prefetch-order file write occurs only on the main process. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Confirmed — with How this was measured. No multi-node cluster was involved: 8 Python processes stand in for 2 nodes x 4 ranks. "Node-local storage" is modelled as one output directory per node (ranks 0-3 write to Results, and what they mean for the question:
If each node should keep its own copy: gating the write on Reproduction script and raw outputRun with """Multi-node simulation for the is_main_process question on PR #2988."""
import json
import multiprocessing as mp
import sys
import tempfile
from pathlib import Path
N_NODES = 2
LOCAL_WORLD = 4
ITERS = 50
def _unique_tmp_save(state_tracker, directory, rank):
"""The proposed writer: process-unique temp name + atomic rename."""
path = state_tracker._ramtorch_prefetch_order_path(directory)
data = state_tracker._normalise_ramtorch_prefetch_orders(state_tracker.ramtorch_prefetch_orders)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(f"{path.suffix}.tmp.{rank}")
with tmp.open("w") as handle:
json.dump(data, handle)
tmp.replace(path)
def worker(rank, node_dirs, gate, unique_tmp, barrier, errq):
from simpletuner.helpers.training.state_tracker import StateTracker
node = rank // LOCAL_WORLD
local_rank = rank % LOCAL_WORLD
outdir = node_dirs[node]
allowed = {
"all": True, # main today: every rank writes
"global_main": rank == 0, # this PR: is_main_process
"local_main": local_rank == 0, # proposed: is_local_main_process
}[gate]
errors = 0
for _ in range(ITERS):
barrier.wait()
if not allowed:
continue
try:
if unique_tmp:
_unique_tmp_save(StateTracker, outdir, rank)
else:
StateTracker.save_ramtorch_prefetch_orders(outdir)
except FileNotFoundError:
errors += 1
errq.put((rank, node, errors))
def run_case(name, gate, unique_tmp, shared_fs):
base = Path(tempfile.mkdtemp(prefix=f"probe-{name}-"))
node_dirs = [base / "shared"] * N_NODES if shared_fs else [base / f"node{n}" for n in range(N_NODES)]
for d in node_dirs:
d.mkdir(parents=True, exist_ok=True)
world = N_NODES * LOCAL_WORLD
barrier = mp.Barrier(world)
errq = mp.Queue()
procs = [mp.Process(target=worker, args=(r, node_dirs, gate, unique_tmp, barrier, errq)) for r in range(world)]
for p in procs:
p.start()
for p in procs:
p.join()
per_node_errors = {}
for _ in range(world):
rank, node, errors = errq.get()
per_node_errors[node] = per_node_errors.get(node, 0) + errors
files = {
("shared" if shared_fs else f"node{n}"): (node_dirs[n] / "ramtorch_prefetch_orders.json").exists()
for n in range(N_NODES)
}
print(f"=== {name} gate={gate} unique_tmp={unique_tmp} {'shared-FS' if shared_fs else 'node-local'} ===")
print(f" FileNotFoundError per node : {per_node_errors}")
print(f" file present after run : {files}")
return node_dirs
def main():
mp.set_start_method("spawn")
run_case("case1-main-today", gate="all", unique_tmp=False, shared_fs=False)
node_dirs = run_case("case2-this-pr", gate="global_main", unique_tmp=False, shared_fs=False)
from simpletuner.helpers.training.state_tracker import StateTracker
StateTracker.reset_ramtorch_prefetch_orders()
loaded = StateTracker.load_ramtorch_prefetch_orders(node_dirs[1])
print(f" resume on node1 (no file) : load returned {loaded!r} (no exception)")
run_case("case3-local-main", gate="local_main", unique_tmp=True, shared_fs=False)
run_case("case4-local-main-sharedfs", gate="local_main", unique_tmp=True, shared_fs=True)
if __name__ == "__main__":
main()Output (error counts vary run to run; this is one run verbatim): Note |
|
yeah let's be sure multi-node training isn't broken by any PRs submitted, thank you |
Gate the shared prefetch-order write on is_local_main_process instead of is_main_process, so the file also lands on node-local storage in multi-node runs. Give the writer a process-unique temp filename so concurrent node-mains on a shared filesystem cannot race on the rename.
|
Pushed Regression tests added: a non-zero node's local main ( |
Summary
SaveHookManager.save_model_hookwrites the shared RamTorch prefetch-order file beforeis_main_processis resolved:save_training_statewrites a rank-specific filename (training_state-rank{rank}.json,save_hooks.py:330-334) and is correct as-is. The prefetch orders are one shared file percheckpoint directory, written by
StateTracker.save_ramtorch_prefetch_orders(
state_tracker.py:402-416):There is an
flockhere, which is why this looks safe at a glance — but it does not make theoperation atomic. The temp name contains no rank, so every rank opens the same path with
"w"and truncates whatever the others wrote; the lock is released before the handle closes; and the
replace()is outside the lock entirely. Whichever process reachesreplace()after the first hasalready renamed the temp file away finds nothing there and raises
FileNotFoundError.This PR makes each node's local main process the owner of the write
(
is_local_main_process), so the file also lands on node-local storage in multi-node runs, andgives the writer a process-unique temp filename (
.tmp.{pid}+ atomic rename) so concurrentnode-mains on a shared filesystem cannot race on the rename. (The first commit gated the write on
the global main process; the second widened it to one writer per node after the multi-node
discussion below.)
Which configurations are affected
Plain DDP is not affected. The only entry point to the registered save-state pre-hook is
accelerator.save_stateattrainer.py:5982, and its callers gate onis_main_process or use_deepspeed_optimizer or fsdp_enable(trainer.py:5251) andis_main_process or use_deepspeed_optimizerfor the rolling path (trainer.py:6824). Under DDPonly rank 0 ever reaches
save_model_hook, so there is nothing to race. The defect is reachablewith
use_deepspeed_optimizer=trueorfsdp_enable=true, where every rank saves.Who fails
Not just the non-main ranks. Measured over 60 barrier-synchronised iterations, rank 0 raised on
44 of 60 — statistically indistinguishable from ranks 1-7 (41-50 of 60). The loser is whichever
process calls
replace()second, regardless of rank.What the failure costs
Measured: 8 concurrent processes calling
StateTracker.save_ramtorch_prefetch_ordersagainst ashared directory produced 1206
FileNotFoundErrorout of 1600 calls, all atstate_tracker.py:416.That the whole job goes down is reasoned, not measured: accelerate invokes save-state pre-hooks
with no
try/except, and theaccelerator.save_statecall attrainer.py:5982sits outside anyenclosing
tryincheckpoint_state_save(trainer.py:5933). The function contains twotryblocks and both are closed before that line —
:5958-5966catchesExceptionaround debug-loggingof the registered model list, and
:5978-5981catchesFileNotFoundErroraround removal of thecheckpoint guard file, inside the
is_main_processbranch. Neither wraps the save, so theexception propagates out of the training loop on whichever rank raised it. The observed
non-determinism is consistent with an intermittent hard failure at checkpoint time.
Changes
StateTracker.save_training_state()call exactly where it is.is_local_main_processbefore writing the shared RamTorch prefetch-order file, and callStateTracker.save_ramtorch_prefetch_orders()only from each node's local main process — onewriter per node, so multi-node runs with node-local
output_dirkeep a copy on every node..tmp.{pid}), making the finalreplace()anatomic last-writer-wins rename even when two node-mains share a filesystem.
is_main_process=False/is_local_main_process=Truecase that models a non-zero node), per-rank training-state writes,and a two-process barrier-synchronised concurrency test against the real writer.
Notes
restore paths are unchanged, as is the checkpoint format.
transition statistics), so last-writer-wins does not change behaviour.
concurrent CPU processes against the real
StateTrackerwriter; the multi-node layouts weresimulated the same way (see the review thread for the full script and measurements).
Verification
CPU-only container, current
main(2d6df1245).Commit 1 (
25cd2460, single-writer gate) — RED ismainplus this branch's test file withsave_hooks.pyrestored frommain:Commit 2 (
2891e79d, per-node writer + unique temp) — RED is the previous head25cd2460plus the new test file, i.e. the tests are pinned to this commit's production delta:
The two RED failures are exactly the tests that encode this commit:
test_ramtorch_prefetch_orders_ignore_global_main_flag(a non-zero node's local main must write)and
test_concurrent_writers_do_not_race(two real processes, 30 barrier-synchronised rounds —red with the shared temp name, green with the process-unique one). Driving the production writer
through the 2-node simulation from the review thread: 0 errors, the file present on every node
(node-local layout) and valid JSON with no leftover temp files (shared layout).
The one error present on both trees is environmental —
ModuleNotFoundError: No module named 'torchaudio'intests.test_acestep_lora_targets— and is unrelated to this change.