55# LICENSE file in the root directory of this source tree.
66
77import asyncio
8+ import inspect
89import logging
910import sys
1011from asyncio import ALL_COMPLETED , FIRST_COMPLETED , Task
1112from collections .abc import Callable , Coroutine , Sequence
12- from dataclasses import dataclass , field
13+ from dataclasses import dataclass , field , replace
1314from fractions import Fraction
1415from functools import partial
1516from typing import Any , TypeAlias , TypeVar
@@ -484,6 +485,8 @@ def _build_node(
484485 fc_class : type [_FailCounter ],
485486 task_hook_factory : Callable [[StageInfo ], list [TaskHook ]],
486487 max_failures : int | Fraction ,
488+ scheduler : Any = None ,
489+ depth : int = 0 ,
487490) -> None :
488491 """Build a coroutine for a single node based on its configuration type.
489492
@@ -520,6 +523,17 @@ def _build_node(
520523 task_hook_factory: A factory function for creating task hooks for
521524 monitoring.
522525 max_failures: The maximum number of failures allowed before halting.
526+ scheduler: Optional :py:class:`PriorityScheduler` instance. When
527+ provided, sync stages get a per-stage
528+ :py:class:`_PrioritizedExecutor` shim injected into
529+ ``_PipeArgs.executor`` so that
530+ :py:meth:`asyncio.AbstractEventLoop.run_in_executor` routes
531+ through the scheduler's priority heap instead of the
532+ underlying ``ThreadPoolExecutor``'s FIFO.
533+ depth: Depth of this node in the pipeline graph (source-to-sink
534+ distance). Used to compute scheduler priority as
535+ ``priority = -depth`` (deeper stages dispatch first). Only
536+ consumed when ``scheduler is not None``.
523537
524538 Raises:
525539 ValueError: If an unsupported configuration type is encountered.
@@ -578,14 +592,31 @@ def _build_node(
578592 in_q , out_q = node .input_queue , node .output_queue
579593 hooks = task_hook_factory (node .info )
580594 fc = fc_class (max_failures , cfg ._max_failures )
595+
596+ args = cfg ._args
597+
598+ # When a scheduler is provided, register this stage's
599+ # priority and inject a per-stage executor (created
600+ # by `scheduler.make_stage_executor`) so
601+ # loop.run_in_executor() routes through the heap.
602+ # Only sync ops are routed (async/generator ops
603+ # bypass the executor entirely in convert_to_async).
604+ # The factory call decouples `_node.py` from the
605+ # concrete `_PrioritizedExecutor` class — this avoids
606+ # a Buck dep cycle through `_scheduler.py`.
607+ if scheduler is not None and _is_sync_op (args ):
608+ scheduler .register_stage (node .info , priority = - depth )
609+ per_stage_exec = scheduler .make_stage_executor (node .info )
610+ args = replace (args , executor = per_stage_exec )
611+
581612 match cfg ._type :
582613 case _PipeType .Pipe :
583614 node ._coro = _pipe (
584- node .info , in_q , out_q , cfg . _args , fc , hooks , False
615+ node .info , in_q , out_q , args , fc , hooks , False
585616 )
586617 case _PipeType .OrderedPipe :
587618 node ._coro = _ordered_pipe (
588- node .info , in_q , out_q , cfg . _args , fc , hooks
619+ node .info , in_q , out_q , args , fc , hooks
589620 )
590621 case _: # pragma: no cover
591622 raise ValueError (
@@ -623,11 +654,52 @@ def _build_node(
623654 )
624655
625656
657+ def _is_sync_op (args : _PipeArgs ) -> bool :
658+ """Whether ``convert_to_async`` will use the executor branch for ``args.op``.
659+
660+ The :py:class:`PriorityScheduler` only routes work that
661+ :py:func:`~spdl.pipeline._common._convert.convert_to_async` would
662+ submit through a :py:class:`~concurrent.futures.Executor`. Coroutine
663+ functions and async-gen functions bypass the executor entirely, so
664+ they must NOT receive a :py:class:`_PrioritizedExecutor` shim.
665+ Generator functions and process-pool branches are also excluded for
666+ Diff 2 to keep the scope minimal.
667+ """
668+ op = args .op
669+ if inspect .iscoroutinefunction (op ) or inspect .isasyncgenfunction (op ):
670+ return False
671+ if inspect .isgeneratorfunction (op ):
672+ # Generators take the _to_async_gen branch, which uses
673+ # loop.run_in_executor on `next` rather than the user op as a
674+ # whole — routing through the scheduler doesn't fit cleanly.
675+ return False
676+ if args .executor is not None :
677+ # User-supplied executor (e.g., ProcessPoolExecutor). Don't
678+ # override.
679+ return False
680+ return True
681+
682+
683+ def _node_depth (node : _TNodes ) -> int :
684+ """Compute a node's depth (distance from the nearest source).
685+
686+ Source nodes have depth 0; each downstream node is one deeper than
687+ the deepest of its upstream nodes. Used by :py:func:`_build_node`
688+ to compute scheduler priorities (priority = -depth).
689+ """
690+ if isinstance (node , _SourceNode ):
691+ return 0
692+ if not node .upstream :
693+ return 0
694+ return 1 + max (_node_depth (n ) for n in node .upstream )
695+
696+
626697def _build_node_recursive (
627698 node : _TNodes ,
628699 fc_class : type [_FailCounter ],
629700 task_hook_factory : Callable [[StageInfo ], list [TaskHook ]],
630701 max_failures : int | Fraction ,
702+ scheduler : Any = None ,
631703) -> None :
632704 """Recursively build coroutines for a node and all its upstream nodes.
633705
@@ -640,6 +712,8 @@ def _build_node_recursive(
640712 fc_class: The failure counter class for tracking task failures.
641713 task_hook_factory: A factory function for creating task hooks for monitoring.
642714 max_failures: The maximum number of failures allowed before halting.
715+ scheduler: Optional :py:class:`PriorityScheduler` instance for
716+ priority dispatch.
643717
644718 Raises:
645719 RuntimeError: If attempting to build a coroutine for a node that already has one.
@@ -648,9 +722,10 @@ def _build_node_recursive(
648722 return
649723
650724 for n in node .upstream :
651- _build_node_recursive (n , fc_class , task_hook_factory , max_failures )
725+ _build_node_recursive (n , fc_class , task_hook_factory , max_failures , scheduler )
652726
653- _build_node (node , fc_class , task_hook_factory , max_failures )
727+ depth = _node_depth (node ) if scheduler is not None else 0
728+ _build_node (node , fc_class , task_hook_factory , max_failures , scheduler , depth = depth )
654729
655730
656731# Used to append stage name with pipeline
@@ -696,6 +771,7 @@ def _build_pipeline_node(
696771 queue_class : type [AsyncQueue ] | None ,
697772 task_hook_factory : Callable [[StageInfo ], list [TaskHook ]] | None ,
698773 stage_id : int ,
774+ scheduler : Any = None ,
699775) -> _TOutputNodes :
700776 global _PIPELINE_ID
701777 _PIPELINE_ID += 1
@@ -710,7 +786,7 @@ def _build_pipeline_node(
710786 fc_class = _get_fail_counter ()
711787 node = _convert_config (plc , q_class , _PIPELINE_ID , _MutableInt (stage_id ))
712788 _validate_continuous_mode (node )
713- _build_node_recursive (node , fc_class , hook_factory , max_failures )
789+ _build_node_recursive (node , fc_class , hook_factory , max_failures , scheduler )
714790 return node
715791
716792
@@ -950,6 +1026,7 @@ def _build_pipeline_coro(
9501026 task_hook_factory : Callable [[StageInfo ], list [TaskHook ]] | None = None ,
9511027 stage_id : int = 0 ,
9521028 background_tasks : Sequence [BackgroundTaskFactory ] | None = None ,
1029+ scheduler : Any = None ,
9531030) -> tuple [Coroutine [None , None , None ], asyncio .Queue ]:
9541031 try :
9551032 node = _build_pipeline_node (
@@ -959,6 +1036,7 @@ def _build_pipeline_coro(
9591036 queue_class = queue_class ,
9601037 task_hook_factory = task_hook_factory ,
9611038 stage_id = stage_id ,
1039+ scheduler = scheduler ,
9621040 )
9631041 coro = _run_pipeline_coroutines (node , background_tasks = background_tasks )
9641042
0 commit comments