Skip to content

Commit 826c0bd

Browse files
justinvjosephfacebook-github-bot
authored andcommitted
Add semaphore registry and admission gate REPLACE for adaptive concurrency (#1391)
Summary: This is Diff 3a in the T262755626 stack. Adds the foundation for runtime concurrency adjustment in SPDL pipelines: 1. `_PipelineImpl._semaphore_registry: dict[str, ResizableSemaphore]` — sibling registry keyed on the qualified stage name. Populated at pipeline build time when a stage opts in. NO modification to `StageInfo` (preserves frozen=True / hashability for third-party code). 2. `_PipelineImpl._dynamic_concurrency: dict[str, int]` — current value per stage; updated by future `Pipeline.resize_concurrency()` calls. 3. `_PipelineImpl._stage_info_by_name: dict[str, StageInfo]` — for error messages and future Track B logging hooks. 4. `_pipe()` admission gate REPLACE (V5.6): when a `ResizableSemaphore` is registered for a stage, `await semaphore.acquire()` becomes the admission gate, REPLACING the static `len(tasks) >= concurrency` check. When no semaphore is registered (the default), behaviour is unchanged — ZERO per-task overhead added to the existing fast path. The branch is taken ONCE outside the hot loop (dispatching to `_pipe_with_semaphore`). 5. `build_pipeline(_install_semaphores_for_test: bool = False)` test-only knob: when True, every Pipe stage is built with a `ResizableSemaphore` and registered. Production code MUST NOT pass this — it bypasses the selective opt-in semantics of `Pipeline.resize_concurrency` (Diff 3b). 6. V5.5 throughput regression test (`admission_gate_perf_test.py`): 4-stage sync pipeline, N=10000 items, 5 trials. Regression > 2% on either p50 or p99 fails the diff. Includes registry wiring smoke tests and an in-flight-cap assertion. Note (perf-reviewer WARN-1): the V5.5 perf test covers sync stages only — extending to async/sync_iter handlers is a follow-up after Diff 3a lands. Note (perf-reviewer WARN-2): qualified stage names == `info.stage_name` for non-MultiPipe stages. LCA's MultiPipe is a single SPDL Pipe with internal dispatcher; the `_qualified_name(branch_label=...)` scheme is forward-compatible for true SPDL fan-out. The user-facing API (`Pipeline.resize_concurrency`) lands in Diff 3b. Differential Revision: D102929216
1 parent 012e49f commit 826c0bd

6 files changed

Lines changed: 776 additions & 10 deletions

File tree

src/spdl/pipeline/_build.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
_get_global_id,
3232
_set_global_id,
3333
AsyncQueue,
34+
ResizableSemaphore,
3435
StageInfo,
3536
TaskHook,
3637
)
@@ -129,6 +130,7 @@ def _build_pipeline(
129130
stage_id: int = 0,
130131
background_tasks: list[BackgroundTaskFactory] | None = None,
131132
use_priority_scheduler: bool = False,
133+
_install_semaphores_for_test: bool = False,
132134
) -> Pipeline[U]:
133135
if _DEFAULT_BUILD_CALLBACK is not None:
134136
try:
@@ -175,6 +177,18 @@ def _build_pipeline(
175177
lambda sched=scheduler: _PrioritySchedulerBackgroundTask(sched)
176178
)
177179

