pathwaysutils 0.1.6__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/PKG-INFO +2 -2
  2. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/__init__.py +4 -3
  3. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/collect_profile.py +3 -3
  4. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/debug/watchdog.py +19 -6
  5. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/elastic/elastic.py +46 -15
  6. pathwaysutils-0.1.8/pathwaysutils/elastic/manager.py +398 -0
  7. pathwaysutils-0.1.8/pathwaysutils/experimental/concatenate_by_mesh_axis.py +152 -0
  8. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/profiling.py +3 -2
  9. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/reshard.py +14 -12
  10. pathwaysutils-0.1.8/pathwaysutils/experimental/shared_pathways_service/deploy_pathways_service.py +261 -0
  11. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/shared_pathways_service/gke_utils.py +29 -1
  12. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/shared_pathways_service/isc_pathways.py +170 -12
  13. pathwaysutils-0.1.8/pathwaysutils/experimental/shared_pathways_service/metrics_collector.py +296 -0
  14. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/shared_pathways_service/run_connect_example.py +6 -0
  15. pathwaysutils-0.1.8/pathwaysutils/experimental/shared_pathways_service/run_workload.py +180 -0
  16. pathwaysutils-0.1.8/pathwaysutils/experimental/shared_pathways_service/yamls/pw-service.yaml +152 -0
  17. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/split_by_mesh_axis.py +4 -2
  18. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/jax/__init__.py +20 -18
  19. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/lru_cache.py +7 -3
  20. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/persistence/helper.py +9 -8
  21. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/persistence/orbax_handler.py +2 -2
  22. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/plugin_executable.py +3 -4
  23. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/profiling.py +130 -31
  24. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/proxy_backend.py +4 -4
  25. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/reshard.py +2 -1
  26. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/debug/timing_test.py +20 -16
  27. pathwaysutils-0.1.8/pathwaysutils/test/debug/watchdog_test.py +185 -0
  28. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/profiling_test.py +194 -39
  29. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/proxy_backend_test.py +2 -2
  30. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pyproject.toml +1 -1
  31. pathwaysutils-0.1.6/pathwaysutils/elastic/manager.py +0 -171
  32. pathwaysutils-0.1.6/pathwaysutils/experimental/shared_pathways_service/yamls/pw-service-example.yaml +0 -18
  33. pathwaysutils-0.1.6/pathwaysutils/test/debug/watchdog_test.py +0 -94
  34. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/LICENSE +0 -0
  35. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/README.md +0 -0
  36. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/_initialize.py +0 -0
  37. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/debug/__init__.py +0 -0
  38. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/debug/timing.py +0 -0
  39. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/elastic/__init__.py +0 -0
  40. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/__init__.py +0 -0
  41. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/shared_pathways_service/README.md +0 -0
  42. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/shared_pathways_service/__init__.py +0 -0
  43. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/shared_pathways_service/validators.py +0 -0
  44. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/experimental/shared_pathways_service/yamls/pw-proxy.yaml +0 -0
  45. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/persistence/__init__.py +0 -0
  46. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/__init__.py +0 -0
  47. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/debug/__init__.py +0 -0
  48. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/experimental/reshard_test.py +0 -0
  49. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/initialize_test.py +0 -0
  50. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/lru_cache_test.py +0 -0
  51. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/persistence_test.py +0 -0
  52. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/plugin_executable_test.py +0 -0
  53. {pathwaysutils-0.1.6 → pathwaysutils-0.1.8}/pathwaysutils/test/reshard_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pathwaysutils
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Package of Pathways-on-Cloud utilities.
5
5
  Keywords:
6
6
  Author-email: Pathways-on-Cloud Utilities Developers <pathwaysutils-dev@google.com>
@@ -10,7 +10,7 @@ Classifier: License :: OSI Approved :: Apache Software License
10
10
  License-File: LICENSE
11
11
  Requires-Dist: absl-py
12
12
  Requires-Dist: fastapi
13
- Requires-Dist: jax>=0.7.2
13
+ Requires-Dist: jax>=0.8.0
14
14
  Requires-Dist: orbax-checkpoint
15
15
  Requires-Dist: uvicorn
16
16
  Requires-Dist: requests
@@ -12,12 +12,13 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
  """Package of Pathways-on-Cloud utilities."""
15
+ from collections.abc import Callable
15
16
  from pathwaysutils import _initialize
16
17
 
