1818events. It also provides a utility for waiting for slices to become active.
1919"""
2020
21+ import _thread
2122from collections .abc import Callable , Mapping , Sequence
2223import functools
2324import logging
25+ import threading
2426from typing import Any , TypeVar
2527
2628import jax
@@ -34,6 +36,10 @@ class ElasticRuntimeError(RuntimeError):
3436 """Error raised when elasticity cannot continue."""
3537
3638
39+ class NewSliceAvailableError (RuntimeError ):
40+ """Error raised when a new slice is available."""
41+
42+
3743_F = TypeVar ("_F" , bound = Callable [..., Any ])
3844
3945
@@ -59,6 +65,7 @@ class Manager:
5965 _total_slice_count : int | None = None
6066 slice_to_devices : Mapping [int , Sequence [jax .Device ]]
6167 active_slice_indices : set [int ]
68+ new_slice_event : threading .Event
6269
6370 def __init__ (self , devices : Sequence [jax .Device ] | None = None ) -> None :
6471 """Initializes the manager.
@@ -70,9 +77,12 @@ def __init__(self, devices: Sequence[jax.Device] | None = None) -> None:
7077 devices = jax .devices ()
7178 self .slice_to_devices = elastic .get_slice_to_devices (devices )
7279
80+ self .all_slice_indices = set (self .slice_to_devices .keys ())
81+
7382 self .active_slice_indices = elastic .get_active_slice_indices (
7483 slice_to_devices = self .slice_to_devices
7584 )
85+ self .new_slice_event = threading .Event ()
7686
7787 @property
7888 def total_slice_count (self ) -> int :
@@ -97,6 +107,11 @@ def active_slice_count(self) -> int:
97107 """Returns the number of slices."""
98108 return len (self .active_slice_indices )
99109
110+ @property
111+ def inactive_slice_indices (self ) -> set [int ]:
112+ """Returns the set of inactive slice indices."""
113+ return self .all_slice_indices - self .active_slice_indices
114+
100115 def scale_by_active_slices (self , x : int | float ) -> int | float :
101116 """Scale x by the number of active slices."""
102117 if isinstance (x , int ):
@@ -114,6 +129,20 @@ def scale_by_active_slices(self, x: int | float) -> int | float:
114129 else :
115130 raise ValueError (f"Unsupported type: { type (x )= } " )
116131
132+ def _cleanup_on_retry (self ):
133+ """Cleans up JAX caches and traces on retry."""
134+ try :
135+ _logger .debug ("Cleaning up any ongoing traces" )
136+ jax .profiler .stop_trace ()
137+ except (RuntimeError , ValueError ):
138+ _logger .debug ("No ongoing traces to clean up" )
139+ except Exception : # pylint: disable=broad-exception-caught
140+ _logger .exception ("Error cleaning up ongoing traces" )
141+
142+ jax .clear_caches ()
143+ for array in jax .live_arrays ():
144+ array .delete ()
145+
117146 def _elasticity_retry_decorator (
118147 self ,
119148 max_retries : int ,
@@ -132,7 +161,6 @@ def _elasticity_retry_decorator(
132161 Returns:
133162 A function decorator.
134163 """
135-
136164 if max_retries <= 0 :
137165 raise ValueError ("max_retries must be positive." )
138166 def decorator (func : _F ) -> _F :
@@ -148,20 +176,37 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
148176
149177 with jax .default_device (self .default_device ):
150178 return func (* args , ** kwargs )
179+ except NewSliceAvailableError :
180+ _logger .info ("New slice available. Retrying." )
181+ _elastic_event_cleanup ()
182+
183+ if on_elastic_event_callback is not None :
184+ on_elastic_event_callback ()
151185 except jax .errors .JaxRuntimeError as error :
152186 if not elastic .is_error_due_to_slice_down (error ):
153187 raise
154188
189+ if self .new_slice_event .is_set ():
190+ _logger .info (
191+ "Slice down event and new slice available detected. Retrying."
192+ )
193+ else :
194+ _logger .info ("Slice down event detected. Retrying." )
195+
155196 _elastic_event_cleanup ()
156197
157198 if on_elastic_event_callback is not None :
158199 on_elastic_event_callback ()
159- else :
200+ raise ElasticRuntimeError (
201+ f"Elastic attempt { max_retries } out of { max_retries } failed."
202+ )
203+
204+ return wrapper
205+ else :
160206 raise ElasticRuntimeError (
161207 f"Elastic attempt { max_retries } out of { max_retries } failed."
162208 )
163209
164- return wrapper
165210 return decorator
166211
167212 def pause_resume (
@@ -219,3 +264,100 @@ def internal_pre_callback():
219264 pre_callback = internal_pre_callback ,
220265 on_elastic_event_callback = on_elastic_event_callback ,
221266 )
267+
268+ def _monitor_new_slices (
269+ self , stop_event : threading .Event , poll_interval : float | int
270+ ):
271+ """Monitors for new slices and sets the `new_slice_event` if found."""
272+ while not stop_event .wait (poll_interval ):
273+ try :
274+ if not self .inactive_slice_indices :
275+ _logger .debug ("No inactive slices to check." )
276+ continue
277+
278+ _logger .debug (
279+ "Checking inactive slices: %s" , self .inactive_slice_indices
280+ )
281+ inactive_slice_to_devices = {
282+ i : self .slice_to_devices [i ] for i in self .inactive_slice_indices
283+ }
284+ newly_active_indices = elastic .get_active_slice_indices (
285+ inactive_slice_to_devices
286+ )
287+
288+ if newly_active_indices :
289+ _logger .info (
290+ "New slices found: %s. Setting new slice event." ,
291+ newly_active_indices ,
292+ )
293+ self .new_slice_event .set ()
294+ return
295+
296+ _logger .debug ("No new slices found." )
297+ except Exception : # pylint: disable=broad-exception-caught
298+ _logger .exception ("Error in monitor thread" )
299+
300+ def replica_resize (
301+ self ,
302+ max_resizes : int ,
303+ poll_interval : float = 10 ,
304+ pre_callback : Callable [..., Any ] | None = None ,
305+ on_elastic_event_callback : Callable [..., Any ] | None = None ,
306+ ) -> Callable [[_F ], _F ]:
307+ """Retries a function with replica/resize fault tolerance.
308+
309+ Args:
310+ max_resizes: The maximum number of times to retry the function after
311+ resizing the replica count.
312+ poll_interval: The number of seconds to wait between active slice checks.
313+ Defaults to 10 seconds.
314+ pre_callback: A callback to call before the function is attempted.
315+ on_elastic_event_callback: A callback to call after an elastic failure
316+ occurs.
317+
318+ Returns:
319+ The result of the wrapped function.
320+
321+ Raises:
322+ ElasticRuntimeError: If all retry attempts fail.
323+ Exception: Any other exception raised by the wrapped function that is not
324+ due to a slice down event.
325+ """
326+
327+ def internal_pre_callback ():
328+ self .active_slice_indices = elastic .wait_for_slices (
329+ slice_count = 1 ,
330+ slice_to_devices = self .slice_to_devices ,
331+ poll_interval = poll_interval ,
332+ )
333+
334+ if pre_callback is not None :
335+ pre_callback ()
336+
337+ retry_decorator = self ._elasticity_retry_decorator (
338+ max_retries = max_resizes ,
339+ pre_callback = internal_pre_callback ,
340+ on_elastic_event_callback = on_elastic_event_callback ,
341+ )
342+
343+ def decorator (func ):
344+ @functools .wraps (func )
345+ def wrapper (* args , ** kwargs ):
346+ self .new_slice_event .clear ()
347+ stop_event = threading .Event ()
348+
349+ monitor_thread = threading .Thread (
350+ target = self ._monitor_new_slices ,
351+ args = (stop_event , poll_interval ),
352+ daemon = True ,
353+ )
354+ monitor_thread .start ()
355+ try :
356+ return func (* args , ** kwargs )
357+ finally :
358+ stop_event .set ()
359+ monitor_thread .join ()
360+
361+ return retry_decorator (wrapper )
362+
363+ return decorator
0 commit comments