Skip to content

Commit a77a665

Browse files
committed
[pipeline] 1/4: Add pipeline executor and placement config types
This is part 1/4 of a series (plus a follow-up) adding the `.to()` region API to the SPDL `PipelineBuilder` — a declarative way to designate where a run of pipeline stages executes: the main process, a subprocess pool, or a subinterpreter pool. This diff carries the overview for the whole series; the later diffs point back here for the rationale. Running stages off the main process previously meant attaching a shared `ProcessPoolExecutor` instance to consecutive `pipe()` calls and passing `fuse_subprocess_stages=True` to `build()`. The engine fused adjacent stages that shared the same executor object into one subprocess pipeline, so the value handed from one stage to the next stayed in the worker instead of being pickled back to the main process between stages. That worked but had real limitations: it was implicit (it keyed off executor object identity, which is easy to get wrong and cannot be expressed as static config), it required a live `Executor` (so a pipeline could not be fully described as serializable data), it could not pull `aggregate`/`disaggregate`/`path_variants` stages into a region (a fusion run was bounded at those stages), and it had no subinterpreter support. The `.to()` API makes the region explicit and declarative. `to(ProcessPoolExecutorConfig(...))` or `to(InterpreterPoolExecutorConfig(...))` opens a region; every stage after it runs in that worker pool until `to(MAIN_PROCESS)` closes the region. The target is a serializable spec rather than a live executor, so a pipeline stays expressible as static config; a region may contain `aggregate`/`disaggregate`/`path_variants`; the worker-pool configuration has a single home; and subinterpreter pools (Python 3.14+) are supported alongside subprocess pools. The value passed between stages inside a region never leaves the worker and need not be picklable — only the region's inputs and outputs cross the boundary. - 1/4 — this PR (#1584): the config-layer building blocks (the `ExecutorConfig` spec hierarchy — `ProcessPoolExecutorConfig` and `InterpreterPoolExecutorConfig` — plus `MAIN_PROCESS` and the `PlacementConfig` region marker), and widening `PipelineConfig.pipes` to carry region markers. Additive and inert. - 2/4 — #1585: the engine pass `_fuse_marked_regions` that consumes the markers, replacing each maximal span of stages under a region target with one stage that runs the span as a nested pipeline in a worker pool. Dormant until markers exist. - 3/4 — #1586: the subinterpreter worker-pool backend, behind a `_PoolBackend` seam, so a region can run in subinterpreters as well as subprocesses. - 4/4 — #1587: the public surface (`PipelineBuilder.to()` and its validation), which makes the feature usable end to end. - follow-up — #1588 (BC-breaking): removes the superseded `fuse_subprocess_stages` executor-identity fusion path now that `.to()` provides the same capability with an explicit, statically-configurable surface. Adds the config-layer types, additive only — no behavior change yet: - `ExecutorConfig`: the base spec for a worker-pool executor — a serializable description of a pool (worker count, initializer, initargs) that the pipeline later materializes into a live executor. Kept as a base so more executor targets (e.g. a thread pool) can be added without touching the placement machinery, and so the specs can be reused wherever a pool is configured. - `ProcessPoolExecutorConfig` / `InterpreterPoolExecutorConfig`: the two concrete `ExecutorConfig` subclasses, for a subprocess or subinterpreter worker-pool region. The process variant adds `mp_context`; the subinterpreter variant adds nothing (no new process is started). They describe the pool without holding a live `Executor`. - `MAIN_PROCESS`: a sentinel target that closes a region and brings subsequent stages back to the main process. It reprs as `MAIN_PROCESS` and, unlike `None`, is unambiguous and serializes cleanly. - `PlacementConfig`: a region-marker node carried in `PipelineConfig.pipes`; stages after a marker (until the next one) run on its target (an `ExecutorConfig` or `MAIN_PROCESS`). `PipelineConfig.pipes` now accepts `PlacementConfig`. Nothing produces these markers yet (`PipelineBuilder.to()` lands in 4/4), so this is inert; the fusion pass that consumes them lands in 2/4. Note: a region's worker thread count and stats interval are pipeline/build settings, not properties of a placement, so they are not fields of these specs — the engine derives the region's thread count from the concurrency of the stages it fuses and inherits the stats interval from `build_pipeline`.
1 parent e80d661 commit a77a665

6 files changed

Lines changed: 217 additions & 3 deletions

File tree

