Skip to content

Commit e2c13b6

Browse files
committed
[pipeline] follow-up: Remove fuse_subprocess_stages
Follow-up to the `.to()` region API (1/4–4/4; see #1584 for the overall design and rationale). Removes the executor-identity fusion path now that `.to()` regions provide the same capability with an explicit, statically-configurable surface. Both removed features were added in the still-unreleased 0.6.0 cycle, so no deprecation shim is warranted. Removed: - The `fuse_subprocess_stages` keyword from `PipelineBuilder.build`, `build_pipeline`, and `run_pipeline_in_subprocess`. - The executor-identity fusion machinery in `_fuse.py` (`_find_fusable_runs`, `_scan_run`, `_FusableRun`, `_fusable_*`, `_fuse_subprocess_stages`, `_pool_params`, the identity `_build_fused_stage`) and the async-op-as-fusion-tag pass (`_strip_async_executor_tags`). The marker path (`_fuse_marked_regions` and friends) is kept. Reverted (the async-op executor relaxation from #1582): an async op may no longer be given an `executor`. `PipeConfig.__post_init__` again rejects any executor on an async op, and `convert_to_async` asserts it is `None`. In a `.to()` region an async op is placed by the marker and needs no per-stage executor tag, so the relaxation is obsolete. `run_pipeline_in_subprocess` now fuses `.to()` regions in the main process (via `_fuse_marked_regions`), preserving the main-ownership of region worker pools that the old flag provided. Docs: the parallelism guide's "Multi-processing (fused)" section is rewritten as "Multi-processing (region)" using `.to()`.
1 parent 3648eb9 commit e2c13b6

13 files changed

Lines changed: 1001 additions & 2041 deletions

File tree

docs/source/getting_started/parallelism.rst

Lines changed: 54 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -309,85 +309,76 @@ It is also cumbersome: you must hand-combine the stages into one function,
309309
which collapses the per-stage performance stats into a single number and
310310
discards each stage's individual ``concurrency``.
311311

312-
Multi-processing (fused)
313-
------------------------
312+
Multi-processing (region)
313+
-------------------------
314314

315-
Instead of hand-combining stages, you can ask the builder to fuse them for
316-
you by passing ``fuse_subprocess_stages=True`` to
317-
:py:meth:`~spdl.pipeline.PipelineBuilder.build`. Runs of consecutive
318-
:py:meth:`~spdl.pipeline.PipelineBuilder.pipe` stages that share the **same**
319-
process-pool (or interpreter-pool) executor instance are fused into a single
320-
stage that runs the run as one nested :py:class:`Pipeline` inside the worker
321-
pool:
315+
Instead of hand-combining stages, you can mark a **region** of the pipeline to
316+
run together in a worker pool with :py:meth:`~spdl.pipeline.PipelineBuilder.to`.
317+
Every stage between ``.to(ProcessPoolExecutorConfig(...))`` and ``.to(MAIN_PROCESS)`` runs
318+
as one nested :py:class:`Pipeline` inside a pool of worker processes:
322319

323320
.. code-block::
324321
325-
executor = ProcessPoolExecutor(max_workers=4)
322+
from spdl.pipeline.defs import MAIN_PROCESS, ProcessPoolExecutorConfig
326323
327324
pipeline = (
328325
PipelineBuilder()
329326
.add_source(...)
330-
.pipe(op1, executor=executor, concurrency=2)
331-
.pipe(op2, executor=executor, concurrency=3)
327+
.to(ProcessPoolExecutorConfig(max_workers=4))
328+
.pipe(op1, concurrency=2) # runs in a worker process
329+
.aggregate(batch_size) # runs in a worker process
330+
.pipe(op2, concurrency=3) # runs in a worker process
331+
.to(MAIN_PROCESS) # data returns to the main process
332332
.add_sink(...)
333-
.build(num_threads=..., fuse_subprocess_stages=True)
333+
.build(num_threads=...)
334334
)
335335
336-
Because ``op1`` and ``op2`` now run back-to-back inside one worker, the
337-
intermediate value is **not** copied back to the main process between them.
338-
This removes the inter-stage IPC entirely, and — unlike the per-stage
339-
multi-processing above — the value handed from ``op1`` to ``op2`` does **not**
340-
need to be picklable. Each fused stage keeps its own ``concurrency`` and its
341-
own per-stage performance stats (the nested pipeline is built with the usual
342-
hooks, so the stats are reported from inside the worker).
343-
344-
A **generator op** (a function that ``yield``\ s) is supported as a fused
345-
process-pool stage: each input item fans out into the values the generator
346-
yields, exactly as in an unfused pipeline. As with any sync generator on a
347-
process-pool executor, the yielded items are materialized once the generator is
348-
exhausted rather than streamed out incrementally.
349-
350-
An **async op** (an ``async def`` function or an async generator) can be fused
351-
too. Because an async op always runs on the event loop, it takes no executor to
352-
*run* it; instead, tag it with the **same** pool executor as its neighbours and
353-
it joins their fused run, executing on the worker's own event loop:
354-
355-
.. code-block::
356-
357-
.pipe(sync_op, executor=executor)
358-
.pipe(async_op, executor=executor) # runs on the worker's event loop
359-
.pipe(sync_op, executor=executor)
360-
361-
All three fuse into one subprocess run, so an async op between two pool stages no
362-
longer splits the run in two. The executor is used only to group the stage, not
363-
to run the coroutine; a fused async op must be picklable, like any fused stage.
364-
Passing a non-isolating executor (e.g. a thread pool) to an async op is an error.
365-
Unfused, the tag is ignored and the async op runs in the main process.
366-
367-
Only *adjacent* pool stages on the same executor are fused. An
368-
:py:meth:`~spdl.pipeline.PipelineBuilder.aggregate` or
369-
:py:meth:`~spdl.pipeline.PipelineBuilder.disaggregate` between two pool stages
370-
is **not** fused — it runs in the main process and keeps its usual batching
371-
semantics, and it splits the surrounding pool stages into separate runs (so
372-
each side fuses on its own only if it has two or more adjacent pool stages).
373-
374-
The same option is accepted by
375-
:py:func:`spdl.pipeline.run_pipeline_in_subprocess`, where the fused worker
376-
pool is owned by the main process and the run executes in those workers — so
377-
the per-stage round-trip between the pipeline subprocess and the pool is
378-
removed as well.
379-
380-
Fusion also works with a **continuous source**
336+
Because the region's stages run back-to-back inside one worker, the value handed
337+
from one stage to the next is **not** copied back to the main process between
338+
them. This removes the inter-stage IPC entirely, and — unlike the per-stage
339+
multi-processing above — those intermediate values do **not** need to be
340+
picklable; only the region's inputs and outputs cross the process boundary. Each
341+
stage keeps its own ``concurrency`` and its own per-stage performance stats (the
342+
nested pipeline is built with the usual hooks, so the stats are reported from
343+
inside the worker).
344+
345+
Unlike passing ``executor=`` to individual
346+
:py:meth:`~spdl.pipeline.PipelineBuilder.pipe` calls, a region also carries
347+
:py:meth:`~spdl.pipeline.PipelineBuilder.aggregate`,
348+
:py:meth:`~spdl.pipeline.PipelineBuilder.disaggregate`, and
349+
:py:meth:`~spdl.pipeline.PipelineBuilder.path_variants` stages into the worker,
350+
and gives the worker-pool configuration (worker count, ``mp_context``,
351+
``initializer``) a single home. A pipeline starts on the main process, so a
352+
region is opened by a ``.to(ProcessPoolExecutorConfig(...))`` and closed by
353+
``.to(MAIN_PROCESS)``; the region must be closed before
354+
:py:meth:`~spdl.pipeline.PipelineBuilder.add_sink`.
355+
356+
A **generator op** (a function that ``yield``\ s) works inside a region: each
357+
input item fans out into the values it yields, exactly as in an unfused pipeline.
358+
An **async op** works too — it runs on the worker's own event loop. Every stage
359+
inside a region must be picklable, since the region config is shipped to the
360+
worker.
361+
362+
Regions also compose with :py:func:`spdl.pipeline.run_pipeline_in_subprocess`:
363+
the region's worker pool is owned by the main process and the run executes in
364+
those workers, so the per-stage round-trip between the pipeline subprocess and
365+
the pool is removed as well.
366+
367+
Regions also work with a **continuous source**
381368
(:py:meth:`~spdl.pipeline.PipelineBuilder.add_source(..., continuous=True)
382369
<spdl.pipeline.PipelineBuilder.add_source>`). The worker sub-pipelines run in
383-
continuous mode and stay warm across epochs, and epoch boundaries are
384-
propagated across the pool: each fused stage emits one epoch boundary per epoch
385-
just like an unfused pipeline.
370+
continuous mode and stay warm across epochs, and epoch boundaries are propagated
371+
across the pool: each region emits one epoch boundary per epoch just like an
372+
unfused pipeline.
373+
374+
To run a region in **subinterpreters** (Python 3.14+) instead of subprocesses,
375+
pass a :py:class:`~spdl.pipeline.defs.InterpreterPoolExecutorConfig`; the region's ops
376+
must avoid NumPy/PyTorch, which cannot be imported in a subinterpreter.
386377

387378
.. note::
388379

389-
Fusion preserves results but produces them in completion order across the
390-
pool workers. Stages built with ``output_order="input"`` are not fused.
380+
A region produces results in completion order across its pool workers, so a
381+
stage built with ``output_order="input"`` cannot appear inside a region.
391382

392383
Multi-threading in subprocess
393384
-----------------------------

src/spdl/pipeline/_build.py

Lines changed: 29 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,7 @@
3535
TaskHook,
3636
)
3737
from spdl.pipeline._executor_proxy import _make_config_executors_picklable
38-
from spdl.pipeline._fuse import (
39-
_fuse_marked_regions,
40-
_fuse_subprocess_stages,
41-
_strip_async_executor_tags,
42-
)
38+
from spdl.pipeline._fuse import _fuse_marked_regions
4339
from spdl.pipeline._iter_utils import iterate_in_subinterpreter, iterate_in_subprocess
4440
from spdl.pipeline._random_seed import _capture_rng_initializers
4541
from spdl.pipeline._subprocess_pipeline_pool import _shutdown_pipeline_pools
@@ -142,39 +138,20 @@ def _build_pipeline(
142138
stage_id: int = 0,
143139
background_tasks: list[BackgroundTaskFactory] | None = None,
144140
use_thread_output_queue: bool = False,
145-
fuse_subprocess_stages: bool = False,
146141
) -> Pipeline[U]:
147142
if _DEFAULT_BUILD_CALLBACK is not None:
148143
try:
149144
_DEFAULT_BUILD_CALLBACK(pipeline_cfg)
150145
except Exception:
151146
_LG.exception("Build callback failed.")
152147

