Skip to content

Commit ad5c7c8

Browse files
lukebaumanncopybara-github
authored andcommitted
Implement pathways-based in-process snapshot elasticity in MaxText.
PiperOrigin-RevId: 936955022
1 parent 8304999 commit ad5c7c8

2 files changed

Lines changed: 176 additions & 113 deletions

File tree

pathwaysutils/elastic/elastic.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ def validate(self) -> Set[int]:
121121
_logger.debug(
122122
"Caught JaxRuntimeError for slice_index=%s: %s", slice_index, error
123123
)
124-
if not is_error_due_to_slice_down(error):
124+
if not is_error_due_to_slice_down(error, log_traceback=False):
125125
raise
126-
return active_slice_indices
126+
return frozenset(active_slice_indices)
127127

128128

129129

@@ -249,7 +249,9 @@ def wait_for_slices(
249249
time.sleep(time_to_sleep)
250250

251251

252-
def is_error_due_to_slice_down(error: Exception) -> bool:
252+
def is_error_due_to_slice_down(
253+
error: Exception, log_traceback: bool = True
254+
) -> bool:
253255
"""Returns True if the error is due to slice down.
254256
255257
The error types that are considered due to slice down are
@@ -261,6 +263,7 @@ def is_error_due_to_slice_down(error: Exception) -> bool:
261263
262264
Args:
263265
error: The error to check.
266+
log_traceback: If True, log the traceback of the error.
264267
"""
265268
error_due_to_slice_down = False
266269
traceback_logging_level = logging.DEBUG
@@ -290,9 +293,7 @@ def is_error_due_to_slice_down(error: Exception) -> bool:
290293

291294
error_due_to_slice_down = True
292295

293-
if not error_due_to_slice_down:
294-
_logger.debug("Caught an error not due to slice down")
295-
296-
_logger.log(traceback_logging_level, "Error details:", exc_info=True)
296+
if not error_due_to_slice_down and log_traceback:
297+
_logger.log(traceback_logging_level, "Error details:", exc_info=True)
297298

298299
return error_due_to_slice_down

pathwaysutils/elastic/manager.py

Lines changed: 168 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)