parallelazy 0.0.1__py3-none-any.whl
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.
- parallelazy/__init__.py +43 -0
- parallelazy/environment_manager.py +55 -0
- parallelazy/examples/__init__.py +0 -0
- parallelazy/examples/example_configuration.py +194 -0
- parallelazy/examples/example_count.py +84 -0
- parallelazy/examples/example_data_pipeline.py +226 -0
- parallelazy/examples/example_env_variables.py +158 -0
- parallelazy/examples/example_error_handling.py +266 -0
- parallelazy/examples/example_graceful_shutdown.py +139 -0
- parallelazy/examples/example_ml_training.py +280 -0
- parallelazy/examples/example_pubsub.py +137 -0
- parallelazy/examples/example_rpc_calls.py +182 -0
- parallelazy/examples/example_worker_pool.py +188 -0
- parallelazy/plugin.py +223 -0
- parallelazy/plugin_data.py +466 -0
- parallelazy/plugin_manager.py +617 -0
- parallelazy/plugin_rpc.py +511 -0
- parallelazy/plugin_transport.py +347 -0
- parallelazy/plugin_worker.py +700 -0
- parallelazy/tests/__init__.py +0 -0
- parallelazy/utils.py +71 -0
- parallelazy-0.0.1.dist-info/METADATA +17 -0
- parallelazy-0.0.1.dist-info/RECORD +26 -0
- parallelazy-0.0.1.dist-info/WHEEL +5 -0
- parallelazy-0.0.1.dist-info/licenses/LICENSE +339 -0
- parallelazy-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import logging
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
from torch import cuda
|
|
10
|
+
import torch.nn as nn
|
|
11
|
+
from torch.multiprocessing.reductions import reduce_tensor
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def mark_graph_output(t: torch.Tensor) -> torch.Tensor:
|
|
17
|
+
"""Call this immediately after a reduce-overhead compiled fn returns.
|
|
18
|
+
PT2 compiled graphs sometimes pool memory in ways that shouldn't cross IPC."""
|
|
19
|
+
if isinstance(t, torch.Tensor):
|
|
20
|
+
t._from_graph_pool = True
|
|
21
|
+
return t
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_ipc_received(t: torch.Tensor) -> bool:
|
|
25
|
+
"""True if `t` sits on a CUDA storage that was itself received via IPC
|
|
26
|
+
from another process (and therefore can't be re-shared as-is), OR if
|
|
27
|
+
it was pooled by a compiled graph."""
|
|
28
|
+
if getattr(t, "_from_graph_pool", False):
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
if not t.is_cuda:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
reduce_tensor(t)
|
|
36
|
+
return False
|
|
37
|
+
except RuntimeError as e:
|
|
38
|
+
if "received from another process" in str(e):
|
|
39
|
+
return True
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _module_needs_clone(module: nn.Module) -> bool:
|
|
44
|
+
return any(
|
|
45
|
+
_is_ipc_received(t)
|
|
46
|
+
for t in (*module.parameters(recurse=True), *module.buffers(recurse=True))
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _deepcopy_without_hooks(module: nn.Module) -> nn.Module:
|
|
51
|
+
"""deepcopy(module) will choke if any hook closure holds state that
|
|
52
|
+
isn't deepcopy-able (a Queue, a Channel, a CUDA event, a lock...).
|
|
53
|
+
Hooks are meaningless once the module crosses a process boundary
|
|
54
|
+
anyway, so strip *all* hook registries for the duration of the copy
|
|
55
|
+
and restore them on the original afterward. We iterate by attribute
|
|
56
|
+
name rather than hardcoding _forward_hooks/_forward_pre_hooks/
|
|
57
|
+
_backward_hooks, since torch has added more hook dicts over time
|
|
58
|
+
(_forward_hooks_with_kwargs, _backward_pre_hooks, etc.) and any of
|
|
59
|
+
them can hold non-copyable closures."""
|
|
60
|
+
saved = []
|
|
61
|
+
for m in module.modules():
|
|
62
|
+
hook_attrs = {
|
|
63
|
+
name: val for name, val in vars(m).items()
|
|
64
|
+
if "hook" in name and isinstance(val, OrderedDict)
|
|
65
|
+
}
|
|
66
|
+
saved.append((m, hook_attrs))
|
|
67
|
+
for name in hook_attrs:
|
|
68
|
+
setattr(m, name, OrderedDict())
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return copy.deepcopy(module)
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.error(
|
|
74
|
+
"deepcopy failed while sanitizing module %r for IPC even after "
|
|
75
|
+
"stripping hooks -- likely a non-hook attribute holding a "
|
|
76
|
+
"non-copyable object (lock, open handle, CUDA event, etc.)",
|
|
77
|
+
type(module).__name__,
|
|
78
|
+
exc_info=True,
|
|
79
|
+
)
|
|
80
|
+
raise
|
|
81
|
+
finally:
|
|
82
|
+
for m, hook_attrs in saved:
|
|
83
|
+
for name, val in hook_attrs.items():
|
|
84
|
+
setattr(m, name, val)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def sanitize_for_ipc(obj):
|
|
88
|
+
"""
|
|
89
|
+
Recursively return a version of `obj` safe to put on a
|
|
90
|
+
torch.multiprocessing Pipe/Queue, cloning only when necessary.
|
|
91
|
+
|
|
92
|
+
- torch.Tensor -> obj.clone() if IPC-received OR compiled-graph output
|
|
93
|
+
- nn.Module -> deepcopy(obj) if any param/buffer needs cloning
|
|
94
|
+
- dict/list/tuple -> rebuilt recursively (payloads are usually dicts
|
|
95
|
+
of tensors)
|
|
96
|
+
- anything else -> returned unchanged
|
|
97
|
+
"""
|
|
98
|
+
if obj is None:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
if isinstance(obj, torch.Tensor):
|
|
102
|
+
return obj.clone() if _is_ipc_received(obj) else obj
|
|
103
|
+
|
|
104
|
+
if isinstance(obj, nn.Module):
|
|
105
|
+
return _deepcopy_without_hooks(obj) if _module_needs_clone(obj) else obj
|
|
106
|
+
|
|
107
|
+
if isinstance(obj, dict):
|
|
108
|
+
return {k: sanitize_for_ipc(v) for k, v in obj.items()}
|
|
109
|
+
|
|
110
|
+
if isinstance(obj, (list, tuple)):
|
|
111
|
+
cloned = [sanitize_for_ipc(v) for v in obj]
|
|
112
|
+
return type(obj)(cloned) if isinstance(obj, tuple) else cloned
|
|
113
|
+
|
|
114
|
+
return obj
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _IpcEventRingBuffer:
|
|
120
|
+
"""
|
|
121
|
+
Fixed-size, round-robin pool of interprocess CUDA events for a single layer.
|
|
122
|
+
|
|
123
|
+
Why a ring buffer instead of one reused event: a single shared
|
|
124
|
+
torch.cuda.Event(interprocess=True) can get re-armed by record() before
|
|
125
|
+
the downstream consumer (a different process, reading through the IPC
|
|
126
|
+
handle) has finished waiting on / reading the data associated with the
|
|
127
|
+
previous record() call. That's a race, not just a style issue.
|
|
128
|
+
|
|
129
|
+
A ring buffer of N events gives the consumer up to N-1 steps of slack:
|
|
130
|
+
while we're recording event k, the consumer can still be draining event
|
|
131
|
+
k-1, k-2, ... To avoid unbounded growth (the original leaked-IPC-handle
|
|
132
|
+
bug), the buffer is preallocated once per layer and reused forever --
|
|
133
|
+
never grows past `size`.
|
|
134
|
+
|
|
135
|
+
size=3 is a reasonable default: it tolerates a consumer that's briefly
|
|
136
|
+
one or two steps behind without giving it so much slack that the
|
|
137
|
+
dashboard reads badly stale activations. Raise it if the consumer proves
|
|
138
|
+
to be reliably more than 1-2 steps behind; lower it to 2 (the minimum
|
|
139
|
+
that's still safe) to reduce IPC handle count if fewer processes read
|
|
140
|
+
less frequently.
|
|
141
|
+
"""
|
|
142
|
+
def __init__(self, size: int = 3):
|
|
143
|
+
self.size = size
|
|
144
|
+
self._events: List[cuda.Event] = [cuda.Event(interprocess=True) for _ in range(size)]
|
|
145
|
+
self._idx = 0
|
|
146
|
+
|
|
147
|
+
def next(self) -> cuda.Event:
|
|
148
|
+
event = self._events[self._idx]
|
|
149
|
+
self._idx = (self._idx + 1) % self.size
|
|
150
|
+
return event
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class IpcEventPool:
|
|
156
|
+
"""
|
|
157
|
+
Demand-grown, capped pool of interprocess CUDA events for a single layer.
|
|
158
|
+
|
|
159
|
+
Why not a single reused event: a shared torch.cuda.Event(interprocess=True)
|
|
160
|
+
can get re-armed by record() before the downstream consumer (a different
|
|
161
|
+
process, reading through the IPC handle) has finished waiting on / reading
|
|
162
|
+
the data tied to the previous record() call. That's a race, not just a
|
|
163
|
+
style issue.
|
|
164
|
+
|
|
165
|
+
Why not mint one per call: that's the original leaked-IPC-handle bug --
|
|
166
|
+
unbounded handle growth, thousands of live handles per epoch.
|
|
167
|
+
|
|
168
|
+
This pool grows lazily instead of preallocating a fixed size: on each
|
|
169
|
+
next() call it looks for an existing slot whose recorded GPU work has
|
|
170
|
+
already completed (event.query() == True) and reuses that one; if none
|
|
171
|
+
are free it allocates a new event, up to `max_size`. Once `max_size` is
|
|
172
|
+
hit it falls back to round-robin reuse of the oldest slot and logs a
|
|
173
|
+
warning, rather than growing further (that would reintroduce the leak).
|
|
174
|
+
|
|
175
|
+
IMPORTANT CAVEAT: event.query() only reports whether the *producer-side*
|
|
176
|
+
GPU work recorded up to that event has finished executing (i.e. `scores`
|
|
177
|
+
is fully computed) -- it does NOT confirm the downstream consumer has
|
|
178
|
+
finished reading the data through the IPC handle. There is no ack channel
|
|
179
|
+
here from the consumer back to the producer. So this is a heuristic that
|
|
180
|
+
avoids the worst case (re-arming work that isn't even done yet) but is
|
|
181
|
+
not a strict guarantee against a slow consumer racing a reused event. If
|
|
182
|
+
that matters for correctness (not just staleness), add an explicit
|
|
183
|
+
consumer -> producer ack (e.g. publish "consumed layer_id/step_id" and
|
|
184
|
+
only reuse an event once its consumption has been acked) instead of
|
|
185
|
+
relying on query() alone.
|
|
186
|
+
"""
|
|
187
|
+
def __init__(self, max_size: int = 8):
|
|
188
|
+
self.max_size = max_size
|
|
189
|
+
self._events: List[cuda.Event] = []
|
|
190
|
+
self._rr_idx = 0 # round-robin cursor used only once max_size is hit
|
|
191
|
+
|
|
192
|
+
def next(self, layer_id: str = "") -> cuda.Event:
|
|
193
|
+
# 1. Reuse a slot whose previously recorded work has completed.
|
|
194
|
+
for event in self._events:
|
|
195
|
+
if event.query():
|
|
196
|
+
return event
|
|
197
|
+
|
|
198
|
+
# 2. No free slot -- grow if we're under the cap.
|
|
199
|
+
if len(self._events) < self.max_size:
|
|
200
|
+
event = cuda.Event(interprocess=True)
|
|
201
|
+
self._events.append(event)
|
|
202
|
+
return event
|
|
203
|
+
|
|
204
|
+
# 3. At the cap and everything still appears busy: fall back to
|
|
205
|
+
# round-robin reuse of the oldest slot rather than growing further
|
|
206
|
+
# or blocking. This trades a bit of staleness risk for a hard bound
|
|
207
|
+
# on IPC handle count.
|
|
208
|
+
logger.warning(
|
|
209
|
+
f"IPC event pool for layer {layer_id!r} exhausted at max_size="
|
|
210
|
+
f"{self.max_size} with no free slots; reusing oldest event. "
|
|
211
|
+
f"Consider raising max_size or checking whether the consumer "
|
|
212
|
+
f"is falling behind."
|
|
213
|
+
)
|
|
214
|
+
event = self._events[self._rr_idx]
|
|
215
|
+
self._rr_idx = (self._rr_idx + 1) % self.max_size
|
|
216
|
+
return event
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# =====================================================================
|
|
222
|
+
# IPC event pool for the score-publishing pipeline.
|
|
223
|
+
#
|
|
224
|
+
# NOTE: earlier iterations of this file defined three different versions
|
|
225
|
+
# of this idea in sequence (_IpcEventRingBuffer, a demand-grown
|
|
226
|
+
# _IpcEventPool, then this paired-slot _IpcEventPool) without removing the
|
|
227
|
+
# previous ones. Because the last two both used the class name
|
|
228
|
+
# `_IpcEventPool`, the demand-grown version was silently shadowed and dead
|
|
229
|
+
# -- the same failure mode as the duplicate `runtime_map` def caught
|
|
230
|
+
# earlier in modularobserver.py. Only this final version remains below;
|
|
231
|
+
# the other two were deleted rather than left as unused dead code.
|
|
232
|
+
# =====================================================================
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class IpcEventCuda:
|
|
236
|
+
ready: cuda.Event
|
|
237
|
+
release: cuda.Event
|
|
238
|
+
|
|
239
|
+
def record(self):
|
|
240
|
+
"""Record the ready event on the producer's stream."""
|
|
241
|
+
self.ready.record()
|
|
242
|
+
|
|
243
|
+
def wait(self):
|
|
244
|
+
"""Blocks until the producer has recorded the ready event."""
|
|
245
|
+
self.ready.synchronize()
|
|
246
|
+
|
|
247
|
+
def clear(self):
|
|
248
|
+
"""Signals that the consumer has finished reading the data."""
|
|
249
|
+
self.release.record()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class _IpcEventSlot:
|
|
253
|
+
"""
|
|
254
|
+
One reusable slot: a paired (producer, consumer) interprocess CUDA event.
|
|
255
|
+
|
|
256
|
+
- produced_event: recorded by the producer right after `scores` is
|
|
257
|
+
computed. The consumer waits on this before reading.
|
|
258
|
+
- consumed_event: recorded by the CONSUMER, on its own stream, once it
|
|
259
|
+
has actually finished reading/using the data from this slot. This is
|
|
260
|
+
the piece a single-event design is missing: it's a real signal of
|
|
261
|
+
consumer progress instead of a proxy (query() on the producer's own
|
|
262
|
+
event only ever reflects producer-side GPU completion, which happens
|
|
263
|
+
almost immediately and tells us nothing about the consumer).
|
|
264
|
+
"""
|
|
265
|
+
__slots__ = ("produced_event", "consumed_event", "produced_at")
|
|
266
|
+
|
|
267
|
+
def __init__(self):
|
|
268
|
+
self.produced_event = cuda.Event(interprocess=True)
|
|
269
|
+
self.consumed_event = cuda.Event(interprocess=True)
|
|
270
|
+
self.produced_at: Optional[float] = None # time.monotonic() of last use; None = never used
|
|
271
|
+
|
|
272
|
+
def to_event(self) -> IpcEventCuda:
|
|
273
|
+
"""Return a simple pair of events for the producer to publish."""
|
|
274
|
+
return IpcEventCuda(ready=self.produced_event, release=self.consumed_event)
|
|
275
|
+
|
|
276
|
+
class IpcEventPoolManaged:
|
|
277
|
+
"""
|
|
278
|
+
Fixed-size, preallocated pool of paired producer/consumer event slots for
|
|
279
|
+
a single layer (or any single IPC-published stream that needs one).
|
|
280
|
+
|
|
281
|
+
A slot is safe to reuse once `consumed_event.query()` is True -- meaning
|
|
282
|
+
the consumer has actually recorded it after finishing with the data, not
|
|
283
|
+
merely that our own GPU work finished. query()-ing only the producer's
|
|
284
|
+
own event can never tell you anything about the consumer, since GPU
|
|
285
|
+
compute for the published tensor finishes long before any cross-process
|
|
286
|
+
consumer has even opened the IPC handle -- that's why the ack exists.
|
|
287
|
+
|
|
288
|
+
FAILSAFE: if a consumer crashes, hangs, or has a bug that skips recording
|
|
289
|
+
consumed_event, that slot's consumed_event.query() would never return
|
|
290
|
+
True and a naive pool would strand it forever -- with enough stuck slots
|
|
291
|
+
the pool starves completely. To guard against that, each slot tracks
|
|
292
|
+
produced_at (wall-clock time of its last use). If a slot is still unacked
|
|
293
|
+
after `stale_after_s` seconds, the pool reclaims it anyway and logs a
|
|
294
|
+
warning, so a consumer that forgets (or dies) can't permanently wedge the
|
|
295
|
+
pool. This is a bounded risk, not a guarantee: a genuinely slow but
|
|
296
|
+
still-alive consumer could theoretically be raced if it takes longer than
|
|
297
|
+
stale_after_s. Set stale_after_s comfortably above the consumer's
|
|
298
|
+
worst-case processing time.
|
|
299
|
+
"""
|
|
300
|
+
def __init__(self, size: int = 4, stale_after_s: float = 30.0):
|
|
301
|
+
self.size = size
|
|
302
|
+
self.stale_after_s = stale_after_s
|
|
303
|
+
self._slots: List[_IpcEventSlot] = [_IpcEventSlot() for _ in range(size)]
|
|
304
|
+
self._rr_idx = 0
|
|
305
|
+
|
|
306
|
+
def acquire(self, stream_id: str = "") -> IpcEventCuda:
|
|
307
|
+
now = time.monotonic()
|
|
308
|
+
|
|
309
|
+
# 1. Prefer a slot that's either never been used, or whose consumer
|
|
310
|
+
# has genuinely finished with it (real ack, not a GPU-completion proxy).
|
|
311
|
+
for slot in self._slots:
|
|
312
|
+
if slot.produced_at is None or slot.consumed_event.query():
|
|
313
|
+
slot.produced_at = now
|
|
314
|
+
return slot.to_event()
|
|
315
|
+
|
|
316
|
+
# 2. Nothing acked yet: reclaim the oldest slot if it's past the
|
|
317
|
+
# staleness timeout, even without an ack. This is the failsafe --
|
|
318
|
+
# it bounds how long a stuck/crashed consumer can hold a slot
|
|
319
|
+
# hostage, at the cost of possibly racing a live-but-slow consumer.
|
|
320
|
+
oldest = min(self._slots, key=lambda s: s.produced_at or 0.0)
|
|
321
|
+
age = now - (oldest.produced_at or 0.0)
|
|
322
|
+
if age >= self.stale_after_s:
|
|
323
|
+
logger.warning(
|
|
324
|
+
f"IPC event slot for {stream_id!r} reclaimed after "
|
|
325
|
+
f"{age:.1f}s without a consumer ack (stale_after_s="
|
|
326
|
+
f"{self.stale_after_s}). Consumer may be stuck, crashed, or "
|
|
327
|
+
f"not recording consumed_event -- check the subscriber."
|
|
328
|
+
)
|
|
329
|
+
oldest.produced_at = now
|
|
330
|
+
return oldest.to_event()
|
|
331
|
+
|
|
332
|
+
# 3. Everything is busy and still within the staleness window: fall
|
|
333
|
+
# back to round-robin reuse without an ack. This can race a live but
|
|
334
|
+
# slow consumer; it exists only so we never block or grow past
|
|
335
|
+
# `size`. If this fires often, raise `size` rather than lowering
|
|
336
|
+
# stale_after_s.
|
|
337
|
+
logger.warning(
|
|
338
|
+
f"IPC event pool for {stream_id!r} exhausted (size={self.size}); "
|
|
339
|
+
f"reusing slot {self._rr_idx} without an ack. Consumer is "
|
|
340
|
+
f"falling behind."
|
|
341
|
+
)
|
|
342
|
+
slot = self._slots[self._rr_idx]
|
|
343
|
+
self._rr_idx = (self._rr_idx + 1) % self.size
|
|
344
|
+
slot.produced_at = now
|
|
345
|
+
return slot.to_event()
|
|
346
|
+
|
|
347
|
+
|