Skip to content

Commit 5e77ac1

Browse files
committed
Detect missing GPU health snapshots
1 parent be9ec69 commit 5e77ac1

5 files changed

Lines changed: 183 additions & 18 deletions

File tree

flagscale/runner/heartbeat/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class HeartbeatLaunchConfig:
6363
scan_interval_s: float = 1.0
6464
monitor_nice: int = 10
6565
expected_world_size: int = 0
66+
expected_node_count: int = 0
6667
hardware_health_enabled: bool = False
6768
hardware_health_interval_s: float = 60.0
6869
hardware_health_command_timeout_s: float = 10.0
@@ -193,6 +194,8 @@ def shell_setup_lines(self, node_rank: int) -> list[str]:
193194
monitor_cmd.extend(
194195
[
195196
"--hardware-health-enabled",
197+
"--expected-node-count",
198+
str(self.expected_node_count),
196199
"--hardware-health-stale-after",
197200
f"{self.hardware_health_stale_after_s:g}",
198201
]
@@ -241,6 +244,14 @@ def _infer_world_size(runner: Any) -> int:
241244
return nnodes * nproc_per_node if nnodes > 0 and nproc_per_node > 0 else 0
242245

243246

247+
def _infer_node_count(runner: Any) -> int:
248+
try:
249+
nnodes = int(runner.get("nnodes", 1))
250+
except (TypeError, ValueError):
251+
return 0
252+
return nnodes if nnodes > 0 else 0
253+
254+
244255
def prepare_heartbeat_launch_config(config: DictConfig, run_id: str) -> HeartbeatLaunchConfig:
245256
"""Resolve ``experiment.runner.heartbeat`` without leaking it to torchrun."""
246257

@@ -333,6 +344,7 @@ def prepare_heartbeat_launch_config(config: DictConfig, run_id: str) -> Heartbea
333344
raise ValueError("heartbeat.expected_world_size must be a non-negative integer") from exc
334345
if expected_world_size < 0:
335346
raise ValueError("heartbeat.expected_world_size must be a non-negative integer")
347+
expected_node_count = _infer_node_count(config.experiment.runner)
336348

337349
hardware_health = raw_dict.get("hardware_health", {})
338350
if not isinstance(hardware_health, dict):
@@ -370,6 +382,7 @@ def prepare_heartbeat_launch_config(config: DictConfig, run_id: str) -> Heartbea
370382
scan_interval_s=_positive_float(raw_dict.get("scan_interval_s", 1.0), "scan_interval_s"),
371383
monitor_nice=monitor_nice,
372384
expected_world_size=expected_world_size,
385+
expected_node_count=expected_node_count,
373386
hardware_health_enabled=hardware_health_enabled,
374387
hardware_health_interval_s=hardware_health_interval_s,
375388
hardware_health_command_timeout_s=hardware_health_command_timeout_s,

flagscale/runner/heartbeat/health_reader.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@ class HardwareHealthIndex:
4343
now_unix_ns: int
4444
stale_after_s: float
4545
snapshots: tuple[dict[str, Any], ...] = ()
46+
missing_node_ranks: tuple[int, ...] = ()
4647

4748
@property
4849
def overall_status(self) -> str:
4950
if not self.enabled:
5051
return "not_collected"
5152
statuses = [self._snapshot_status(snapshot) for snapshot in self.snapshots]
53+
statuses.extend("unavailable" for _ in self.missing_node_ranks)
5254
return _worst_status(statuses, "unavailable")
5355

5456
def _snapshot_age_s(self, snapshot: dict[str, Any]) -> float | None:
@@ -80,6 +82,18 @@ def summary(self) -> dict[str, Any]:
8082
"gpus": snapshot.get("gpus", []),
8183
}
8284
)
85+
for node_rank in self.missing_node_ranks:
86+
nodes.append(
87+
{
88+
"node_rank": node_rank,
89+
"hostname": None,
90+
"status": "unavailable",
91+
"sample_age_s": None,
92+
"source": None,
93+
"error": "gpu_health_snapshot_not_generated",
94+
"gpus": [],
95+
}
96+
)
8397
return {"enabled": True, "status": self.overall_status, "nodes": nodes}
8498