153-
pools: list[Any] = []
154-
# Both fusion passes eagerly spawn worker pools. Reap them together on failure: each pass
155-
# only reaps its own pools if it raises, so without this a failure in the second pass would
156-
# leak the pools the first already spawned -- this half-built pipeline is never returned to
157-
# the caller to be stopped.
158-
try:
159-
# Honor explicit `.to()` region markers first. This is a no-op when the config has no
160-
# markers, so it is always safe to run and independent of `fuse_subprocess_stages`.
161-
# stacklevel=4: _fuse_marked_regions -> _build_pipeline -> build_pipeline -> user.
162-
pipeline_cfg, region_pools = _fuse_marked_regions(
163-
pipeline_cfg, report_stats_interval=report_stats_interval, stacklevel=4
164-
)
165-
pools.extend(region_pools)
166-
if fuse_subprocess_stages:
167-
# Fuse consecutive same-pool stages so each run executes as one nested pipeline
168-
# inside a worker pool, eliminating the inter-stage IPC. The pools are owned by the
169-
# returned Pipeline and reaped when it stops.
170-
# stacklevel=4: _fuse_subprocess_stages -> _build_pipeline -> build_pipeline -> user.
171-
pipeline_cfg, id_pools = _fuse_subprocess_stages(
172-
pipeline_cfg, report_stats_interval=report_stats_interval, stacklevel=4
173-
)
174-
pools.extend(id_pools)
175-
except BaseException:
176-
_shutdown_pipeline_pools(pools)
177-
raise
148+
# Fuse each `.to()` region into one nested-pipeline stage that runs in a worker pool,
149+
# eliminating the inter-stage IPC within the region. A no-op when the config has no markers.
150+
# The pools are owned by the returned Pipeline and reaped when it stops.
151+
# stacklevel=4: _fuse_marked_regions -> _build_pipeline -> build_pipeline -> user.
152+
pipeline_cfg, pools = _fuse_marked_regions(
153+
pipeline_cfg, report_stats_interval=report_stats_interval, stacklevel=4
154+
)
178155

