Download code/tests/test_control_plane.py from viai957/align: direct link, hf CLI and curl.
- Browser
- Download file 39.4 kB
-
https://huggingface.co/viai957/align/resolve/main/code/tests/test_control_plane.py
- Command line
-
hf download hf://viai957/align/code/tests/test_control_plane.py
-
curl -L -o test_control_plane.py https://huggingface.co/viai957/align/resolve/main/code/tests/test_control_plane.py
39.4 kB
| """G0 control plane (single process): coordinated stop, rank-reduced HALT, | |
| skip caps, process-group timeout + flight recorder env, checkpoint durability | |
| and completeness, RNG + pin persistence. | |
| The collective halves of these properties -- "a signal / HALT / NaN loss on ONE | |
| rank acts on BOTH" -- need two ranks and live in tests/test_trainer_dist.py. | |
| What is pinned here is everything a single process can see: the decisions | |
| themselves, the on-disk protocol, and the helpers' contracts. | |
| Run: .venv/bin/python -m pytest tests/test_control_plane.py -q -m "not gpu and not oracle" -p no:cacheprovider | |
| """ | |
| from __future__ import annotations | |
| import datetime | |
| import logging | |
| import os | |
| import random | |
| import signal | |
| import threading | |
| import numpy as np | |
| import pytest | |
| import torch | |
| from test_model_parity import tiny_config | |
| from test_trainer import _tiny_trainer | |
| import align.train.trainer as trainer_mod | |
| import align.utils.dist as udist | |
| from align.train.probes import TripwireResult | |
| from align.train.trainer import HaltError, LossOutput, checkpoint_problem | |
| def tiny(tiny_model_constants): | |
| return tiny_config(tiny_model_constants) | |
| def _cfg(**train_attrs): | |
| """cfg_hook that sets TrainConfig attributes the Trainer reads in __init__ | |
| (they are read with getattr, so they need not exist on the dataclass).""" | |
| def hook(cfg): | |
| for k, v in train_attrs.items(): | |
| setattr(cfg.train, k, v) | |
| return hook | |
| def _signal_at(micro: int, signum=signal.SIGTERM, also_at=()): | |
| """wrap_adapter that delivers a REAL signal to this process on the | |
| `micro`-th adapter call (one adapter call == one micro-step).""" | |
| def wrap(inner): | |
| n = {"i": 0} | |
| def adapter(m, batch): | |
| n["i"] += 1 | |
| if n["i"] == micro or n["i"] in also_at: | |
| os.kill(os.getpid(), signum) | |
| return inner(m, batch) | |
| return adapter | |
| return wrap | |
| def _loss_transform(fn): | |
| """wrap_adapter that rewrites each micro's loss: fn(micro_index, loss).""" | |
| def wrap(inner): | |
| n = {"i": 0} | |
| def adapter(m, batch): | |
| n["i"] += 1 | |
| out = inner(m, batch) | |
| return LossOutput(loss=fn(n["i"], out.loss), n_tokens=out.n_tokens, metrics=out.metrics) | |
| return adapter | |
| return wrap | |
| def _weights(trainer) -> dict: | |
| return {k: v.detach().clone() for k, v in trainer.model.state_dict().items()} | |
| def _same(a: dict, b: dict) -> bool: | |
| return a.keys() == b.keys() and all(torch.equal(a[k], b[k]) for k in a) | |
| # --------------------------------------------------------------------------- | |
| # 1. coordinated stop: signal + wall clock | |
| # --------------------------------------------------------------------------- | |
| def test_sigterm_mid_window_stops_at_the_boundary_and_resumes_bit_exact(tiny, tmp_workdir): | |
| """SIGTERM-MID-SAVE / MID-WINDOW (REFERENCE_GAP_ANALYSIS gap 1). | |
| The signal arrives at micro 3: the first micro of the second accumulation | |
| window (accum 2). The run must NOT stop there -- a checkpoint holds no | |
| gradients, so a mid-window save resumes into a different run. It stops at | |
| micro 4, the boundary, with stop_reason "signal", and writes a checkpoint | |
| that a fresh process resumes from to weights BIT-EQUAL to the run nobody | |
| interrupted. save_every is 8, so the micro-4 checkpoint can only exist | |
| because the stop wrote it. | |
| """ | |
| ref, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "ref"), save_every_micro_steps=8) | |
| ref.train() | |
| want = _weights(ref) | |
| run = os.path.join(tmp_workdir, "run") | |
| t, _ = _tiny_trainer( | |
| tiny, run, save_every_micro_steps=8, wrap_adapter=_signal_at(3) | |
| ) | |
| summary = t.train() | |
| assert summary["stop_reason"] == "signal", summary | |
| assert summary["micro_step"] == 4 and summary["optimizer_step"] == 2, summary | |
| assert summary["checkpoints_written"] == [4], summary | |
| assert os.listdir(os.path.join(run, "checkpoints")) == ["micro_00000004"] | |
| assert checkpoint_problem(os.path.join(run, "checkpoints", "micro_00000004")) is None | |
| t2, _ = _tiny_trainer(tiny, run, save_every_micro_steps=8) | |
| assert t2.resume_from_latest().endswith("micro_00000004") | |
| assert t2.micro_step == 4 | |
| # vacuity guard: the stopped run really was behind the finished one | |
| assert not _same(_weights(t2), want) | |
| t2.train() | |
| assert _same(_weights(t2), want), "resume from a signal stop diverged from the uninterrupted run" | |
| def test_signal_handlers_are_restored_after_train_and_after_an_exception(tiny, tmp_workdir): | |
| """Handlers live only for the duration of `train()`: a Trainer that leaked | |
| its SIGTERM handler would swallow the NEXT stage's signals (stage scripts | |
| run several trainers in one process).""" | |
| before = {s: signal.getsignal(s) for s in (signal.SIGTERM, signal.SIGINT)} | |
| t, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "a")) | |
| t.train() | |
| assert {s: signal.getsignal(s) for s in before} == before | |
| def boom(inner): | |
| def adapter(m, batch): | |
| raise ValueError("adapter failed") | |
| return adapter | |
| t2, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "b"), wrap_adapter=boom) | |
| with pytest.raises(ValueError, match="adapter failed"): | |
| t2.train() | |
| assert {s: signal.getsignal(s) for s in before} == before | |
| def test_handler_is_installed_during_train_and_only_sets_a_flag(tiny, tmp_workdir): | |
| """While train() runs, SIGTERM is OURS (flag only) -- not the default | |
| action that kills the process wherever it is.""" | |
| seen = {} | |
| def wrap(inner): | |
| def adapter(m, batch): | |
| seen.setdefault("handler", signal.getsignal(signal.SIGTERM)) | |
| return inner(m, batch) | |
| return adapter | |
| t, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "a"), wrap_adapter=wrap) | |
| t.train() | |
| assert seen["handler"] not in (signal.SIG_DFL, signal.SIG_IGN, None) | |
| assert getattr(seen["handler"], "__self__", None) is t._stop_signals | |
| def test_second_sigint_escapes_and_leaves_no_partial_checkpoint(tiny, tmp_workdir): | |
| """The first SIGINT is honoured at the boundary; a SECOND one raises | |
| KeyboardInterrupt so an operator is never locked out by a slow stop. The | |
| unwind must not leave a `.partial` that looks resumable.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| t, _ = _tiny_trainer( | |
| tiny, run, wrap_adapter=_signal_at(1, signal.SIGINT, also_at=(2,)), save_every_micro_steps=8 | |
| ) | |
| with pytest.raises(KeyboardInterrupt): | |
| t.train() | |
| assert not [d for d in os.listdir(os.path.join(run, "checkpoints")) if d.endswith(".partial")] | |
| assert signal.getsignal(signal.SIGINT) is signal.default_int_handler | |
| def test_a_stop_request_wins_over_max_micro_steps(tiny, tmp_workdir): | |
| """A capped run normally writes NO end checkpoint (probes must not spend | |
| minutes gathering 14GB). A SIGTERM during one must still save: otherwise | |
| the window's work is discarded, which is the defect.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| t, _ = _tiny_trainer( | |
| tiny, run, save_every_micro_steps=8, max_micro_steps=4, wrap_adapter=_signal_at(3) | |
| ) | |
| summary = t.train() | |
| assert summary["stop_reason"] == "signal" and summary["micro_step"] == 4 | |
| assert os.listdir(os.path.join(run, "checkpoints")) == ["micro_00000004"] | |
| def test_a_capped_run_without_a_signal_still_writes_no_end_checkpoint(tiny, tmp_workdir): | |
| """The other direction: the stop-save must not leak into ordinary capped | |
| runs (the probe-artifact rule in train()).""" | |
| run = os.path.join(tmp_workdir, "run") | |
| t, _ = _tiny_trainer(tiny, run, save_every_micro_steps=8, max_micro_steps=4) | |
| assert t.train()["stop_reason"] == "max_micro_steps" | |
| assert os.listdir(os.path.join(run, "checkpoints")) == [] | |
| def test_wall_clock_limit_stops_at_the_first_boundary_and_saves(tiny, tmp_workdir): | |
| """`train.max_wall_seconds`: a 1ns limit has expired by the first boundary | |
| (micro 2); the stop is at that boundary, never at micro 1.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| t, _ = _tiny_trainer( | |
| tiny, run, save_every_micro_steps=8, cfg_hook=_cfg(max_wall_seconds=1e-9) | |
| ) | |
| summary = t.train() | |
| assert summary["stop_reason"] == "wall_clock", summary | |
| assert summary["micro_step"] == 2 and summary["optimizer_step"] == 1, summary | |
| assert os.listdir(os.path.join(run, "checkpoints")) == ["micro_00000002"] | |
| def test_wall_clock_generous_limit_and_default_do_not_stop_the_run(tiny, tmp_workdir): | |
| for name, hook in (("none", None), ("big", _cfg(max_wall_seconds=3600.0))): | |
| t, _ = _tiny_trainer( | |
| tiny, os.path.join(tmp_workdir, name), save_every_micro_steps=8, cfg_hook=hook | |
| ) | |
| summary = t.train() | |
| assert summary["stop_reason"] == "data_exhausted" and summary["micro_step"] == 8, summary | |
| def test_wall_clock_limit_must_be_positive(tiny, tmp_workdir, bad): | |
| with pytest.raises(ValueError, match="max_wall_seconds"): | |
| _tiny_trainer(tiny, os.path.join(tmp_workdir, "a"), cfg_hook=_cfg(max_wall_seconds=bad)) | |
| def test_train_off_the_main_thread_runs_without_handlers(tiny, tmp_workdir, caplog): | |
| """signal.signal() raises ValueError off the main thread; a harness that | |
| runs the trainer in a worker thread must degrade (loudly), not crash.""" | |
| t, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "a")) | |
| box = {} | |
| with caplog.at_level(logging.WARNING, logger="align.train.trainer"): | |
| th = threading.Thread(target=lambda: box.update(summary=t.train())) | |
| th.start() | |
| th.join(120) | |
| assert box["summary"]["micro_step"] == 8 | |
| assert "cannot be installed" in caplog.text | |
| assert t._stop_signals.installed is False | |
| # --------------------------------------------------------------------------- | |
| # 2. rank-reduced HALT (the multi-rank half is in test_trainer_dist.py) | |
| # --------------------------------------------------------------------------- | |
| class _Halting: | |
| HALT = True | |
| INPUT_KEYS: tuple = () | |
| def __init__(self, at): | |
| self.at, self.n = at, 0 | |
| def update(self, _x): | |
| self.n += 1 | |
| return TripwireResult(self.n >= self.at, "stop now", value=3.5) | |
| def test_halt_tripwire_raises_haltError_with_the_original_message(tiny, tmp_workdir): | |
| """Single process: the HALT is raised exactly as before (RuntimeError, same | |
| text) -- the rank-reduction must not change what an operator reads.""" | |
| t, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "a")) | |
| t.tripwires = {"grad_norm": _Halting(at=3)} | |
| with pytest.raises(RuntimeError, match=r"HALT tripwire 'grad_norm' fired at micro_step 3: stop now \(value=3.5\)") as ei: | |
| t.train() | |
| assert isinstance(ei.value, HaltError) | |
| assert t.tripwire_alarms[-1]["alarm"] == "grad_norm" | |
| def test_agree_or_raise_peer_failure_makes_a_healthy_rank_raise(tiny, tmp_workdir, monkeypatch): | |
| """The reduction itself, simulated: THIS rank is healthy, rank 1 reports a | |
| failure. Without the reduction this rank would carry on into the next | |
| collective alone.""" | |
| t, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "a")) | |
| monkeypatch.setattr(trainer_mod, "_dist_world_size", lambda: 2) | |
| monkeypatch.setattr(udist, "any_rank", lambda flag: True) # a peer said yes | |
| monkeypatch.setattr( | |
| trainer_mod, "all_gather_objects", lambda obj: [None, ("HaltError: boom on rank 1", 80)] | |
| ) | |
| with pytest.raises(HaltError, match=r"\[rank 1\] HaltError: boom on rank 1.*rank-reduced during x") as ei: | |
| t._agree_or_raise(None, "x") | |
| assert type(ei.value) is HaltError and ei.value.exit_code == 80 | |
| def test_agree_or_raise_keeps_the_original_exception_on_the_failing_rank(tiny, tmp_workdir, monkeypatch): | |
| """A non-HALT failure (a KeyError from a missing metric) is re-raised AS | |
| ITSELF where it happened -- the traceback an engineer needs -- while peers | |
| get the HaltError summary.""" | |
| t, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "a")) | |
| monkeypatch.setattr(trainer_mod, "_dist_world_size", lambda: 2) | |
| monkeypatch.setattr(udist, "any_rank", lambda flag: True) | |
| err = KeyError("rewards/margins") | |
| monkeypatch.setattr(trainer_mod, "all_gather_objects", lambda obj: [obj, None]) | |
| with pytest.raises(KeyError): | |
| t._agree_or_raise(err, "tripwire check") | |
| def test_agree_or_raise_is_silent_when_no_rank_failed(tiny, tmp_workdir, monkeypatch): | |
| t, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "a")) | |
| monkeypatch.setattr(trainer_mod, "_dist_world_size", lambda: 2) | |
| monkeypatch.setattr(udist, "any_rank", lambda flag: False) | |
| t._agree_or_raise(None, "x") # must not raise | |
| def test_a_missing_tripwire_input_still_fails_loudly(tiny, tmp_workdir): | |
| """The 'KeyError surfaces' contract of _check_tripwires survives the | |
| reduction wrapper (single process re-raises the original).""" | |
| t, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, "a"), armed=("logps_chosen",)) | |
| t.tripwires["logps_chosen"].INPUT_KEYS = () # let the arm-time test pass | |
| with pytest.raises(KeyError): | |
| t.train() | |
| # --------------------------------------------------------------------------- | |
| # 3. skip caps | |
| # --------------------------------------------------------------------------- | |
| NAN = float("nan") | |
| def _nan_grads_for(micros): | |
| """Loss * NaN on the listed micros: NaN loss AND NaN grads (the grad gate | |
| skips the step).""" | |
| return _loss_transform(lambda i, loss: loss * NAN if i in micros else loss) | |
| def test_consecutive_skipped_steps_beyond_the_cap_halt_the_run(tiny, tmp_workdir): | |
| """THE SILENT-SKIP RUN. Micros 1-8 are four consecutive NaN windows. The | |
| default cap is 3 CONSECUTIVE, so the fourth is the one that halts -- at its | |
| boundary (micro 8), with a message naming the knob.""" | |
| t, _ = _tiny_trainer( | |
| tiny, os.path.join(tmp_workdir, "a"), n_batches=12, wrap_adapter=_nan_grads_for(range(1, 9)) | |
| ) | |
| with pytest.raises(HaltError, match=r"4 consecutive bad optimizer steps exceed train\.max_consecutive_skipped_steps=3"): | |
| t.train() | |
| assert t.micro_step == 8 | |
| assert t.train_module.skipped_steps == 4, "vacuous: the grad gate did not skip" | |
| assert t.tripwire_alarms[-1]["alarm"] == "skip_cap" | |
| def test_exactly_the_cap_is_allowed_and_a_good_step_resets_the_streak(tiny, tmp_workdir): | |
| """3 bad windows (== cap) then a good one then 3 more: never more than 3 in | |
| a row, so no halt. Guards the off-by-one in both directions.""" | |
| bad = set(range(1, 7)) | set(range(9, 15)) # windows 1-3 and 5-7 bad, 4 good | |
| t, _ = _tiny_trainer( | |
| tiny, os.path.join(tmp_workdir, "a"), n_batches=10, epochs=2, wrap_adapter=_nan_grads_for(bad), | |
| save_every_micro_steps=100, max_micro_steps=16, | |
| ) | |
| t.train() | |
| assert t.skip_stats["bad_total"] == 6 and t.skip_stats["consecutive"] == 0 | |
| assert t.skip_stats["boundaries"] == 8 | |
| def test_non_finite_loss_counts_even_when_the_gradients_are_finite(tiny, tmp_workdir): | |
| """'Non-finite loss must also count, not only grads.' `loss + nan` has a | |
| NaN value and an UNCHANGED, finite gradient: the optimizer applies every | |
| step (skipped_steps stays 0), so only the loss check can see it.""" | |
| t, _ = _tiny_trainer( | |
| tiny, | |
| os.path.join(tmp_workdir, "a"), | |
| n_batches=12, | |
| wrap_adapter=_loss_transform(lambda i, loss: loss + NAN), | |
| ) | |
| with pytest.raises(HaltError, match="non-finite loss"): | |
| t.train() | |
| assert t.train_module.skipped_steps == 0, "vacuous: the grad gate fired, not the loss check" | |
| assert t.optimizer_step == 4 and t.micro_step == 8 | |
| def test_one_non_finite_micro_in_a_window_marks_the_whole_step_bad(tiny, tmp_workdir): | |
| t, _ = _tiny_trainer( | |
| tiny, | |
| os.path.join(tmp_workdir, "a"), | |
| wrap_adapter=_loss_transform(lambda i, loss: loss + NAN if i == 3 else loss), | |
| save_every_micro_steps=100, | |
| max_micro_steps=4, | |
| ) | |
| t.train() | |
| assert t.skip_stats == {"consecutive": 1, "bad_total": 1, "boundaries": 2} | |
| assert t.train_module.skipped_steps == 0 | |
| def _health(tiny, tmp_workdir, name, **cfg_attrs): | |
| t, _ = _tiny_trainer(tiny, os.path.join(tmp_workdir, name), cfg_hook=_cfg(**cfg_attrs)) | |
| return t | |
| def test_fractional_cap_waits_for_100_steps_then_halts_above_5_percent(tiny, tmp_workdir): | |
| """>5% bad, evaluated only once 100 boundaries exist. Driven through | |
| `_record_step_health` (the decision) with a pattern that is never more than | |
| one bad step in a row, so the consecutive cap cannot be what fires.""" | |
| t = _health(tiny, tmp_workdir, "frac") | |
| # 3 bad of the first 10 = 30%: far above 5%, but under the minimum sample | |
| for i in range(10): | |
| t._record_step_health(False, i in (1, 5, 8)) | |
| assert t.skip_stats["bad_total"] == 3 # no halt yet | |
| # 90 more good steps: boundary 100, 3/100 = 3% -> still fine | |
| for _ in range(89): | |
| t._record_step_health(False, False) | |
| t._record_step_health(False, False) | |
| assert t.skip_stats["boundaries"] == 100 | |
| # push to 6/106 > 5% | |
| with pytest.raises(HaltError, match=r"train\.max_skipped_fraction=0\.05"): | |
| for i in range(10): | |
| t._record_step_health(False, i % 2 == 0) | |
| def test_fractional_cap_exactly_at_the_threshold_does_not_halt(tiny, tmp_workdir): | |
| t = _health(tiny, tmp_workdir, "edge") | |
| for i in range(100): # 5 of 100 = 5.0%: not ABOVE the cap | |
| t._record_step_health(False, i % 20 == 3) | |
| assert t.skip_stats == {"consecutive": 0, "bad_total": 5, "boundaries": 100} | |
| def test_fractional_cap_halts_at_the_hundredth_step_when_already_above(tiny, tmp_workdir): | |
| t = _health(tiny, tmp_workdir, "fire") | |
| with pytest.raises(HaltError, match="10 of 100 optimizer steps were bad"): | |
| for i in range(100): | |
| t._record_step_health(False, i % 10 == 0) | |
| assert t.skip_stats["boundaries"] == 100 | |
| def test_skip_caps_can_be_overridden_or_disabled_by_config(tiny, tmp_workdir): | |
| t = _health(tiny, tmp_workdir, "off", max_consecutive_skipped_steps=None, max_skipped_fraction=None) | |
| for _ in range(300): | |
| t._record_step_health(True, True) # never halts | |
| t2 = _health(tiny, tmp_workdir, "tight", max_consecutive_skipped_steps=0) | |
| with pytest.raises(HaltError, match=r"1 consecutive bad optimizer steps exceed .*=0"): | |
| t2._record_step_health(False, True) | |
| t3 = _health(tiny, tmp_workdir, "default") | |
| assert (t3.max_consecutive_skipped, t3.max_skipped_fraction) == (3, 0.05) | |
| def test_skip_cap_config_is_validated(tiny, tmp_workdir, attrs): | |
| with pytest.raises(ValueError): | |
| _health(tiny, tmp_workdir, "bad", **attrs) | |
| # --------------------------------------------------------------------------- | |
| # 4. process-group timeout + NCCL flight recorder | |
| # --------------------------------------------------------------------------- | |
| def test_pg_timeout_default_and_env_override(): | |
| assert udist.pg_timeout_s({}) == 1800.0 | |
| assert udist.pg_timeout_s({"ALIGN_PG_TIMEOUT_S": "90"}) == 90.0 | |
| assert udist.pg_timeout_s({"ALIGN_PG_TIMEOUT_S": " "}) == 1800.0 | |
| def test_pg_timeout_rejects_values_that_would_silently_disarm_the_watchdog(raw): | |
| with pytest.raises(ValueError, match="ALIGN_PG_TIMEOUT_S"): | |
| udist.pg_timeout_s({"ALIGN_PG_TIMEOUT_S": raw}) | |
| def _isolate_env(monkeypatch, *names): | |
| """Make monkeypatch restore these env vars (to absent) at teardown, so what | |
| `init_distributed` exports does not leak into later tests.""" | |
| for n in names: | |
| monkeypatch.setenv(n, "placeholder") | |
| monkeypatch.delenv(n) | |
| FR_VARS = ( | |
| "TORCH_FR_BUFFER_SIZE", | |
| "TORCH_NCCL_TRACE_BUFFER_SIZE", | |
| "TORCH_NCCL_DUMP_ON_TIMEOUT", | |
| "TORCH_FR_DUMP_TEMP_FILE", | |
| "TORCH_NCCL_DEBUG_INFO_TEMP_FILE", | |
| ) | |
| def test_init_distributed_passes_an_explicit_timeout(monkeypatch): | |
| import torch.distributed as dist | |
| calls = [] | |
| monkeypatch.setenv("WORLD_SIZE", "2") | |
| monkeypatch.setenv("ALIGN_PG_TIMEOUT_S", "77") | |
| monkeypatch.setattr(dist, "is_initialized", lambda: False) | |
| monkeypatch.setattr(dist, "init_process_group", lambda **kw: calls.append(kw)) | |
| udist.init_distributed(backend="gloo") | |
| assert calls == [{"backend": "gloo", "timeout": datetime.timedelta(seconds=77)}] | |
| # explicit argument beats the environment | |
| calls.clear() | |
| udist.init_distributed(backend="gloo", timeout_s=5) | |
| assert calls[0]["timeout"] == datetime.timedelta(seconds=5) | |
| # the default (no env) is 30 minutes, not torch's backend-dependent one | |
| calls.clear() | |
| monkeypatch.delenv("ALIGN_PG_TIMEOUT_S") | |
| udist.init_distributed(backend="gloo") | |
| assert calls[0]["timeout"] == datetime.timedelta(minutes=30) | |
| def test_init_distributed_nccl_arms_the_flight_recorder_before_init(monkeypatch, tmp_workdir): | |
| import torch.distributed as dist | |
| _isolate_env(monkeypatch, *FR_VARS) | |
| order = [] | |
| monkeypatch.setenv("WORLD_SIZE", "2") | |
| monkeypatch.setattr(dist, "is_initialized", lambda: False) | |
| monkeypatch.setattr( | |
| dist, | |
| "init_process_group", | |
| lambda **kw: order.append(("init", os.environ.get("TORCH_NCCL_DUMP_ON_TIMEOUT"))), | |
| ) | |
| monkeypatch.setattr(torch.cuda, "is_available", lambda: False) | |
| udist.init_distributed(backend="nccl", run_dir=tmp_workdir) | |
| # the env was already set when init_process_group ran | |
| assert order == [("init", "1")] | |
| def test_gloo_init_does_not_touch_the_nccl_environment(monkeypatch): | |
| import torch.distributed as dist | |
| _isolate_env(monkeypatch, *FR_VARS) | |
| monkeypatch.setenv("WORLD_SIZE", "2") | |
| monkeypatch.setattr(dist, "is_initialized", lambda: False) | |
| monkeypatch.setattr(dist, "init_process_group", lambda **kw: None) | |
| udist.init_distributed(backend="gloo") | |
| assert not [v for v in FR_VARS if v in os.environ] | |
| def test_flight_recorder_env_defaults_live_under_the_run_dir_and_never_override(tmp_workdir): | |
| env: dict = {} | |
| applied = udist.configure_nccl_diagnostics(tmp_workdir, environ=env, torch_version="2.11.0+cu128") | |
| assert env["TORCH_FR_BUFFER_SIZE"] == "20000" | |
| assert "TORCH_NCCL_TRACE_BUFFER_SIZE" not in env | |
| assert env["TORCH_NCCL_DUMP_ON_TIMEOUT"] == "1" | |
| prefix = env["TORCH_FR_DUMP_TEMP_FILE"] | |
| assert prefix.startswith(os.path.join(tmp_workdir, "nccl_trace") + os.sep), prefix | |
| assert env["TORCH_NCCL_DEBUG_INFO_TEMP_FILE"] == prefix | |
| assert os.path.isdir(os.path.dirname(prefix)), "the dump directory must exist before a timeout" | |
| assert not prefix.startswith("/tmp"), "the torch default (/tmp/nccl_trace_rank_) is forbidden here" | |
| assert set(applied) == set(FR_VARS) - {"TORCH_NCCL_TRACE_BUFFER_SIZE"} | |
| keep = {"TORCH_FR_BUFFER_SIZE": "5", "TORCH_NCCL_DUMP_ON_TIMEOUT": "0"} | |
| env2 = dict(keep) | |
| udist.configure_nccl_diagnostics(tmp_workdir, environ=env2, torch_version="2.11.0") | |
| assert {k: env2[k] for k in keep} == keep, "an operator's explicit export must win" | |
| def test_flight_recorder_variable_names_exist_in_the_installed_torch(): | |
| """The names are not guesses (CLAUDE.md: verify against the installed | |
| torch). If a torch upgrade renames one, this fails instead of the recorder | |
| going silently dark.""" | |
| import mmap | |
| root = os.path.dirname(torch.__file__) | |
| hpp = os.path.join(root, "include/torch/csrc/distributed/c10d/ProcessGroupNCCL.hpp") | |
| lib = os.path.join(root, "lib/libtorch_cpu.so") | |
| if not (os.path.isfile(hpp) and os.path.isfile(lib)): | |
| pytest.skip("torch headers / libtorch_cpu.so not shipped in this install") | |
| header = open(hpp).read() | |
| for name in ("TORCH_NCCL_TRACE_BUFFER_SIZE", "TORCH_NCCL_DUMP_ON_TIMEOUT"): | |
| assert name in header, name | |
| with open(lib, "rb") as f, mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm: | |
| for name in ("TORCH_FR_DUMP_TEMP_FILE", "TORCH_NCCL_DEBUG_INFO_TEMP_FILE"): | |
| assert mm.find(name.encode()) != -1, name | |
| # --------------------------------------------------------------------------- | |
| # 5. checkpoint durability + completeness | |
| # --------------------------------------------------------------------------- | |
| def _two_checkpoints(tiny, run_dir, **kw): | |
| t, _ = _tiny_trainer(tiny, run_dir, save_every_micro_steps=4, **kw) | |
| t.train() | |
| root = os.path.join(run_dir, "checkpoints") | |
| assert sorted(os.listdir(root)) == ["micro_00000004", "micro_00000008"] | |
| return t, root | |
| def test_every_file_and_directory_is_fsynced_before_the_rename(tiny, tmp_workdir, monkeypatch): | |
| """THE ATOMIC-RENAME-IS-NOT-DURABLE DEFECT. Recorded as a timeline: every | |
| file in the checkpoint, and the staging directory, are fsynced BEFORE | |
| `os.rename(.partial -> final)`; the checkpoint root is fsynced AFTER it so | |
| the new directory entry itself survives a power loss.""" | |
| events = [] | |
| real_fsync, real_rename = os.fsync, os.rename | |
| def spy_fsync(fd): | |
| events.append(("fsync", os.readlink(f"/proc/self/fd/{fd}"))) | |
| return real_fsync(fd) | |
| def spy_rename(src, dst, *a, **k): | |
| events.append(("rename", src, dst)) | |
| return real_rename(src, dst, *a, **k) | |
| run = os.path.join(tmp_workdir, "run") | |
| t, _ = _tiny_trainer(tiny, run, save_every_micro_steps=8, max_micro_steps=8) | |
| monkeypatch.setattr(os, "fsync", spy_fsync) | |
| monkeypatch.setattr(os, "rename", spy_rename) | |
| t.save_checkpoint() | |
| monkeypatch.undo() | |
| root = os.path.realpath(os.path.join(run, "checkpoints")) | |
| staging = os.path.join(root, "micro_00000000.partial") | |
| renames = [i for i, e in enumerate(events) if e[0] == "rename" and e[1].endswith(".partial")] | |
| assert len(renames) == 1, events | |
| r = renames[0] | |
| before = {os.path.relpath(e[1], staging) for e in events[:r] if e[0] == "fsync" and e[1].startswith(staging)} | |
| final_files = set(os.listdir(os.path.join(root, "micro_00000000"))) | |
| assert {"model.safetensors", "resume.pt", "config.json"} <= final_files | |
| assert final_files <= before, f"not fsynced before the rename: {sorted(final_files - before)}" | |
| assert "." in before, "the staging DIRECTORY entry must be fsynced before the rename" | |
| assert ("fsync", root) in events[r + 1 :], "the checkpoint root must be fsynced after the rename" | |
| def test_a_complete_checkpoint_passes_and_the_newest_is_returned(tiny, tmp_workdir): | |
| t, root = _two_checkpoints(tiny, os.path.join(tmp_workdir, "run")) | |
| for d in os.listdir(root): | |
| assert checkpoint_problem(os.path.join(root, d)) is None | |
| assert t.latest_checkpoint() == os.path.join(root, "micro_00000008") | |
| def _truncate_half(path): | |
| size = os.path.getsize(path) | |
| with open(path, "r+b") as f: | |
| f.truncate(size // 2) | |
| MUTATIONS = { | |
| "weights_truncated": lambda d: _truncate_half(os.path.join(d, "model.safetensors")), | |
| "weights_zero_length": lambda d: open(os.path.join(d, "model.safetensors"), "wb").close(), | |
| "weights_missing": lambda d: os.remove(os.path.join(d, "model.safetensors")), | |
| "resume_missing": lambda d: os.remove(os.path.join(d, "resume.pt")), | |
| "resume_truncated": lambda d: _truncate_half(os.path.join(d, "resume.pt")), | |
| "resume_empty": lambda d: open(os.path.join(d, "resume.pt"), "wb").close(), | |
| } | |
| def test_incomplete_newest_checkpoint_is_skipped_with_a_warning_and_kept( | |
| tiny, tmp_workdir, mutation, caplog | |
| ): | |
| """A host seizure can leave a `micro_N` directory whose rename survived and | |
| whose data did not. `latest_checkpoint` must hand back the previous good one, | |
| warn, and NOT delete the evidence.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| t, root = _two_checkpoints(tiny, run) | |
| bad = os.path.join(root, "micro_00000008") | |
| MUTATIONS[mutation](bad) | |
| assert checkpoint_problem(bad) is not None | |
| with caplog.at_level(logging.WARNING, logger="align.train.trainer"): | |
| got = t.latest_checkpoint() | |
| assert got == os.path.join(root, "micro_00000004"), got | |
| assert "INCOMPLETE checkpoint micro_00000008" in caplog.text | |
| assert os.path.isdir(bad), "an incomplete checkpoint must be skipped, never deleted" | |
| # and a resume actually takes the good one | |
| t2, _ = _tiny_trainer(tiny, run, save_every_micro_steps=4) | |
| assert t2.resume_from_latest() == got and t2.micro_step == 4 | |
| def test_all_checkpoints_incomplete_means_no_checkpoint(tiny, tmp_workdir): | |
| t, root = _two_checkpoints(tiny, os.path.join(tmp_workdir, "run")) | |
| for d in os.listdir(root): | |
| os.remove(os.path.join(root, d, "resume.pt")) | |
| assert t.latest_checkpoint() is None | |
| def test_sharded_weights_are_checked_against_the_index(tiny, tmp_workdir): | |
| """A multi-shard layout (`model.safetensors.index.json`): every shard it | |
| names must exist and be complete.""" | |
| import json | |
| _t, root = _two_checkpoints(tiny, os.path.join(tmp_workdir, "run")) | |
| d = os.path.join(root, "micro_00000008") | |
| index = os.path.join(d, "model.safetensors.index.json") | |
| with open(index, "w") as f: | |
| json.dump({"weight_map": {"a": "model.safetensors"}}, f) | |
| assert checkpoint_problem(d) is None | |
| with open(index, "w") as f: | |
| json.dump({"weight_map": {"a": "model.safetensors", "b": "model-00002-of-00002.safetensors"}}, f) | |
| assert "missing weights file model-00002-of-00002.safetensors" in checkpoint_problem(d) | |
| with open(index, "w") as f: | |
| f.write("{not json") | |
| assert "index.json" in checkpoint_problem(d) | |
| def test_per_rank_optimizer_files_must_all_be_present(tiny, tmp_workdir): | |
| """When the manifest says per_rank_files, the newest checkpoint is complete | |
| only if EVERY saved rank's optimizer file is: a missing one makes a resume | |
| die on that rank alone, mid-load.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| t, root = _two_checkpoints(tiny, run, cfg_hook=_cfg(per_rank_optimizer_state=True)) | |
| d = os.path.join(root, "micro_00000008") | |
| assert checkpoint_problem(d) is None | |
| assert os.path.isfile(os.path.join(d, "optimizer_rank0.pt")) | |
| # the manifest names two ranks but only rank 0's file exists | |
| state = torch.load(os.path.join(d, "resume.pt"), weights_only=True, map_location="cpu") | |
| state["optimizer_state"] = {"layout": "per_rank_files", "world_size": 2} | |
| torch.save(state, os.path.join(d, "resume.pt")) | |
| assert "optimizer_rank1.pt" in checkpoint_problem(d) | |
| state["optimizer_state"] = {"layout": "per_rank_files", "world_size": 1} | |
| torch.save(state, os.path.join(d, "resume.pt")) | |
| os.remove(os.path.join(d, "optimizer_rank0.pt")) | |
| assert "optimizer_rank0.pt" in checkpoint_problem(d) | |
| assert t.latest_checkpoint() == os.path.join(root, "micro_00000004") | |
| _truncate_half(os.path.join(root, "micro_00000004", "optimizer_rank0.pt")) | |
| assert t.latest_checkpoint() is None, "a cut optimizer file must disqualify its checkpoint" | |
| def test_partial_directories_are_still_discarded_at_startup_and_never_listed(tiny, tmp_workdir): | |
| run = os.path.join(tmp_workdir, "run") | |
| os.makedirs(os.path.join(run, "checkpoints", "micro_00000100.partial")) | |
| t, _ = _tiny_trainer(tiny, run) | |
| assert os.listdir(os.path.join(run, "checkpoints")) == [] | |
| assert t.latest_checkpoint() is None | |
| # --------------------------------------------------------------------------- | |
| # 6. RNG state + pinned checkpoints + skip stats persisted in resume.pt | |
| # --------------------------------------------------------------------------- | |
| def _draw(): | |
| return [float(torch.rand(1)), random.random(), float(np.random.rand()), int(np.random.randint(1 << 30))] | |
| def test_rng_state_is_restored_for_torch_python_and_numpy(tiny, tmp_workdir): | |
| """A resumed process re-seeds from scratch and replays the original run's | |
| first draws; with the state in resume.pt it CONTINUES the stream the saving | |
| process was on.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| t, _ = _tiny_trainer(tiny, run, save_every_micro_steps=4, max_micro_steps=4) | |
| t.train() # the cadence writes micro 4 | |
| want = _draw() | |
| udist.seed_everything(999) # a different stream, as a fresh process would have | |
| assert _draw() != want, "vacuous: the reseed reproduced the saved stream" | |
| t2, _ = _tiny_trainer(tiny, run, save_every_micro_steps=4) | |
| t2.resume_from_latest() | |
| assert _draw() == want | |
| def test_resume_payload_holds_rng_pins_and_skip_stats_and_is_weights_only_loadable(tiny, tmp_workdir): | |
| run = os.path.join(tmp_workdir, "run") | |
| t, _ = _tiny_trainer(tiny, run, save_every_micro_steps=4, max_micro_steps=4) | |
| t._pin_next_save = True | |
| t.train() | |
| state = torch.load( | |
| os.path.join(run, "checkpoints", "micro_00000004", "resume.pt"), | |
| weights_only=True, | |
| map_location="cpu", | |
| ) | |
| assert state["pinned_checkpoints"] == [4] | |
| assert state["skip_stats"] == {"consecutive": 0, "bad_total": 0, "boundaries": 2} | |
| (rng,) = state["rng_per_rank"] | |
| assert {"torch", "python", "numpy"} <= rng.keys() | |
| assert rng["torch"].dtype == torch.uint8 | |
| def test_pinned_checkpoint_survives_a_resume_and_the_following_prune(tiny, tmp_workdir): | |
| """R5 PERSISTENCE. Before: the pin set lived in process memory. A resumed | |
| run registered the resumed checkpoint as ITS OWN (so keep-N could prune it) | |
| and had forgotten it was pinned -- the first save after the resume deleted | |
| the one checkpoint it had been told to keep. keep_checkpoints=1 makes the | |
| prune immediate.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| a, _ = _tiny_trainer( | |
| tiny, run, n_batches=10, epochs=2, save_every_micro_steps=4, keep_checkpoints=1, max_micro_steps=4 | |
| ) | |
| a._pin_next_save = True | |
| a.train() | |
| root = os.path.join(run, "checkpoints") | |
| assert os.listdir(root) == ["micro_00000004"] | |
| b, _ = _tiny_trainer( | |
| tiny, run, n_batches=10, epochs=2, save_every_micro_steps=4, keep_checkpoints=1, max_micro_steps=16 | |
| ) | |
| b.resume_from_latest() | |
| assert b.pinned_checkpoints == {4} | |
| b.train() | |
| assert sorted(os.listdir(root)) == ["micro_00000004", "micro_00000016"], os.listdir(root) | |
| def test_resume_adopts_older_checkpoints_so_keep_n_does_not_leak_one_per_resume(tiny, tmp_workdir): | |
| """TRACK M (2026-10-12, OLMoE 4-GPU SIGTERM/resume): keep_checkpoints=2 left micro_40, _44 and _80 on disk | |
| (83GB, disk 95% full). The interrupted attempt wrote 40 and 44; the new process registered only 44, so after | |
| saving 80 its list was [44, 80] and 40 was unprunable forever -- one leaked checkpoint per resume. Directories | |
| numbered BELOW the resumed one are this lineage's older saves and are adopted. A HIGHER-numbered directory is a | |
| stale-run zombie (the Arm A rerun incident that made pruning per-process) and must survive untouched. | |
| MUTANT (resume registers only the resumed step): micro_00000004 is still on disk at the end.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| a, _ = _tiny_trainer( | |
| tiny, run, n_batches=10, epochs=2, save_every_micro_steps=4, keep_checkpoints=3, max_micro_steps=8 | |
| ) | |
| a.train() | |
| root = os.path.join(run, "checkpoints") | |
| assert sorted(os.listdir(root)) == ["micro_00000004", "micro_00000008"] | |
| os.makedirs(os.path.join(root, "micro_00000999")) # a zombie from an unrelated older run: incomplete, higher-numbered | |
| b, _ = _tiny_trainer( | |
| tiny, run, n_batches=10, epochs=2, save_every_micro_steps=4, keep_checkpoints=2, max_micro_steps=12 | |
| ) | |
| assert b.resume_from_latest().endswith("micro_00000008") | |
| assert b.checkpoints_written == [4, 8], "older save adopted, zombie not, and the resumed one stays LAST (= newest)" | |
| b.train() | |
| assert sorted(os.listdir(root)) == ["micro_00000008", "micro_00000012", "micro_00000999"], os.listdir(root) | |
| def test_skip_stats_survive_a_resume(tiny, tmp_workdir): | |
| """A resume must not forgive a run that was already skipping.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| a, _ = _tiny_trainer( | |
| tiny, run, save_every_micro_steps=4, max_micro_steps=4, wrap_adapter=_nan_grads_for({1, 2, 3, 4}) | |
| ) | |
| a.train() | |
| assert a.skip_stats == {"consecutive": 2, "bad_total": 2, "boundaries": 2} | |
| b, _ = _tiny_trainer(tiny, run, save_every_micro_steps=4) | |
| b.resume_from_latest() | |
| assert b.skip_stats == a.skip_stats | |
| def test_a_checkpoint_from_before_g0_still_resumes(tiny, tmp_workdir, caplog): | |
| """Back-compat: strip the new keys from resume.pt (what every existing | |
| checkpoint looks like). Resume must work, warn about the RNG, and leave the | |
| defaults in place -- not KeyError.""" | |
| run = os.path.join(tmp_workdir, "run") | |
| a, _ = _tiny_trainer(tiny, run, save_every_micro_steps=4, max_micro_steps=4) | |
| a.train() | |
| path = os.path.join(run, "checkpoints", "micro_00000004", "resume.pt") | |
| state = torch.load(path, weights_only=True, map_location="cpu") | |
| for k in ("rng_per_rank", "pinned_checkpoints", "skip_stats"): | |
| del state[k] | |
| torch.save(state, path) | |
| # a pre-G0 checkpoint also predates the integrity sidecar (audit U01-3) | |
| os.remove(os.path.join(os.path.dirname(path), "integrity.json")) | |
| b, _ = _tiny_trainer(tiny, run, save_every_micro_steps=4) | |
| with caplog.at_level(logging.WARNING, logger="align.train.trainer"): | |
| b.resume_from_latest() | |
| assert "predates RNG persistence" in caplog.text | |
| assert b.micro_step == 4 and b.pinned_checkpoints == set() | |
| assert b.skip_stats == {"consecutive": 0, "bad_total": 0, "boundaries": 0} | |
| def test_rng_capture_roundtrips_exactly_and_is_weights_only_clean(tmp_workdir): | |
| udist.seed_everything(3) | |
| sd = udist.capture_rng_state() | |
| want = _draw() | |
| path = os.path.join(tmp_workdir, "rng.pt") | |
| torch.save(sd, path) | |
| loaded = torch.load(path, weights_only=True, map_location="cpu") | |
| udist.seed_everything(4) | |
| udist.restore_rng_state(loaded) | |
| assert _draw() == want | |
| def test_cuda_rng_is_captured_for_the_current_device_only(monkeypatch): | |
| """Faked CUDA (CPU-only host rules): only the CURRENT device's state is | |
| captured -- `get_rng_state_all` would create a context on every visible GPU | |
| from every rank -- and only when CUDA is already initialised.""" | |
| calls = [] | |
| monkeypatch.setattr(torch.cuda, "is_available", lambda: True) | |
| monkeypatch.setattr(torch.cuda, "is_initialized", lambda: True) | |
| monkeypatch.setattr(torch.cuda, "current_device", lambda: 2) | |
| monkeypatch.setattr(torch.cuda, "device_count", lambda: 4) | |
| monkeypatch.setattr(torch.cuda, "get_rng_state", lambda d: torch.tensor([7, 8, 9], dtype=torch.uint8)) | |
| monkeypatch.setattr(torch.cuda, "set_rng_state", lambda s, device: calls.append((s.tolist(), device))) | |
| monkeypatch.setattr( | |
| torch.cuda, "get_rng_state_all", lambda: (_ for _ in ()).throw(AssertionError("all devices")) | |
| ) | |
| sd = udist.capture_rng_state() | |
| assert list(sd["cuda"]) == ["2"] and sd["cuda"]["2"].tolist() == [7, 8, 9] | |
| udist.restore_rng_state(sd) | |
| assert calls == [([7, 8, 9], 2)] | |
| monkeypatch.setattr(torch.cuda, "is_initialized", lambda: False) | |
| assert "cuda" not in udist.capture_rng_state() | |
| def test_all_reduce_helpers_are_identity_on_one_process(): | |
| assert udist.all_reduce_max([0, 3, 1]) == [0, 3, 1] | |
| assert udist.any_rank(False) is False and udist.any_rank(True) is True | |
| assert udist.all_gather_objects({"a": 1}) == [{"a": 1}] | |