8599
def for_rank(self, rank_event: dict[str, Any]) -> dict[str, Any]:
@@ -145,13 +159,22 @@ def _match_rank_gpu(
145159

146160
class HardwareHealthReader:
147161
def __init__(
148-
self, heartbeat_dir: Path, run_id: str, enabled: bool, stale_after_s: float
162+
self,
163+
heartbeat_dir: Path,
164+
run_id: str,
165+
enabled: bool,
166+
stale_after_s: float,
167+
expected_node_count: int = 0,
168+
monitor_started_unix_ns: int = 0,
149169
) -> None:
150170
self.heartbeat_dir = heartbeat_dir
151171
self.run_id = run_id
152172
self.enabled = enabled
153173
self.stale_after_s = stale_after_s
174+
self.expected_node_count = expected_node_count
175+
self.monitor_started_unix_ns = monitor_started_unix_ns
154176
self._reported: set[tuple[str, str, tuple[str, ...]]] = set()
177+
self._reported_missing_nodes: set[int] = set()
155178

156179
def poll(self, now_unix_ns: int) -> tuple[HardwareHealthIndex, list[dict[str, Any]]]:
157180
if not self.enabled:
@@ -164,8 +187,67 @@ def poll(self, now_unix_ns: int) -> tuple[HardwareHealthIndex, list[dict[str, An
164187
continue
165188
if isinstance(payload, dict) and payload.get("run_id") == self.run_id:
166189
snapshots.append(payload)
167-
index = HardwareHealthIndex(True, now_unix_ns, self.stale_after_s, tuple(snapshots))
168-
return index, self._new_findings(index, now_unix_ns)
190+
missing_node_ranks = self._missing_node_ranks(snapshots, now_unix_ns)
191+
index = HardwareHealthIndex(
192+
True,
193+
now_unix_ns,
194+
self.stale_after_s,
195+
tuple(snapshots),
196+
missing_node_ranks,
197+
)
198+
findings = self._new_findings(index, now_unix_ns)
199+
findings.extend(self._new_missing_findings(index, now_unix_ns))
200+
return index, findings
201+
202+
def _missing_node_ranks(
203+
self, snapshots: list[dict[str, Any]], now_unix_ns: int
204+
) -> tuple[int, ...]:
205+
if self.expected_node_count <= 0:
206+
return ()
207+
monitor_age_s = max(
208+
0.0,
209+
(now_unix_ns - self.monitor_started_unix_ns) / 1_000_000_000,
210+
)
211+
if monitor_age_s <= self.stale_after_s:
212+
return ()
213+
observed_node_ranks: set[int] = set()
214+
for snapshot in snapshots:
215+
try:
216+
node_rank = int(snapshot.get("node_rank"))
217+
except (TypeError, ValueError):
218+
continue
219+
if 0 <= node_rank < self.expected_node_count:
220+
observed_node_ranks.add(node_rank)
221+
return tuple(
222+
node_rank
223+
for node_rank in range(self.expected_node_count)
224+
if node_rank not in observed_node_ranks
225+
)
226+
227+
def _new_missing_findings(
228+
self, index: HardwareHealthIndex, now_unix_ns: int
229+
) -> list[dict[str, Any]]:
230+
missing_node_ranks = set(index.missing_node_ranks)
231+
self._reported_missing_nodes.intersection_update(missing_node_ranks)
232+
findings: list[dict[str, Any]] = []
233+
for node_rank in index.missing_node_ranks:
234+
if node_rank in self._reported_missing_nodes:
235+
continue
236+
self._reported_missing_nodes.add(node_rank)
237+
findings.append(
238+
{
239+
"finding_type": "gpu_health_snapshot_missing",
240+
"run_id": self.run_id,
241+
"detected_at_unix_ns": now_unix_ns,
242+
"node_rank": node_rank,
243+
"hostname": None,
244+
"gpu_device_health": "unavailable",
245+
"expected_file": f"gpu_health_node_{node_rank}.json",
246+
"reason": "gpu_health_snapshot_not_generated",
247+
"confidence": "observed",
248+
}
249+
)
250+
return findings
169251

170252
def _new_findings(self, index: HardwareHealthIndex, now_unix_ns: int) -> list[dict[str, Any]]:
171253
findings: list[dict[str, Any]] = []

flagscale/runner/heartbeat/monitor.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,8 @@ def run_monitor(args: argparse.Namespace) -> int:
392392
except (AttributeError, OSError):
393393
logger.debug("Could not adjust heartbeat monitor niceness", exc_info=True)
394394

395+
monitor_started_s = time.monotonic()
396+
monitor_started_unix_ns = time.time_ns()
395397
analyzer = HeartbeatAnalyzer(
396398
args.run_id,
397399
args.initial_process_timeout,
@@ -400,14 +402,16 @@ def run_monitor(args: argparse.Namespace) -> int:
400402
args.progress_timeout,
401403
args.checkpoint_timeout,
402404
expected_world_size=args.expected_world_size,
403-
monitor_started_s=time.monotonic(),
405+
monitor_started_s=monitor_started_s,
404406
)
405407
tailer = JsonlTailer(heartbeat_dir)
406408
hardware_reader = HardwareHealthReader(
407409
heartbeat_dir,
408410
args.run_id,
409411
args.hardware_health_enabled,
410412
args.hardware_health_stale_after,
413+
expected_node_count=args.expected_node_count,
414+
monitor_started_unix_ns=monitor_started_unix_ns,
411415
)
412416
stopping = False
413417
failed_seen_s: float | None = None
@@ -446,6 +450,7 @@ def build_parser() -> argparse.ArgumentParser:
446450
parser.add_argument("--heartbeat-dir", required=True)
447451
parser.add_argument("--run-id", required=True)
448452
parser.add_argument("--expected-world-size", type=int, default=0)
453+
parser.add_argument("--expected-node-count", type=int, default=0)
449454
parser.add_argument("--initial-process-timeout", type=float, default=30.0)
450455
parser.add_argument("--process-timeout", type=float, default=30.0)
451456
parser.add_argument("--initial-progress-timeout", type=float, default=600.0)
@@ -469,6 +474,8 @@ def main() -> int:
469474
args = build_parser().parse_args()
470475
if args.expected_world_size < 0:
471476
raise SystemExit("--expected-world-size must be non-negative")
477+
if args.expected_node_count < 0:
478+
raise SystemExit("--expected-node-count must be non-negative")
472479
for name in (
473480
"initial_process_timeout",
474481
"process_timeout",

tests/unit_tests/runner/heartbeat/test_config.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,27 +76,28 @@ def test_enabled_gpu_progress_heartbeat_has_no_preload_or_nccl_dependency(tmp_pa
7676

7777

7878
def test_optional_hardware_health_starts_one_node_local_cpu_collector(tmp_path):
79-
resolved = prepare_heartbeat_launch_config(
80-
_config(
81-
tmp_path,
82-
{
79+
config = _config(
80+
tmp_path,
81+
{
82+
"enabled": True,
83+
"hardware_health": {
8384
"enabled": True,
84-
"hardware_health": {
85-
"enabled": True,
86-
"interval_s": 60,
87-
"command_timeout_s": 10,
88-
"stale_after_s": 180,
89-
},
85+
"interval_s": 60,
86+
"command_timeout_s": 10,
87+
"stale_after_s": 180,
9088
},
91-
),
92-
"run",
89+
},
9390
)
91+
config.experiment.runner.nnodes = 2
92+
resolved = prepare_heartbeat_launch_config(config, "run")
9493
node_zero = "\n".join(resolved.shell_setup_lines(0))
9594
node_one = "\n".join(resolved.shell_setup_lines(1))
9695

9796
assert "flagscale.runner.heartbeat.gpu_health" in node_zero
9897
assert "gpu_health_node_0.json" in node_zero
9998
assert "--hardware-health-enabled" in node_zero
99+
assert "--expected-node-count 2" in node_zero
100+
assert resolved.expected_node_count == 2
100101
assert "flagscale.runner.heartbeat.gpu_health" in node_one
101102
assert "gpu_health_node_1.json" in node_one
102103
assert "flagscale.runner.heartbeat.monitor" not in node_one

tests/unit_tests/runner/heartbeat/test_health_reader.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
from flagscale.runner.heartbeat.health_reader import HardwareHealthReader
1818

1919

20-
def _snapshot(status="warning"):
21-
return {
20+
def _snapshot(status="warning", **overrides):
21+
payload = {
2222
"run_id": "run",
2323
"node_rank": 0,
2424
"hostname": "host-a",
@@ -35,6 +35,8 @@ def _snapshot(status="warning"):
3535
},
3636
],
3737
}
38+
payload.update(overrides)
39+
return payload
3840

3941

4042
def test_reader_correlates_cuda_visible_devices_and_deduplicates_findings(tmp_path):
@@ -74,3 +76,63 @@ def test_stale_snapshot_is_not_reported_as_hardware_failure(tmp_path):
7476

7577
assert index.overall_status == "stale"
7678
assert findings == []
79+
80+
81+
def test_missing_node_snapshot_is_reported_after_startup_grace(tmp_path):
82+
node_zero = tmp_path / "gpu_health_node_0.json"
83+
node_zero.write_text(json.dumps(_snapshot("healthy")), encoding="utf-8")
84+
reader = HardwareHealthReader(
85+
tmp_path,
86+
"run",
87+
True,
88+
stale_after_s=30,
89+
expected_node_count=2,
90+
monitor_started_unix_ns=0,
91+
)
92+
93+
initial_index, initial_findings = reader.poll(now_unix_ns=20_000_000_000)
94+
assert initial_index.overall_status == "healthy"
95+
assert initial_index.missing_node_ranks == ()
96+
assert initial_findings == []
97+
98+
missing_index, missing_findings = reader.poll(now_unix_ns=31_000_000_000)
99+
assert missing_index.overall_status == "unavailable"
100+
assert missing_index.missing_node_ranks == (1,)
101+
assert missing_index.summary()["nodes"][1] == {
102+
"node_rank": 1,
103+
"hostname": None,
104+
"status": "unavailable",
105+
"sample_age_s": None,
106+
"source": None,
107+
"error": "gpu_health_snapshot_not_generated",
108+
"gpus": [],
109+
}
110+
assert [finding["finding_type"] for finding in missing_findings] == [
111+
"gpu_health_snapshot_missing"
112+
]
113+
assert missing_findings[0]["node_rank"] == 1
114+
assert missing_findings[0]["expected_file"] == "gpu_health_node_1.json"
115+
assert reader.poll(now_unix_ns=32_000_000_000)[1] == []
116+
117+
node_one = tmp_path / "gpu_health_node_1.json"
118+
node_one.write_text(
119+
json.dumps(
120+
_snapshot(
121+
"healthy",
122+
node_rank=1,
123+
hostname="host-b",
124+
collected_at_unix_ns=32_000_000_000,
125+
)
126+
),
127+
encoding="utf-8",
128+
)
129+
recovered_index, recovered_findings = reader.poll(now_unix_ns=33_000_000_000)
130+
assert recovered_index.overall_status == "healthy"
131+
assert recovered_index.missing_node_ranks == ()
132+
assert recovered_findings == []
133+
134+
node_one.unlink()
135+
repeated_findings = reader.poll(now_unix_ns=34_000_000_000)[1]
136+
assert [finding["finding_type"] for finding in repeated_findings] == [
137+
"gpu_health_snapshot_missing"
138+
]

0 commit comments

Comments
 (0)