@@ -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 , ...],
0 commit comments