Skip to content

Commit 35a1301

Browse files
lukebaumanncopybara-github
authored andcommitted
Implement checkpoint-based elasticity using set-based slice tracking in MaxText.
PiperOrigin-RevId: 936972164
1 parent 8304999 commit 35a1301

2 files changed

Lines changed: 184 additions & 112 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: 176 additions & 105 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:
@@ -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

Comments
 (0)