Skip to content

Commit ede2326

Browse files
authored
[pipeline] support async ops in fused subprocess stages (#1582)
`fuse_subprocess_stages=True` fuses runs of adjacent pipe stages that share the same isolating-pool (process/interpreter) executor instance into one nested `Pipeline` that runs inside a worker process, eliminating the per-stage IPC round-trip. Previously an async op could never be part of a fused run: `PipeConfig` rejected any `executor` on an async op, and fusion groups stages purely by executor-instance identity. An async op between two pool stages therefore split the fusable run in two, forcing the intermediate value to round-trip (and be pickled) through the main process. This lets an async op join a fused run by tagging it with the same isolating-pool executor as its neighbours. The executor is never used to run the coroutine (an async op always runs on the event loop) — it is only the fusion-group key. When fused, the tag is stripped and the op runs on the worker's own event loop, exactly as fused `path_variants` async branches already do. When not fused (fusion off, or a lone async op), the tag is ignored and the op runs on the main loop as before. The execution engine is unchanged: each worker already rebuilds the sub-config with the normal `build_pipeline`, which runs a full event loop. Builds on the parent diff, which moves the `_is_isolating_pool`/`_is_interpreter_pool` helpers into `_common/_convert`; this diff adds their new call sites (notably in `defs`). Details: - `PipeConfig.__post_init__` now allows an isolating-pool executor on an async op and rejects only non-isolating executors (e.g. a thread pool), which have no effect on an async op. - `convert_to_async` ignores the executor for async ops instead of asserting it is `None`. - Fusion detection: an async op that now carries an executor flows through `_fusable_pool_executor` automatically. Inside a `path_variants` stage, `_scan_variant_pool_executors` ignores an async op's pool tag instead of treating it as an input-ordered pool-pipe — an async op's executor is only a fusion-group tag (it runs on the loop, not the pool), so its `output_order="input"` cannot be broken by pool parallelism and must not block fusing the same-pool stage. Only a sync input-ordered pool-pipe still blocks fusion. `_stage_concurrency` counts async ops as zero worker threads (they run on the loop, not the thread pool). - `run_pipeline_in_subprocess` strips any executor tag left on an unfused async op before the op-agnostic executor-hoisting pass, so a tag never spawns an idle worker pool the op will not use. A fused async op must be picklable, like any fused stage. This is documented on `PipelineBuilder.pipe` and in the parallelism guide.
1 parent 5d3d098 commit ede2326

7 files changed

Lines changed: 343 additions & 21 deletions

File tree

docs/source/getting_started/parallelism.rst

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,24 @@ A **generator op** (a function that ``yield``\ s) is supported as a fused
345345
process-pool stage: each input item fans out into the values the generator
346346
yields, exactly as in an unfused pipeline. As with any sync generator on a
347347
process-pool executor, the yielded items are materialized once the generator is
348-
exhausted rather than streamed out incrementally. (An *async* generator cannot
349-
take an ``executor`` and so is never itself a fused stage, but it composes with
350-
fusion when placed before or after a fused run, running in the main process.)
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.
351366

352367
Only *adjacent* pool stages on the same executor are fused. An
353368
:py:meth:`~spdl.pipeline.PipelineBuilder.aggregate` or

src/spdl/pipeline/_build.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
TaskHook,
3636
)
3737
from spdl.pipeline._executor_proxy import _make_config_executors_picklable
38-
from spdl.pipeline._fuse import _fuse_subprocess_stages
38+
from spdl.pipeline._fuse import _fuse_subprocess_stages, _strip_async_executor_tags
3939
from spdl.pipeline._iter_utils import iterate_in_subinterpreter, iterate_in_subprocess
4040
from spdl.pipeline._random_seed import _capture_rng_initializers
4141
from spdl.pipeline._subprocess_pipeline_pool import _shutdown_pipeline_pools
@@ -281,9 +281,11 @@ def build_pipeline(
281281
construct (router and branches) moves into the worker — and fuses on its own even
282282
when it is the only such stage. An ``aggregate``/``disaggregate`` between two pool
283283
stages is not fused (it keeps its main-process batching) and splits them into
284-
separate runs. Continuous sources are supported (the fused worker sub-pipelines stay
285-
warm across epochs and epoch boundaries are propagated across the pool). Default:
286-
``False``.
284+
separate runs. An async op joins a fused run when tagged with the same executor as
285+
its neighbours (see :py:meth:`~spdl.pipeline.PipelineBuilder.pipe`), running on the
286+
worker's own event loop. Continuous sources are supported (the fused worker
287+
sub-pipelines stay warm across epochs and epoch boundaries are propagated across the
288+
pool). Default: ``False``.
287289
288290
.. versionadded:: 0.6.0
289291
The ``fuse_subprocess_stages`` argument.
@@ -565,7 +567,9 @@ def run_pipeline_in_subprocess(
565567
This removes the per-stage round-trip between the pipeline subprocess and the pool
566568
workers (so intermediate values need not be picklable). A ``path_variants`` stage
567569
whose branches all use the same pool executor is fused too (router and branches move
568-
into the worker). Continuous sources are supported. Default: ``False``.
570+
into the worker). An async op joins a fused run when tagged with the same executor as
571+
its neighbours (see :py:meth:`~spdl.pipeline.PipelineBuilder.pipe`), running on the
572+
worker's own event loop. Continuous sources are supported. Default: ``False``.
569573
570574
.. versionadded:: 0.6.0
571575
The ``fuse_subprocess_stages`` argument.
@@ -616,6 +620,11 @@ def run_pipeline_in_subprocess(
616620
stacklevel=3,
617621
)
618622

