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.
@@ -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
+