Skip to content

Commit 7f939c2

Browse files
committed
Make H1 checkpoint phase aware
1 parent 75c3f06 commit 7f939c2

5 files changed

Lines changed: 201 additions & 10 deletions

File tree

flagscale/runner/tracing/analyzer.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class _CollectiveRound:
6767
expected_nranks: int
6868
first_seen_monotonic_s: float
6969
enters: dict[int, dict[str, Any]] = field(default_factory=dict)
70+
detection_phase: str = "unknown"
7071

7172

7273
@dataclass
@@ -93,6 +94,7 @@ def __init__(
9394
heartbeat_timeout_s: float = 30.0,
9495
collective_timeout_s: float = 60.0,
9596
delayed_enter_threshold_s: float = 30.0,
97+
checkpoint_timeout_s: float = 1800.0,
9698
p2p_timeout_s: float = 60.0,
9799
p2p_match_window_s: float = 30.0,
98100
detect_heartbeat_timeouts: bool = False,
@@ -106,6 +108,7 @@ def __init__(
106108
)
107109
self.collective_timeout_s = float(collective_timeout_s)
108110
self.delayed_enter_threshold_s = float(delayed_enter_threshold_s)
111+
self.checkpoint_timeout_s = float(checkpoint_timeout_s)
109112
self.p2p_timeout_s = float(p2p_timeout_s)
110113
self.p2p_match_window_s = float(p2p_match_window_s)
111114
self.detect_heartbeat_timeouts = detect_heartbeat_timeouts
@@ -243,6 +246,8 @@ def scan(
243246
)
244247
completed_rounds: list[tuple[str, str, int]] = []
245248
for key, collective in list(self._rounds.items()):
249+
detection_phase = self._collective_detection_phase(collective)
250+
checkpointing = detection_phase == "checkpointing"
246251
mismatch = self._detect_signature_mismatch(collective, now_unix_ns)
247252
if mismatch is not None:
248253
self._emit_once(("collective_signature_mismatch", *key), mismatch, findings)
@@ -255,19 +260,26 @@ def scan(
255260
# never entered. Keep evaluating the incomplete round for H1.
256261

257262
if len(collective.enters) >= collective.expected_nranks:
258-
delayed = self._detect_delayed_enter(collective, now_unix_ns)
263+
delayed = self._detect_delayed_enter(
264+
collective,
265+
now_unix_ns,
266+
detection_phase=detection_phase,
267+
)
259268
self._emit_once(("delayed_collective_enter", *key), delayed, findings)
260269
completed_rounds.append(key)
261270
continue
262271

263272
elapsed_s = now_monotonic_s - collective.first_seen_monotonic_s
264-
if elapsed_s < self.collective_timeout_s:
273+
timeout_s = self.checkpoint_timeout_s if checkpointing else self.collective_timeout_s
274+
if elapsed_s < timeout_s:
265275
continue
266276
missing = self._detect_missing_enter(
267277
collective,
268278
now_monotonic_s=now_monotonic_s,
269279
now_unix_ns=now_unix_ns,
270280
elapsed_s=elapsed_s,
281+
detection_phase=detection_phase,
282+
timeout_s=timeout_s,
271283
)
272284
self._emit_once(("collective_missing_enter", *key), missing, findings)
273285

@@ -760,7 +772,11 @@ def _detect_signature_mismatch(
760772
)
761773

762774
def _detect_delayed_enter(
763-
self, collective: _CollectiveRound, now_unix_ns: int
775+
self,
776+
collective: _CollectiveRound,
777+
now_unix_ns: int,
778+
*,
779+
detection_phase: str,
764780
) -> Finding | None:
765781
if not collective.enters:
766782
return None
@@ -773,12 +789,17 @@ def _detect_delayed_enter(
773789
earliest = min(timestamps.values())
774790
latest = max(timestamps.values())
775791
spread_s = (latest - earliest) / 1_000_000_000
776-
if spread_s <= self.delayed_enter_threshold_s:
792+
threshold_s = (
793+
self.checkpoint_timeout_s
794+
if detection_phase == "checkpointing"
795+
else self.delayed_enter_threshold_s
796+
)
797+
if spread_s <= threshold_s:
777798
return None
778799
slow_ranks = sorted(
779800
rank
780801
for rank, timestamp in timestamps.items()
781-
if (timestamp - earliest) / 1_000_000_000 > self.delayed_enter_threshold_s
802+
if (timestamp - earliest) / 1_000_000_000 > threshold_s
782803
)
783804
return Finding(
784805
hang_type="delayed_collective_enter",
@@ -791,6 +812,10 @@ def _detect_delayed_enter(
791812
"api": next(iter(collective.enters.values())).get("api"),
792813
"enter_spread_s": spread_s,
793814
"slow_comm_ranks": slow_ranks,
815+
"detection_phase": detection_phase,
816+
"detection_threshold_s": threshold_s,
817+
"threshold_reason": self._threshold_reason(detection_phase),
818+
"reason": "collective_enter_spread_exceeded_threshold",
794819
"clock_assumption": "hosts have synchronized wall clocks",
795820
"confidence": "suspected",
796821
},
@@ -803,6 +828,8 @@ def _detect_missing_enter(
803828
now_monotonic_s: float,
804829
now_unix_ns: int,
805830
elapsed_s: float,
831+
detection_phase: str,
832+
timeout_s: float,
806833
) -> Finding:
807834
entered = sorted(collective.enters)
808835
missing = sorted(set(range(collective.expected_nranks)) - set(entered))
@@ -828,6 +855,7 @@ def _detect_missing_enter(
828855
{
829856
"heartbeat": state,
830857
"heartbeat_age_s": max(0.0, age_s),
858+
"phase": str(heartbeat.event.get("phase") or "unknown"),
831859
}
832860
)
833861
known_states.append(state)
@@ -861,12 +889,49 @@ def _detect_missing_enter(
861889
"missing_comm_ranks": missing,
862890
"missing_rank_status": rank_status,
863891
"waited_s": elapsed_s,
892+
"detection_phase": detection_phase,
893+
"detection_threshold_s": timeout_s,
894+
"threshold_reason": self._threshold_reason(detection_phase),
864895
"reason": reason,
865896
"confidence": confidence,
866897
"trace_event_loss_possible": trace_event_loss_possible,
867898
},
868899
)
869900

901+
def _collective_detection_phase(self, collective: _CollectiveRound) -> str:
902+
"""Return a stable phase for one collective round.
903+
904+
A checkpoint can block the heartbeat publisher, so a round that has once
905+
been correlated with ``checkpointing`` keeps that phase until it resolves.
906+
"""
907+
if collective.detection_phase == "checkpointing":
908+
return collective.detection_phase
909+
910+
global_ranks = {
911+
_as_int(event.get("rank"), default=-1) for event in collective.enters.values()
912+
}
913+
global_ranks.update(
914+
self._comm_members.get((collective.run_id, collective.comm_uid_hash), {}).values()
915+
)
916+
phases = {
917+
str(heartbeat.event.get("phase") or "unknown")
918+
for rank in global_ranks
919+
if rank >= 0 and (heartbeat := self._heartbeats.get(rank)) is not None
920+
}
921+
if "checkpointing" in phases:
922+
collective.detection_phase = "checkpointing"
923+
elif len(phases) == 1:
924+
collective.detection_phase = next(iter(phases))
925+
elif phases:
926+
collective.detection_phase = "mixed"
927+
return collective.detection_phase
928+
929+
@staticmethod
930+
def _threshold_reason(detection_phase: str) -> str:
931+
if detection_phase == "checkpointing":
932+
return "heartbeat_phase_checkpointing"
933+
return "normal_collective_phase"
934+
870935
def _emit_once(
871936
self,
872937
dedupe_key: tuple[Any, ...],

flagscale/runner/tracing/config.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class TraceLaunchConfig:
6161
heartbeat_timeout_s: float = 30.0
6262
collective_timeout_s: float = 60.0
6363
delayed_enter_threshold_s: float = 30.0
64+
checkpoint_timeout_s: float = 1800.0
6465
p2p_timeout_s: float = 60.0
6566
p2p_match_window_s: float = 30.0
6667
failure_grace_period_s: float = 60.0
@@ -121,6 +122,8 @@ def shell_setup_lines(self, node_rank: int) -> list[str]:
121122
f"{self.collective_timeout_s:g}",
122123
"--delayed-enter-threshold",
123124
f"{self.delayed_enter_threshold_s:g}",
125+
"--checkpoint-timeout",
126+
f"{self.checkpoint_timeout_s:g}",
124127
"--p2p-timeout",
125128
f"{self.p2p_timeout_s:g}",
126129
"--p2p-match-window",
@@ -246,6 +249,19 @@ def prepare_trace_launch_config(
246249
collective_timeout_s = _positive_float(
247250
raw_dict.get("collective_timeout_s", 60.0), "collective_timeout_s"
248251
)
252+
delayed_enter_threshold_s = _positive_float(
253+
raw_dict.get("delayed_enter_threshold_s", 30.0),
254+
"delayed_enter_threshold_s",
255+
)
256+
inherited_checkpoint_timeout_s = (
257+
getattr(heartbeat_config, "checkpoint_timeout_s", 1800.0)
258+
if heartbeat_config is not None and heartbeat_config.enabled
259+
else 1800.0
260+
)
261+
checkpoint_timeout_s = _positive_float(
262+
raw_dict.get("checkpoint_timeout_s", inherited_checkpoint_timeout_s),
263+
"checkpoint_timeout_s",
264+
)
249265
p2p_timeout_s = _positive_float(
250266
raw_dict.get("p2p_timeout_s", collective_timeout_s), "p2p_timeout_s"
251267
)
@@ -280,10 +296,8 @@ def prepare_trace_launch_config(
280296
else 30.0
281297
),
282298
collective_timeout_s=collective_timeout_s,
283-
delayed_enter_threshold_s=_positive_float(
284-
raw_dict.get("delayed_enter_threshold_s", 30.0),
285-
"delayed_enter_threshold_s",
286-
),
299+
delayed_enter_threshold_s=delayed_enter_threshold_s,
300+
checkpoint_timeout_s=checkpoint_timeout_s,
287301
p2p_timeout_s=p2p_timeout_s,
288302
p2p_match_window_s=p2p_match_window_s,
289303
failure_grace_period_s=failure_grace_period_s,
@@ -301,5 +315,13 @@ def prepare_trace_launch_config(
301315
raise ValueError(
302316
"tracing.failure_grace_period_s must be at least the largest enabled detection timeout"
303317
)
318+
if resolved.checkpoint_timeout_s < max(
319+
resolved.collective_timeout_s,
320+
resolved.delayed_enter_threshold_s,
321+
):
322+
raise ValueError(
323+
"tracing.checkpoint_timeout_s must be greater than or equal to the normal "
324+
"collective thresholds"
325+
)
304326

305327
return resolved

flagscale/runner/tracing/monitor.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def run_monitor(args: argparse.Namespace) -> int:
110110
heartbeat_timeout_s=args.heartbeat_timeout,
111111
collective_timeout_s=args.collective_timeout,
112112
delayed_enter_threshold_s=args.delayed_enter_threshold,
113+
checkpoint_timeout_s=args.checkpoint_timeout,
113114
p2p_timeout_s=args.p2p_timeout,
114115
p2p_match_window_s=args.p2p_match_window,
115116
)
@@ -190,6 +191,7 @@ def build_parser() -> argparse.ArgumentParser:
190191
parser.add_argument("--heartbeat-timeout", type=float, default=30.0)
191192
parser.add_argument("--collective-timeout", type=float, default=60.0)
192193
parser.add_argument("--delayed-enter-threshold", type=float, default=30.0)
194+
parser.add_argument("--checkpoint-timeout", type=float, default=1800.0)
193195
parser.add_argument("--p2p-timeout", type=float, default=60.0)
194196
parser.add_argument("--p2p-match-window", type=float, default=30.0)
195197
parser.add_argument("--failure-grace-period", type=float, default=60.0)
@@ -210,6 +212,7 @@ def main() -> int:
210212
"heartbeat_timeout",
211213
"collective_timeout",
212214
"delayed_enter_threshold",
215+
"checkpoint_timeout",
213216
"p2p_timeout",
214217
"p2p_match_window",
215218
"failure_grace_period",

tests/unit_tests/runner/tracing/test_analyzer.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,84 @@ def test_delayed_enter_is_reported_after_every_rank_enters():
185185
assert findings[0].details["enter_spread_s"] == 7.0
186186

187187

188+
def test_checkpoint_missing_enter_uses_checkpoint_timeout_and_still_reports_hang():
189+
analyzer = TraceAnalyzer(
190+
run_id=RUN_ID,
191+
collective_timeout_s=5,
192+
checkpoint_timeout_s=20,
193+
)
194+
_ingest(analyzer, _event("comm_init", rank=0, comm_rank=0))
195+
_ingest(analyzer, _event("comm_init", rank=1, comm_rank=1))
196+
_ingest(
197+
analyzer,
198+
_event("heartbeat", rank=1, phase="checkpointing"),
199+
observed=1.0,
200+
)
201+
_ingest(analyzer, _event("nccl_call", rank=0, comm_rank=0), observed=1.0)
202+
203+
assert analyzer.scan(now_monotonic_s=8.0, now_unix_ns=8_000_000_000) == []
204+
205+
findings = analyzer.scan(now_monotonic_s=22.0, now_unix_ns=22_000_000_000)
206+
assert [finding.hang_type for finding in findings] == ["collective_missing_enter"]
207+
assert findings[0].details["detection_phase"] == "checkpointing"
208+
assert findings[0].details["detection_threshold_s"] == 20
209+
assert findings[0].details["threshold_reason"] == "heartbeat_phase_checkpointing"
210+
211+
212+
def test_training_missing_enter_keeps_normal_timeout():
213+
analyzer = TraceAnalyzer(
214+
run_id=RUN_ID,
215+
collective_timeout_s=5,
216+
checkpoint_timeout_s=20,
217+
)
218+
_ingest(analyzer, _event("comm_init", rank=1, comm_rank=1))
219+
_ingest(analyzer, _event("heartbeat", rank=1, phase="train"), observed=1.0)
220+
_ingest(analyzer, _event("nccl_call", rank=0, comm_rank=0), observed=1.0)
221+
222+
findings = analyzer.scan(now_monotonic_s=8.0, now_unix_ns=8_000_000_000)
223+
assert [finding.hang_type for finding in findings] == ["collective_missing_enter"]
224+
assert findings[0].details["detection_phase"] == "train"
225+
assert findings[0].details["detection_threshold_s"] == 5
226+
assert findings[0].details["threshold_reason"] == "normal_collective_phase"
227+
228+
229+
def test_checkpoint_delayed_enter_keeps_phase_after_heartbeat_returns_to_train():
230+
analyzer = TraceAnalyzer(
231+
run_id=RUN_ID,
232+
delayed_enter_threshold_s=5,
233+
checkpoint_timeout_s=20,
234+
)
235+
_ingest(
236+
analyzer,
237+
_event("heartbeat", rank=0, phase="checkpointing"),
238+
observed=1.0,
239+
)
240+
_ingest(
241+
analyzer,
242+
_event("nccl_call", rank=0, comm_rank=0, timestamp_unix_ns=1_000_000_000),
243+
observed=1.0,
244+
)
245+
assert analyzer.scan(now_monotonic_s=2.0, now_unix_ns=2_000_000_000) == []
246+
247+
_ingest(
248+
analyzer,
249+
_event(
250+
"heartbeat",
251+
rank=0,
252+
phase="train",
253+
timestamp_unix_ns=3_000_000_000,
254+
),
255+
observed=3.0,
256+
)
257+
_ingest(
258+
analyzer,
259+
_event("nccl_call", comm_rank=1, rank=1, timestamp_unix_ns=8_000_000_000),
260+
observed=8.0,
261+
)
262+
263+
assert analyzer.scan(now_monotonic_s=9.0, now_unix_ns=9_000_000_000) == []
264+
265+
188266
def test_events_from_other_runs_are_ignored():
189267
analyzer = TraceAnalyzer(run_id=RUN_ID)
190268
accepted = _ingest(analyzer, _event("nccl_call", run_id="other-run"))

tests/unit_tests/runner/tracing/test_config.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import os
16+
from types import SimpleNamespace
1617

1718
import pytest
1819
from omegaconf import OmegaConf
@@ -56,7 +57,13 @@ def test_enabled_tracing_renders_cpu_probe_and_analyzer_shell(tmp_path):
5657
},
5758
)
5859

59-
resolved = prepare_trace_launch_config(config, "run-123")
60+
heartbeat = SimpleNamespace(
61+
enabled=True,
62+
heartbeat_dir=str(tmp_path / "heartbeat"),
63+
process_timeout_s=30,
64+
checkpoint_timeout_s=600,
65+
)
66+
resolved = prepare_trace_launch_config(config, "run-123", heartbeat)
6067
shell = "\n".join(resolved.shell_setup_lines(0))
6168

6269
assert resolved.enabled is True
@@ -67,6 +74,8 @@ def test_enabled_tracing_renders_cpu_probe_and_analyzer_shell(tmp_path):
6774
assert "flagscale.runner.tracing.monitor" in shell
6875
assert "--p2p-timeout 12" in shell
6976
assert "--p2p-match-window 4" in shell
77+
assert "--checkpoint-timeout 600" in shell
78+
assert resolved.checkpoint_timeout_s == 600
7079
assert "rc=\\$?" in resolved.command_body(0)
7180
assert resolved.shell_setup_lines(1)
7281
assert "flagscale.runner.tracing.monitor" not in "\n".join(resolved.shell_setup_lines(1))
@@ -97,6 +106,20 @@ def test_p2p_match_window_cannot_exceed_timeout(tmp_path):
97106
prepare_trace_launch_config(config, "run")
98107

99108

109+
def test_checkpoint_timeout_cannot_be_shorter_than_normal_thresholds(tmp_path):
110+
config = _config(
111+
tmp_path,
112+
{
113+
"enabled": True,
114+
"collective_timeout_s": 10,
115+
"delayed_enter_threshold_s": 5,
116+
"checkpoint_timeout_s": 9,
117+
},
118+
)
119+
with pytest.raises(ValueError, match="checkpoint_timeout_s"):
120+
prepare_trace_launch_config(config, "run")
121+
122+
100123
def test_no_shared_filesystem_is_rejected_for_cross_rank_analysis(tmp_path):
101124
config = _config(tmp_path, {"enabled": True})
102125
config.experiment.runner.no_shared_fs = True

0 commit comments

Comments
 (0)