mirror of
https://github.com/deepseek-ai/DeepInfer.git
synced 2026-10-29 23:37:28 +08:00
Monitor process sentinels and reap sibling ranks on worker failures, interruption, or partial startup failure. Bound graceful termination and report global DP rank IDs. Add CPU-only subprocess regression coverage. Co-Authored-By: Codex <codex@openai.com>
163 lines
5.8 KiB
Python
163 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import multiprocessing as mp
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from examples import _launch
|
|
|
|
pytestmark = [pytest.mark.ci, pytest.mark.ci_npu]
|
|
|
|
|
|
def _worker(dp_size: int, dp_rank: int) -> None:
|
|
directory = Path(os.environ["LAUNCH_TEST_DIR"])
|
|
mode = os.environ["LAUNCH_TEST_MODE"]
|
|
failed_rank = int(os.environ["LAUNCH_TEST_FAILED_RANK"])
|
|
if mode == "ignore_sigterm" and dp_rank != failed_rank:
|
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
(directory / f"rank-{dp_rank}.pid").write_text(str(os.getpid()))
|
|
if mode == "success":
|
|
return
|
|
if mode != "interrupt" and dp_rank == failed_rank:
|
|
# Ensure the sibling has started (and installed its signal handler).
|
|
deadline = time.monotonic() + 10
|
|
while len(list(directory.glob("rank-*.pid"))) < dp_size // int(os.environ["WORLD_SIZE"]):
|
|
if time.monotonic() >= deadline:
|
|
raise RuntimeError("sibling failed to start")
|
|
time.sleep(0.01)
|
|
raise RuntimeError("intentional worker failure")
|
|
# Stand in for a rank stuck in a collective after another rank has failed.
|
|
time.sleep(60)
|
|
|
|
|
|
def _run_case(
|
|
tmp_path: Path,
|
|
*,
|
|
mode: str,
|
|
failed_rank: int = 1,
|
|
node_rank: int = 0,
|
|
num_nodes: int = 1,
|
|
dp_size: int = 2,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
environment = dict(
|
|
os.environ,
|
|
PYTHONPATH=str(Path(__file__).resolve().parents[2]),
|
|
RANK=str(node_rank),
|
|
WORLD_SIZE=str(num_nodes),
|
|
LAUNCH_TEST_DIR=str(tmp_path),
|
|
LAUNCH_TEST_MODE=mode,
|
|
LAUNCH_TEST_FAILED_RANK=str(failed_rank),
|
|
LAUNCH_TEST_DP_SIZE=str(dp_size),
|
|
)
|
|
process = subprocess.Popen(
|
|
[sys.executable, str(Path(__file__).resolve())],
|
|
env=environment,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
start_new_session=True,
|
|
)
|
|
try:
|
|
if mode == "interrupt":
|
|
deadline = time.monotonic() + 10
|
|
while len(list(tmp_path.glob("rank-*.pid"))) < dp_size // num_nodes:
|
|
if process.poll() is not None or time.monotonic() >= deadline:
|
|
pytest.fail("workers failed to start before interruption")
|
|
time.sleep(0.01)
|
|
process.send_signal(signal.SIGINT)
|
|
stdout, stderr = process.communicate(timeout=15)
|
|
except subprocess.TimeoutExpired:
|
|
pytest.fail("launcher did not exit after a worker failed")
|
|
finally:
|
|
# Also bounds the regression test when run against the broken launcher.
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
process.wait()
|
|
assert json.loads((tmp_path / "remaining_children.json").read_text()) == []
|
|
return subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr)
|
|
|
|
|
|
def test_successful_workers_are_joined(tmp_path: Path) -> None:
|
|
result = _run_case(tmp_path, mode="success")
|
|
assert result.returncode == 0, result.stderr
|
|
assert len(list(tmp_path.glob("rank-*.pid"))) == 2
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("node_rank", "num_nodes", "dp_size", "failed_rank"),
|
|
[(0, 1, 2, 1), (0, 1, 2, 0), (1, 2, 4, 3)],
|
|
)
|
|
def test_worker_failure_reaps_siblings(tmp_path: Path, node_rank: int, num_nodes: int, dp_size: int, failed_rank: int) -> None:
|
|
result = _run_case(tmp_path, mode="failure", node_rank=node_rank, num_nodes=num_nodes, dp_size=dp_size, failed_rank=failed_rank)
|
|
assert result.returncode != 0
|
|
assert "intentional worker failure" in result.stderr
|
|
assert f"Some processes failed: [{failed_rank}]" in result.stderr
|
|
|
|
|
|
def test_unresponsive_sibling_is_killed(tmp_path: Path) -> None:
|
|
result = _run_case(tmp_path, mode="ignore_sigterm")
|
|
assert result.returncode != 0
|
|
assert "Some processes failed: [1]" in result.stderr
|
|
|
|
|
|
def test_interruption_reaps_workers(tmp_path: Path) -> None:
|
|
result = _run_case(tmp_path, mode="interrupt")
|
|
assert result.returncode != 0
|
|
assert "KeyboardInterrupt" in result.stderr
|
|
|
|
|
|
def test_start_failure_reaps_started_worker(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("RANK", "0")
|
|
monkeypatch.setenv("WORLD_SIZE", "1")
|
|
context = mp.get_context("spawn")
|
|
processes = []
|
|
|
|
def fail_start() -> None:
|
|
raise RuntimeError("could not start worker")
|
|
|
|
def make_process(**kwargs: object) -> mp.Process:
|
|
process = context.Process(target=time.sleep, args=(60,))
|
|
if processes:
|
|
monkeypatch.setattr(process, "start", fail_start)
|
|
processes.append(process)
|
|
return process
|
|
|
|
monkeypatch.setattr(_launch, "get_context", lambda _: SimpleNamespace(Process=make_process))
|
|
try:
|
|
with pytest.raises(RuntimeError, match="could not start worker"):
|
|
_launch.run_dp(_worker, 2)
|
|
assert processes[0].exitcode is not None
|
|
assert processes[1].pid is None
|
|
finally:
|
|
for process in processes:
|
|
if process.pid is not None:
|
|
if process.is_alive():
|
|
process.kill()
|
|
process.join()
|
|
|
|
|
|
def test_single_rank_runs_in_parent(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("RANK", "0")
|
|
monkeypatch.setenv("WORLD_SIZE", "1")
|
|
calls = []
|
|
_launch.run_dp(lambda size, rank: calls.append((size, rank, os.getpid())), 1)
|
|
assert calls == [(1, 0, os.getpid())]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_launch._PROCESS_TERMINATION_TIMEOUT_S = 0.2
|
|
try:
|
|
_launch.run_dp(_worker, int(os.environ["LAUNCH_TEST_DP_SIZE"]))
|
|
finally:
|
|
remaining = [child.pid for child in mp.active_children()]
|
|
(Path(os.environ["LAUNCH_TEST_DIR"]) / "remaining_children.json").write_text(json.dumps(remaining))
|