179156
desc = repr(pipeline_cfg)
180157

@@ -218,7 +195,6 @@ def build_pipeline(
218195
stage_id: int = 0,
219196
background_tasks: list[BackgroundTaskFactory] | None = None,
220197
use_thread_output_queue: bool = False,
221-
fuse_subprocess_stages: bool = False,
222198
) -> Pipeline[U]:
223199
"""Build a pipeline from the config.
224200
@@ -291,24 +267,11 @@ def build_pipeline(
291267
``asyncio.run_coroutine_threadsafe``, reducing per-batch latency from
292268
~200-400us to ~10us. Default: ``False``.
293269
294-
fuse_subprocess_stages: If ``True``, fuse runs of two or more adjacent pipe stages that
295-
share the same process-pool (or interpreter-pool) executor instance into a single
296-
stage that executes the run as one nested pipeline inside a worker pool. This
297-
eliminates the inter-stage IPC that otherwise round-trips data back to this process
298-
between each stage (so intermediate values need not be picklable), while each fused
299-
stage keeps its own ``concurrency`` and per-stage stats. A ``path_variants`` stage
300-
whose branches all use the same pool executor is fused too — the whole routing
301-
construct (router and branches) moves into the worker — and fuses on its own even
302-
when it is the only such stage. An ``aggregate``/``disaggregate`` between two pool
303-
stages is not fused (it keeps its main-process batching) and splits them into
304-
separate runs. An async op joins a fused run when tagged with the same executor as
305-
its neighbours (see :py:meth:`~spdl.pipeline.PipelineBuilder.pipe`), running on the
306-
worker's own event loop. Continuous sources are supported (the fused worker
307-
sub-pipelines stay warm across epochs and epoch boundaries are propagated across the
308-
pool). Default: ``False``.
309-
310-
.. versionadded:: 0.6.0
311-
The ``fuse_subprocess_stages`` argument.
270+
.. seealso::
271+
272+
:py:meth:`~spdl.pipeline.PipelineBuilder.to`
273+
Designate a region of stages to run together in a subprocess (or subinterpreter)
274+
worker pool, eliminating the inter-stage IPC within the region.
312275
"""
313276
from . import _profile
314277