180+
# V5.1+V5.5 Diff 3a: pre-allocate the per-pipeline registries that
181+
# `_components/_node.py` populates when ``_install_semaphores_for_test``
182+
# is True. These are then handed off to ``_PipelineImpl.__init__`` so
183+
# ``Pipeline.resize_concurrency`` (Diff 3b) can find them.
184+
# ``output_queue_by_name`` (Phase D) caches each registered stage's
185+
# output ``AsyncQueue`` so the Diff 6 controller can read its lap
186+
# stats — the same key set as ``semaphore_registry``.
187+
semaphore_registry: dict[str, ResizableSemaphore] = {}
188+
dynamic_concurrency: dict[str, int] = {}
189+
stage_info_by_name: dict[str, StageInfo] = {}
190+
output_queue_by_name: dict[str, AsyncQueue] = {}
191+
178192
coro, queue = _build_pipeline_coro(
179193
pipeline_cfg,
180194
max_failures=max_failures,
@@ -184,9 +198,23 @@ def _build_pipeline(
184198
stage_id=stage_id,
185199
background_tasks=all_bg_tasks or None,
186200
scheduler=scheduler,
201+
install_semaphores_for_test=_install_semaphores_for_test,
202+
semaphore_registry=semaphore_registry,
203+
dynamic_concurrency=dynamic_concurrency,
204+
stage_info_by_name=stage_info_by_name,
205+
output_queue_by_name=output_queue_by_name,
187206
)
188207

189-
return Pipeline(coro, queue, executor, desc=desc)
208+
return Pipeline(
209+
coro,
210+
queue,
211+
executor,
212+
desc=desc,
213+
semaphore_registry=semaphore_registry,
214+
dynamic_concurrency=dynamic_concurrency,
215+
stage_info_by_name=stage_info_by_name,
216+
output_queue_by_name=output_queue_by_name,
217+
)
190218

191219

192220
def build_pipeline(
@@ -201,6 +229,7 @@ def build_pipeline(
201229
stage_id: int = 0,
202230
background_tasks: list[BackgroundTaskFactory] | None = None,
203231
use_priority_scheduler: bool = False,
232+
_install_semaphores_for_test: bool = False,
204233
) -> Pipeline[U]:
205234
"""Build a pipeline from the config.
206235
@@ -271,6 +300,16 @@ def build_pipeline(
271300
dispatch for sync stages via :py:class:`PriorityScheduler`.
272301
Deeper stages (closer to sink) are given higher priority,
273302
reducing pipeline bubble time.
303+
304+
_install_semaphores_for_test: **Test-only.** When ``True``, every
305+
``Pipe`` stage is built with a :py:class:`ResizableSemaphore`
306+
whose initial value matches its static ``concurrency``, and
307+
the semaphore is wired to the V5.6 REPLACE admission gate in
308+
:py:func:`~spdl.pipeline._components._pipe._pipe`. Used by
309+
the V5.5 throughput regression test and the
310+
:py:meth:`Pipeline.resize_concurrency` continuous-mode test.
311+
Production code MUST NOT pass this — it bypasses the
312+
selective opt-in semantics of ``Pipeline.resize_concurrency``.
274313
"""
275314
from . import _profile
276315

@@ -287,6 +326,7 @@ def build_pipeline(
287326
stage_id=stage_id,
288327
background_tasks=background_tasks,
289328
use_priority_scheduler=use_priority_scheduler,
329+
_install_semaphores_for_test=_install_semaphores_for_test,
290330
)
291331

292332

src/spdl/pipeline/_components/_node.py

Lines changed: 177 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
_pipe,
4343
)
4444
from ._queue import AsyncQueue, get_default_queue_class
45+
from ._semaphore import ResizableSemaphore
4546
from ._sink import _sink
4647
from ._source import _source, _source_continuous
4748
from ._variants import _path_variants_router
@@ -480,13 +481,77 @@ def _convert_config(
480481
return n
481482

482483

484+
def _qualified_name(info: StageInfo, branch_label: str | None = None) -> str:
485+
"""Build the qualified stage name used by ``Pipeline.resize_concurrency``.
486+
487+
For non-MultiPipe stages, ``qualified_name = info.stage_name``.
488+
For MultiPipe sub-pipelines, the branch label is prefixed with ``/``
489+
(e.g., ``"video/decode_frame"``). LCA's MultiPipe is a single SPDL
490+
Pipe with internal dispatcher so qualified names match plain
491+
``stage_name`` in practice today; the addressing scheme is
492+
forward-compatible for true SPDL fan-out.
493+
"""
494+
if branch_label is None:
495+
return info.stage_name
496+
return f"{branch_label}/{info.stage_name}"
497+
498+
499+
def _register_semaphore(
500+
semaphore_registry: dict[str, ResizableSemaphore] | None,
501+
dynamic_concurrency: dict[str, int] | None,
502+
stage_info_by_name: dict[str, StageInfo] | None,
503+
output_queue_by_name: dict[str, AsyncQueue] | None,
504+
qname: str,
505+
info: StageInfo,
506+
sem: ResizableSemaphore,
507+
initial_value: int,
508+
output_queue: AsyncQueue,
509+
) -> None:
510+
"""Insert ``sem`` into the per-pipeline registries under ``qname``.
511+
512+
Duplicate ``qname`` is treated as an internal error (each stage must
513+
have a unique qualified name across the pipeline). This is the only
514+
write site for ``_PipelineImpl._semaphore_registry`` /
515+
``_dynamic_concurrency`` / ``_stage_info_by_name`` /
516+
``_output_queue_by_name``.
517+
518+
Phase D: ``output_queue_by_name`` mirrors the same key set as
519+
``semaphore_registry``. The captured queue handle is the SAME
520+
instance that this stage's ``_pipe()`` coroutine writes to and
521+
the next stage's coroutine reads from — i.e., the canonical
522+
per-stage output queue. The Diff 6
523+
``DomeVideoConcurrencyController`` reads this dict to obtain
524+
each stage's ``StatsQueue._last_lap_stats`` (or equivalent
525+
cached lap stats) for adaptive-tuning decisions.
526+
"""
527+
if semaphore_registry is None:
528+
return
529+
if qname in semaphore_registry:
530+
raise RuntimeError(
531+
f"Duplicate qualified stage name {qname!r}. "
532+
f"This is an internal error — please report."
533+
)
534+
semaphore_registry[qname] = sem
535+
if dynamic_concurrency is not None:
536+
dynamic_concurrency[qname] = initial_value
537+
if stage_info_by_name is not None:
538+
stage_info_by_name[qname] = info
539+
if output_queue_by_name is not None:
540+
output_queue_by_name[qname] = output_queue
541+
542+
483543
def _build_node(
484544
node: _TNodes,
485545
fc_class: type[_FailCounter],
486546
task_hook_factory: Callable[[StageInfo], list[TaskHook]],
487547
max_failures: int | Fraction,
488548
scheduler: Any = None,
489549
depth: int = 0,
550+
install_semaphores_for_test: bool = False,
551+
semaphore_registry: dict[str, ResizableSemaphore] | None = None,
552+
dynamic_concurrency: dict[str, int] | None = None,
553+
stage_info_by_name: dict[str, StageInfo] | None = None,
554+
output_queue_by_name: dict[str, AsyncQueue] | None = None,
490555
) -> None:
491556
"""Build a coroutine for a single node based on its configuration type.
492557
@@ -609,12 +674,49 @@ def _build_node(
609674
per_stage_exec = scheduler.make_stage_executor(node.info)
610675
args = replace(args, executor=per_stage_exec)
611676

677+
# V5.1+V5.6 Diff 3a: opt the stage into the per-pipeline
678+
# semaphore registry. When ``install_semaphores_for_test``
679+
# is True (test-only knob), every Pipe stage gets a
680+
# ``ResizableSemaphore`` whose initial value matches the
681+
# static ``args.concurrency``. The semaphore is passed
682+
# to ``_pipe`` which uses V5.6 REPLACE semantics: the
683+
# semaphore IS the admission gate (the static
684+
# ``len(tasks) >= concurrency`` check is skipped).
685+
# When the knob is off, ``semaphore=None`` and ``_pipe``
686+
# uses its existing static gate — ZERO per-task overhead.
687+
pipe_semaphore: ResizableSemaphore | None = None
688+
if install_semaphores_for_test:
689+
qname = _qualified_name(node.info)
690+
pipe_semaphore = ResizableSemaphore(args.concurrency)
691+
_register_semaphore(
692+
semaphore_registry,
693+
dynamic_concurrency,
694+
stage_info_by_name,
695+
output_queue_by_name,
696+
qname,
697+
node.info,
698+
pipe_semaphore,
699+
args.concurrency,
700+
out_q,
701+
)
702+
612703
match cfg._type:
613704
case _PipeType.Pipe:
614705
node._coro = _pipe(
615-
node.info, in_q, out_q, args, fc, hooks, False
706+
node.info,
707+
in_q,
708+
out_q,
709+
args,
710+
fc,
711+
hooks,
712+
False,
713+
semaphore=pipe_semaphore,
616714
)
617715
case _PipeType.OrderedPipe:
716+
# OrderedPipe uses an intermediate queue sized
717+
# to ``concurrency`` and is not part of the
718+
# Diff 3a admission-gate change. Static
719+
# concurrency only.
618720
node._coro = _ordered_pipe(
619721
node.info, in_q, out_q, args, fc, hooks
620722
)
@@ -700,6 +802,11 @@ def _build_node_recursive(
700802
task_hook_factory: Callable[[StageInfo], list[TaskHook]],
701803
max_failures: int | Fraction,
702804
scheduler: Any = None,
805+
install_semaphores_for_test: bool = False,
806+
semaphore_registry: dict[str, ResizableSemaphore] | None = None,
807+
dynamic_concurrency: dict[str, int] | None = None,
808+
stage_info_by_name: dict[str, StageInfo] | None = None,
809+
output_queue_by_name: dict[str, AsyncQueue] | None = None,
703810
) -> None:
704811
"""Recursively build coroutines for a node and all its upstream nodes.
705812
@@ -714,6 +821,22 @@ def _build_node_recursive(
714821
max_failures: The maximum number of failures allowed before halting.
715822
scheduler: Optional :py:class:`PriorityScheduler` instance for
716823
priority dispatch.
824+
install_semaphores_for_test: V5.5 test-only knob. When True,
825+
every Pipe stage gets a :py:class:`ResizableSemaphore` whose
826+
initial value matches its static ``concurrency``, and the
827+
semaphore is passed to ``_pipe()`` so its V5.6 REPLACE branch
828+
governs admission. Production code should use
829+
:py:meth:`Pipeline.resize_concurrency` (Diff 3b) instead.
830+
semaphore_registry: Per-pipeline ``dict[qualified_name, ResizableSemaphore]``
831+
populated when ``install_semaphores_for_test=True``.
832+
dynamic_concurrency: Per-pipeline ``dict[qualified_name, int]``
833+
populated when ``install_semaphores_for_test=True``.
834+
stage_info_by_name: Per-pipeline ``dict[qualified_name, StageInfo]``
835+
populated when ``install_semaphores_for_test=True``.
836+
output_queue_by_name: Per-pipeline
837+
``dict[qualified_name, AsyncQueue]`` populated when
838+
``install_semaphores_for_test=True``. Phase D: enables the
839+
Diff 6 controller to read each stage's lap stats.
717840
718841
Raises:
719842
RuntimeError: If attempting to build a coroutine for a node that already has one.
@@ -722,10 +845,33 @@ def _build_node_recursive(
722845
return
723846

724847
for n in node.upstream:
725-
_build_node_recursive(n, fc_class, task_hook_factory, max_failures, scheduler)
848+
_build_node_recursive(
849+
n,
850+
fc_class,
851+
task_hook_factory,
852+
max_failures,
853+
scheduler,
854+
install_semaphores_for_test=install_semaphores_for_test,
855+
semaphore_registry=semaphore_registry,
856+
dynamic_concurrency=dynamic_concurrency,
857+
stage_info_by_name=stage_info_by_name,
858+
output_queue_by_name=output_queue_by_name,
859+
)
726860

727861
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)
862+
_build_node(
863+
node,
864+
fc_class,
865+
task_hook_factory,
866+
max_failures,
867+
scheduler,
868+
depth=depth,
869+
install_semaphores_for_test=install_semaphores_for_test,
870+
semaphore_registry=semaphore_registry,
871+
dynamic_concurrency=dynamic_concurrency,
872+
stage_info_by_name=stage_info_by_name,
873+
output_queue_by_name=output_queue_by_name,
874+
)
729875

730876

731877
# Used to append stage name with pipeline
@@ -772,6 +918,11 @@ def _build_pipeline_node(
772918
task_hook_factory: Callable[[StageInfo], list[TaskHook]] | None,
773919
stage_id: int,
774920
scheduler: Any = None,
921+
install_semaphores_for_test: bool = False,
922+
semaphore_registry: dict[str, ResizableSemaphore] | None = None,
923+
dynamic_concurrency: dict[str, int] | None = None,
924+
stage_info_by_name: dict[str, StageInfo] | None = None,
925+
output_queue_by_name: dict[str, AsyncQueue] | None = None,
775926
) -> _TOutputNodes:
776927
global _PIPELINE_ID
777928
_PIPELINE_ID += 1
@@ -786,7 +937,18 @@ def _build_pipeline_node(
786937
fc_class = _get_fail_counter()
787938
node = _convert_config(plc, q_class, _PIPELINE_ID, _MutableInt(stage_id))
788939
_validate_continuous_mode(node)
789-
_build_node_recursive(node, fc_class, hook_factory, max_failures, scheduler)
940+
_build_node_recursive(
941+
node,
942+
fc_class,
943+
hook_factory,
944+
max_failures,
945+
scheduler,
946+
install_semaphores_for_test=install_semaphores_for_test,
947+
semaphore_registry=semaphore_registry,
948+
dynamic_concurrency=dynamic_concurrency,
949+
stage_info_by_name=stage_info_by_name,
950+
output_queue_by_name=output_queue_by_name,
951+
)
790952
return node
791953

792954

@@ -1027,7 +1189,12 @@ def _build_pipeline_coro(
10271189
stage_id: int = 0,
10281190
background_tasks: Sequence[BackgroundTaskFactory] | None = None,
10291191
scheduler: Any = None,
1030-
) -> tuple[Coroutine[None, None, None], asyncio.Queue]:
1192+
install_semaphores_for_test: bool = False,
1193+
semaphore_registry: dict[str, ResizableSemaphore] | None = None,
1194+
dynamic_concurrency: dict[str, int] | None = None,
1195+
stage_info_by_name: dict[str, StageInfo] | None = None,
1196+
output_queue_by_name: dict[str, AsyncQueue] | None = None,
1197+
) -> tuple[Coroutine[None, None, None], AsyncQueue]:
10311198
try:
10321199
node = _build_pipeline_node(
10331200
plc,
@@ -1037,6 +1204,11 @@ def _build_pipeline_coro(
10371204
task_hook_factory=task_hook_factory,
10381205
stage_id=stage_id,
10391206
scheduler=scheduler,
1207+
install_semaphores_for_test=install_semaphores_for_test,
1208+
semaphore_registry=semaphore_registry,
1209+
dynamic_concurrency=dynamic_concurrency,
1210+
stage_info_by_name=stage_info_by_name,
1211+
output_queue_by_name=output_queue_by_name,
10401212
)
10411213
coro = _run_pipeline_coroutines(node, background_tasks=background_tasks)
10421214

0 commit comments

Comments
 (0)