Skip to content

Commit 52a9958

Browse files
justinvjosephfacebook-github-bot
authored andcommitted
Add Pipeline.resize_concurrency runtime API + enable_adaptive_concurrency flag
Summary: This is Diff 3b in the T262755626 stack. Adds the user-facing API on top of Diff 3a's foundation: 1. `Pipeline.resize_concurrency(qualified_name, new_value)` — synchronous foreground-thread API. Detects in-loop callers via `asyncio.get_running_loop()` and raises RuntimeError to prevent self-deadlock. Lifecycle gated against NOT_STARTED and STOPPED states. Forwards to `_resize_concurrency_async` via `_EventLoop.run_coroutine_threadsafe`. 2. `Pipeline._resize_concurrency_async(qualified_name, new_value)` — async source-of-truth. Safe to call from a `BackgroundTask` or any in-loop coroutine. Updates the registered `ResizableSemaphore` AND the `_dynamic_concurrency` sibling dict atomically (single asyncio turn). Track B's `AdaptiveTuner` (RFC) will use this directly. 3. `Pipeline.list_stages()` — returns sorted qualified names registered with semaphores. Useful for notebook discovery during A/B sweeps. 4. `build_pipeline(..., enable_adaptive_concurrency=False)` and `PipelineBuilder.build(..., enable_adaptive_concurrency=False)` — public flag that wires every Pipe stage with a `ResizableSemaphore` (matching its static `concurrency`) so `Pipeline.resize_concurrency` can adjust admission caps at runtime. Default False keeps the static admission gate (zero per-task overhead) on the no-opt path. Production callers (e.g. LCA's `SPDLConfig.enable_adaptive_concurrency` in Diff 4) use this flag; the existing `_install_semaphores_for_test` knob remains for SPDL's own tests. V5.7 continuous-mode regression test included: builds a continuous-mode pipeline, runs across 3 epochs, calls `resize_concurrency` mid-run, and verifies (a) no crash on epoch sentinel propagation, (b) resized cap takes effect for subsequent items, (c) in-flight items at resize time complete normally. Error semantics: - `KeyError` on unknown qualified_name; the message lists valid names from the registry. - `ValueError` on `new_value < 1`. - `RuntimeError` on lifecycle violations or in-loop sync calls. Documentation note included in `resize_concurrency` docstring: Meta-internal dashboards (e.g., `_logging.py`) currently report build-time `StageInfo.concurrency` and do not reflect resizes performed via this API. Operators reading live values from a notebook should use `list_stages()` and `pipeline._impl._dynamic_concurrency` directly. Future Track B work may extend Meta logging to read the dict. Differential Revision: D102929252
1 parent 826c0bd commit 52a9958

6 files changed

Lines changed: 437 additions & 19 deletions

File tree

src/spdl/pipeline/_build.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def _build_pipeline(
130130
stage_id: int = 0,
131131
background_tasks: list[BackgroundTaskFactory] | None = None,
132132
use_priority_scheduler: bool = False,
133+
enable_adaptive_concurrency: bool = False,
133134
_install_semaphores_for_test: bool = False,
134135
) -> Pipeline[U]:
135136
if _DEFAULT_BUILD_CALLBACK is not None:
@@ -178,9 +179,12 @@ def _build_pipeline(
178179
)
179180

180181
# 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.
182+
# `_components/_node.py` populates when semaphore installation is
183+
# enabled (either via the public ``enable_adaptive_concurrency`` flag
184+
# or the test-only ``_install_semaphores_for_test`` knob). These are
185+
# then handed off to ``_PipelineImpl.__init__`` so
186+
# ``Pipeline._resize_concurrency_async`` (Diff 3b internal) can find
187+
# them.
184188
# ``output_queue_by_name`` (Phase D) caches each registered stage's
185189
# output ``AsyncQueue`` so the Diff 6 controller can read its lap
186190
# stats — the same key set as ``semaphore_registry``.
@@ -189,6 +193,8 @@ def _build_pipeline(
189193
stage_info_by_name: dict[str, StageInfo] = {}
190194
output_queue_by_name: dict[str, AsyncQueue] = {}
191195

196+
install_semaphores = enable_adaptive_concurrency or _install_semaphores_for_test
197+
192198
coro, queue = _build_pipeline_coro(
193199
pipeline_cfg,
194200
max_failures=max_failures,
@@ -198,7 +204,7 @@ def _build_pipeline(
198204
stage_id=stage_id,
199205
background_tasks=all_bg_tasks or None,
200206
scheduler=scheduler,
201-
install_semaphores_for_test=_install_semaphores_for_test,
207+
install_semaphores_for_test=install_semaphores,
202208
semaphore_registry=semaphore_registry,
203209
dynamic_concurrency=dynamic_concurrency,
204210
stage_info_by_name=stage_info_by_name,
@@ -229,6 +235,7 @@ def build_pipeline(
229235
stage_id: int = 0,
230236
background_tasks: list[BackgroundTaskFactory] | None = None,
231237
use_priority_scheduler: bool = False,
238+
enable_adaptive_concurrency: bool = False,
232239
_install_semaphores_for_test: bool = False,
233240
) -> Pipeline[U]:
234241
"""Build a pipeline from the config.
@@ -301,15 +308,25 @@ def build_pipeline(
301308
Deeper stages (closer to sink) are given higher priority,
302309
reducing pipeline bubble time.
303310
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``.
311+
enable_adaptive_concurrency: If ``True``, every ``Pipe`` stage is
312+
built with a :py:class:`ResizableSemaphore` whose initial value
313+
matches its static ``concurrency``, and the semaphore is wired
314+
to the V5.6 REPLACE admission gate in
315+
:py:func:`~spdl.pipeline._components._pipe._pipe`. This enables
316+
runtime adjustment of per-stage concurrency via the internal
317+
:py:meth:`Pipeline._resize_concurrency_async` (intended to be
318+
driven by an in-loop adaptive-concurrency controller running
319+
as a :py:class:`BackgroundTask`). Default: ``False``
320+
(per-stage concurrency is fixed at build time, with zero
321+
per-task overhead in the admission gate).
322+
323+
_install_semaphores_for_test: **Test-only.** Same mechanical effect
324+
as ``enable_adaptive_concurrency`` (both flip the same internal
325+
switch), kept as a separate flag so tests can opt in without
326+
implying the production-facing semantic. Used by the V5.5
327+
throughput regression test and the in-loop async-resize
328+
regression test. Production code MUST use
329+
``enable_adaptive_concurrency``.
313330
"""
314331
from . import _profile
315332

@@ -326,6 +343,7 @@ def build_pipeline(
326343
stage_id=stage_id,
327344
background_tasks=background_tasks,
328345
use_priority_scheduler=use_priority_scheduler,
346+
enable_adaptive_concurrency=enable_adaptive_concurrency,
329347
_install_semaphores_for_test=_install_semaphores_for_test,
330348
)
331349

src/spdl/pipeline/_builder.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ def build(
292292
task_hook_factory: Callable[[StageInfo], list[TaskHook]] | None = None,
293293
stage_id: int = 0,
294294
use_priority_scheduler: bool = False,
295+
enable_adaptive_concurrency: bool = False,
295296
) -> Pipeline[U]:
296297
"""Build the pipeline.
297298
@@ -334,6 +335,14 @@ def build(
334335
dispatch for sync stages via :py:class:`PriorityScheduler`.
335336
Deeper stages (closer to sink) are given higher priority,
336337
reducing pipeline bubble time.
338+
339+
enable_adaptive_concurrency: If ``True``, every ``Pipe`` stage
340+
is built with a :py:class:`ResizableSemaphore` so per-stage
341+
concurrency can be adjusted at runtime via the internal
342+
:py:meth:`Pipeline._resize_concurrency_async` (intended to
343+
be driven by an in-loop adaptive-concurrency controller
344+
running as a :py:class:`BackgroundTask`). Default:
345+
``False`` (per-stage concurrency is fixed at build time).
337346
"""
338347
return build_pipeline(
339348
self.get_config(),
@@ -344,4 +353,5 @@ def build(
344353
task_hook_factory=task_hook_factory,
345354
stage_id=stage_id,
346355
use_priority_scheduler=use_priority_scheduler,
356+
enable_adaptive_concurrency=enable_adaptive_concurrency,
347357
)

src/spdl/pipeline/_components/_node.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ def _convert_config(
482482

483483

484484
def _qualified_name(info: StageInfo, branch_label: str | None = None) -> str:
485-
"""Build the qualified stage name used by ``Pipeline.resize_concurrency``.
485+
"""Build the qualified stage name used by ``Pipeline._resize_concurrency_async``.
486486
487487
For non-MultiPipe stages, ``qualified_name = info.stage_name``.
488488
For MultiPipe sub-pipelines, the branch label is prefixed with ``/``
@@ -825,8 +825,11 @@ def _build_node_recursive(
825825
every Pipe stage gets a :py:class:`ResizableSemaphore` whose
826826
initial value matches its static ``concurrency``, and the
827827
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.
828+
governs admission. Production code should pass
829+
``enable_adaptive_concurrency=True`` to
830+
:py:func:`build_pipeline` instead so that an in-loop
831+
controller can call
832+
:py:meth:`Pipeline._resize_concurrency_async`.
830833
semaphore_registry: Per-pipeline ``dict[qualified_name, ResizableSemaphore]``
831834
populated when ``install_semaphores_for_test=True``.
832835
dynamic_concurrency: Per-pipeline ``dict[qualified_name, int]``

src/spdl/pipeline/_components/_pipe.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,8 @@ def _pipe_with_semaphore(
381381
"""V5.6 REPLACE branch: ``semaphore.acquire()`` IS the admission gate.
382382
383383
``args.concurrency`` is ignored — the registered semaphore (whose value
384-
is mutable via :py:meth:`Pipeline.resize_concurrency`) governs the
385-
in-flight cap. Task completion calls ``semaphore.release()`` via a
384+
is mutable via :py:meth:`Pipeline._resize_concurrency_async`) governs
385+
the in-flight cap. Task completion calls ``semaphore.release()`` via a
386386
done-callback so the admit cycle is symmetric with the gate.
387387
"""
388388

@@ -421,7 +421,7 @@ async def pipe() -> None:
421421
# V5.6 admission gate: REPLACES `len(tasks) >= args.concurrency`.
422422
# `acquire()` blocks here when the in-flight count reaches the
423423
# semaphore's current value (which may have been resized via
424-
# Pipeline.resize_concurrency).
424+
# Pipeline._resize_concurrency_async).
425425
await semaphore.acquire()
426426

427427
task = create_task(

src/spdl/pipeline/_pipeline.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,66 @@ def __iter__(self) -> Iterator[T]:
600600
"""Call :py:meth:`~spdl.pipeline.Pipeline.get_iterator` without arguments."""
601601
return self.get_iterator()
602602

603+
# --------------------------------------------------------------
604+
# Diff 3b — runtime concurrency adjustment (INTERNAL)
605+
# --------------------------------------------------------------
606+
607+
async def _resize_concurrency_async(
608+
self,
609+
qualified_name: str,
610+
new_value: int,
611+
) -> None:
612+
"""Resize the in-flight admission cap for a registered stage.
613+
614+
INTERNAL — must be awaited from a coroutine running on the
615+
pipeline's own event loop (e.g., a
616+
:py:class:`~spdl.pipeline.BackgroundTask`). There is no
617+
public, foreground-thread wrapper: cross-thread resize is
618+
intentionally not exposed because the only intended caller is
619+
an in-loop adaptive-concurrency controller.
620+
621+
Atomicity: the body has zero ``await`` statements between
622+
:py:meth:`ResizableSemaphore.resize` and the
623+
``_dynamic_concurrency`` dict assignment. asyncio is
624+
single-threaded, so the entire method runs in one event-loop
625+
turn and is therefore cancel-safe by structural invariant
626+
(``CancelledError`` can only fire BEFORE the call begins or
627+
AFTER it completes, never between the two writes).
628+
629+
Args:
630+
qualified_name: A fully-qualified stage name. For
631+
non-MultiPipe stages this equals
632+
:py:attr:`StageInfo.stage_name` (e.g.,
633+
``"decode_single_frame"``). For MultiPipe sub-pipelines
634+
it is ``"<branch_label>/<stage_name>"``. The set of
635+
valid names is ``pipeline._impl._semaphore_registry``.
636+
new_value: New admission cap; must be >= 1. Resize-up is
637+
immediate; resize-down is graceful — in-flight tasks
638+
finish, but no new tasks admit until in-flight drops
639+
below ``new_value``.
640+
641+
Raises:
642+
ValueError: ``new_value < 1``.
643+
KeyError: ``qualified_name`` is not registered. The error
644+
message includes the list of valid names.
645+
"""
646+
if new_value < 1:
647+
raise ValueError(f"new_value must be >= 1, got {new_value}")
648+
649+
registry = self._impl._semaphore_registry
650+
sem = registry.get(qualified_name)
651+
if sem is None:
652+
valid = sorted(registry.keys())
653+
raise KeyError(
654+
f"qualified_name {qualified_name!r} not found. "
655+
f"Valid stage names: {valid}"
656+
)
657+
sem.resize(new_value)
658+
# Keep the sibling registry in sync for observability.
659+
# asyncio is single-threaded so this update is atomic with the
660+
# semaphore.resize() above (no concurrent writer).
661+
self._impl._dynamic_concurrency[qualified_name] = new_value
662+
603663

604664
class PipelineIterator(Generic[T]):
605665
"""PipelineIterator()"""

0 commit comments

Comments
 (0)