17
- initialize = _initialize.initialize
18
- is_pathways_backend_used = _initialize.is_pathways_backend_used
18
+ initialize: Callable[[], None] = _initialize.initialize
19
+ is_pathways_backend_used: Callable[[], bool] = _initialize.is_pathways_backend_used
19
20
 
20
21
  del _initialize
21
22
 
22
23
  # When changing this, also update the CHANGELOG.md.
23
- __version__ = "v0.1.6"
24
+ __version__ = "v0.1.8"
@@ -26,7 +26,7 @@ _logger = logging.getLogger(__name__)
26
26
  _logger.setLevel(logging.INFO)
27
27
 
28
28
 
29
- _DESCRIPTION = """
29
+ _DESCRIPTION: str = """
30
30
  To profile running JAX programs, you first need to start the profiler server
31
31
  in the program of interest. You can do this via
32
32
  `jax.profiler.start_server(<port>)`. Once the program is running and the
@@ -36,7 +36,7 @@ for a provided duration. The trace file will be dumped into a GCS bucket
36
36
  """
37
37
 
38
38
 
39
- def _get_parser():
39
+ def _get_parser() -> argparse.ArgumentParser:
40
40
  """Returns an argument parser for the collect_profile script."""
41
41
  parser = argparse.ArgumentParser(description=_DESCRIPTION)
42
42
  parser.add_argument(
@@ -62,7 +62,7 @@ def _get_parser():
62
62
  return parser
63
63
 
64
64
 
65
- def main():
65
+ def main() -> None:
66
66
  parser = _get_parser()
67
67
  args = parser.parse_args()
68
68
 
@@ -43,12 +43,17 @@ def _log_thread_stack(thread: threading.Thread):
43
43
 
44
44
 
45
45
  @contextlib.contextmanager
46
- def watchdog(timeout: float, repeat: bool = True):
46
+ def watchdog(
47
+ name: str,
48
+ timeout: float,
49
+ repeat: bool = True,
50
+ ):
47
51
  """Watchdog context manager.
48
52
 
49
53
  Prints the stack trace of all threads after `timeout` seconds.
50
54
 
51
55
  Args:
56
+ name: The name of the watchdog, used in log messages.
52
57
  timeout: The timeout in seconds. If the timeout is reached, the stack trace
53
58
  of all threads will be printed.
54
59
  repeat: Whether to repeat the watchdog after the timeout. If False, the
@@ -63,7 +68,10 @@ def watchdog(timeout: float, repeat: bool = True):
63
68
  count = 0
64
69
  while not event.wait(timeout):
65
70
  _logger.debug(
66
- "Watchdog thread dump every %s seconds. Count: %s", timeout, count
71
+ "'%s' watchdog thread stack dump every %s seconds. Count: %s",
72
+ name,
73
+ timeout,
74
+ count,
67
75
  )
68
76
  try:
69
77
  for thread in threading.enumerate():
@@ -73,17 +81,22 @@ def watchdog(timeout: float, repeat: bool = True):
73
81
  _logger.debug("Error print traceback for thread: %s", thread.ident)
74
82
  finally:
75
83
  if not repeat:
76
- _logger.critical("Timeout from watchdog!")
84
+ _logger.critical("Timeout from watchdog '%s'", name)
77
85
  os.abort()
78
86
 
79
87
  count += 1
80
88
 
81
- _logger.debug("Registering watchdog")
82
- watchdog_thread = threading.Thread(target=handler, name="watchdog")
89
+ _logger.debug(
90
+ "Registering '%s' watchdog with timeout %s seconds and repeat=%s",
91
+ name,
92
+ timeout,
93
+ repeat,
94
+ )
95
+ watchdog_thread = threading.Thread(target=handler, name=name)
83
96
  watchdog_thread.start()
84
97
  try:
85
98
  yield
86
99
  finally:
87
100
  event.set()
88
101
  watchdog_thread.join()
