@@ -86,17 +86,24 @@ class Manager:
8686 Attributes:
8787 slice_to_devices: A mapping from slice index to a sequence of `jax.Device`
8888 objects for that slice.
89- all_slice_indices: A set of all possible slice indices.
90- active_slice_indices: A set of indices of the currently active slices.
91- new_slice_event: A `threading.Event` that is set when new slices become
92- available during replica/resize mode.
89+ all_slice_indices: A set of all possible slice indices in the allocation.
90+ active_slice_indices: A set of indices of the slices currently participating
91+ in the JAX computation mesh.
92+ inactive_slice_indices: A set of indices of the slices in the allocation
93+ that are not currently participating in the JAX computation mesh (e.g.
94+ because they are down or have not joined yet).
95+ available_inactive_slices: A set of indices of the inactive slices that
96+ have been detected as up and healthy by the background monitor thread,
97+ but have not yet been joined to the active JAX computation mesh.
9398 """
9499
95100 slice_to_devices : Mapping [int , Sequence [jax .Device ]]
96101 all_slice_indices : Set [int ]
97102 active_slice_indices : Set [int ]
98- new_slice_event : threading .Event
99-
103+ inactive_slice_indices : Set [int ]
104+ available_inactive_slices : Set [int ]
105+ _stop_event : threading .Event | None
106+ _monitor_thread : threading .Thread | None
100107 def __init__ (self , devices : Sequence [jax .Device ] | None = None ) -> None :
101108 """Initializes the manager.
102109
@@ -107,12 +114,55 @@ def __init__(self, devices: Sequence[jax.Device] | None = None) -> None:
107114 devices = jax .devices ()
108115 self .slice_to_devices = elastic .get_slice_to_devices (devices )
109116
110- self .all_slice_indices = set (self .slice_to_devices .keys ())
117+ self .all_slice_indices = frozenset (self .slice_to_devices .keys ())
111118
112119 self .active_slice_indices = elastic .get_active_slice_indices (
113120 slice_to_devices = self .slice_to_devices
114121 )
115- self .new_slice_event = threading .Event ()
122+ self .inactive_slice_indices = self .all_slice_indices - self .active_slice_indices
123+ self .available_inactive_slices = frozenset ()
124+
125+ self ._stop_event = None
126+ self ._monitor_thread = None
127+
128+ def start_monitoring (self , poll_interval : float | int = 10 ) -> None :
129+ """Starts the background monitor thread.
130+
131+ Args:
132+ poll_interval: The number of seconds to wait between activity checks.
133+ """
134+ if self ._monitor_thread is not None and self ._monitor_thread .is_alive ():
135+ _logger .warning ("Monitor thread is already running." )
136+ return
137+
138+ self ._stop_event = threading .Event ()
139+ self ._monitor_thread = threading .Thread (
140+ target = self ._monitor_new_slices ,
141+ args = (self ._stop_event , poll_interval ),
142+ daemon = True ,
143+ )
144+ self ._monitor_thread .start ()
145+ _logger .info ("Elastic monitor thread started with interval %s." , poll_interval )
146+
147+ def close (self ) -> None :
148+ """Stops the background monitor thread."""
149+ if self ._stop_event is not None :
150+ self ._stop_event .set ()
151+ if self ._monitor_thread is not None :
152+ _logger .info ("Closing manager, waiting for monitor thread to stop..." )
153+ try :
154+ self ._monitor_thread .join (timeout = 5 )
155+ if self ._monitor_thread .is_alive ():
156+ _logger .warning (
157+ "Elastic monitor thread failed to stop within 5s timeout."
158+ )
159+ except RuntimeError as e :
160+ if "cannot join thread" in str (e ):
161+ pass
162+ else :
163+ raise
164+ self ._monitor_thread = None
165+ self ._stop_event = None
116166
117167 @functools .cached_property
118168 def total_slice_count (self ) -> int :
@@ -135,10 +185,7 @@ def active_slice_count(self) -> int:
135185 """The number of active slices."""
136186 return len (self .active_slice_indices )
137187
138- @property
139- def inactive_slice_indices (self ) -> set [int ]:
140- """The set of inactive slice indices."""
141- return self .all_slice_indices - self .active_slice_indices # pyrefly: ignore[bad-return]
188+
142189
143190 def scale_by_active_slices (self , x : int | float ) -> int | float :
144191 """Scale x by the number of active slices."""
@@ -171,37 +218,57 @@ def _cleanup_on_retry(self):
171218 for array in jax .live_arrays ():
172219 array .delete ()
173220
174- def _monitor_new_slices (
175- self , stop_event : threading .Event , poll_interval : float | int
176- ) -> None :
177- """Monitors for new slices and sets the `new_slice_event` if found."""
178- while not stop_event .wait (poll_interval ):
179- try :
180- if not self .inactive_slice_indices :
181- _logger .debug ("No inactive slices to check." )
182- continue
221+ def _check_inactive_slices (self ) -> None :
222+ """Checks inactive slices and updates available_inactive_slices."""
223+ if not self .inactive_slice_indices :
224+ _logger .debug ("No inactive slices to check." )
225+ if self .available_inactive_slices :
226+ self .available_inactive_slices = frozenset ()
227+ return
183228
184- _logger .debug (
185- "Checking inactive slices: %s" , self .inactive_slice_indices
186- )
187- inactive_slice_to_devices = {
188- i : self .slice_to_devices [i ] for i in self .inactive_slice_indices
189- }
190- newly_active_indices = elastic .get_active_slice_indices (
191- inactive_slice_to_devices
192- )
229+ _logger .debug (
230+ "Now checking inactive slices %s" , self .inactive_slice_indices
231+ )
232+ inactive_slice_to_devices = {
233+ i : self .slice_to_devices [i ] for i in self .inactive_slice_indices
234+ }
235+ found_slices = elastic .get_active_slice_indices (
236+ inactive_slice_to_devices
237+ )
193238
194- if newly_active_indices :
195- _logger .info (
196- "New slices found: %s. Setting new slice event." ,
197- newly_active_indices ,
198- )
199- self .new_slice_event .set ()
200- return
239+ _logger .debug (
240+ "Found available and inactive slices %s" , found_slices
241+ )
242+
243+ # Filter against active_slice_indices in case the main thread initiated scale-up
244+ # and claimed these slices while this background health check was running.
245+ found_slices = found_slices - self .active_slice_indices
246+
247+ if found_slices != self .available_inactive_slices :
248+ _logger .info (
249+ "Newly available but inactive slices %s" , found_slices
250+ )
251+ self .available_inactive_slices = frozenset (found_slices )
201252
202- _logger .debug ("No new slices found." )
203- except Exception : # pylint: disable=broad-exception-caught
204- _logger .exception ("Error in monitor thread" )
253+ def _monitor_new_slices (
254+ self , stop_event : threading .Event , poll_interval : float | int
255+ ) -> None :
256+ """Monitors for new slices and updates available_inactive_slices."""
257+ _logger .info ("Elastic monitor thread started." )
258+ try :
259+ while not stop_event .wait (poll_interval ):
260+ try :
261+ self ._check_inactive_slices ()
262+ except Exception : # pylint: disable=broad-exception-caught
263+ _logger .exception ("Error in monitor thread loop" )
264+ except BaseException as e :
265+ _logger .critical (
266+ "Catastrophic error in monitor thread, thread is dying!" ,
267+ exc_info = True ,
268+ )
269+ raise
270+ finally :
271+ _logger .info ("Elastic monitor thread stopped." )
205272
206273 def elastic_retry (
207274 self ,
@@ -227,10 +294,10 @@ def elastic_retry(
227294 (i.e., it waits for all slices to be active).
228295
229296 When `minimum_slice_count` is less than the total number of slices, a
230- background thread will monitor for new slices becoming available and set
231- `self.new_slice_event `. The user code can then poll this event and raise
232- a `ScaleUpSignalError` to gracefully interrupt the current execution and
233- trigger a retry.
297+ background thread will monitor for newly joined inactive slices and populate
298+ `self.available_inactive_slices `. User code can check this set (e.g. at step
299+ boundaries) and raise a `ScaleUpSignalError` to gracefully interrupt the
300+ current execution and trigger a retry with the expanded hardware .
234301
235302 Often, the function will dispatch JAX operations and wait for them to
236303 complete while creating a log message. If using Python logging, it is
@@ -286,6 +353,7 @@ def elastic_retry(
286353 def decorator (func : _F ) -> _F :
287354 @functools .wraps (func )
288355 def wrapper (* args : Any , ** kwargs : Any ) -> Any :
356+ self .start_monitoring (poll_interval )
289357
290358 def attempt_execution (attempt : int ) -> Any :
291359 _logger .info ("Elastic attempt %d" , attempt )
@@ -295,73 +363,67 @@ def attempt_execution(attempt: int) -> Any:
295363 poll_interval = poll_interval ,
296364 timeout = timeout ,
297365 )
366+ self .inactive_slice_indices = (
367+ self .all_slice_indices - self .active_slice_indices
368+ )
369+ # Reset available_inactive_slices at attempt start since
370+ # active_slice_indices has just been updated by wait_for_slices.
371+ self .available_inactive_slices = frozenset ()
298372 if pre_callback is not None :
299373 pre_callback ()
300374
301375 with jax .default_device (self .default_device ):
302- self .new_slice_event .clear ()
303- stop_event = threading .Event ()
304-
305- if target_slice_count < self .total_slice_count :
306- monitor_thread = threading .Thread (
307- target = self ._monitor_new_slices ,
308- args = (stop_event , poll_interval ),
309- daemon = True ,
310- )
311- monitor_thread .start ()
312- else :
313- monitor_thread = None
314-
376+ return func (* args , ** kwargs )
377+
378+ def handle_scale_up_error (attempt : int , error : ScaleUpSignalError ) -> None :
379+ _logger .info ("Scale up requested." )
380+ _elastic_event_cleanup ()
381+ # Reset available_inactive_slices before retry callback and next attempt.
382+ self .available_inactive_slices = frozenset ()
383+
384+ if on_elastic_event_callback is not None :
385+ on_elastic_event_callback ()
386+
387+ if not retry_policy (attempt , error ):
388+ _logger .info ("Retry policy rejected retry after ScaleUpSignalError." )
389+ raise ElasticRuntimeError (
390+ f"Elastic attempt { attempt } failed."
391+ ) from error
392+
393+ def handle_slice_down_error (
394+ attempt : int , error : jax .errors .JaxRuntimeError
395+ ) -> None :
396+ if not elastic .is_error_due_to_slice_down (error ):
397+ raise
398+
399+ _logger .exception ("Elastic event detected" )
400+ _elastic_event_cleanup ()
401+ # Reset available_inactive_slices on slice-down failure before retry.
402+ self .available_inactive_slices = frozenset ()
403+
404+ if on_elastic_event_callback is not None :
405+ on_elastic_event_callback ()
406+
407+ if not retry_policy (attempt , error ):
408+ _logger .info (
409+ "Retry policy rejected retry after jax.errors.JaxRuntimeError."
410+ )
411+ raise ElasticRuntimeError (
412+ f"Elastic attempt { attempt } failed."
413+ ) from error
414+
415+ try :
416+ attempt = 1
417+ while True :
315418 try :
316- return func (* args , ** kwargs )
317- finally :
318- stop_event .set ()
319- if monitor_thread is not None :
320- monitor_thread .join ()
321-
322- attempt = 1
323- while True :
324- try :
325- return attempt_execution (attempt )
326- except ScaleUpSignalError as error :
327- _logger .info ("Scale up requested." )
328- _elastic_event_cleanup ()
329-
330- if on_elastic_event_callback is not None :
331- on_elastic_event_callback ()
332-
333- if not retry_policy (attempt , error ):
334- _logger .info (
335- "Retry policy rejected retry after ScaleUpSignalError."
336- )
337- raise ElasticRuntimeError (
338- f"Elastic attempt { attempt } failed."
339- ) from error
340-
341- _logger .info ("Retrying." )
342- except jax .errors .JaxRuntimeError as error :
343- if not elastic .is_error_due_to_slice_down (error ):
344- raise
345-
346- if self .new_slice_event .is_set ():
347- _logger .info ("Slice down event and new slice available detected." )
348- else :
349- _logger .info ("Slice down event detected." )
350-
351- _elastic_event_cleanup ()
352-
353- if on_elastic_event_callback is not None :
354- on_elastic_event_callback ()
355-
356- if not retry_policy (attempt , error ):
357- _logger .info ("Retry policy rejected retry after JaxRuntimeError." )
358- raise ElasticRuntimeError (
359- f"Elastic attempt { attempt } failed."
360- ) from error
361-
362- _logger .info ("Retrying." )
363-
364- attempt += 1
419+ return attempt_execution (attempt )
420+ except ScaleUpSignalError as error :
421+ handle_scale_up_error (attempt , error )
422+ except jax .errors .JaxRuntimeError as error :
423+ handle_slice_down_error (attempt , error )
424+ attempt += 1
425+ finally :
426+ self .close ()
365427
366428 return wrapper # pyrefly: ignore[bad-return]
367429
0 commit comments