File size: 14,889 Bytes
201dc77 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 | """Regressions from the adversarial review of the G0 control plane (2026-10-12).
Each test pins one defect found by reading `trainer.py` / `dist.py` /
`supervisor.py` for the shapes that wedged this box before: a rank acting alone
while a peer waits in a collective, a checkpoint trusted without being checked,
a retry loop that outlives its reason, and a handler that does more than set a
flag. Every test fails against the code it was written for (the mutant that
removes the fix is named in the docstring).
Run: .venv/bin/python -m pytest tests/test_control_plane_review.py -q -m "not gpu and not oracle" -p no:cacheprovider
"""
from __future__ import annotations
import json
import os
import signal
import socket
import sys
import threading
import time
import traceback
import pytest
import torch
import torch.multiprocessing as mp
from test_model_parity import tiny_config
from test_trainer import _tiny_trainer
import align.utils.supervisor as sv
from align.train.trainer import checkpoint_problem
@pytest.fixture
def tiny(tiny_model_constants):
return tiny_config(tiny_model_constants)
def _truncate_half(path):
size = os.path.getsize(path)
with open(path, "r+b") as f:
f.truncate(size // 2)
def _saved_run(tiny, run_dir):
t, _ = _tiny_trainer(tiny, run_dir, save_every_micro_steps=4)
t.train()
root = os.path.join(run_dir, "checkpoints")
assert sorted(os.listdir(root)) == ["micro_00000004", "micro_00000008"]
return t, root
# ---------------------------------------------------------------------------
# 1. the idempotent re-save must not trust a broken directory
# ---------------------------------------------------------------------------
def test_resave_replaces_a_corrupt_existing_checkpoint_and_keeps_the_evidence(tiny, tmp_workdir):
"""THE TRUSTED-CORRUPTION DEFECT. micro_8 is cut; resume falls back to
micro_4; the resumed run reaches 8 again and used to log 'already exists'
and write nothing, leaving a broken newest directory (and, in
`checkpoints_written`, a broken one counted against keep-N). Mutant: restore
`os.path.isdir(final)` as the skip test."""
run = os.path.join(tmp_workdir, "run")
_saved_run(tiny, run)
root = os.path.join(run, "checkpoints")
bad = os.path.join(root, "micro_00000008")
_truncate_half(os.path.join(bad, "model.safetensors"))
assert checkpoint_problem(bad) is not None
t2, _ = _tiny_trainer(tiny, run, save_every_micro_steps=4)
assert t2.resume_from_latest().endswith("micro_00000004")
t2.train() # walks back up to 8
assert checkpoint_problem(bad) is None, "micro_8 must have been re-saved"
kept = [d for d in os.listdir(root) if d.startswith("corrupt_micro_00000008.")]
assert len(kept) == 1, "the broken directory is moved aside, not deleted"
assert checkpoint_problem(os.path.join(root, kept[0])) is not None
# neither resume nor the supervisor's progress rule can see the quarantined dir
assert t2.latest_checkpoint() == bad
assert sv.latest_checkpoint_micro_step(run, checkpoint_problem) == 8
def test_resave_still_skips_a_complete_checkpoint(tiny, tmp_workdir):
"""The idempotence this fix must keep: a COMPLETE micro_N is neither
rewritten nor moved (re-reaching an already-saved boundary is routine)."""
run = os.path.join(tmp_workdir, "run")
t, root = _saved_run(tiny, run)
final = os.path.join(root, "micro_00000008")
before = os.stat(os.path.join(final, "resume.pt")).st_mtime_ns
assert t.save_checkpoint() == final
assert os.stat(os.path.join(final, "resume.pt")).st_mtime_ns == before
assert sorted(os.listdir(root)) == ["micro_00000004", "micro_00000008"]
# ---------------------------------------------------------------------------
# 2. a malformed manifest is a verdict, not a crash
# ---------------------------------------------------------------------------
def _rewrite_manifest(d, **kv):
state = torch.load(os.path.join(d, "resume.pt"), weights_only=True, map_location="cpu")
state.update(kv)
torch.save(state, os.path.join(d, "resume.pt"))
# The test models a LEGITIMATE checkpoint with a different manifest, so keep the integrity
# sidecar (audit U01-3) truthful; a hand-edit without this is what the sidecar refuses.
ip = os.path.join(d, "integrity.json")
if os.path.isfile(ip):
from align.train import sharded_ckpt
rec = sharded_ckpt.read_integrity(d)
rec["resume.pt"] = sharded_ckpt.file_sha256(os.path.join(d, "resume.pt"))
sharded_ckpt.write_integrity(d, rec)
def test_malformed_per_rank_manifest_is_skipped_by_latest_checkpoint(tiny, tmp_workdir):
"""A `per_rank_files` manifest without `world_size` raised KeyError out of
`checkpoint_problem`, so `latest_checkpoint` (and the supervisor's progress
rule) died on the very directory they exist to step over. Mutant: call
`_checkpoint_problem` directly from `checkpoint_problem`."""
t, root = _saved_run(tiny, os.path.join(tmp_workdir, "run"))
d = os.path.join(root, "micro_00000008")
_rewrite_manifest(d, optimizer_state={"layout": "per_rank_files"})
assert "malformed" in checkpoint_problem(d)
assert t.latest_checkpoint() == os.path.join(root, "micro_00000004")
def test_safetensors_header_that_is_json_but_not_an_object_is_a_problem(tiny, tmp_workdir):
t, root = _saved_run(tiny, os.path.join(tmp_workdir, "run"))
d = os.path.join(root, "micro_00000008")
body = b"[]"
with open(os.path.join(d, "model.safetensors"), "wb") as f:
f.write(len(body).to_bytes(8, "little") + body)
assert checkpoint_problem(d) is not None
assert t.latest_checkpoint() == os.path.join(root, "micro_00000004")
# ---------------------------------------------------------------------------
# 3. gather-layout resume at a different world size
# ---------------------------------------------------------------------------
def test_gather_layout_resume_refuses_a_different_world_size(tiny, tmp_workdir):
"""The gather layout recorded no world size: a 1-rank resume of a 4-rank
save silently took shard 0 of 4. The list length IS the saved world size.
Mutant: delete the length check."""
run = os.path.join(tmp_workdir, "run")
_t, root = _saved_run(tiny, run)
d = os.path.join(root, "micro_00000008")
state = torch.load(os.path.join(d, "resume.pt"), weights_only=True, map_location="cpu")
_rewrite_manifest(d, optimizer_per_rank=state["optimizer_per_rank"] * 4)
t2, _ = _tiny_trainer(tiny, run, save_every_micro_steps=4)
with pytest.raises(RuntimeError, match=r"4 rank\(s\).*world_size=1"):
t2.resume_from(d)
# ---------------------------------------------------------------------------
# 4. supervisor: a signal that lands before the child is visible
# ---------------------------------------------------------------------------
def test_signal_landing_before_the_child_is_registered_still_reaches_the_child(tmp_workdir):
"""THE LOST-SIGNAL WINDOW. `_on_signal` forwards SIGTERM to `self._proc`; a
signal between `Popen` returning and the assignment found None, so the
child (the job) never got the SIGTERM that makes it checkpoint -- it was
SIGKILLed after grace_s (exit -9). Mutant: delete the forward after Popen."""
script = os.path.join(tmp_workdir, "child.py")
with open(script, "w") as f:
f.write("import time\ntime.sleep(60)\n")
run = os.path.join(tmp_workdir, "run")
s = sv.Supervisor([sys.executable, script], run, max_retries=0, grace_s=4.0, preflight=lambda: None)
real = sv.subprocess.Popen
def popen(*a, **k):
p = real(*a, **k)
s._on_signal(signal.SIGTERM, None) # lands before `self._proc = ...`
return p
sv.subprocess.Popen = popen
try:
t0 = time.monotonic()
res = s.run(install_signal_handlers=False)
took = time.monotonic() - t0
finally:
sv.subprocess.Popen = real
assert res.reason == "supervisor_terminated"
assert res.child_codes == [-signal.SIGTERM], res.child_codes # TERM, not the grace-expiry KILL
assert took < 3.5
def test_signal_handler_takes_no_lock_the_interrupted_code_may_hold(tmp_workdir):
"""THE HANDLER-DEADLOCK DEFECT. A Python signal handler runs on the main
thread between bytecodes; `threading.Event.set()` takes the event's
non-reentrant lock, which `Event.wait()` (the whole backoff) holds for a few
bytecodes. Simulated by running the handler on a thread while the test
holds every lock the stop flag owns. Mutant: `self._stop = threading.Event()`."""
run = os.path.join(tmp_workdir, "run")
s = sv.Supervisor([sys.executable, "-c", "pass"], run, preflight=lambda: None)
held = [lk for lk in (getattr(s._stop, "_cond", None),) if lk is not None]
done = threading.Event()
def handler():
s._on_signal(signal.SIGTERM, None)
done.set()
for lk in held:
lk.acquire()
try:
th = threading.Thread(target=handler, daemon=True)
th.start()
assert done.wait(2.0), "the signal handler blocked on a lock: it can deadlock the supervisor"
finally:
for lk in held:
lk.release()
assert s._stop.is_set() and s._term_signal == signal.SIGTERM
assert s._stop.wait(0.0) is True
def test_stop_flag_wait_times_out_and_wakes_on_set():
flag = sv._SignalFlag()
t0 = time.monotonic()
assert flag.wait(0.25) is False and 0.2 <= time.monotonic() - t0 < 1.0
threading.Timer(0.15, flag.set).start()
t0 = time.monotonic()
assert flag.wait(5.0) is True and time.monotonic() - t0 < 1.5
# ---------------------------------------------------------------------------
# 5. two real ranks: the gloo control group honours the armed timeout, and the
# re-save verdict is one verdict
# ---------------------------------------------------------------------------
WS = 2
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _body_gloo_timeout(rank: int, run_dir: str) -> dict:
from align.utils import dist as u
u.gloo_group() # collective: every rank builds it
if rank == 1:
time.sleep(14) # alive but silent: a wedged rank, not a dead one
return {}
t0 = time.monotonic()
try:
u.all_reduce_max([1])
return {"raised": False, "secs": time.monotonic() - t0}
except Exception as e: # noqa: BLE001
return {"raised": True, "secs": time.monotonic() - t0, "type": type(e).__name__}
def _body_resave_verdict(rank: int, run_dir: str) -> dict:
"""Rank 1's filesystem view says the directory is absent (stale attribute
cache / node-local disk); rank 0 sees a complete one. One verdict, or rank 0
skips the save while rank 1 enters the state-gather alone."""
import align.train.trainer as tr
ckpt_root = os.path.join(run_dir, "ckpts")
os.makedirs(os.path.join(ckpt_root, "micro_00000004"), exist_ok=True)
tr.checkpoint_problem = lambda p: None # existence == complete, for this probe
if rank == 1:
real = os.path.isdir
os.path.isdir = lambda p: False if p.endswith("micro_00000004") else real(p)
class Stub:
pass
stub = Stub()
stub.ckpt_root = ckpt_root
return {"redundant": tr.Trainer._resave_is_redundant(stub, "micro_00000004")}
_BODIES = {"gloo_timeout": _body_gloo_timeout, "resave_verdict": _body_resave_verdict}
def _child(rank: int, port: int, mode: str, run_dir: str) -> None:
import torch.distributed as dist
os.environ.update(
RANK=str(rank),
WORLD_SIZE=str(WS),
LOCAL_RANK=str(rank),
MASTER_ADDR="127.0.0.1",
MASTER_PORT=str(port),
)
from align.utils.dist import init_distributed
init_distributed(backend="gloo")
code = 0
try:
result = _BODIES[mode](rank, run_dir)
with open(os.path.join(run_dir, f"{mode}_{rank}.json"), "w") as f:
json.dump(result, f)
except BaseException:
with open(os.path.join(run_dir, f"{mode}_{rank}.err"), "w") as f:
f.write(traceback.format_exc())
code = 1
finally:
try:
dist.destroy_process_group()
except Exception:
pass
if code:
sys.exit(code)
def _run(mode: str, run_dir: str, monkeypatch, join_timeout: float = 120.0) -> list[dict]:
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "")
ctx = mp.get_context("spawn")
port = _free_port()
procs = [ctx.Process(target=_child, args=(r, port, mode, run_dir)) for r in range(WS)]
for p in procs:
p.start()
for p in procs:
p.join(join_timeout)
hung = [p for p in procs if p.is_alive()]
for p in hung:
p.kill()
p.join()
errs = [
f"rank {r}:\n{open(os.path.join(run_dir, f'{mode}_{r}.err')).read()}"
for r in range(WS)
if os.path.isfile(os.path.join(run_dir, f"{mode}_{r}.err"))
]
assert not hung, f"{len(hung)} rank(s) HUNG in mode {mode!r}\n" + "\n".join(errs)
assert not errs, "\n".join(errs)
return [json.load(open(os.path.join(run_dir, f"{mode}_{r}.json"))) for r in range(WS)]
def test_gloo_control_group_honours_the_armed_process_group_timeout(tmp_workdir, monkeypatch):
"""THE UNARMED-WATCHDOG DEFECT. `dist.new_group(backend="gloo")` without a
timeout gets torch's 30-minute gloo default, not the group's: every stop /
HALT / skip-cap decision (the boundary all-reduce) ignored
ALIGN_PG_TIMEOUT_S. Rank 1 stays alive but silent for 14 s; with a 2 s
timeout rank 0 must give up in seconds, not when the peer finally exits.
Mutant: `dist.new_group(backend="gloo")` with no timeout."""
monkeypatch.setenv("ALIGN_PG_TIMEOUT_S", "2")
r0, _r1 = _run("gloo_timeout", tmp_workdir, monkeypatch)
assert r0["raised"] is True, r0
assert r0["secs"] < 9, f"control collective waited {r0['secs']:.1f}s against a 2s timeout"
def test_resave_verdict_is_rank_zeros_and_identical_on_every_rank(tmp_workdir, monkeypatch):
"""A rank whose filesystem view differs must not decide alone. Mutant:
decide per rank from `os.path.isdir`."""
r0, r1 = _run("resave_verdict", tmp_workdir, monkeypatch)
assert r0 == r1 == {"redundant": True}
def test_resave_verdict_on_one_process_is_local(tmp_workdir):
class Stub:
ckpt_root = os.path.join(tmp_workdir, "c")
from align.train.trainer import Trainer
os.makedirs(Stub.ckpt_root)
assert Trainer._resave_is_redundant(Stub, "micro_00000004") is False # nothing there
os.makedirs(os.path.join(Stub.ckpt_root, "micro_00000004"))
assert Trainer._resave_is_redundant(Stub, "micro_00000004") is False # empty dir = incomplete
assert any(d.startswith("corrupt_micro_00000004.") for d in os.listdir(Stub.ckpt_root))
|