pathwaysutils 0.1.10__tar.gz → 0.1.12__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 (69) hide show
  1. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/PKG-INFO +18 -3
  2. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/__init__.py +1 -1
  3. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/_initialize.py +9 -5
  4. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/collect_profile.py +4 -1
  5. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/debug/watchdog.py +1 -1
  6. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/elastic/elastic.py +15 -9
  7. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/elastic/manager.py +182 -109
  8. pathwaysutils-0.1.12/pathwaysutils/experimental/gke/jobset.py +921 -0
  9. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/README.md +43 -14
  10. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/deploy_pathways_service.py +123 -52
  11. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/gke_utils.py +376 -56
  12. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/isc_pathways.py +269 -97
  13. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/metrics_collector.py +10 -1
  14. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/run_connect_example.py +35 -7
  15. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/run_workload.py +28 -7
  16. pathwaysutils-0.1.12/pathwaysutils/experimental/shared_pathways_service/tpu_specs.py +109 -0
  17. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/validators.py +0 -16
  18. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/split_by_mesh_axis.py +1 -1
  19. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/lru_cache.py +3 -3
  20. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/persistence/helper.py +5 -18
  21. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/plugin_executable.py +2 -2
  22. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/profiling.py +141 -38
  23. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/proxy_backend.py +14 -4
  24. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/reshard.py +1 -1
  25. pathwaysutils-0.1.12/pathwaysutils/test/experimental/gke/jobset_test.py +850 -0
  26. pathwaysutils-0.1.10/pathwaysutils/experimental/shared_pathways_service/yamls/pw-service.yaml → pathwaysutils-0.1.12/pathwaysutils/test/experimental/gke/testdata/model_lite_tpuv5e_4x8.yaml +104 -75
  27. pathwaysutils-0.1.12/pathwaysutils/test/experimental/shared_pathways_service/deploy_pathways_service_test.py +313 -0
  28. pathwaysutils-0.1.12/pathwaysutils/test/experimental/shared_pathways_service/gke_utils_test.py +1599 -0
  29. pathwaysutils-0.1.12/pathwaysutils/test/experimental/shared_pathways_service/isc_pathways_test.py +1507 -0
  30. pathwaysutils-0.1.12/pathwaysutils/test/experimental/shared_pathways_service/metrics_collector_test.py +247 -0
  31. pathwaysutils-0.1.12/pathwaysutils/test/experimental/shared_pathways_service/run_workload_test.py +240 -0
  32. pathwaysutils-0.1.12/pathwaysutils/test/experimental/shared_pathways_service/start_vscode_on_cpu_np_test.py +324 -0
  33. pathwaysutils-0.1.12/pathwaysutils/test/experimental/shared_pathways_service/tpu_specs_test.py +77 -0
  34. pathwaysutils-0.1.12/pathwaysutils/test/experimental/shared_pathways_service/validators_test.py +314 -0
  35. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/initialize_test.py +15 -0
  36. pathwaysutils-0.1.12/pathwaysutils/test/mock_fault_injection.py +293 -0
  37. pathwaysutils-0.1.12/pathwaysutils/test/mock_fault_injection_test.py +201 -0
  38. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/profiling_test.py +335 -19
  39. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/proxy_backend_test.py +41 -0
  40. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pyproject.toml +21 -2
  41. pathwaysutils-0.1.10/pathwaysutils/experimental/gke/jobset.py +0 -579
  42. pathwaysutils-0.1.10/pathwaysutils/persistence/orbax_handler.py +0 -292
  43. pathwaysutils-0.1.10/pathwaysutils/test/experimental/gke/jobset_test.py +0 -210
  44. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/LICENSE +0 -0
  45. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/README.md +0 -0
  46. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/debug/__init__.py +0 -0
  47. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/debug/timing.py +0 -0
  48. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/elastic/__init__.py +0 -0
  49. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/__init__.py +0 -0
  50. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/concatenate_by_mesh_axis.py +0 -0
  51. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/gke/__init__.py +0 -0
  52. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/profiling.py +0 -0
  53. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/reshard.py +0 -0
  54. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/__init__.py +0 -0
  55. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/dockerfiles/sample_sidecar.dockerfile +0 -0
  56. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/start_vscode_on_cpu_np.py +0 -0
  57. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/yamls/code-server.yaml +0 -0
  58. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/experimental/shared_pathways_service/yamls/pw-proxy.yaml +0 -0
  59. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/jax/__init__.py +0 -0
  60. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/persistence/__init__.py +0 -0
  61. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/__init__.py +0 -0
  62. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/debug/__init__.py +0 -0
  63. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/debug/timing_test.py +0 -0
  64. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/debug/watchdog_test.py +0 -0
  65. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/experimental/reshard_test.py +0 -0
  66. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/lru_cache_test.py +0 -0
  67. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/persistence_test.py +0 -0
  68. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/plugin_executable_test.py +0 -0
  69. {pathwaysutils-0.1.10 → pathwaysutils-0.1.12}/pathwaysutils/test/reshard_test.py +0 -0