89
- _logger.debug("Deregistering watchdog")
102
+ _logger.debug("Deregistering '%s' watchdog", name)
@@ -19,7 +19,7 @@ events. It also provides a utility for waiting for slices to become active.
19
19
  """
20
20
 
21
21
  import collections
22
- from collections.abc import Mapping, Sequence
22
+ from collections.abc import Mapping, Sequence, Set
23
23
  import logging
24
24
  import time
25
25
 
@@ -83,7 +83,7 @@ def _simple_execution(devices: Sequence[jax.Device]) -> jax.Array:
83
83
 
84
84
  def get_slice_to_devices(
85
85
  devices: Sequence[jax.Device],
86
- ) -> dict[int, Sequence[jax.Device]]:
86
+ ) -> Mapping[int, Sequence[jax.Device]]:
87
87
  """Returns the mapping from slice index to devices."""
88
88
  slice_to_devices = collections.defaultdict(list)
89
89
  for d in devices:
@@ -94,7 +94,7 @@ def get_slice_to_devices(
94
94
  @timing.timeit
95
95
  def get_active_slice_indices(
96
96
  slice_to_devices: Mapping[int, Sequence[jax.Device]] | None = None,
97
- ) -> set[int]:
97
+ ) -> Set[int]:
98
98
  """Returns the set of active slices indices.
99
99
 
100
100
  Args:
