@@ -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 :
@@ -136,9 +186,15 @@ def active_slice_count(self) -> int:
136186 return len (self .active_slice_indices )
137187
138188 @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]
189+ def new_slice_event (self ) -> threading .Event :
190+ """Deprecated compatibility property for un-updated MaxText code.
191+
192+ TODO: b/527183831 - Remove this property once MaxText CL 2 is submitted.
193+ """
194+ event = threading .Event ()
195+ if self .available_inactive_slices :
196+ event .set ()
197+ return event
142198
143199 def scale_by_active_slices (self , x : int | float ) -> int | float :
144200 """Scale x by the number of active slices."""
@@ -171,37 +227,57 @@ def _cleanup_on_retry(self):
171227 for array in jax .live_arrays ():
172228 array .delete ()
173229
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
230+ def _check_inactive_slices (self ) -> None :
231+ """Checks inactive slices and updates available_inactive_slices."""
232+ if not self .inactive_slice_indices :
233+ _logger .debug ("No inactive slices to check." )
234+ if self .available_inactive_slices :
235+ self .available_inactive_slices = frozenset ()
236+ return
183237
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- )
238+ _logger .debug (
239+ "Now checking inactive slices %s" , self .inactive_slice_indices
240+ )
241+ inactive_slice_to_devices = {
242+ i : self .slice_to_devices [i ] for i in self .inactive_slice_indices
243+ }
244+ found_slices = elastic .get_active_slice_indices (
245+ inactive_slice_to_devices
246+ )
193247
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
248+ _logger .debug (
249+ "Found available and inactive slices %s" , found_slices
250+ )
251+
252+ # Filter against active_slice_indices in case the main thread initiated scale-up
253+ # and claimed these slices while this background health check was running.
254+ found_slices = found_slices - self .active_slice_indices
255+
256+ if found_slices != self .available_inactive_slices :
257+ _logger .info (
258+ "Newly available but inactive slices %s" , found_slices
259+ )
260+ self .available_inactive_slices = frozenset (found_slices )
201261
202- _logger .debug ("No new slices found." )
203- except Exception : # pylint: disable=broad-exception-caught
204- _logger .exception ("Error in monitor thread" )
262+ def _monitor_new_slices (
263+ self , stop_event : threading .Event , poll_interval : float | int
264+ ) -> None :
265+ """Monitors for new slices and updates available_inactive_slices."""
266+ _logger .info ("Elastic monitor thread started." )
267+ try :
268+ while not stop_event .wait (poll_interval ):
269+ try :
270+ self ._check_inactive_slices ()
271+ except Exception : # pylint: disable=broad-exception-caught
272+ _logger .exception ("Error in monitor thread loop" )
273+ except BaseException as e :
274+ _logger .critical (
275+ "Catastrophic error in monitor thread, thread is dying!" ,
276+ exc_info = True ,
277+ )
278+ raise
279+ finally :
280+ _logger .info ("Elastic monitor thread stopped." )
205281
206282 def elastic_retry (
207283 self ,
@@ -227,10 +303,10 @@ def elastic_retry(
227303 (i.e., it waits for all slices to be active).
228304
229305 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.
306+ background thread will monitor for newly joined inactive slices and populate
307+ `self.available_inactive_slices `. User code can check this set (e.g. at step
308+ boundaries) and raise a `ScaleUpSignalError` to gracefully interrupt the
309+ current execution and trigger a retry with the expanded hardware .
234310
235311 Often, the function will dispatch JAX operations and wait for them to
236312 complete while creating a log message. If using Python logging, it is
@@ -286,6 +362,7 @@ def elastic_retry(
286362 def decorator (func : _F ) -> _F :
287363 @functools .wraps (func )
288364 def wrapper (* args : Any , ** kwargs : Any ) -> Any :
365+ self .start_monitoring (poll_interval )
289366
290367 def attempt_execution (attempt : int ) -> Any :
291368 _logger .info ("Elastic attempt %d" , attempt )
@@ -295,73 +372,67 @@ def attempt_execution(attempt: int) -> Any:
295372 poll_interval = poll_interval ,
296373 timeout = timeout ,
297374 )
375+ self .inactive_slice_indices = (
376+ self .all_slice_indices - self .active_slice_indices
377+ )
378+ # Reset available_inactive_slices at attempt start since
379+ # active_slice_indices has just been updated by wait_for_slices.
380+ self .available_inactive_slices = frozenset ()
298381 if pre_callback is not None :
299382 pre_callback ()
300383
301384 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-
385+ return func (* args , ** kwargs )
386+
387+ def handle_scale_up_error (attempt : int , error : ScaleUpSignalError ) -> None :
388+ _logger .info ("Scale up requested." )
389+ _elastic_event_cleanup ()
390+ # Reset available_inactive_slices before retry callback and next attempt.
391+ self .available_inactive_slices = frozenset ()
392+
393+ if on_elastic_event_callback is not None :
394+ on_elastic_event_callback ()
395+
396+ if not retry_policy (attempt , error ):
397+ _logger .info ("Retry policy rejected retry after ScaleUpSignalError." )
398+ raise ElasticRuntimeError (
399+ f"Elastic attempt { attempt } failed."
400+ ) from error
401+
402+ def handle_slice_down_error (
403+ attempt : int , error : jax .errors .JaxRuntimeError
404+ ) -> None :
405+ if not elastic .is_error_due_to_slice_down (error ):
406+ raise
407+
408+ _logger .exception ("Elastic event detected" )
409+ _elastic_event_cleanup ()
410+ # Reset available_inactive_slices on slice-down failure before retry.
411+ self .available_inactive_slices = frozenset ()
412+
413+ if on_elastic_event_callback is not None :
414+ on_elastic_event_callback ()
415+
416+ if not retry_policy (attempt , error ):
417+ _logger .info (
418+ "Retry policy rejected retry after jax.errors.JaxRuntimeError."
419+ )
420+ raise ElasticRuntimeError (
421+ f"Elastic attempt { attempt } failed."
422+ ) from error
423+
424+ try :
425+ attempt = 1
426+ while True :
315427 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
428+ return attempt_execution (attempt )
429+ except ScaleUpSignalError as error :
430+ handle_scale_up_error (attempt , error )
431+ except jax .errors .JaxRuntimeError as error :
432+ handle_slice_down_error (attempt , error )
433+ attempt += 1
434+ finally :
435+ self .close ()
365436
366437 return wrapper # pyrefly: ignore[bad-return]
367438
0 commit comments