@@ -1,26 +1,41 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pathwaysutils
3
- Version: 0.1.10
3
+ Version: 0.1.12
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>
7
- Requires-Python: >=3.10
7
+ Requires-Python: >=3.11
8
8
  Description-Content-Type: text/markdown
9
9
  Classifier: License :: OSI Approved :: Apache Software License
10
10
  License-File: LICENSE
11
11
  Requires-Dist: absl-py
12
12
  Requires-Dist: fastapi
13
13
  Requires-Dist: jax>=0.8.3
14
- Requires-Dist: orbax-checkpoint
14
+ Requires-Dist: orbax-checkpoint>=0.12.3
15
15
  Requires-Dist: uvicorn
16
16
  Requires-Dist: requests
17
17
  Requires-Dist: packaging
18
18
  Requires-Dist: pylint>=2.6.0 ; extra == "dev"
19
19
  Requires-Dist: pyink ; extra == "dev"
20
+ Requires-Dist: kubernetes ; extra == "gke"
21
+ Requires-Dist: pyyaml ; extra == "gke"
22
+ Requires-Dist: portpicker ; extra == "sps"
23
+ Requires-Dist: google-cloud-monitoring ; extra == "sps"
24
+ Requires-Dist: portpicker ; extra == "sps-without-monitoring"
25
+ Requires-Dist: google-cloud-monitoring ; extra == "test"
26
+ Requires-Dist: httpx ; extra == "test"
27
+ Requires-Dist: kubernetes ; extra == "test"
28
+ Requires-Dist: portpicker ; extra == "test"
29
+ Requires-Dist: pyyaml ; extra == "test"
30
+ Requires-Dist: pytest ; extra == "test"
20
31
  Project-URL: changelog, https://github.com/AI-Hypercomputer/pathways-utils/blob/main/CHANGELOG.md
21
32
  Project-URL: homepage, https://github.com/AI-Hypercomputer/pathways-utils
22
33
  Project-URL: repository, https://github.com/AI-Hypercomputer/pathways-utils
23
34
  Provides-Extra: dev
35
+ Provides-Extra: gke
36
+ Provides-Extra: sps
37
+ Provides-Extra: sps-without-monitoring
38
+ Provides-Extra: test
24
39
 
25
40
  # Package of Pathways-on-Cloud utilities
26
41
 
@@ -21,4 +21,4 @@ is_pathways_backend_used: Callable[[], bool] = _initialize.is_pathways_backend_u
21
21
  del _initialize
22
22
 
23
23
  # When changing this, also update the CHANGELOG.md.
24
- __version__ = "v0.1.10"
24
+ __version__ = "v0.1.12"
@@ -18,10 +18,11 @@ import logging
18
18
  import os
19
19
 
20
20
  import jax
