Skip to content

Commit 40d561c

Browse files
lukebaumannSharon Yu
authored andcommitted
Add a replica_resize decorator for fault tolerance in elastic Pathways.
PiperOrigin-RevId: 857246882
1 parent 83d0aa3 commit 40d561c

2 files changed

Lines changed: 183 additions & 13 deletions

File tree

pathwaysutils/elastic/elastic.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,14 @@ def get_active_slice_indices(
106106
A set of integers representing the indices of the active slices.
107107
"""
108108
if slice_to_devices is None:
109+
_logger.debug("slice_to_devices is None. Getting from jax.devices().")
109110
slice_to_devices = get_slice_to_devices(tuple(jax.devices()))
110111

112+
_logger.debug(
113+
"Getting active slice indices for slices: %s",
114+
sorted(list(slice_to_devices.keys())),
115+
)
116+
111117
active_slice_indices = set()
112118

113119
results = {
@@ -116,17 +122,19 @@ def get_active_slice_indices(
116122
}
117123

118124
for slice_index, x in results.items():
119-
_logger.info("Checking slice_index=%s", slice_index)
125+
_logger.debug("Checking slice_index=%s", slice_index)
120126
expected = (
121127
np.zeros(len(slice_to_devices[slice_index]), dtype=float)
122128
+ _SIMPLE_EXECUTION_TEST_VALUE
123129
)
124130
try:
125131
with timing.Timer(f"Checking {slice_index=}"):
132+
_logger.debug("Blocking until ready for slice_index=%s", slice_index)
126133
jax.block_until_ready(x)
134+
_logger.debug("Execution finished for slice_index=%s", slice_index)
127135
if np.allclose(x, expected):
128136
active_slice_indices.add(slice_index)
129-
_logger.info("slice_index=%s active", slice_index)
137+
_logger.debug("slice_index=%s active", slice_index)
130138
else:
131139
_logger.error(
132140
"Error with _simple_execution for slice_index=%s. "
@@ -140,8 +148,9 @@ def get_active_slice_indices(
140148
)
141149
except jax.errors.JaxRuntimeError as error:
142150
if not is_error_due_to_slice_down(error):
151+
_logger.info("Re-raising error for slice_index=%s", slice_index)
143152
raise
144-
_logger.info("slice_index=%s bad", slice_index)
153+
_logger.debug("slice_index=%s bad", slice_index)
145154

146155
_logger.info("active_slice_indices=%s", active_slice_indices)
147156

@@ -174,22 +183,36 @@ def wait_for_slices(
174183
active.
175184
"""
176185
if slice_to_devices is None:
186+
_logger.debug("slice_to_devices is None. Getting from jax.devices().")
177187
slice_to_devices = get_slice_to_devices(jax.devices())
178188

189+
_logger.info(
190+
"Waiting for %s slices. Poll interval: %s, Timeout: %s",
191+
slice_count,
192+
poll_interval,
193+
timeout,
194+
)
179195
start_time = time.time()
180196

181197
while True:
182198
check_start_time = time.time()
183199

200+
_logger.debug("Checking active slices...")
184201
active_slice_indices = get_active_slice_indices(slice_to_devices)
185202
if len(active_slice_indices) >= slice_count:
186-
_logger.info("%s slices active.", len(active_slice_indices))
203+
_logger.info(
204+
"Sufficient slices active: %s >= %s. Active indices: %s",
205+
len(active_slice_indices),
206+
slice_count,
207+
active_slice_indices,
208+
)
187209
return active_slice_indices
188210

189211
_logger.info(
190-
"%s slices active. Wanting at least %s.",
212+
"%s slices active. Wanting at least %s. Active indices: %s",
191213
len(active_slice_indices),
192214
slice_count,
215+
active_slice_indices,
193216
)
194217

195218
time_to_sleep = max(0, poll_interval - (time.time() - check_start_time))
@@ -206,7 +229,7 @@ def wait_for_slices(
206229
)
207230

208231
if time_to_sleep > 0:
209-
_logger.info("Sleeping for %.2f seconds.", time_to_sleep)
232+
_logger.debug("Sleeping for %.2f seconds.", time_to_sleep)
210233

211234
time.sleep(time_to_sleep)
212235

@@ -228,10 +251,14 @@ def is_error_due_to_slice_down(error: Exception) -> bool:
228251
traceback_logging_level = logging.DEBUG
229252

230253
if isinstance(error, jax.errors.JaxRuntimeError):
254+
_logger.debug("Checking if JaxRuntimeError is due to slice down: %s", error)
231255
if any(
232256
error_type in str(error) for error_type in _ELASTIC_DOWN_ERROR_TYPES
233257
):
234-
_logger.info("Caught an error due to slice down")
258+
_logger.debug(
259+
"Caught an error due to slice down (matched"
260+
" _ELASTIC_DOWN_ERROR_TYPES)"
261+
)
235262

236263
error_due_to_slice_down = True
237264

@@ -240,15 +267,16 @@ def is_error_due_to_slice_down(error: Exception) -> bool:
240267
for error_type in _ELASTIC_DOWN_ADDITIONAL_ERROR_TYPES
241268
):
242269
_logger.warning(
243-
"Caught an error due that may or may not be due to slice down. This"
244-
" error will be treated as due to slice down."
270+
"Caught an error that may or may not be due to slice down (matched"
271+
" _ELASTIC_DOWN_ADDITIONAL_ERROR_TYPES). This error will be treated"
272+
" as due to slice down."
245273
)
246274
traceback_logging_level = logging.WARNING
247275

248276
error_due_to_slice_down = True
249277

250278
if not error_due_to_slice_down:
251-
_logger.info("Caught an error not due to slice down")
279+
_logger.debug("Caught an error not due to slice down")
252280

253281
_logger.log(traceback_logging_level, "Error details:", exc_info=True)
254282

pathwaysutils/elastic/manager.py

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
events. It also provides a utility for waiting for slices to become active.
1919
"""
2020

21+
import _thread
2122
from collections.abc import Callable, Mapping, Sequence
2223
import functools
2324
import logging
25+
import threading
2426
from typing import Any, TypeVar
2527

2628
import 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

Comments
 (0)