Skip to content

Commit 33458bf

Browse files
lukebaumanncopybara-github
authored andcommitted
Refactor manager.py.
Extracted functions from the manager class that may be useful without a manager and placed them in elastic.py. Refactored manager_test.py and created elastic_test.py with their respective tests. PiperOrigin-RevId: 874376968
1 parent b752db7 commit 33458bf

3 files changed

Lines changed: 268 additions & 338 deletions

File tree

pathwaysutils/elastic/elastic.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Elasticity manager.
15+
16+
This class provides a utility for elastic training. It provides a decorator that
17+
retries a function in case of `jax.errors.JaxRuntimeError` caused by slice down
18+
events. It also provides a utility for waiting for slices to become active.
19+
"""
20+
21+
import collections
22+
from collections.abc import Mapping, Sequence
23+
import logging
24+
import time
25+
26+
import jax
27+
import numpy as np
28+
from pathwaysutils.debug import timing
29+
30+
31+
_logger = logging.getLogger(__name__)
32+
33+
_SIMPLE_EXECUTION_TEST_VALUE = 100
34+
_ELASTIC_DOWN_ERROR_TYPES = frozenset([
35+
"DATA_LOSS",
36+
])
37+
_ELASTIC_DOWN_ADDITIONAL_ERROR_TYPES = frozenset([
38+
"DEADLINE_EXCEEDED",
39+
"NOT_FOUND",
40+
"INTERNAL",
41+
])
42+
43+
44+
def _plus_one(x: jax.Array) -> jax.Array:
45+
"""Adds one to each element in the array.
46+
47+
Used to test if a slice is active.
48+
49+
Args:
50+
x: The array to add one to.
51+
52+
Returns:
53+
The array with one added to each element.
54+
"""
55+
return x + 1
56+
57+
58+
def _simple_execution(devices: Sequence[jax.Device]) -> jax.Array:
59+
"""Simple execution to test if a slice is active.
60+
61+
This function is used to test if a slice is active. It executes a simple
62+
computation on the devices and returns the result. If any of the devices are
63+
not active, the returned array will fail with a JaxRuntimeError used.
64+
65+
Simply executing this function is not enough to determine if the slice is
66+
active. We also need to check the value of the returned array.
67+
68+
Args:
69+
devices: The devices to execute on.
70+
71+
Returns:
72+
The result of the execution.
73+
"""
74+
if not devices:
75+
raise ValueError("No devices")
76+
77+
test_input = np.zeros(len(devices), dtype=float) + (
78+
_SIMPLE_EXECUTION_TEST_VALUE - 1
79+
)
80+
81+
return jax.pmap(_plus_one, devices=devices)(test_input)
82+
83+
84+
def get_slice_to_devices(
85+
devices: Sequence[jax.Device],
86+
) -> dict[int, Sequence[jax.Device]]:
87+
"""Returns the mapping from slice index to devices."""
88+
slice_to_devices = collections.defaultdict(list)
89+
for d in devices:
90+
slice_to_devices[d.slice_index].append(d)
91+
return dict(slice_to_devices)
92+
93+
94+
@timing.timeit
95+
def get_active_slice_indices(
96+
slice_to_devices: Mapping[int, Sequence[jax.Device]] | None = None,
97+
) -> set[int]:
98+
"""Returns the set of active slices indices.
99+
100+
Args:
101+
slice_to_devices: A mapping from slice index to devices. If None,
102+
`get_slice_to_devices(jax.devices())` is used to gather all available
103+
devices and group them by slice.
104+
105+
Returns:
106+
A set of integers representing the indices of the active slices.
107+
"""
108+
if slice_to_devices is None:
109+
slice_to_devices = get_slice_to_devices(tuple(jax.devices()))
110+
111+
active_slice_indices = set()
112+
113+
results = {
114+
slice_index: _simple_execution(devices)
115+
for slice_index, devices in slice_to_devices.items()
116+
}
117+
118+
for slice_index, x in results.items():
119+
_logger.info("Checking slice_index=%s", slice_index)
120+
expected = (
121+
np.zeros(len(slice_to_devices[slice_index]), dtype=float)
122+
+ _SIMPLE_EXECUTION_TEST_VALUE
123+
)
124+
try:
125+
with timing.Timer(f"Checking {slice_index=}"):
126+
jax.block_until_ready(x)
127+
if np.allclose(x, expected):
128+
active_slice_indices.add(slice_index)
129+
_logger.info("slice_index=%s active", slice_index)
130+
else:
131+
_logger.error(
132+
"Error with _simple_execution for slice_index=%s. "
133+
"This should never happen. Expected: %r, Actual: %r",
134+
slice_index,
135+
expected,
136+
x,
137+
)
138+
raise ValueError(
139+
f"Error with _simple_execution for slice_index={slice_index}."
140+
)
141+
except jax.errors.JaxRuntimeError as error:
142+
if not is_error_due_to_slice_down(error):
143+
raise
144+
_logger.info("slice_index=%s bad", slice_index)
145+
146+
_logger.info("active_slice_indices=%s", active_slice_indices)
147+
148+
return active_slice_indices
149+
150+
151+
def wait_for_slices(
152+
slice_count: int,
153+
poll_interval: float | int = 10,
154+
timeout: float | int | None = None,
155+
slice_to_devices: Mapping[int, Sequence[jax.Device]] | None = None,
156+
) -> set[int]:
157+
"""Waits until after at least `slice_count` slices become active.
158+
159+
Args:
160+
slice_count: The number of slices to wait for.
161+
poll_interval: The minimum number of seconds to wait between availability
162+
checks. If the check takes longer than this, the next check will start
163+
immediately after the current check completes. Defaults to 10 seconds.
164+
timeout: The maximum number of seconds to wait. If None, there is no
165+
timeout.
166+
slice_to_devices: A mapping from slice index to devices. If None,
167+
`get_slice_to_devices(jax.devices())` is used.
168+
169+
Returns:
170+
The active slice indices
171+
172+
Raises:
173+
TimeoutError: If the timeout is reached before the slices become
174+
active.
175+
"""
176+
if slice_to_devices is None:
177+
slice_to_devices = get_slice_to_devices(jax.devices())
178+
179+
start_time = time.time()
180+
181+
while True:
182+
check_start_time = time.time()
183+
184+
active_slice_indices = get_active_slice_indices(slice_to_devices)
185+
if len(active_slice_indices) >= slice_count:
186+
_logger.info("%s slices active.", len(active_slice_indices))
187+
return active_slice_indices
188+
189+
_logger.info(
190+
"%s slices active. Wanting at least %s.",
191+
len(active_slice_indices),
192+
slice_count,
193+
)
194+
195+
time_to_sleep = max(0, poll_interval - (time.time() - check_start_time))
196+
197+
if timeout is not None:
198+
elapsed_time = time.time() - start_time
199+
if elapsed_time + time_to_sleep >= timeout:
200+
raise TimeoutError(
201+
f"Timed out waiting for {slice_count} slices. Only"
202+
f" {len(active_slice_indices)} active after"
203+
f" {elapsed_time:.2f} seconds."
204+
f" Next check would occur after the timeout of {timeout}"
205+
" seconds."
206+
)
207+
208+
if time_to_sleep > 0:
209+
_logger.info("Sleeping for %.2f seconds.", time_to_sleep)
210+
211+
time.sleep(time_to_sleep)
212+
213+
214+
def is_error_due_to_slice_down(error: Exception) -> bool:
215+
"""Returns True if the error is due to slice down.
216+
217+
The error types that are considered due to slice down are
218+
jax.errors.JaxRuntimeError with the following error kind in the message:
219+
- DATA_LOSS
220+
- DEADLINE_EXCEEDED
221+
- NOT_FOUND
222+
- INTERNAL
223+
224+
Args:
225+
error: The error to check.
226+
"""
227+
error_due_to_slice_down = False
228+
traceback_logging_level = logging.DEBUG
229+
230+
if isinstance(error, jax.errors.JaxRuntimeError):
231+
if any(
232+
error_type in str(error) for error_type in _ELASTIC_DOWN_ERROR_TYPES
233+
):
234+
_logger.info("Caught an error due to slice down")
235+
236+
error_due_to_slice_down = True
237+
238+
elif any(
239+
error_type in str(error)
240+
for error_type in _ELASTIC_DOWN_ADDITIONAL_ERROR_TYPES
241+
):
242+
_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."
245+
)
246+
traceback_logging_level = logging.WARNING
247+
248+
error_due_to_slice_down = True
249+
250+
if not error_due_to_slice_down:
251+
_logger.info("Caught an error not due to slice down")
252+
253+
_logger.log(traceback_logging_level, "Error details:", exc_info=True)
254+
255+
return error_due_to_slice_down

0 commit comments

Comments
 (0)