Skip to content

Commit 3eab967

Browse files
justinvjosephfacebook-github-bot
authored andcommitted
Add AdaptiveScheduler with priority-based dispatch for pipeline sync ops
Summary: Introduces an opt-in priority scheduler that replaces the ThreadPoolExecutor's FIFO dispatch with a PriorityQueue. Deeper pipeline stages (closer to sink) are dispatched first, reducing pipeline bubble time and WIP. Key changes: - New `_scheduler.py` with `AdaptiveScheduler` class - `_PipeArgs` gains `nice` and `_depth` fields for priority control - `convert_to_async()` routes through scheduler when provided - `_build_node()` registers stages and intercepts sync ops - `build_pipeline()` gains `use_scheduler=True` flag - `PipelineBuilder.pipe()` gains `nice` parameter When `use_scheduler=False` (default), behavior is identical to today. Differential Revision: D99935461
1 parent 0789747 commit 3eab967

6 files changed

Lines changed: 1310 additions & 10 deletions

File tree

src/spdl/pipeline/_build.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ def _build_pipeline(
128128
task_hook_factory: Callable[[StageInfo], list[TaskHook]] | None = None,
129129
stage_id: int = 0,
130130
background_tasks: list[BackgroundTaskFactory] | None = None,
131+
use_priority_scheduler: bool = False,
131132
) -> Pipeline[U]:
132133
if _DEFAULT_BUILD_CALLBACK is not None:
133134
try:
@@ -147,6 +148,33 @@ def _build_pipeline(
147148
if background_tasks:
148149
all_bg_tasks.extend(background_tasks)
149150

151+
# Create executor before building the pipeline so the scheduler can
152+
# reference it via _underlying_executor attribute binding.
153+
executor = ThreadPoolExecutor(
154+
max_workers=num_threads,
155+
thread_name_prefix="spdl_worker_thread_",
156+
)
157+
158+
# Construct PriorityScheduler if requested. Per V5.4 plumbing, we
159+
# bind the underlying ThreadPoolExecutor by direct attribute
160+
# assignment (no _bind_executor method) and wire its run loop as a
161+
# BackgroundTask via _PrioritySchedulerBackgroundTask.
162+
scheduler = None
163+
if use_priority_scheduler:
164+
from spdl.pipeline._scheduler import (
165+
_PrioritySchedulerBackgroundTask,
166+
PriorityScheduler,
167+
)
168+
169+
scheduler = PriorityScheduler(max_concurrent=num_threads)
170+
scheduler._underlying_executor = executor
171+
172+
# Capture in a default arg so the lambda refers to *this* scheduler
173+
# instance (avoid late-binding in a loop, defensive).
174+
all_bg_tasks.append(
175+
lambda sched=scheduler: _PrioritySchedulerBackgroundTask(sched)
176+
)
177+
150178
coro, queue = _build_pipeline_coro(
151179
pipeline_cfg,
152180
max_failures=max_failures,
@@ -155,12 +183,9 @@ def _build_pipeline(
155183
task_hook_factory=task_hook_factory,
156184
stage_id=stage_id,
157185
background_tasks=all_bg_tasks or None,
186+
scheduler=scheduler,
158187
)
159188

160-
executor = ThreadPoolExecutor(
161-
max_workers=num_threads,
162-
thread_name_prefix="spdl_worker_thread_",
163-
)
164189
return Pipeline(coro, queue, executor, desc=desc)
165190

166191

@@ -175,6 +200,7 @@ def build_pipeline(
175200
task_hook_factory: Callable[[StageInfo], list[TaskHook]] | None = None,
176201
stage_id: int = 0,
177202
background_tasks: list[BackgroundTaskFactory] | None = None,
203+
use_priority_scheduler: bool = False,
178204
) -> Pipeline[U]:
179205
"""Build a pipeline from the config.
180206
@@ -240,6 +266,11 @@ def build_pipeline(
240266
:py:meth:`~BackgroundTask.run` method runs alongside the pipeline stages.
241267
Tasks are cancelled when the pipeline completes. Their errors are logged
242268
but do not cause the pipeline to fail.
269+
270+
use_priority_scheduler: If ``True``, enable priority-based
271+
dispatch for sync stages via :py:class:`PriorityScheduler`.
272+
Deeper stages (closer to sink) are given higher priority,
273+
reducing pipeline bubble time.
243274
"""
244275
from . import _profile
245276

@@ -255,6 +286,7 @@ def build_pipeline(
255286
task_hook_factory=task_hook_factory,
256287
stage_id=stage_id,
257288
background_tasks=background_tasks,
289+
use_priority_scheduler=use_priority_scheduler,
258290
)
259291

260292

src/spdl/pipeline/_builder.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ def build(
291291
queue_class: type[AsyncQueue] | None = None,
292292
task_hook_factory: Callable[[StageInfo], list[TaskHook]] | None = None,
293293
stage_id: int = 0,
294+
use_priority_scheduler: bool = False,
294295
) -> Pipeline[U]:
295296
"""Build the pipeline.
296297
@@ -328,6 +329,11 @@ def build(
328329
To disable hooks, provide a function that returns an empty list.
329330
330331
stage_id: The index of the initial stage used for logging.
332+
333+
use_priority_scheduler: If ``True``, enable priority-based
334+
dispatch for sync stages via :py:class:`PriorityScheduler`.
335+
Deeper stages (closer to sink) are given higher priority,
336+
reducing pipeline bubble time.
331337
"""
332338
return build_pipeline(
333339
self.get_config(),
@@ -337,4 +343,5 @@ def build(
337343
report_stats_interval=report_stats_interval,
338344
task_hook_factory=task_hook_factory,
339345
stage_id=stage_id,
346+
use_priority_scheduler=use_priority_scheduler,
340347
)

src/spdl/pipeline/_components/_node.py

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
# LICENSE file in the root directory of this source tree.
66

77
import asyncio
8+
import inspect
89
import logging
910
import sys
1011
from asyncio import ALL_COMPLETED, FIRST_COMPLETED, Task
1112
from collections.abc import Callable, Coroutine, Sequence
12-
from dataclasses import dataclass, field
13+
from dataclasses import dataclass, field, replace
1314
from fractions import Fraction
1415
from functools import partial
1516
from 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+
626697
def _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

Comments
 (0)