Skip to content

Commit 4597cbd

Browse files
authored
[pipeline] 3/4: Subinterpreter worker-pool backend for placement regions (#1586)
Part 3/4 of the `.to()` region API; see #1584 for the overall design. Adds the subinterpreter worker-pool backend so a region can run in subinterpreters, not just subprocesses. Still no public surface — dormant until markers exist. - Factors the process-specific bits of `_SubprocessPipelinePool` behind a small `_PoolBackend` seam (`make_queue`, `spawn`, `try_put_shutdown`, `close_queue`) with two implementations: `_ProcessBackend` (existing `multiprocessing` behavior, unchanged) and `_InterpreterBackend` (`concurrent.interpreters`, Python 3.14+). The streaming protocol and worker body (`_pipeline_worker_loop`) are backend-agnostic and unchanged; the worker is spawned via `interp.call_in_thread` and reaped by joining its thread (subinterpreters can't be force-killed, so teardown relies on the broadcast `_POOL_SHUTDOWN` marker the worker loop already honors). - `_fuse.py` now selects the backend by region-spec type: `ProcessPoolExecutorConfig` → process backend (with the fork+threads warning); `InterpreterPoolExecutorConfig` → subinterpreter backend, or a clear `RuntimeError` on Python < 3.14. Replaces the previous `NotImplementedError`. - `interpreters.Queue` transports items by pickling like `mp.Queue`, so no queue-protocol changes were needed and unpicklable intermediates stay in the worker.
1 parent 7b42bd1 commit 4597cbd

3 files changed

Lines changed: 346 additions & 104 deletions

File tree

src/spdl/pipeline/_fuse.py

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import inspect
3636
import multiprocessing as mp
3737
import os
38+
import sys
3839
import threading
3940
import warnings
4041
from collections.abc import Sequence
@@ -46,7 +47,12 @@
4647
from spdl.pipeline._common._convert import _is_isolating_pool
4748
from spdl.pipeline._components import _get_global_id, _set_global_id
4849
from spdl.pipeline._executor_proxy import _ensure_executor_unused
49-
from spdl.pipeline._subprocess_pipeline_pool import _SubprocessPipelinePool
50+
from spdl.pipeline._subprocess_pipeline_pool import (
51+
_InterpreterBackend,
52+
_PoolBackend,
53+
_ProcessBackend,
54+
_SubprocessPipelinePool,
55+
)
5056
from spdl.pipeline.defs._defs import (
5157
_MainProcess,
5258
_PipeType,
@@ -324,18 +330,18 @@ def _warn_fork_with_threads(ctx: Any, stacklevel: int) -> None:
324330
def _build_fused_stage_core(
325331
stages: Sequence[object],
326332
*,
327-
ctx: Any,
333+
backend: _PoolBackend,
328334
num_threads: int,
329335
max_workers: int,
330336
user_initializer: Any,
331337
user_initargs: tuple[Any, ...],
332338
report_stats_interval: float,
333339
continuous: bool,
334340
) -> tuple[_SubprocessPipelineConfig, _SubprocessPipelinePool]:
335-
"""Spawn a worker pool that runs ``stages`` as a nested pipeline, and return the pool and its
341+
"""Spawn a worker pool that runs ``stages`` as a nested pipeline; return the pool and its
336342
replacement stage. Shared by the executor-identity fusion (:py:func:`_build_fused_stage`) and
337-
the ``.to()`` marker fusion (:py:func:`_build_fused_stage_from_spec`) — the two differ only in
338-
where the pool parameters come from (a live executor vs. a serializable spec)."""
343+
the ``.to()`` marker fusion (:py:func:`_build_fused_stage_from_spec`); they differ only in
344+
where the pool params and backend come from (a live executor vs. a spec)."""
339345
stripped = [_strip_executor(s) for s in stages]
340346
sub_config: PipelineConfig[Any] = PipelineConfig(
341347
src=SourceConfig(
@@ -352,7 +358,7 @@ def _build_fused_stage_core(
352358
_worker_initializer, _get_global_id(), user_initializer, user_initargs
353359
)
354360
pool = _SubprocessPipelinePool(
355-
ctx,
361+
backend,
356362
max_workers,
357363
sub_config,
358364
build_kwargs,
@@ -375,7 +381,7 @@ def _build_fused_stage(
375381
max_workers, user_initializer, user_initargs = _pool_params(executor)
376382
return _build_fused_stage_core(
377383
stages,
378-
ctx=ctx,
384+
backend=_ProcessBackend(ctx),
379385
num_threads=max(1, sum(_stage_concurrency(s) for s in stages)),
380386
max_workers=max_workers,
381387
user_initializer=user_initializer,
@@ -387,21 +393,22 @@ def _build_fused_stage(
387393

388394
def _build_fused_stage_from_spec(
389395
stages: Sequence[object],
390-
spec: ProcessPoolExecutorConfig,
391-
ctx: Any,
396+
spec: ProcessPoolExecutorConfig | InterpreterPoolExecutorConfig,
397+
backend: _PoolBackend,
392398
report_stats_interval: float,
393399
continuous: bool,
394400
) -> tuple[_SubprocessPipelineConfig, _SubprocessPipelinePool]:
395401
"""Build the worker pool and replacement stage for one ``.to()`` region, reading the pool
396402
parameters from ``spec``. ``num_threads`` for the nested pipeline is the sum of the region
397403
stages' concurrency, ``max_workers`` falls back to the CPU count, and
398404
``report_stats_interval`` is inherited from
399-
:py:func:`~spdl.pipeline._build.build_pipeline`."""
405+
:py:func:`~spdl.pipeline._build.build_pipeline`. ``backend`` (process or subinterpreter) is
406+
chosen by the caller from the spec type."""
400407
num_threads = max(1, sum(_stage_concurrency(s) for s in stages))
401408
max_workers = spec.max_workers or os.cpu_count() or 1
402409
return _build_fused_stage_core(
403410
stages,
404-
ctx=ctx,
411+
backend=backend,
405412
num_threads=num_threads,
406413
max_workers=max_workers,
407414
user_initializer=spec.initializer,
@@ -534,20 +541,25 @@ def _fuse_marked_regions(
534541
def _flush() -> None:
535542
if not region:
536543
return
537-
if isinstance(target, InterpreterPoolExecutorConfig):
538-
raise NotImplementedError(
539-
"Subinterpreter regions (`.to(InterpreterPoolExecutorConfig(...))`) are not yet "
540-
"supported; use `ProcessPoolExecutorConfig` for now."
541-
)
542-
assert isinstance(
543-
target, ProcessPoolExecutorConfig
544-
) # narrowed: not main, not subinterpreter
545-
ctx = mp.get_context(target.mp_context)
546-
# +2, not +1: this runs inside the nested ``_flush`` closure, one frame deeper than
547-
# ``_fuse_marked_regions`` itself, so the warning still points at the user's call site.
548-
_warn_fork_with_threads(ctx, stacklevel + 2)
544+
backend: _PoolBackend
545+
if isinstance(target, ProcessPoolExecutorConfig):
546+
ctx = mp.get_context(target.mp_context)
547+
# +2, not +1: this runs inside the nested ``_flush`` closure, one frame deeper than
548+
# ``_fuse_marked_regions`` itself, so the warning still points at the user's call site.
549+
_warn_fork_with_threads(ctx, stacklevel + 2)
550+
backend = _ProcessBackend(ctx)
551+
elif isinstance(target, InterpreterPoolExecutorConfig):
552+
if sys.version_info < (3, 14):
553+
raise RuntimeError(
554+
"Subinterpreter regions (`.to(InterpreterPoolExecutorConfig(...))`) require "
555+
"Python 3.14 or later. Current version: "
556+
f"{sys.version_info.major}.{sys.version_info.minor}"
557+
)
558+
backend = _InterpreterBackend()
559+
else: # pragma: no cover -- a main-process target never accumulates a region
560+
raise AssertionError(f"Unexpected region target: {target!r}")
549561
fused, pool = _build_fused_stage_from_spec(
550-
region, target, ctx, report_stats_interval, continuous
562+
region, target, backend, report_stats_interval, continuous
551563
)
552564
pools.append(pool)
553565
new_pipes.append(fused)

0 commit comments

Comments
 (0)