623+
# Clear executor tags left on any unfused async op: they are subprocess fusion-group hints,
624+
# not real pools, and the executor-hoisting/pickling passes below are op-agnostic -- an async
625+
# op's process-pool tag would otherwise spawn an idle worker pool it never submits to.
626+
config = _strip_async_executor_tags(config)
627+
619628
# Spawn workers for any stdlib ``ProcessPoolExecutor`` in the main process (as children of
620629
# main, not grandchildren via the pipeline subprocess), then replace the executor with a
621630
# queue-backed proxy that the subprocess submits to. The remaining stdlib executors

src/spdl/pipeline/_builder.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,21 @@ def pipe(
170170
executor: A custom executor object to be used to convert the synchronous operation
171171
into asynchronous one. If ``None``, the default executor is used.
172172
173-
It is invalid to provide this argument when the given op is already async.
173+
When ``op`` is already async, ``executor`` must be an isolating-pool (process
174+
or interpreter) executor and is **not** used to run the op -- an async op always
175+
runs on the event loop. It serves only as a subprocess fusion-group tag: with
176+
``fuse_subprocess_stages=True`` (see
177+
:py:meth:`~spdl.pipeline.PipelineBuilder.build`), adjacent stages sharing the
178+
same executor instance are fused into one worker sub-pipeline, so tagging an
179+
async op lets it join such a run (it then runs on the worker's own event loop
180+
and, like any fused stage, must be picklable). Unfused, the tag is ignored and
181+
the op runs in the main process. Passing a non-isolating executor (e.g. a thread
182+
pool) with an async op is an error.
183+
184+
.. versionchanged:: 0.6.0
185+
An async ``op`` may now be given an isolating-pool ``executor`` as a
186+
subprocess fusion-group tag; previously any ``executor`` on an async op was
187+
rejected.
174188
name: The name (prefix) to give to the task.
175189
output_order: If ``"completion"`` (default), the items are put to output queue
176190
in the order their process is completed.

src/spdl/pipeline/_common/_convert.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,11 @@ def convert_to_async(
202202
op = op.__call__
203203

204204
if inspect.iscoroutinefunction(op) or inspect.isasyncgenfunction(op):
205-
# op is async function. No need to convert.
206-
assert executor is None # This has been checked in `PipelineBuilder.pipe()`
205+
# op is async function. No need to convert. An async op always runs on the event loop,
206+
# so any ``executor`` it carries is not used to run it here -- it is only a subprocess
207+
# fusion-group tag. When the stage is fused the tag is stripped by ``_strip_executor``;
208+
# when it is not fused the ``_strip_async_executor_tags`` pass clears it before executor
209+
# hoisting, so no idle worker pool is spawned for a pool the async op never submits to.
207210
return op # pyre-ignore: [7]
208211

209212
if inspect.isgeneratorfunction(op):

src/spdl/pipeline/_fuse.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
from __future__ import annotations
3434

35+
import inspect
3536
import multiprocessing as mp
3637
import os
3738
import threading
@@ -61,6 +62,7 @@
6162
"_FusableRun",
6263
"_find_fusable_runs",
6364
"_fuse_subprocess_stages",
65+
"_strip_async_executor_tags",
6466
]
6567

6668
# Buffer size for the fused sub-pipeline's sink. Small: the worker drains it straight onto the
@@ -113,8 +115,8 @@ def _scan_variant_pool_executors(
113115
Recurses into nested path-variants. Appends each completion-ordered pool-pipe's executor to
114116
``acc``; async/thread/default pipes are ignored (they run on the worker's own loop/threads
115117
once the stage is fused). Returns ``False`` if any branch holds an *input-ordered*
116-
(``output_order="input"``) pool-pipe, which is not fusable for the same reason a top-level
117-
one is not — its global input order cannot be preserved across the pool workers.
118+
(``output_order="input"``) *sync* pool-pipe, which is not fusable for the same reason a
119+
top-level one is not — its global input order cannot be preserved across the pool workers.
118120
"""
119121
for path in cfg.paths:
120122
for stage in path:
@@ -129,8 +131,12 @@ def _scan_variant_pool_executors(
129131
isinstance(stage, PipeConfig)
130132
and stage._args.executor is not None
131133
and _is_isolating_pool(stage._args.executor)
134+
and not _is_async_op(stage._args.op)
132135
):
133-
# A pool-pipe that _fusable_pool_executor rejected -> input-ordered: not fusable.
136+
# A sync pool-pipe that _fusable_pool_executor rejected -> input-ordered:
137+
# its global input order can't be preserved across pool workers, so it is not
138+
# fusable. An async op's executor is only a fusion-group tag (it runs on the
139+
# loop, not the pool), so its ordering is unaffected and it never blocks fusion.
134140
return False
135141
return True
136142

@@ -251,11 +257,20 @@ def _strip_executor(cfg: object) -> object:
251257
return cfg
252258

253259

260+
def _is_async_op(op: object) -> bool:
261+
"""Whether ``op`` runs on the event loop rather than the worker's thread pool."""
262+
return inspect.iscoroutinefunction(op) or inspect.isasyncgenfunction(op)
263+
264+
254265
def _stage_concurrency(cfg: object) -> int:
255266
"""Total worker-thread demand of a fused stage: a pipe's ``concurrency``, or the sum across
256-
every branch pipe of a path-variants stage (recursively). Other stages contribute 0."""
267+
every branch pipe of a path-variants stage (recursively). Other stages contribute 0.
268+
269+
An async pipe contributes 0: its ``concurrency`` bounds concurrent coroutines on the
270+
worker's event loop, which do not consume worker threads.
271+
"""
257272
if isinstance(cfg, PipeConfig):
258-
return cfg._args.concurrency
273+
return 0 if _is_async_op(cfg._args.op) else cfg._args.concurrency
259274
if isinstance(cfg, PathVariantsConfig):
260275
return sum(_stage_concurrency(s) for path in cfg.paths for s in path)
261276
return 0
@@ -409,3 +424,43 @@ def _fuse_subprocess_stages(
409424
pool.shutdown()
410425
raise
411426
return replace(config, pipes=new_pipes), pools # pyre-ignore[6]
427+
428+
429+
def _strip_async_executor_tag(cfg: object) -> object:
430+
"""Clear the executor tag on an async-op stage, recursing into path-variants branches.
431+
432+
An async op's executor is only a subprocess fusion-group tag; once fusion has run, any tag
433+
left on an *unfused* async op is a no-op that must not reach the subprocess
434+
executor-hoisting machinery (which is op-agnostic and would otherwise spawn an idle worker
435+
pool for it). Sync stages are returned unchanged.
436+
"""
437+
if isinstance(cfg, PipeConfig):
438+
if cfg._args.executor is not None and _is_async_op(cfg._args.op):
439+
return replace(cfg, _args=replace(cfg._args, executor=None))
440+
return cfg
441+
if isinstance(cfg, PathVariantsConfig):
442+
new_paths = tuple(
443+
tuple(_strip_async_executor_tag(stage) for stage in path)
444+
for path in cfg.paths
445+
)
446+
return replace(cfg, paths=new_paths)
447+
return cfg
448+
449+
450+
def _strip_async_executor_tags(config: PipelineConfig[Any]) -> PipelineConfig[Any]:
451+
"""Return ``config`` with executor tags cleared from every unfused async-op stage.
452+
453+
Walks the top-level pipes, path-variants branches, and merged sub-configs (mirroring the
454+
executor rewrite in :py:mod:`spdl.pipeline._executor_proxy`). Fused async ops are already
455+
inside a :py:class:`_SubprocessPipelineConfig` handle (their tags stripped when the run was
456+
built), so this only touches tags left on stages that stayed in the enclosing pipeline.
457+
"""
458+
src = config.src
459+
if isinstance(src, MergeConfig):
460+
new_configs = tuple(
461+
_strip_async_executor_tags(plc) for plc in src.pipeline_configs
462+
)
463+
# pyre-ignore[6]: MergeConfig annotates a 1-tuple but holds N configs.
464+
src = replace(src, pipeline_configs=new_configs)
465+
new_pipes = [_strip_async_executor_tag(p) for p in config.pipes]
466+
return replace(config, src=src, pipes=new_pipes) # pyre-ignore[6]

src/spdl/pipeline/defs/_defs.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from functools import partial
2222
from typing import Any, Generic, Protocol, runtime_checkable, TypeAlias, TypeVar
2323

24+
from spdl.pipeline._common._convert import _is_isolating_pool
2425
from spdl.pipeline._common._source_locator import locate_source
2526
from spdl.pipeline._common._types import _TCallables, _TMergeOp
2627

@@ -212,8 +213,14 @@ class PipeConfig(Generic[T, U]):
212213
def __post_init__(self) -> None:
213214
op = self._args.op
214215
if inspect.iscoroutinefunction(op) or inspect.isasyncgenfunction(op):
215-
if self._args.executor is not None:
216-
raise ValueError("`executor` cannot be specified when op is async.")
216+
executor = self._args.executor
217+
if executor is not None and not _is_isolating_pool(executor):
218+
raise ValueError(
219+
"An async op may only be given an isolating-pool (process or interpreter) "
220+
"executor, which is used solely to group the op into a subprocess fusion "
221+
"run -- not to execute it. A non-isolating executor (e.g. a thread pool) "
222+
"has no effect on an async op and is not allowed."
223+
)
217224
if inspect.isasyncgenfunction(op):
218225
if self._type == _PipeType.OrderedPipe:
219226
raise ValueError(
@@ -790,7 +797,11 @@ def Pipe(
790797
executor: A custom executor object to be used to convert the synchronous operation
791798
into asynchronous one. If ``None``, the default executor is used.
792799
793-
It is invalid to provide this argument when the given op is already async.
800+
When ``op`` is already async, ``executor`` must be an isolating-pool (process or
801+
interpreter) executor and is not used to run the op (an async op always runs on the
802+
event loop) -- it serves only as a subprocess fusion-group tag (see
803+
``fuse_subprocess_stages`` on :py:meth:`~spdl.pipeline.PipelineBuilder.build`).
804+
Passing a non-isolating executor (e.g. a thread pool) with an async op is an error.
794805
name: The name (prefix) to give to the task.
795806
output_order: If ``"completion"`` (default), the items are put to output queue
796807
in the order their process is completed.

0 commit comments

Comments
 (0)