4848from spdl .pipeline ._executor_proxy import _ensure_executor_unused
4949from spdl .pipeline ._subprocess_pipeline_pool import _SubprocessPipelinePool
5050from 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+
357423def _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+
429581def _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