@@ -106,8 +106,14 @@ def get_active_slice_indices(
106
106
  A set of integers representing the indices of the active slices.
107
107
  """
108
108
  if slice_to_devices is None:
109
+ _logger.debug("slice_to_devices is None. Getting from jax.devices().")
109
110
  slice_to_devices = get_slice_to_devices(tuple(jax.devices()))
110
111
 
112
+ _logger.debug(
113
+ "Getting active slice indices for slices: %s",
114
+ sorted(list(slice_to_devices.keys())),
115
+ )
116
+
111
117
  active_slice_indices = set()
112
118
 
113
119
  results = {
@@ -116,17 +122,19 @@ def get_active_slice_indices(
116
122
  }
117
123
 
118
124
  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)
120
126
  expected = (
121
127
  np.zeros(len(slice_to_devices[slice_index]), dtype=float)
122
128
  + _SIMPLE_EXECUTION_TEST_VALUE
123
129
  )
124
130
  try:
125
131
  with timing.Timer(f"Checking {slice_index=}"):
132
+ _logger.debug("Blocking until ready for slice_index=%s", slice_index)
126
133
  jax.block_until_ready(x)
134
+ _logger.debug("Execution finished for slice_index=%s", slice_index)
127
135
  if np.allclose(x, expected):
128
136
  active_slice_indices.add(slice_index)
129
- _logger.info("slice_index=%s active", slice_index)
137
+ _logger.debug("slice_index=%s active", slice_index)
130
138
  else:
131
139
  _logger.error(
132
140
  "Error with _simple_execution for slice_index=%s. "
@@ -139,11 +147,15 @@ def get_active_slice_indices(
139
147
  f"Error with _simple_execution for slice_index={slice_index}."
140
148
  )
141
149
  except jax.errors.JaxRuntimeError as error:
150
+ _logger.debug(
151
+ "Caught JaxRuntimeError for slice_index=%s: %s", slice_index, error
152
+ )
142
153
  if not is_error_due_to_slice_down(error):
154
+ _logger.info("Re-raising error for slice_index=%s", slice_index)
143
155
  raise
144
- _logger.info("slice_index=%s bad", slice_index)
156
+ _logger.debug("slice_index=%s bad", slice_index)
145
157
 
146
- _logger.info("active_slice_indices=%s", active_slice_indices)
158
+ _logger.debug("active_slice_indices=%s", active_slice_indices)
147
159
 
148
160
  return active_slice_indices
149
161
 
@@ -153,7 +165,7 @@ def wait_for_slices(
153
165
  poll_interval: float | int = 10,
154
166
  timeout: float | int | None = None,
155
167
  slice_to_devices: Mapping[int, Sequence[jax.Device]] | None = None,
156
- ) -> set[int]:
168
+ ) -> Set[int]:
157
169
  """Waits until after at least `slice_count` slices become active.
158
170
 
159
171
  Args:
@@ -174,22 +186,36 @@ def wait_for_slices(
174
186
  active.
175
187
  """
176
188
  if slice_to_devices is None:
189
+ _logger.debug("slice_to_devices is None. Getting from jax.devices().")
177
190
  slice_to_devices = get_slice_to_devices(jax.devices())
178
191
 
192
+ _logger.info(
193
+ "Waiting for %s slices. Poll interval: %s, Timeout: %s",
194
+ slice_count,
195
+ poll_interval,
196
+ timeout,
197
+ )
179
198
  start_time = time.time()
180
199
 
181
200
  while True:
182
201
  check_start_time = time.time()
183
202
 
203
+ _logger.debug("Checking active slices...")
184
204
  active_slice_indices = get_active_slice_indices(slice_to_devices)
185
205
  if len(active_slice_indices) >= slice_count:
186
- _logger.info("%s slices active.", len(active_slice_indices))
206
+ _logger.info(
207
+ "Sufficient slices active: %s >= %s. Active indices: %s",
208
+ len(active_slice_indices),
209
+ slice_count,
210
+ active_slice_indices,
211
+ )
187
212
  return active_slice_indices
188
213
 
189
214
  _logger.info(
190
- "%s slices active. Wanting at least %s.",
215
+ "%s slices active. Wanting at least %s. Active indices: %s",
191
216
  len(active_slice_indices),
192
217
  slice_count,
218
+ active_slice_indices,
193
219
  )
194
220
 
195
221
  time_to_sleep = max(0, poll_interval - (time.time() - check_start_time))
@@ -206,7 +232,7 @@ def wait_for_slices(
206
232
  )
207
233
 
208
234
  if time_to_sleep > 0:
209
- _logger.info("Sleeping for %.2f seconds.", time_to_sleep)
235
+ _logger.debug("Sleeping for %.2f seconds.", time_to_sleep)
210
236
 
211
237
  time.sleep(time_to_sleep)
212
238
 
@@ -228,10 +254,14 @@ def is_error_due_to_slice_down(error: Exception) -> bool:
228
254
  traceback_logging_level = logging.DEBUG
229
255
 
230
256
  if isinstance(error, jax.errors.JaxRuntimeError):
257
+ _logger.debug("Checking if JaxRuntimeError is due to slice down: %s", error)
231
258
  if any(
232
259
  error_type in str(error) for error_type in _ELASTIC_DOWN_ERROR_TYPES
233
260
  ):
234
- _logger.info("Caught an error due to slice down")
261
+ _logger.debug(
262
+ "Caught an error due to slice down (matched"
263
+ " _ELASTIC_DOWN_ERROR_TYPES)"
264
+ )
235
265
 
236
266
  error_due_to_slice_down = True
237
267
 
@@ -240,15 +270,16 @@ def is_error_due_to_slice_down(error: Exception) -> bool:
240
270
  for error_type in _ELASTIC_DOWN_ADDITIONAL_ERROR_TYPES
241
271
  ):
242
272
  _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."
273
+ "Caught an error that may or may not be due to slice down (matched"
274
+ " _ELASTIC_DOWN_ADDITIONAL_ERROR_TYPES). This error will be treated"
275
+ " as due to slice down."
245
276
  )
246
277
  traceback_logging_level = logging.WARNING
247
278
 
248
279
  error_due_to_slice_down = True
249
280
 
250
281
  if not error_due_to_slice_down:
251
- _logger.info("Caught an error not due to slice down")
282
+ _logger.debug("Caught an error not due to slice down")
252
283
 
253
284
  _logger.log(traceback_logging_level, "Error details:", exc_info=True)
254
285
 
@@ -0,0 +1,398 @@
1
+ # Copyright 2025 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
+ from collections.abc import Callable, Mapping, Sequence, Set
22
+ import functools
23
+ import logging
24
+ import threading
25
+ from typing import Any, TypeVar
26
+ import warnings
27
+
28
+ import jax
29
+ from pathwaysutils.elastic import elastic
30
+
31
+
32
+ _logger = logging.getLogger(__name__)
33
+
34
+
35
+ class ElasticRuntimeError(RuntimeError):
36
+ """Error raised when elasticity cannot continue."""
37
+
38
+
39
+ class ScaleUpSignalError(Exception):
40
+ """Signals that the workload is ready to scale up.
41
+
42
+ This exception should be raised by user code when it detects that new hardware
43
+ is available and it wants to restart computation to make use of it.
44
+ Raising this exception will interrupt the current computation and cause the
45
+ elasticity manager to retry it with an updated slice configuration that
46
+ includes the new hardware.
47
+ """
48
+
49
+
50
+ _F = TypeVar("_F", bound=Callable[..., Any])
51
+
52
+
53
+ def _elastic_event_cleanup() -> None:
54
+ """Cleans up JAX profiles, caches, and live arrays."""
55
+ try:
56
+ _logger.info("Cleaning up any ongoing traces")
57
+ jax.profiler.stop_trace()
58
+ except (RuntimeError, ValueError):
59
+ _logger.info("No ongoing traces to clean up")
60
+ except Exception:
61
+ _logger.exception("Error cleaning up ongoing traces")
62
+ raise
63
+
64
+ jax.clear_caches()
65
+ for array in jax.live_arrays():
66
+ array.delete()
67
+
68
+
69
+ class Manager:
70
+ """Utility class for elastic training.
71
+
72
+ Attributes:
73
+ slice_to_devices: A mapping from slice index to a sequence of `jax.Device`
74
+ objects for that slice.
75
+ all_slice_indices: A set of all possible slice indices.
76
+ active_slice_indices: A set of indices of the currently active slices.
77
+ new_slice_event: A `threading.Event` that is set when new slices become
78
+ available during replica/resize mode.
79
+ """
80
+
81
+ slice_to_devices: Mapping[int, Sequence[jax.Device]]
82
+ all_slice_indices: Set[int]
83
+ active_slice_indices: Set[int]
84
+ new_slice_event: threading.Event
85
+
86
+ def __init__(self, devices: Sequence[jax.Device] | None = None) -> None:
87
+ """Initializes the manager.
88
+
89
+ Args:
90
+ devices: The devices to use. If None, jax.devices() is used.
91
+ """
92
+ if devices is None:
93
+ devices = jax.devices()
94
+ self.slice_to_devices = elastic.get_slice_to_devices(devices)
95
+
96
+ self.all_slice_indices = set(self.slice_to_devices.keys())
97
+
98
+ self.active_slice_indices = elastic.get_active_slice_indices(
99
+ slice_to_devices=self.slice_to_devices
100
+ )
101
+ self.new_slice_event = threading.Event()
102
+
103
+ @functools.cached_property
104
+ def total_slice_count(self) -> int:
105
+ """The total number of slices."""
106
+ return len(self.slice_to_devices)
107
+
108
+ @property
109
+ def default_device(self) -> jax.Device:
110
+ """The device that should be set to the default device.
111
+
112
+ This will be from one of the slices in `active_slice_indices`.
113
+ """
114
+ try:
115
+ return self.slice_to_devices[next(iter(self.active_slice_indices))][0]
116
+ except StopIteration as error:
117
+ raise ValueError("No active slices") from error
118
+
119
+ @property
120
+ def active_slice_count(self) -> int:
121
+ """The number of active slices."""
122
+ return len(self.active_slice_indices)
123
+
124
+ @property
125
+ def inactive_slice_indices(self) -> set[int]:
126
+ """The set of inactive slice indices."""
127
+ return self.all_slice_indices - self.active_slice_indices
128
+
129
+ def scale_by_active_slices(self, x: int | float) -> int | float:
130
+ """Scale x by the number of active slices."""
131
+ if isinstance(x, int):
132
+ quotient, remainder = divmod(
133
+ x * self.active_slice_count, self.total_slice_count
134
+ )
135
+ if remainder:
136
+ raise ValueError(
137
+ f"Cannot scale {x=} by active slices because it will result in a "
138
+ f"remainder of {remainder=}."
139
+ )
140
+ return quotient
141
+ elif isinstance(x, float):
142
+ return x * self.active_slice_count / self.total_slice_count
143
+ else:
144
+ raise ValueError(f"Unsupported type: {type(x)=}")
145
+
146
+ def _cleanup_on_retry(self):
147
+ """Cleans up JAX caches and traces on retry."""
148
+ try:
149
+ _logger.debug("Cleaning up any ongoing traces")
150
+ jax.profiler.stop_trace()
151
+ except (RuntimeError, ValueError):
152
+ _logger.debug("No ongoing traces to clean up")
153
+ except Exception: # pylint: disable=broad-exception-caught
154
+ _logger.exception("Error cleaning up ongoing traces")
155
+
156
+ jax.clear_caches()
157
+ for array in jax.live_arrays():
158
+ array.delete()
159
+
160
+ def _monitor_new_slices(
161
+ self, stop_event: threading.Event, poll_interval: float | int
162
+ ) -> None:
163
+ """Monitors for new slices and sets the `new_slice_event` if found."""
164
+ while not stop_event.wait(poll_interval):
165
+ try:
166
+ if not self.inactive_slice_indices:
167
+ _logger.debug("No inactive slices to check.")
168
+ continue
169
+
170
+ _logger.debug(
171
+ "Checking inactive slices: %s", self.inactive_slice_indices
172
+ )
173
+ inactive_slice_to_devices = {
174
+ i: self.slice_to_devices[i] for i in self.inactive_slice_indices
175
+ }
176
+ newly_active_indices = elastic.get_active_slice_indices(
177
+ inactive_slice_to_devices
178
+ )
179
+
180
+ if newly_active_indices:
181
+ _logger.info(
182
+ "New slices found: %s. Setting new slice event.",
183
+ newly_active_indices,
184
+ )
185
+ self.new_slice_event.set()
186
+ return
187
+
188
+ _logger.debug("No new slices found.")
189
+ except Exception: # pylint: disable=broad-exception-caught
190
+ _logger.exception("Error in monitor thread")
191
+
192
+ def elastic_retry(
193
+ self,
194
+ max_retries: int,
195
+ minimum_slice_count: int | None = None,
196
+ poll_interval: float | int = 10,
197
+ timeout: float | None = None,
198
+ pre_callback: Callable[..., Any] | None = None,
199
+ on_elastic_event_callback: Callable[..., Any] | None = None,
200
+ ) -> Callable[[_F], _F]:
201
+ """Retries a function with elasticity fault tolerance.
202
+
203
+ This decorator wraps a function to automatically retry execution in case of
204
+ `jax.errors.JaxRuntimeError` caused by slice down events. It waits for
205
+ `minimum_slice_count` active slices before each attempt and cleans up JAX
206
+ caches on failure.
207
+
208
+ If `minimum_slice_count` is not met, the function will wait until at least
209
+ `minimum_slice_count` slices are active before execution. If
210
+ `minimum_slice_count` is None, it defaults to the total number of slices
211
+ (i.e., it waits for all slices to be active).
212
+
213
+ When `minimum_slice_count` is less than the total number of slices, a
214
+ background thread will monitor for new slices becoming available and set
215
+ `self.new_slice_event`. The user code can then poll this event and raise
216
+ a `ScaleUpSignalError` to gracefully interrupt the current execution and
217
+ trigger a retry.
218
+
219
+ Often, the function will dispatch JAX operations and wait for them to
220
+ complete while creating a log message. If using Python logging, it is
221
+ recommended to set `logging.raiseExceptions=True` to ensure that the
222
+ `jax.errors.JaxRuntimeError` is not silently ignored within the logging
223
+ call.
224
+
225
+ Args:
226
+ max_retries: The maximum number of times to retry the function.
227
+ minimum_slice_count: The minimum number of slices required to run the
228
+ function. If None, defaults to the total number of slices.
229
+ poll_interval: The number of seconds to wait between activity checks.
230
+ Defaults to 10 seconds.
231
+ timeout: The maximum number of seconds to wait for slices to become active
232
+ before each retry attempt. If None, there is no timeout.
233
+ pre_callback: A callback to call before the function is attempted.
234
+ on_elastic_event_callback: A callback to call after an elastic failure
235
+ occurs.
236
+
237
+ Returns:
238
+ A decorator that retries the wrapped function.
239
+
240
+ Raises:
241
+ ElasticRuntimeError: If all retry attempts fail.
242
+ Exception: Any other exception raised by the wrapped function that is not
243
+ due to a slice down event.
244
+ """
245
+ target_slice_count = (
246
+ self.total_slice_count
247
+ if minimum_slice_count is None
248
+ else minimum_slice_count
249
+ )
250
+
251
+ if max_retries <= 0:
252
+ raise ValueError("max_retries must be positive.")
253
+
254
+ def decorator(func: _F) -> _F:
255
+ @functools.wraps(func)
256
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
257
+
258
+ def attempt_execution(retry_index: int) -> Any:
259
+ _logger.info(
260
+ "Elastic attempt %d out of %d", retry_index + 1, max_retries
261
+ )
262
+ self.active_slice_indices = elastic.wait_for_slices(
263
+ slice_count=target_slice_count,
264
+ slice_to_devices=self.slice_to_devices,
265
+ poll_interval=poll_interval,
266
+ timeout=timeout,
267
+ )
268
+ if pre_callback is not None:
269
+ pre_callback()
270
+
271
+ with jax.default_device(self.default_device):
272
+ self.new_slice_event.clear()
273
+ stop_event = threading.Event()
274
+
275
+ if target_slice_count < self.total_slice_count:
276
+ monitor_thread = threading.Thread(
277
+ target=self._monitor_new_slices,
278
+ args=(stop_event, poll_interval),
279
+ daemon=True,
280
+ )
281
+ monitor_thread.start()
282
+ else:
283
+ monitor_thread = None
284
+
285
+ try:
286
+ return func(*args, **kwargs)
287
+ finally:
288
+ stop_event.set()
289
+ if monitor_thread is not None:
290
+ monitor_thread.join()
291
+
292
+ for retry_index in range(max_retries):
293
+ try:
294
+ return attempt_execution(retry_index)
295
+ except ScaleUpSignalError:
296
+ _logger.info("Scale up requested. Retrying.")
297
+ _elastic_event_cleanup()
298
+
299
+ if on_elastic_event_callback is not None:
300
+ on_elastic_event_callback()
301
+ except jax.errors.JaxRuntimeError as error:
302
+ if not elastic.is_error_due_to_slice_down(error):
303
+ raise
304
+
305
+ if self.new_slice_event.is_set():
306
+ _logger.info(
307
+ "Slice down event and new slice available detected. Retrying."
308
+ )
309
+ else:
310
+ _logger.info("Slice down event detected. Retrying.")
311
+
312
+ _elastic_event_cleanup()
313
+
314
+ if on_elastic_event_callback is not None:
315
+ on_elastic_event_callback()
316
+ else:
317
+ raise ElasticRuntimeError(
318
+ f"Elastic attempt {max_retries} out of {max_retries} failed."
319
+ )
320
+
321
+ return wrapper
322
+
323
+ return decorator
324
+
325
+ def pause_resume(
326
+ self,
327
+ max_retries: int,
328
+ poll_interval: float | int = 10,
329
+ timeout: float | None = None,
330
+ pre_callback: Callable[..., Any] | None = None,
331
+ on_elastic_event_callback: Callable[..., Any] | None = None,
332
+ ) -> Callable[[_F], _F]:
333
+ """Retries a function with pause/resume fault tolerance.
334
+
335
+ DEPRECATED: Use `elastic_retry` instead.
336
+
337
+ Args:
338
+ max_retries: The maximum number of times to retry the function.
339
+ poll_interval: The number of seconds to wait between activity checks.
340
+ Defaults to 10 seconds.
341
+ timeout: The maximum number of seconds to wait for slices to become active
342
+ before each retry attempt. If None, there is no timeout.
343
+ pre_callback: A callback to call before the function is attempted.
344
+ on_elastic_event_callback: A callback to call after an elastic failure
345
+ occurs.
346
+
347
+ Returns:
348
+ A decorator that retries the wrapped function.
349
+ """
350
+ warnings.warn(
351
+ "`pause_resume` is deprecated. Please use `elastic_retry` instead.",
352
+ DeprecationWarning,
353
+ stacklevel=2,
354
+ )
355
+ return self.elastic_retry(
356
+ max_retries=max_retries,
357
+ minimum_slice_count=None,
358
+ poll_interval=poll_interval,
359
+ timeout=timeout,
360
+ pre_callback=pre_callback,
361
+ on_elastic_event_callback=on_elastic_event_callback,
362
+ )
363
+
364
+ def replica_resize(
365
+ self,
366
+ max_resizes: int,
367
+ poll_interval: float = 10,
368
+ pre_callback: Callable[..., Any] | None = None,
369
+ on_elastic_event_callback: Callable[..., Any] | None = None,
370
+ ) -> Callable[[_F], _F]:
371
+ """Retries a function with replica/resize fault tolerance.
372
+
373
+ DEPRECATED: Use `elastic_retry` instead.
374
+
375
+ Args:
376
+ max_resizes: The maximum number of times to retry the function after
377
+ resizing the replica count.
378
+ poll_interval: The number of seconds to wait between active slice checks.
379
+ Defaults to 10 seconds.
380
+ pre_callback: A callback to call before the function is attempted.
381
+ on_elastic_event_callback: A callback to call after an elastic failure
382
+ occurs.
383
+
384
+ Returns:
385
+ A decorator that retries the wrapped function.
386
+ """
387
+ warnings.warn(
388
+ "`replica_resize` is deprecated. Please use `elastic_retry` instead.",
389
+ DeprecationWarning,
390
+ stacklevel=2,
391
+ )
392
+ return self.elastic_retry(
393
+ max_retries=max_resizes,
394
+ minimum_slice_count=1,
395
+ poll_interval=poll_interval,
396
+ pre_callback=pre_callback,
397
+ on_elastic_event_callback=on_elastic_event_callback,
398
+ )