21
+ from orbax.checkpoint import type_handlers
21
22
  from orbax.checkpoint._src.metadata import array_metadata_store as array_metadata_store_lib
23
+ from orbax.checkpoint._src.serialization import cloud_pathways_array_handler
22
24
  from pathwaysutils import profiling
23
25
  from pathwaysutils import proxy_backend
24
- from pathwaysutils.persistence import orbax_handler
25
26
 
26
27
 
27
28
  _logger = logging.getLogger(__name__)
@@ -91,11 +92,14 @@ def initialize() -> None:
91
92
  _logger.debug("Detected Pathways-on-Cloud backend. Applying changes.")
92
93
  proxy_backend.register_backend_factory()
93
94
  profiling.monkey_patch_jax()
94
- # TODO: b/365549911 - Remove when OCDBT-compatible
95
95
  if _is_persistence_enabled():
96
- orbax_handler.register_pathways_handlers(
97
- timeout=datetime.timedelta(hours=1),
98
- array_metadata_store=array_metadata_store_lib.Store(),
96
+ type_handlers.register_type_handler(
97
+ jax.Array,
98
+ cloud_pathways_array_handler.CloudPathwaysArrayHandler(
99
+ timeout=datetime.timedelta(hours=1),
100
+ array_metadata_store=array_metadata_store_lib.Store(),
101
+ ),
102
+ override=True,
99
103
  )
100
104
 
101
105
  # Turn off JAX compilation cache because Pathways handles its own
@@ -67,7 +67,10 @@ def main() -> None:
67
67
  args = parser.parse_args()
68
68
 
69
69
  if profiling.collect_profile(
70
- args.port, args.duration_ms, args.host, args.log_dir
70
+ args.port,
71
+ args.duration_ms,
72
+ args.host,
73
+ args.log_dir,
71
74
  ):
72
75
  _logger.info("Dumped profiling information in: %s", args.log_dir)
73
76
  else:
@@ -34,7 +34,7 @@ def _log_thread_stack(thread: threading.Thread):
34
34
  _logger.debug(
35
35
  "".join(
36
36
  traceback.format_stack(
37
- sys._current_frames().get( # pylint: disable=protected-access
37
+ sys._current_frames().get( # pylint: disable=protected-access # pyrefly: ignore[no-matching-overload]
38
38
  thread.ident, []
39
39
  )
40
40
  )
@@ -121,9 +121,9 @@ class DefaultSliceHealthChecker(SliceHealthChecker):
121
121
  _logger.debug(
122
122
  "Caught JaxRuntimeError for slice_index=%s: %s", slice_index, error
123
123
  )
124
- if not is_error_due_to_slice_down(error):
124
+ if not is_error_due_to_slice_down(error, log_traceback=False):
125
125
  raise
126
- return active_slice_indices
126
+ return frozenset(active_slice_indices)
127
127
 
128
128
 
129
129
 
@@ -171,7 +171,7 @@ def get_active_slice_indices(
171
171
 
172
172
 
173
173
  def wait_for_slices(
174
- slice_count: int,
174
+ slice_count: int | None = None,
175
175
  poll_interval: float | int = 10,
176
176
  timeout: float | int | None = None,
177
177
  slice_to_devices: Mapping[int, Sequence[jax.Device]] | None = None,
@@ -180,7 +180,8 @@ def wait_for_slices(
180
180
  """Waits until after at least `slice_count` slices become active.
181
181
 
182
182
  Args:
183
- slice_count: The number of slices to wait for.
183
+ slice_count: The number of slices to wait for. If None, defaults to the
184
+ total number of slices.
184
185
  poll_interval: The minimum number of seconds to wait between availability
185
186
  checks. If the check takes longer than this, the next check will start
186
187
  immediately after the current check completes. Defaults to 10 seconds.
@@ -201,6 +202,10 @@ def wait_for_slices(
201
202
  _logger.debug("slice_to_devices is None. Getting from jax.devices().")
202
203
  slice_to_devices = get_slice_to_devices(jax.devices())
203
204
 
205
+ if slice_count is None:
206
+ _logger.debug("slice_count is None. Using len(slice_to_devices).")
207
+ slice_count = len(slice_to_devices)
208
+
204
209
  _logger.info(
205
210
  "Waiting for %s slices. Poll interval: %s, Timeout: %s",
206
211
  slice_count,
@@ -249,7 +254,9 @@ def wait_for_slices(
249
254
  time.sleep(time_to_sleep)
250
255
 
251
256
 
252
- def is_error_due_to_slice_down(error: Exception) -> bool:
257
+ def is_error_due_to_slice_down(
258
+ error: Exception, log_traceback: bool = True
259
+ ) -> bool:
253
260
  """Returns True if the error is due to slice down.
254
261
 
255
262
  The error types that are considered due to slice down are
@@ -261,6 +268,7 @@ def is_error_due_to_slice_down(error: Exception) -> bool:
261
268
 
262
269
  Args:
263
270
  error: The error to check.
271
+ log_traceback: If True, log the traceback of the error.
264
272
  """
265
273
  error_due_to_slice_down = False
266
274
  traceback_logging_level = logging.DEBUG
@@ -290,9 +298,7 @@ def is_error_due_to_slice_down(error: Exception) -> bool:
290
298
 
291
299
  error_due_to_slice_down = True
292
300
 
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)
301
+ if not error_due_to_slice_down and log_traceback:
302
+ _logger.log(traceback_logging_level, "Error details:", exc_info=True)
297
303
 
298
304
  return error_due_to_slice_down
@@ -86,17 +86,24 @@ class Manager:
86
86
  Attributes:
87
87
  slice_to_devices: A mapping from slice index to a sequence of `jax.Device`
88
88
  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.
93
98
  """
94
99
 
95
100
  slice_to_devices: Mapping[int, Sequence[jax.Device]]
96
101
  all_slice_indices: Set[int]
97
102
  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
100
107
  def __init__(self, devices: Sequence[jax.Device] | None = None) -> None:
101
108
  """Initializes the manager.
102
109
 
@@ -107,12 +114,56 @@ class Manager:
107
114
  devices = jax.devices()
108
115
  self.slice_to_devices = elastic.get_slice_to_devices(devices)
109
116
 
110
- self.all_slice_indices = set(self.slice_to_devices.keys())
117
+ self.all_slice_indices = frozenset(self.slice_to_devices.keys())
118
+
119
+ self._active_slice_indices = frozenset()
120
+ self.available_inactive_slices = frozenset()
111
121
 
112
122
  self.active_slice_indices = elastic.get_active_slice_indices(
113
123
  slice_to_devices=self.slice_to_devices
114
124
  )
115
- self.new_slice_event = threading.Event()
125
+
126
+ self._stop_event = None
127
+ self._monitor_thread = None
128
+
129
+ def start_monitoring(self, poll_interval: float | int = 10) -> None:
130
+ """Starts the background monitor thread.
131
+
132
+ Args:
133
+ poll_interval: The number of seconds to wait between activity checks.
134
+ """
135
+ if self._monitor_thread is not None and self._monitor_thread.is_alive():
136
+ _logger.warning("Monitor thread is already running.")
137
+ return
138
+
139
+ self._stop_event = threading.Event()
140
+ self._monitor_thread = threading.Thread(
141
+ target=self._monitor_new_slices,
142
+ args=(self._stop_event, poll_interval),
143
+ daemon=True,
144
+ )
145
+ self._monitor_thread.start()
146
+ _logger.info("Elastic monitor thread started with interval %s.", poll_interval)
147
+
148
+ def close(self) -> None:
149
+ """Stops the background monitor thread."""
150
+ if self._stop_event is not None:
151
+ self._stop_event.set()
152
+ if self._monitor_thread is not None:
153
+ _logger.info("Closing manager, waiting for monitor thread to stop...")
154
+ try:
155
+ self._monitor_thread.join(timeout=5)
156
+ if self._monitor_thread.is_alive():
157
+ _logger.warning(
158
+ "Elastic monitor thread failed to stop within 5s timeout."
159
+ )
160
+ except RuntimeError as e:
161
+ if "cannot join thread" in str(e):
162
+ pass
163
+ else:
164
+ raise
165
+ self._monitor_thread = None
166
+ self._stop_event = None
116
167
 
117
168
  @functools.cached_property
118
169
  def total_slice_count(self) -> int:
@@ -130,16 +181,26 @@ class Manager:
130
181
  except StopIteration as error:
131
182
  raise ValueError("No active slices") from error
132
183
 
184
+ @property
185
+ def active_slice_indices(self) -> frozenset[int]:
186
+ """The indices of active slices."""
187
+ return self._active_slice_indices
188
+
189
+ @active_slice_indices.setter
190
+ def active_slice_indices(self, value: Set[int]) -> None:
191
+ self._active_slice_indices = frozenset(value)
192
+ self.inactive_slice_indices = (
193
+ self.all_slice_indices - self._active_slice_indices
194
+ )
195
+ self.available_inactive_slices = frozenset(
196
+ self.available_inactive_slices - self._active_slice_indices
197
+ )
198
+
133
199
  @property
134
200
  def active_slice_count(self) -> int:
135
201
  """The number of active slices."""
136
202
  return len(self.active_slice_indices)
137
203
 
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
142
-
143
204
  def scale_by_active_slices(self, x: int | float) -> int | float:
144
205
  """Scale x by the number of active slices."""
145
206
  if isinstance(x, int):
@@ -171,37 +232,57 @@ class Manager:
171
232
  for array in jax.live_arrays():
172
233
  array.delete()
173
234
 
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
235
+ def _check_inactive_slices(self) -> None:
236
+ """Checks inactive slices and updates available_inactive_slices."""
237
+ if not self.inactive_slice_indices:
238
+ _logger.debug("No inactive slices to check.")
239
+ if self.available_inactive_slices:
240
+ self.available_inactive_slices = frozenset()
241
+ return
183
242
 
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
- )
243
+ _logger.debug(
244
+ "Now checking inactive slices %s", self.inactive_slice_indices
245
+ )
246
+ inactive_slice_to_devices = {
247
+ i: self.slice_to_devices[i] for i in self.inactive_slice_indices
248
+ }
249
+ found_slices = elastic.get_active_slice_indices(
250
+ inactive_slice_to_devices
251
+ )
193
252
 
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
253
+ _logger.debug(
254
+ "Found available and inactive slices %s", found_slices
255
+ )
201
256
 
202
- _logger.debug("No new slices found.")
203
- except Exception: # pylint: disable=broad-exception-caught
204
- _logger.exception("Error in monitor thread")
257
+ # Filter against active_slice_indices in case the main thread initiated scale-up
258
+ # and claimed these slices while this background health check was running.
259
+ found_slices = found_slices - self.active_slice_indices
260
+
261
+ if found_slices != self.available_inactive_slices:
262
+ _logger.info(
263
+ "Newly available but inactive slices %s", found_slices
264
+ )
265
+ self.available_inactive_slices = frozenset(found_slices)
266
+
267
+ def _monitor_new_slices(
268
+ self, stop_event: threading.Event, poll_interval: float | int
269
+ ) -> None:
270
+ """Monitors for new slices and updates available_inactive_slices."""
271
+ _logger.info("Elastic monitor thread started.")
272
+ try:
273
+ while not stop_event.wait(poll_interval):
274
+ try:
275
+ self._check_inactive_slices()
276
+ except Exception: # pylint: disable=broad-exception-caught
277
+ _logger.exception("Error in monitor thread loop")
278
+ except BaseException as e:
279
+ _logger.critical(
280
+ "Catastrophic error in monitor thread, thread is dying!",
281
+ exc_info=True,
282
+ )
283
+ raise
284
+ finally:
285
+ _logger.info("Elastic monitor thread stopped.")
205
286
 
206
287
  def elastic_retry(
207
288
  self,
@@ -227,10 +308,10 @@ class Manager:
227
308
  (i.e., it waits for all slices to be active).
228
309
 
229
310
  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.
311
+ background thread will monitor for newly joined inactive slices and populate
312
+ `self.available_inactive_slices`. User code can check this set (e.g. at step
313
+ boundaries) and raise a `ScaleUpSignalError` to gracefully interrupt the
314
+ current execution and trigger a retry with the expanded hardware.
234
315
 
235
316
  Often, the function will dispatch JAX operations and wait for them to
236
317
  complete while creating a log message. If using Python logging, it is
@@ -286,6 +367,7 @@ class Manager:
286
367
  def decorator(func: _F) -> _F:
287
368
  @functools.wraps(func)
288
369
  def wrapper(*args: Any, **kwargs: Any) -> Any:
370
+ self.start_monitoring(poll_interval)
289
371
 
290
372
  def attempt_execution(attempt: int) -> Any:
291
373
  _logger.info("Elastic attempt %d", attempt)
@@ -295,75 +377,66 @@ class Manager:
295
377
  poll_interval=poll_interval,
296
378
  timeout=timeout,
297
379
  )
380
+ # Reset available_inactive_slices at attempt start since
381
+ # active_slice_indices has just been updated by wait_for_slices.
382
+ self.available_inactive_slices = frozenset()
298
383
  if pre_callback is not None:
299
384
  pre_callback()
300
385
 
301
386
  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
-
387
+ return func(*args, **kwargs)
388
+
389
+ def handle_scale_up_error(attempt: int, error: ScaleUpSignalError) -> None:
390
+ _logger.info("Scale up requested.")
391
+ _elastic_event_cleanup()
392
+ # Reset available_inactive_slices before retry callback and next attempt.
393
+ self.available_inactive_slices = frozenset()
394
+
395
+ if on_elastic_event_callback is not None:
396
+ on_elastic_event_callback()
397
+
398
+ if not retry_policy(attempt, error):
399
+ _logger.info("Retry policy rejected retry after ScaleUpSignalError.")
400
+ raise ElasticRuntimeError(
401
+ f"Elastic attempt {attempt} failed."
402
+ ) from error
403
+
404
+ def handle_slice_down_error(
405
+ attempt: int, error: jax.errors.JaxRuntimeError
406
+ ) -> None:
407
+ if not elastic.is_error_due_to_slice_down(error):
408
+ raise
409
+
410
+ _logger.exception("Elastic event detected")
411
+ _elastic_event_cleanup()
412
+ # Reset available_inactive_slices on slice-down failure before retry.
413
+ self.available_inactive_slices = frozenset()
414
+
415
+ if on_elastic_event_callback is not None:
416
+ on_elastic_event_callback()
417
+
418
+ if not retry_policy(attempt, error):
419
+ _logger.info(
420
+ "Retry policy rejected retry after jax.errors.JaxRuntimeError."
421
+ )
422
+ raise ElasticRuntimeError(
423
+ f"Elastic attempt {attempt} failed."
424
+ ) from error
425
+
426
+ try:
427
+ attempt = 1
428
+ while True:
315
429
  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
365
-
366
- return wrapper
430
+ return attempt_execution(attempt)
431
+ except ScaleUpSignalError as error:
432
+ handle_scale_up_error(attempt, error)
433
+ except jax.errors.JaxRuntimeError as error:
434
+ handle_slice_down_error(attempt, error)
435
+ attempt += 1
436
+ finally:
437
+ self.close()
438
+
439
+ return wrapper # pyrefly: ignore[bad-return]
367
440
 
368
441
  return decorator
369
442