4242 _pipe ,
4343)
4444from ._queue import AsyncQueue , get_default_queue_class
45+ from ._semaphore import ResizableSemaphore
4546from ._sink import _sink
4647from ._source import _source , _source_continuous
4748from ._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+
483543def _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