Skip to content

Commit 2656948

Browse files
committed
[pipeline] 2/4: Fuse .to() subprocess regions in the engine
Part 2/4 of the `.to()` region API; see D110604018 for the overall design and rationale. Behind-the-scenes engine change — backward compatible and dormant until region markers exist, so it lands before the public `.to()` surface (4/4) without altering any current behavior. Adds `_fuse_marked_regions`: it walks `PipelineConfig.pipes`, tracks the current execution target set by `ExecutorConfig` markers (a pipeline starts on the main process), and replaces each maximal span of stages under a `SubprocessConfig` target with one stage that runs the span as a nested pipeline in a worker pool — eliminating inter-stage IPC within the region. Crucially, `aggregate`/`disaggregate`/`path_variants` stages inside a region are absorbed into it, which the existing executor-identity fusion cannot do (it bounds a run at those stages). Pool parameters come from the serializable spec rather than a live executor. `_build_fused_stage` is refactored to share a core with the new spec-driven builder. `build_pipeline` now runs `_fuse_marked_regions` unconditionally; it is a no-op when the config has no markers, so the existing `fuse_subprocess_stages` path is untouched. Subinterpreter regions raise `NotImplementedError` pending the subinterpreter worker-pool backend (3/4).
1 parent 9f9a2e3 commit 2656948

3 files changed

Lines changed: 394 additions & 12 deletions

File tree