@@ -325,7 +288,6 @@ def build_pipeline(
325288
stage_id=stage_id,
326289
background_tasks=background_tasks,
327290
use_thread_output_queue=use_thread_output_queue,
328-
fuse_subprocess_stages=fuse_subprocess_stages,
329291
)
330292

331293

@@ -427,7 +389,6 @@ def run_pipeline_in_subprocess(
427389
task_hook_factory: Callable[[StageInfo], list[TaskHook]] | None = None,
428390
background_tasks: list[BackgroundTaskFactory] | None = None,
429391
use_thread_output_queue: bool = False,
430-
fuse_subprocess_stages: bool = False,
431392
**kwargs: Any,
432393
) -> Iterable[T]:
433394
"""Run the given Pipeline in a subprocess, and iterate on the result.
@@ -579,22 +540,16 @@ def run_pipeline_in_subprocess(
579540
580541
num_threads,max_failures,report_stats_interval,queue_class,task_hook_factory,background_tasks:
581542
Passed to :py:func:`build_pipeline`.
582-
fuse_subprocess_stages: If ``True``, fuse runs of two or more adjacent pipe stages that
583-
share the same process-pool (or interpreter-pool) executor instance into a single
584-
stage that runs the run as one nested pipeline inside a worker pool. The worker
585-
processes are spawned in (and owned by) the main process, exactly like a hoisted
586-
``ProcessPoolExecutor``; the pipeline subprocess drives them through a queue handle.
587-
This removes the per-stage round-trip between the pipeline subprocess and the pool
588-
workers (so intermediate values need not be picklable). A ``path_variants`` stage
589-
whose branches all use the same pool executor is fused too (router and branches move
590-
into the worker). An async op joins a fused run when tagged with the same executor as
591-
its neighbours (see :py:meth:`~spdl.pipeline.PipelineBuilder.pipe`), running on the
592-
worker's own event loop. Continuous sources are supported. Default: ``False``.
593-
594-
.. versionadded:: 0.6.0
595-
The ``fuse_subprocess_stages`` argument.
596543
kwargs: Passed to :py:func:`iterate_in_subprocess`.
597544
545+
.. seealso::
546+
547+
:py:meth:`~spdl.pipeline.PipelineBuilder.to`
548+
Designate a region of stages to run together in a subprocess (or subinterpreter)
549+
worker pool. When the config has such a region, its worker pool is spawned in (and
550+
owned by) the main process, so it is not orphaned if the pipeline subprocess is
551+
force-killed.
552+
598553
Yields:
599554
The results yielded from the pipeline.
600555
@@ -625,11 +580,11 @@ def run_pipeline_in_subprocess(
625580
else config_or_builder.get_config() # pyre-ignore[16]
626581
)
627582

628-
# Every pass below eagerly spawns worker pools, so they all run inside one try/except:
629-
# ``_fuse_marked_regions`` spawns ``fuse_pools`` up front, and if any *later* pass
630-
# (``_fuse_subprocess_stages``, ``_hoist_process_pools``) or the iterable creation
631-
# raises, both ``fuse_pools`` and the hoisted ``pools`` must be reaped -- this half-built
632-
# iterable is never returned to the caller to be stopped. Mirrors ``_build_pipeline``.
583+
# Both passes below eagerly spawn worker pools, so they run inside one try/except:
584+
# ``_fuse_marked_regions`` spawns ``fuse_pools`` up front, so if a later pass
585+
# (``_hoist_process_pools``) or the iterable creation raises, both ``fuse_pools`` and the
586+
# hoisted ``pools`` must be reaped -- this half-built iterable is never returned to the
587+
# caller to be stopped. Mirrors the guard in ``_build_pipeline``.
633588
fuse_pools: list[Any] = []
634589
pools: list[Any] = []
635590
try:
@@ -648,23 +603,6 @@ def run_pipeline_in_subprocess(
648603
stacklevel=3,
649604
)
650605
fuse_pools.extend(region_pools)
651-
# Also fuse runs of same-pool stages tagged with an identical executor into one nested
652-
# pipeline inside a worker pool, eliminating the inter-stage IPC (before hoisting, so
653-
# only unfused ProcessPoolExecutor stages remain).
654-
if fuse_subprocess_stages:
655-
# stacklevel=3: _fuse_subprocess_stages -> run_pipeline_in_subprocess -> user.
656-
config, id_pools = _fuse_subprocess_stages(
657-
config,
658-
mp_context=kwargs.get("mp_context"),
659-
report_stats_interval=report_stats_interval,
660-
stacklevel=3,
661-
)
662-
fuse_pools.extend(id_pools)
663-
664-
# Clear executor tags left on any unfused async op: they are subprocess fusion-group
665-
# hints, not real pools, and the executor-hoisting/pickling passes below are op-agnostic
666-
# -- an async op's process-pool tag would otherwise spawn an idle pool it never uses.
667-
config = _strip_async_executor_tags(config)
668606

669607
# Spawn workers for any stdlib ``ProcessPoolExecutor`` in the main process (as children
670608
# of main, not grandchildren via the pipeline subprocess), then replace the executor
@@ -693,9 +631,9 @@ def run_pipeline_in_subprocess(
693631
**kwargs,
694632
)
695633
except BaseException:
696-
# Any eager-spawn pass above (region fusion, identity fusion, hoisting) or the iterable
697-
# creation failed; the iterable is never returned to the caller, so reap the region and
698-
# hoisted pools here to avoid leaking their worker processes and pipe fds.
634+
# Any eager-spawn pass above (region fusion, hoisting) or the iterable creation failed;
635+
# the iterable is never returned to the caller, so reap the region and hoisted pools
636+
# here to avoid leaking their worker processes and pipe fds.
699637
_shutdown_pools(pools)
700638
_shutdown_pipeline_pools(fuse_pools)
701639
raise

0 commit comments

Comments
 (0)