Skip to content

Commit e80d661

Browse files
authored
[pipeline] Stop unstopped pipelines at interpreter exit to avoid hang (#1591)
A program that keeps a strong reference to a `Pipeline` and never calls `stop()` (e.g. a training framework that stores the dataloader for the whole run) can hang at interpreter exit. The pipeline's background event-loop thread is non-daemon, so CPython joins it inside `threading._shutdown()` during finalization; because nothing stopped the pipeline, that join blocks forever. The existing `weakref.finalize` cleanup runs too late: it is an `atexit`-module callback (a later finalization phase), after non-daemon threads have already been joined. Fix: register a per-pipeline shutdown hook via `threading._register_atexit` (the same private mechanism `concurrent.futures` relies on) so it runs inside `threading._shutdown()` — before non-daemon threads and child processes are joined — and stops the pipeline first. The hook is a thin weakref wrapper around the existing `_stop_impl` (the single stop-with-timeout helper, shared with the GC finalizer), registered in `Pipeline.start()` after the pipeline's own threads/processes are up, so it lands after the stdlib's atexit hooks and — being LIFO — runs before them. It holds only a weak reference so it never keeps the pipeline alive, and is a safe no-op once the pipeline is stopped or collected. A regular `atexit.register` hook was verified insufficient: it runs after the non-daemon thread join and still hangs. Also make the subprocess-region bridge's blocking queue put interruptible. A `.to(...)` region feeder parked on a full worker queue (backpressure once the consumer stopped) cannot be released by pool teardown, so at exit it is joined — and hangs — by `concurrent.futures`. It now polls a teardown event and exits promptly. This also fixes a pre-existing hang that reproduced on an explicit `stop()`. No public API change; holding a `Pipeline` reference and relying on GC is unchanged.
1 parent cca926b commit e80d661

6 files changed

Lines changed: 527 additions & 61 deletions

File tree

docs/source/getting_started/intro.rst

Lines changed: 83 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,13 @@ Finally call :py:meth:`Pipeline.stop` to stop the background thread.
6666
[19, 21, 23]
6767
>>> pipeline.stop()
6868
69-
It is important to call :py:meth:`Pipeline.stop`.
70-
Forgetting to do so will leave the background thread running,
71-
which can cause the Python interpreter to hang at exit.
69+
Calling :py:meth:`Pipeline.stop` promptly is good practice: it releases the
70+
background thread and any worker processes as soon as you are done, instead of
71+
leaving them running until the object is garbage collected. It is no longer
72+
required to avoid a hang at exit, though — a ``Pipeline`` stops itself when it is
73+
garbage collected, and :py:meth:`Pipeline.start` additionally registers a hook
74+
that stops any still-running pipeline at interpreter shutdown (see
75+
:py:meth:`Pipeline.start` for details).
7276

7377
In practice, there is always a possibility that the application is
7478
interrupted for unexpected reasons.
@@ -97,59 +101,95 @@ To make sure that the pipeline is stopped, it is recommended to use
97101
Unlike processes, threads cannot be killed.
98102
The ``Pipeline`` object uses a thread pool, which must be shut down properly.
99103

100-
There are seemingly unharmful patterns, which can cause a deadlock
101-
at the end of the Python interpreter, preventing Python from exiting.
102-
103-
.. admonition:: Keeping unnecessary references to ``Pipeline``
104-
:class: danger
105-
106-
It is recommended to keep the resulting ``Pipeline`` object as a
107-
local variable of an iterator, and NOT TO assign it to an object
108-
attribute.
104+
The library cleans a ``Pipeline`` up automatically (see below), so these
105+
patterns no longer hang the interpreter at exit. They can still keep the
106+
background thread and worker processes alive longer than necessary, so they are
107+
worth avoiding for prompt resource release.
108+
109+
.. admonition:: Holding a reference to a ``Pipeline``
110+
:class: note
111+
112+
A ``Pipeline`` cleans itself up automatically, so holding a reference to one
113+
is safe. When the object is garbage collected, a ``weakref.finalize`` drains
114+
and stops its background thread — and any worker processes or subinterpreters
115+
it spawned — even if you never called :py:meth:`Pipeline.stop`. And if a
116+
reference survives until the program ends, :py:meth:`Pipeline.start` has
117+
registered a hook that stops any still-running pipeline at the very start of
118+
interpreter finalization, so the process does not hang at exit (see
119+
:py:meth:`Pipeline.start` for how that ordering works). Holding a reference is
120+
in fact **required** to re-iterate a ``continuous=True`` source across epochs:
121+
the same ``Pipeline`` must stay alive to be iterated again.
122+
123+
You should still release the reference (or call :py:meth:`Pipeline.stop`) once
124+
you are done, so the worker processes and memory are freed promptly rather than
125+
lingering until GC — but this is resource hygiene, not a requirement to avoid a
126+
hang.
109127

110128
.. code-block::
111129
112130
class DataLoader:
113-
...
131+
def __init__(self) -> None:
132+
# Safe (and, for a continuous source, required) to keep the
133+
# pipeline as an attribute so it is reused across epochs.
134+
self._pipeline = self.get_pipeline(...)
114135
115136
def __iter__(self) -> Iterator[T]:
116-
# 👍 Leave the `pipeline` variable as a local variable.
117-
pipeline = self.get_pipeline(...)
118-
# So that the `pipeline` will get garbage collected after the
119-
# iterator goes out of the scope.
137+
yield from self._pipeline.get_iterator(...)
120138
121-
with pipeline.auto_stop():
122-
yield from pipeline.get_iterator(...)
139+
def close(self) -> None:
140+
# Optional: drop the reference when done so the pipeline's
141+
# resources are freed promptly instead of at GC / interpreter exit.
142+
self._pipeline = None
123143
124-
# The reference count of the `pipeline` object goes to zero
125-
# here, so it will be garbage collected.
144+
Some frameworks stash the dataloader on a long-lived object.
145+
`TorchTNT <https://pytorch.org/tnt/>`_, for example, keeps a strong reference
146+
to the dataloader on its ``State`` (``PhaseState._dataloader``) until the
147+
process exits. This no longer hangs the run — the shutdown hook cleans the
148+
pipeline up at exit — but if you want its worker processes and memory released
149+
as soon as training ends (e.g. between fit and eval) rather than at exit, you
150+
can clear those references and force a collection with a callback:
126151

127-
.. code-block::
152+
.. code-block:: python
128153
129-
class DataLoader:
130-
...
154+
import gc
131155
132-
def __iter__(self) -> Iterator[T]:
133-
# 🚫 Do not assign the pipeline to the object.
134-
self.pipeline = self.get_pipeline(...)
135-
#
136-
# The pipeline won't get garbage collected until
137-
# the DataLoader instance goes out of scope,
138-
# which might cause dead-lock when Python tries to exit.
156+
from torchtnt.framework.callback import Callback
157+
from torchtnt.framework.state import State
158+
from torchtnt.framework.unit import TEvalUnit, TPredictUnit, TTestUnit, TTrainUnit
159+
160+
161+
class DetachDataloaderCallback(Callback):
162+
"""Optional: drop TNT's dataloader references at the end of training so
163+
the SPDL ``Pipeline`` (and its workers) are released promptly, instead of
164+
lingering on ``State`` until the pipeline is cleaned up at exit."""
165+
166+
def on_train_end(self, state: State, unit: TTrainUnit) -> None:
167+
self._detach(state)
139168
140-
with self.pipeline.auto_stop():
141-
yield from self.pipeline.get_iterator(...)
169+
def on_exception(
170+
self,
171+
state: State,
172+
unit: TTrainUnit | TEvalUnit | TPredictUnit | TTestUnit,
173+
exc: BaseException,
174+
) -> None:
175+
# on_train_end does not fire on failure/preemption, so reap here too.
176+
self._detach(state)
142177
143-
# The `pipeline` object won't get garbage collected here.
178+
def _detach(self, state: State) -> None:
179+
for phase_state in (state.train_state, state.eval_state):
180+
if phase_state is not None:
181+
phase_state._dataloader = None # pyre-ignore[8]
182+
gc.collect()
144183
145184
.. admonition:: Calling ``iter`` on Pipeline
146-
:class: danger
185+
:class: note
147186

148-
We recommend to not call the :py:func:`iter` function
149-
on a ``Pipeline`` object.
150-
It can prevent the :py:meth:`Pipeline.stop` method from being called
151-
at the right time.
152-
It in turn might make the Python interpreter hang at exit.
187+
Prefer not to call the :py:func:`iter` function on a ``Pipeline`` object and
188+
keep the resulting iterator around. Doing so delays :py:meth:`Pipeline.stop`
189+
until the iterator is collected, keeping the background thread and worker
190+
processes running longer than necessary. (It will not hang the interpreter at
191+
exit — the hook registered by :py:meth:`Pipeline.start` covers that — but it
192+
holds resources needlessly.)
153193

154194
Say you wrap a ``Pipeline`` to create a class that resembles conventional
155195
``DataLoader``.
@@ -194,7 +234,6 @@ at the end of the Python interpreter, preventing Python from exiting.
194234
# the pipeline won't be shutdown until the `ite` variable
195235
# goes out of scope. When does that happen??
196236
197-
The ``Pipeline.stop`` is not called until the garbage collector deletes
198-
the object.
199-
It might cause a deadlock, and prevents Python interpreter from
200-
exiting.
237+
Until then, ``Pipeline.stop`` is deferred to whenever the garbage collector
238+
deletes the object, so the background thread and workers keep holding
239+
resources longer than needed.

src/spdl/pipeline/_components/_subprocess_pipe.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
import asyncio
4141
import queue as _queue
42+
import threading
4243
import time
4344
from concurrent.futures import Executor, ThreadPoolExecutor
4445
from typing import Any
@@ -78,14 +79,28 @@
7879
# observe cancellation between polls instead of parking a thread on an indefinite get.
7980
_GET_TIMEOUT: float = 0.5
8081

82+
# How long a blocking put may wait before re-checking the teardown flag. A put onto a *full*
83+
# worker queue (backpressure once the consumer stops) cannot be released by pool teardown -- an
84+
# mp.Queue putter blocked on the queue's semaphore is not woken by closing the queue or
85+
# terminating the consumer workers -- so an indefinite put would park this pool thread forever and
86+
# hang interpreter exit (a non-daemon executor thread joined by concurrent.futures at shutdown).
87+
_PUT_TIMEOUT: float = 0.5
88+
8189
# Fixed bound (15 min) on how long the collector waits for any worker message before assuming a
8290
# worker died abruptly and raising, instead of hanging forever. Comfortably above any per-stage
8391
# latency in a data loader. Not user-configurable for now.
8492
_WORKER_STALL_TIMEOUT: float = 900.0
8593

8694

87-
def _put(q: Any, msg: tuple[int, Any]) -> None:
88-
q.put(msg)
95+
def _put(q: Any, msg: tuple[int, Any], stop: threading.Event) -> None:
96+
# Bounded, interruptible put: poll so this pool thread wakes to observe teardown (``stop``)
97+
# and exit, rather than parking forever on a full queue (see ``_PUT_TIMEOUT``).
98+
while not stop.is_set():
99+
try:
100+
q.put(msg, timeout=_PUT_TIMEOUT)
101+
return
102+
except _queue.Full:
103+
continue
89104

90105

91106
def _drain_one(q: Any) -> tuple[int, Any] | None:
@@ -123,6 +138,7 @@ async def _feed(
123138
executor: Executor,
124139
abort: asyncio.Event,
125140
feeder_idle: asyncio.Event,
141+
put_stop: threading.Event,
126142
) -> None:
127143
"""Round-robin items across the per-worker queues, then end every worker's session.
128144
@@ -165,13 +181,18 @@ async def _feed(
165181
feeder_idle.clear()
166182
if is_eof(item):
167183
break
168-
await loop.run_in_executor(executor, _put, in_qs[i % n], (_ITEM, item))
184+
await loop.run_in_executor(
185+
executor, _put, in_qs[i % n], (_ITEM, item), put_stop
186+
)
169187
i += 1
170188
finally:
171189
abort_wait.cancel()
172190
# Concurrent so a full/slow worker queue does not block the markers to the others.
173191
await asyncio.gather(
174-
*(loop.run_in_executor(executor, _put, q, (_SESSION_END, None)) for q in in_qs)
192+
*(
193+
loop.run_in_executor(executor, _put, q, (_SESSION_END, None), put_stop)
194+
for q in in_qs
195+
)
175196
)
176197

177198

@@ -234,6 +255,7 @@ async def _feed_continuous(
234255
executor: Executor,
235256
epoch_barrier: asyncio.Event,
236257
feeder_idle: asyncio.Event,
258+
put_stop: threading.Event,
237259
) -> None:
238260
"""Round-robin items to per-worker queues; broadcast and barrier each epoch boundary.
239261
@@ -251,7 +273,7 @@ async def _feed_continuous(
251273
async def _broadcast(msg: tuple[int, Any]) -> None:
252274
# Concurrent so a full/slow worker queue does not block the broadcast to the others.
253275
await asyncio.gather(
254-
*(loop.run_in_executor(executor, _put, q, msg) for q in in_qs)
276+
*(loop.run_in_executor(executor, _put, q, msg, put_stop) for q in in_qs)
255277
)
256278

257279
i = 0
@@ -268,7 +290,9 @@ async def _broadcast(msg: tuple[int, Any]) -> None:
268290
await _broadcast((_EPOCH, None))
269291
await epoch_barrier.wait()
270292
continue
271-
await loop.run_in_executor(executor, _put, in_qs[i % n], (_ITEM, item))
293+
await loop.run_in_executor(
294+
executor, _put, in_qs[i % n], (_ITEM, item), put_stop
295+
)
272296
i += 1
273297

274298

@@ -352,12 +376,16 @@ async def _subprocess_pipeline(
352376
# it to tell input starvation (no work dispatched, no worker message expected) apart from an
353377
# unresponsive worker, so its stall guard does not fire spuriously on a slow/idle source.
354378
feeder_idle = asyncio.Event()
379+
# Signals threads parked in a blocking ``_put`` to stop and exit on teardown. Needed because a
380+
# put onto a full worker queue cannot be released by pool teardown (see ``_PUT_TIMEOUT``), so
381+
# without it such a thread would outlive this stage and hang interpreter exit.
382+
put_stop = threading.Event()
355383
async with _queue_stage_hook(output_queue):
356384
if handle.continuous:
357385
epoch_barrier = asyncio.Event()
358386
feeder = create_task(
359387
_feed_continuous(
360-
input_queue, in_qs, executor, epoch_barrier, feeder_idle
388+
input_queue, in_qs, executor, epoch_barrier, feeder_idle, put_stop
361389
)
362390
)
363391
collector = create_task(
@@ -374,7 +402,7 @@ async def _subprocess_pipeline(
374402
# Set by the collector on a worker error so the feeder stops forwarding new items.
375403
abort = asyncio.Event()
376404
feeder = create_task(
377-
_feed(input_queue, in_qs, executor, abort, feeder_idle)
405+
_feed(input_queue, in_qs, executor, abort, feeder_idle, put_stop)
378406
)
379407
collector = create_task(
380408
_collect(out_q, num_workers, output_queue, executor, abort, feeder_idle)
@@ -387,6 +415,8 @@ async def _subprocess_pipeline(
387415
await asyncio.gather(feeder, collector, return_exceptions=True)
388416
raise
389417
finally:
390-
# Don't wait on threads still parked in a blocking get/put — the pool teardown
391-
# unblocks them. ``cancel_futures`` discards anything not yet started.
418+
# Release any thread parked in a blocking ``_put`` so it exits instead of outliving
419+
# this stage, then drop the executor. ``cancel_futures`` discards anything not yet
420+
# started; a still-parked get self-releases within ``_GET_TIMEOUT``.
421+
put_stop.set()
392422
executor.shutdown(wait=False, cancel_futures=True)

0 commit comments

Comments
 (0)