src/spdl/pipeline/_components/_node.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
PathVariantsConfig,
2727
PipeConfig,
2828
PipelineConfig,
29+
PlacementConfig,
2930
SinkConfig,
3031
SourceConfig,
3132
)
@@ -312,6 +313,7 @@ def _convert_pipes(
312313
| DisaggregateConfig
313314
| PathVariantsConfig
314315
| _SubprocessPipelineConfig
316+
| PlacementConfig
315317
],
316318
n: _TNodes,
317319
q_class: type[AsyncQueue],
@@ -340,6 +342,12 @@ def _convert_pipes(
340342
match cfg:
341343
case PathVariantsConfig():
342344
n = _convert_path_variants(cfg, n, q_class, pipeline_id, stage_id, idx)
345+
case PlacementConfig():
346+
# Region markers are build-time directives resolved before the pipeline is
347+
# built; they must never reach node construction.
348+
raise ValueError(
349+
"PlacementConfig region markers must be resolved before building nodes."
350+
)
343351
case _:
344352
in_q = _get_output_queue(n, idx)
345353
info = _get_stage_info(cfg, pipeline_id, stage_id)

src/spdl/pipeline/_profile.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
PathVariantsConfig,
2626
PipeConfig,
2727
PipelineConfig,
28+
PlacementConfig,
2829
SinkConfig,
2930
SourceConfig,
3031
)
@@ -263,7 +264,9 @@ def _profile_pipeline(
263264
raise ValueError(f"Unexpected source type {type(cfg.src)}")
264265

265266
for pipe in cfg.pipes:
266-
if isinstance(pipe, (PathVariantsConfig, _SubprocessPipelineConfig)):
267+
if isinstance(
268+
pipe, (PathVariantsConfig, _SubprocessPipelineConfig, PlacementConfig)
269+
):
267270
_LG.warning(
268271
"Skipping %s stage in profiling (not supported).", type(pipe).__name__
269272
)

src/spdl/pipeline/defs/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"""
2727

2828
from ._defs import (
29+
_MainProcess,
2930
_PipeArgs,
3031
_PipeType,
3132
_SubprocessPipelineConfig,
@@ -36,18 +37,24 @@
3637
Collate,
3738
Disaggregate,
3839
DisaggregateConfig,
40+
ExecutorConfig,
41+
InterpreterPoolExecutorConfig,
42+
MAIN_PROCESS,
3943
Merge,
4044
MergeConfig,
4145
PathVariants,
4246
PathVariantsConfig,
4347
Pipe,
4448
PipeConfig,
4549
PipelineConfig,
50+
PlacementConfig,
51+
ProcessPoolExecutorConfig,
4652
SinkConfig,
4753
SourceConfig,
4854
)
4955

5056
__all__ = [
57+
"_MainProcess",
5158
"_PipeArgs",
5259
"_PipeType",
5360
"_SubprocessPipelineConfig",
@@ -62,13 +69,18 @@
6269
"Collate",
6370
"Disaggregate",
6471
"DisaggregateConfig",
72+
"ExecutorConfig",
73+
"InterpreterPoolExecutorConfig",
74+
"MAIN_PROCESS",
6575
"Merge",
6676
"MergeConfig",
6777
"PathVariants",
6878
"PathVariantsConfig",
6979
"Pipe",
7080
"PipeConfig",
7181
"PipelineConfig",
82+
"PlacementConfig",
83+
"ProcessPoolExecutorConfig",
7284
"SinkConfig",
7385
"SourceConfig",
7486
]

src/spdl/pipeline/defs/_defs.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
"Collate",
4141
"DisaggregateConfig",
4242
"_SubprocessPipelineConfig",
43+
"_MainProcess",
44+
"ExecutorConfig",
45+
"ProcessPoolExecutorConfig",
46+
"InterpreterPoolExecutorConfig",
47+
"MAIN_PROCESS",
48+
"PlacementConfig",
4349
"PipelineConfig",
4450
"SinkConfig",
4551
"SourceConfig",
@@ -436,6 +442,110 @@ class _SubprocessPipelineConfig:
436442
"""The picklable submit-side handle (``_SubprocessPipelineHandle``) for the worker pool."""
437443

438444

445+
################################################################################
446+
# Executor placement (region markers for `.to()`)
447+
################################################################################
448+
449+
450+
@dataclass(frozen=True)
451+
class ExecutorConfig:
452+
"""**[Experimental]** Base spec for a worker-pool executor.
453+
454+
A serializable description of a pool of workers, materialized into a live executor by
455+
the pipeline. Subclassed by :py:class:`ProcessPoolExecutorConfig` and
456+
:py:class:`InterpreterPoolExecutorConfig`; those subclasses are used as ``.to()``
457+
placement targets (via :py:class:`PlacementConfig`).
458+
459+
.. versionadded:: 0.6.0
460+
"""
461+
462+
max_workers: int | None = None
463+
"""Number of workers. If ``None``, defaults to the number of CPUs."""
464+
465+
initializer: Callable[..., object] | None = None
466+
"""Callable run once in each worker before it processes any work."""
467+
468+
initargs: tuple[Any, ...] = ()
469+
"""Positional arguments passed to ``initializer``."""
470+
471+
472+
@dataclass(frozen=True)
473+
class ProcessPoolExecutorConfig(ExecutorConfig):
474+
"""**[Experimental]** A worker-pool executor backed by subprocesses.
475+
476+
Used as a ``.to()`` target to run a region's stages in a pool of worker *processes*
477+
as one nested pipeline — the op->op handoff stays in the worker, so intermediate
478+
values are not copied back to the main process and need not be picklable. The region
479+
ends at the next ``.to()`` target (see :py:data:`MAIN_PROCESS`).
480+
481+
.. versionadded:: 0.6.0
482+
"""
483+
484+
mp_context: str | None = None
485+
"""Multiprocessing start method (e.g. ``"spawn"``, ``"fork"``, ``"forkserver"``),
486+
as accepted by :py:func:`multiprocessing.get_context`. ``None`` uses the default
487+
context."""
488+
489+
490+
@dataclass(frozen=True)
491+
class InterpreterPoolExecutorConfig(ExecutorConfig):
492+
"""**[Experimental]** A worker-pool executor backed by subinterpreters.
493+
494+
Like :py:class:`ProcessPoolExecutorConfig`, but the workers are Python
495+
*subinterpreters* (:py:mod:`concurrent.interpreters`) sharing the process rather than
496+
separate processes. There is no ``mp_context`` because no new process is started.
497+
498+
.. note::
499+
500+
Requires Python 3.14 or later; using this target on an older interpreter is an
501+
error. NumPy and PyTorch cannot be imported inside a subinterpreter, so a region
502+
whose stages need them must use :py:class:`ProcessPoolExecutorConfig` instead.
503+
504+
.. versionadded:: 0.6.0
505+
"""
506+
507+
508+
@dataclass(frozen=True)
509+
class _MainProcess:
510+
"""Sentinel ``.to()`` target for the main process. Use the :py:data:`MAIN_PROCESS`
511+
singleton rather than instantiating this type."""
512+
513+
def __repr__(self) -> str:
514+
return "MAIN_PROCESS"
515+
516+
517+
MAIN_PROCESS: _MainProcess = _MainProcess()
518+
"""**[Experimental]** The main-process execution target. Pass to
519+
:py:meth:`spdl.pipeline.PipelineBuilder.to`
520+
to close a worker-pool region and bring subsequent stages back to the main process.
521+
522+
.. versionadded:: 0.6.0
523+
"""
524+
525+
526+
@dataclass(frozen=True)
527+
class PlacementConfig:
528+
"""**[Experimental]** A region marker designating where the subsequent stages execute.
529+
530+
Sits among the stage configs in :py:attr:`PipelineConfig.pipes`: every stage after
531+
this marker (until the next :py:class:`PlacementConfig`) runs on :py:attr:`target`.
532+
:py:meth:`spdl.pipeline.PipelineBuilder.to` appends one of these. A pipeline
533+
implicitly starts on the main process, so a marker is needed only to enter a
534+
worker-pool region and (with :py:data:`MAIN_PROCESS`) to leave it.
535+
536+
.. versionadded:: 0.6.0
537+
"""
538+
539+
target: "ProcessPoolExecutorConfig | InterpreterPoolExecutorConfig | _MainProcess"
540+
"""Where the stages following this marker execute."""
541+
542+
name: str = "placement"
543+
"""Name of the marker (used only for display)."""
544+
545+
def __repr__(self) -> str:
546+
return f"{self.name}({self.target!r})"
547+
548+
439549
################################################################################
440550
# PathVariants
441551
################################################################################
@@ -607,6 +717,7 @@ class PipelineConfig(Generic[U]):
607717
| DisaggregateConfig[Any]
608718
| PathVariantsConfig[Any]
609719
| _SubprocessPipelineConfig
720+
| PlacementConfig
610721
]
611722
"""Pipe configurations."""
612723

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# pyre-strict
8+
9+
import unittest
10+
11+
from spdl.pipeline.defs import (
12+
InterpreterPoolExecutorConfig,
13+
MAIN_PROCESS,
14+
PlacementConfig,
15+
ProcessPoolExecutorConfig,
16+
)
17+
18+
# NOTE: The behavior of these config types (how a region's worker pool is built and run from a
19+
# spec), including that a ``PipelineConfig`` accepts region markers in its ``pipes``, is covered
20+
# end-to-end by ``marked_region_fuse_test`` and ``builder_to_test``. The tests here cover only what
21+
# those cannot: the hand-written ``__repr__`` methods and the deliberate structural contract of
22+
# ``InterpreterPoolExecutorConfig`` (that it rejects an ``mp_context``). Plain-dataclass mechanics
23+
# (default values, field storage, ``frozen``, auto-generated ``__eq__``) are intentionally not
24+
# re-tested.
25+
26+
27+
class TestInterpreterPoolExecutorConfig(unittest.TestCase):
28+
"""Verify the InterpreterPoolExecutorConfig spec."""
29+
30+
def test_rejects_mp_context(self) -> None:
31+
"""InterpreterPoolExecutorConfig rejects ``mp_context``; ProcessPoolExecutorConfig honors it.
32+
33+
A subinterpreter shares the process, so there is no multiprocessing start method to choose.
34+
Passing ``mp_context`` must raise rather than be silently accepted and ignored -- unlike
35+
:py:class:`ProcessPoolExecutorConfig`, which starts real processes and stores the chosen
36+
start method.
37+
"""
38+
self.assertEqual(
39+
ProcessPoolExecutorConfig(mp_context="spawn").mp_context, "spawn"
40+
)
41+
with self.assertRaises(TypeError):
42+
InterpreterPoolExecutorConfig(mp_context="spawn") # pyre-ignore[28]
43+
44+
45+
class TestMainProcess(unittest.TestCase):
46+
"""Verify the MAIN_PROCESS sentinel target."""
47+
48+
def test_repr(self) -> None:
49+
"""The sentinel's custom ``__repr__`` renders its public name for readable configs."""
50+
self.assertEqual(repr(MAIN_PROCESS), "MAIN_PROCESS")
51+
52+
53+
class TestPlacementConfig(unittest.TestCase):
54+
"""Verify the PlacementConfig region marker."""
55+
56+
def test_repr_includes_target(self) -> None:
57+
"""The marker's custom ``__repr__`` surfaces its target for readable pipeline dumps."""
58+
self.assertEqual(
59+
repr(PlacementConfig(target=MAIN_PROCESS)), "placement(MAIN_PROCESS)"
60+
)
61+
62+
63+
if __name__ == "__main__":
64+
unittest.main()

tests/pipeline/shutdown_hook_test.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,13 +214,29 @@ def _child_exit_code(
214214
class InterpreterExitTopologyTest(unittest.TestCase):
215215
"""Held, unstopped pipelines exit cleanly at interpreter exit, across topologies."""
216216

217+
@unittest.skipUnless(
218+
"forkserver" in mp.get_all_start_methods(),
219+
"forkserver start method is unavailable on this platform (e.g. Windows)",
220+
)
217221
def test_plain_forkserver(self) -> None:
218222
"""Plain thread-only pipeline exits cleanly (forkserver)."""
219-
self.assertEqual(_child_exit_code(_scenario_plain, "forkserver"), 0)
223+
exit_code = _child_exit_code(_scenario_plain, "forkserver")
224+
self.assertIsNotNone(
225+
exit_code,
226+
"Child process hung (did not exit within timeout); the pipeline's "
227+
"non-daemon event-loop thread likely blocked interpreter shutdown.",
228+
)
229+
self.assertEqual(exit_code, 0)
220230

221231
def test_plain_spawn(self) -> None:
222232
"""Plain thread-only pipeline exits cleanly (spawn)."""
223-
self.assertEqual(_child_exit_code(_scenario_plain, "spawn"), 0)
233+
exit_code = _child_exit_code(_scenario_plain, "spawn")
234+
self.assertIsNotNone(
235+
exit_code,
236+
"Child process hung (did not exit within timeout); the pipeline's "
237+
"non-daemon event-loop thread likely blocked interpreter shutdown.",
238+
)
239+
self.assertEqual(exit_code, 0)
224240

225241

226242
# Note: the `.to(ProcessPoolExecutorConfig)` region topology's interpreter-exit teardown is

0 commit comments

Comments
 (0)