src/spdl/pipeline/_build.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@
3535
TaskHook,
3636
)
3737
from spdl.pipeline._executor_proxy import _make_config_executors_picklable
38-
from spdl.pipeline._fuse import _fuse_subprocess_stages, _strip_async_executor_tags
38+
from spdl.pipeline._fuse import (
39+
_fuse_marked_regions,
40+
_fuse_subprocess_stages,
41+
_strip_async_executor_tags,
42+
)
3943
from spdl.pipeline._iter_utils import iterate_in_subinterpreter, iterate_in_subprocess
4044
from spdl.pipeline._random_seed import _capture_rng_initializers
4145
from spdl.pipeline._subprocess_pipeline_pool import _shutdown_pipeline_pools
@@ -147,14 +151,30 @@ def _build_pipeline(
147151
_LG.exception("Build callback failed.")
148152

149153
pools: list[Any] = []
150-
if fuse_subprocess_stages:
151-
# Fuse consecutive same-pool stages so each run executes as one nested pipeline inside a
152-
# worker pool, eliminating the inter-stage IPC. The pools are owned by the returned
153-
# Pipeline and reaped when it stops.
154-
# stacklevel=4: _fuse_subprocess_stages -> _build_pipeline -> build_pipeline -> user.
155-
pipeline_cfg, pools = _fuse_subprocess_stages(
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(
156163
pipeline_cfg, report_stats_interval=report_stats_interval, stacklevel=4
157164
)
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
158178

159179
desc = repr(pipeline_cfg)
160180

src/spdl/pipeline/_fuse.py

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,24 @@
4848
from spdl.pipeline._executor_proxy import _ensure_executor_unused
4949
from spdl.pipeline._subprocess_pipeline_pool import _SubprocessPipelinePool
5050
from spdl.pipeline.defs._defs import (
51+
_MainProcess,
5152
_PipeType,
5253
_SubprocessPipelineConfig,
54+
ExecutorConfig,
5355
MergeConfig,
5456
PathVariantsConfig,
5557
PipeConfig,
5658
PipelineConfig,
5759
SinkConfig,
5860
SourceConfig,
61+
SubinterpreterConfig,
62+
SubprocessConfig,
5963
)
6064

6165
__all__ = [
6266
"_FusableRun",
6367
"_find_fusable_runs",
68+
"_fuse_marked_regions",
6469
"_fuse_subprocess_stages",
6570
"_strip_async_executor_tags",
6671
]
@@ -316,24 +321,29 @@ def _warn_fork_with_threads(ctx: Any, stacklevel: int) -> None:
316321
)
317322

318323

319-
def _build_fused_stage(
324+
def _build_fused_stage_core(
320325
stages: Sequence[object],
321-
executor: Executor,
326+
*,
322327
ctx: Any,
328+
num_threads: int,
329+
max_workers: int,
330+
user_initializer: Any,
331+
user_initargs: tuple[Any, ...],
323332
report_stats_interval: float,
324333
continuous: bool,
325334
) -> tuple[_SubprocessPipelineConfig, _SubprocessPipelinePool]:
326-
"""Build the worker pool and replacement stage for one fusable run."""
335+
"""Spawn a worker pool that runs ``stages`` as a nested pipeline, and return the pool and its
336+
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)."""
327339
stripped = [_strip_executor(s) for s in stages]
328-
num_threads = max(1, sum(_stage_concurrency(s) for s in stages))
329340
sub_config: PipelineConfig[Any] = PipelineConfig(
330341
src=SourceConfig(
331342
[]
332343
), # placeholder; the worker sets the real source per session
333344
pipes=stripped, # pyre-ignore[6]
334345
sink=SinkConfig(_FUSED_SINK_BUFFER),
335346
)
336-
max_workers, user_initializer, user_initargs = _pool_params(executor)
337347
build_kwargs = {
338348
"num_threads": num_threads,
339349
"report_stats_interval": report_stats_interval,
@@ -354,6 +364,62 @@ def _build_fused_stage(
354364
return _SubprocessPipelineConfig(name=name, handle=pool.make_handle()), pool
355365

356366

367+
def _build_fused_stage(
368+
stages: Sequence[object],
369+
executor: Executor,
370+
ctx: Any,
371+
report_stats_interval: float,
372+
continuous: bool,
373+
) -> tuple[_SubprocessPipelineConfig, _SubprocessPipelinePool]:
374+
"""Build the worker pool and replacement stage for one executor-identity fusable run."""
375+
max_workers, user_initializer, user_initargs = _pool_params(executor)
376+
return _build_fused_stage_core(
377+
stages,
378+
ctx=ctx,
379+
num_threads=max(1, sum(_stage_concurrency(s) for s in stages)),
380+
max_workers=max_workers,
381+
user_initializer=user_initializer,
382+
user_initargs=user_initargs,
383+
report_stats_interval=report_stats_interval,
384+
continuous=continuous,
385+
)
386+
387+
388+
def _build_fused_stage_from_spec(
389+
stages: Sequence[object],
390+
spec: SubprocessConfig,
391+
ctx: Any,
392+
report_stats_interval: float,
393+
continuous: bool,
394+
) -> tuple[_SubprocessPipelineConfig, _SubprocessPipelinePool]:
395+
"""Build the worker pool and replacement stage for one ``.to()`` region, reading the pool
396+
parameters from ``spec`` rather than a live executor. Unset spec fields fall back to the same
397+
defaults the identity path uses: ``num_threads`` to the sum of the stages' concurrency,
398+
``max_workers`` to the CPU count, and ``report_stats_interval`` to the value passed to
399+
:py:func:`~spdl.pipeline._build.build_pipeline`."""
400+
num_threads = (
401+
max(1, spec.num_threads)
402+
if spec.num_threads is not None
403+
else max(1, sum(_stage_concurrency(s) for s in stages))
404+
)
405+
max_workers = spec.max_workers or os.cpu_count() or 1
406+
rsi = (
407+
spec.report_stats_interval
408+
if spec.report_stats_interval is not None
409+
else report_stats_interval
410+
)
411+
return _build_fused_stage_core(
412+
stages,
413+
ctx=ctx,
414+
num_threads=num_threads,
415+
max_workers=max_workers,
416+
user_initializer=spec.initializer,
417+
user_initargs=spec.initargs,
418+
report_stats_interval=rsi,
419+
continuous=continuous,
420+
)
421+
422+
357423
def _fuse_subprocess_stages(
358424
config: PipelineConfig[Any],
359425
*,
@@ -426,6 +492,92 @@ def _fuse_subprocess_stages(
426492
return replace(config, pipes=new_pipes), pools # pyre-ignore[6]
427493

428494

495+
def _has_executor_markers(pipes: Sequence[object]) -> bool:
496+
"""Whether ``pipes`` contains any ``.to()`` region marker."""
497+
return any(isinstance(p, ExecutorConfig) for p in pipes)
498+
499+
500+
def _fuse_marked_regions(
501+
config: PipelineConfig[Any],
502+
*,
503+
report_stats_interval: float = -1,
504+
stacklevel: int = 3,
505+
) -> tuple[PipelineConfig[Any], list[_SubprocessPipelinePool]]:
506+
"""Fuse each ``.to()`` region into one subprocess-pipeline stage.
507+
508+
Walks ``config.pipes`` tracking the current execution target set by
509+
:py:class:`~spdl.pipeline.defs.ExecutorConfig` markers (a pipeline starts on the main
510+
process). Every maximal span of stages under a subprocess target — pipes *and* the
511+
aggregate/disaggregate/path-variants stages between them, which the executor-identity fusion
512+
leaves in the main process — is replaced by a single stage that runs the span as one nested
513+
pipeline inside a worker pool. Main-process spans are kept unchanged, and the marker nodes
514+
themselves are dropped. The spawned pools are returned for the caller to reap at teardown; the
515+
input ``config`` is not mutated.
516+
517+
This is a no-op (returns ``config`` and no pools) when there are no markers, so it is safe to
518+
run unconditionally and leaves the executor-identity :py:func:`_fuse_subprocess_stages` path
519+
unchanged.
520+
521+
Args:
522+
config: The pipeline configuration to rewrite.
523+
report_stats_interval: Fallback stats interval for a region whose spec does not set one.
524+
stacklevel: ``warnings.warn`` stack level measured at this function. The fork-with-threads
525+
warning is raised from the nested ``_flush`` closure (two frames deeper), so it uses
526+
``stacklevel + 2``.
527+
528+
Returns:
529+
A tuple ``(new_config, pools)``. ``pools`` is empty when no region is fused.
530+
"""
531+
pipes = list(config.pipes)
532+
if not _has_executor_markers(pipes):
533+
return config, []
534+
535+
continuous = _has_continuous_source(config)
536+
pools: list[_SubprocessPipelinePool] = []
537+
new_pipes: list[object] = []
538+
target: SubprocessConfig | SubinterpreterConfig | _MainProcess = _MainProcess()
539+
region: list[object] = []
540+
541+
def _flush() -> None:
542+
if not region:
543+
return
544+
if isinstance(target, SubinterpreterConfig):
545+
raise NotImplementedError(
546+
"Subinterpreter regions (`.to(SubinterpreterConfig(...))`) are not yet "
547+
"supported; use `SubprocessConfig` for now."
548+
)
549+
assert isinstance(
550+
target, SubprocessConfig
551+
) # narrowed: not main, not subinterpreter
552+
ctx = mp.get_context(target.mp_context)
553+
# +2, not +1: this runs inside the nested ``_flush`` closure, one frame deeper than
554+
# ``_fuse_marked_regions`` itself, so the warning still points at the user's call site.
555+
_warn_fork_with_threads(ctx, stacklevel + 2)
556+
fused, pool = _build_fused_stage_from_spec(
557+
region, target, ctx, report_stats_interval, continuous
558+
)
559+
pools.append(pool)
560+
new_pipes.append(fused)
561+
region.clear()
562+
563+
try:
564+
for p in pipes:
565+
if isinstance(p, ExecutorConfig):
566+
_flush() # close the span running under the previous target
567+
target = p.target
568+
elif isinstance(target, _MainProcess):
569+
new_pipes.append(p)
570+
else:
571+
region.append(p)
572+
_flush() # close a region left open at the end of the pipes
573+
except BaseException:
574+
# Reap any pools spawned before the failure; the caller never receives them to reap.
575+
for pool in pools:
576+
pool.shutdown()
577+
raise
578+
return replace(config, pipes=new_pipes), pools # pyre-ignore[6]
579+
580+
429581
def _strip_async_executor_tag(cfg: object) -> object:
430582
"""Clear the executor tag on an async-op stage, recursing into path-variants branches.
431583

0 commit comments

Comments
 (0)