99# pyre-strict
1010
1111__all__ = [
12+ "_batched_path_variants_merge" ,
1213 "_path_variants_router" ,
1314]
1415
2122
2223from spdl .pipeline ._common ._convert import _to_async
2324
24- from ._common import _EOF , _EPOCH_END , _ShieldedHook , is_eof , is_epoch_end
25+ from ._common import _EOF , _EPOCH_END , _ShieldedHook , is_eof , is_epoch_end , StageInfo
2526from ._hook import _stage_hooks , TaskHook
2627from ._queue import AsyncQueue
2728
2829_LG : logging .Logger = logging .getLogger (__name__ )
2930
3031
3132def _make_async_router (
32- router : Callable [[Any ], int ] | Callable [[Any ], Awaitable [int ]],
33- ) -> Callable [[Any ], Awaitable [int ]]:
34- """Wrap a sync router with run_in_executor; pass through async routers."""
33+ router : Callable [[Any ], Any ] | Callable [[Any ], Awaitable [Any ]],
34+ ) -> Callable [[Any ], Awaitable [Any ]]:
35+ """Wrap a sync router with run_in_executor; pass through async routers.
36+
37+ The router returns an ``int`` (per-item mode) or a ``Sequence[int]`` (batched
38+ mode); this wrapper is agnostic to which."""
3539 if inspect .iscoroutinefunction (router ):
3640 return router
3741 call = getattr (router , "__call__" , None )
@@ -88,14 +92,25 @@ async def _queue_stage_hook(queues: Sequence[AsyncQueue]) -> AsyncGenerator[None
8892def _path_variants_router (
8993 input_queue : AsyncQueue ,
9094 path_queues : Sequence [AsyncQueue ],
91- router : Callable [[Any ], int ] | Callable [[Any ], Awaitable [int ]],
95+ router : Callable [[Any ], Any ] | Callable [[Any ], Awaitable [Any ]],
9296 task_hooks : list [TaskHook ],
97+ batched : bool = False ,
9398) -> Coroutine [None , None , None ]:
9499 """Create a coroutine that routes items to per-path queues.
95100
96- The router reads items from ``input_queue``, calls ``router(item)`` to
97- determine the target path index, and puts the item on the corresponding
98- queue in ``path_queues``.
101+ In the default (per-item) mode the router reads items from ``input_queue``,
102+ calls ``router(item)`` to determine the target path index, and puts the item
103+ on the corresponding queue in ``path_queues``.
104+
105+ In ``batched`` mode each item read from ``input_queue`` is a whole batch (a
106+ list). ``router(batch)`` returns one path index per element; the batch is
107+ partitioned into per-path sub-batches (preserving order) and each sub-batch
108+ is put on its path's queue as a single list. A sub-batch is put on **every**
109+ path queue -- an empty list where a path received no elements -- so that each
110+ input batch contributes exactly one list to every path queue. This keeps the
111+ downstream fan-in merge in lockstep, letting it recombine the sub-batches of
112+ one input batch by reading one list from each path (see
113+ :py:func:`_batched_path_variants_merge`).
99114
100115 Sync routers are wrapped with ``run_in_executor`` to avoid blocking the
101116 event loop. Async routers are awaited directly.
@@ -111,14 +126,47 @@ def _path_variants_router(
111126 Args:
112127 input_queue: The queue to consume items from.
113128 path_queues: Per-path output queues, one per variant path.
114- router: Callable that maps an item to a path index.
129+ router: Callable that maps an item (or a batch, if ``batched``) to a path
130+ index (or a sequence of per-element path indices, if ``batched``).
115131 task_hooks: Hooks for monitoring.
132+ batched: If True, route whole batches (see above) instead of single items.
116133
117134 Returns:
118135 A coroutine that executes the router stage.
119136 """
120137 num_paths : int = len (path_queues )
121- arouter : Callable [[Any ], Awaitable [int ]] = _make_async_router (router )
138+ arouter : Callable [[Any ], Awaitable [Any ]] = _make_async_router (router )
139+
140+ async def _route_item (item : Any ) -> None :
141+ idx = await arouter (item )
142+ if idx < 0 or idx >= num_paths :
143+ raise IndexError (
144+ f"Router returned index { idx } , but there are only "
145+ f"{ num_paths } paths (valid range: [0, { num_paths } ))."
146+ )
147+ await path_queues [idx ].put (item )
148+
149+ async def _route_batch (batch : Any ) -> None :
150+ indices = await arouter (batch )
151+ if len (indices ) != len (batch ):
152+ raise ValueError (
153+ f"Batched router returned { len (indices )} indices for a batch of "
154+ f"{ len (batch )} items; it must return exactly one index per item."
155+ )
156+ parts : list [list [Any ]] = [[] for _ in range (num_paths )]
157+ for item , idx in zip (batch , indices ):
158+ if idx < 0 or idx >= num_paths :
159+ raise IndexError (
160+ f"Router returned index { idx } , but there are only "
161+ f"{ num_paths } paths (valid range: [0, { num_paths } ))."
162+ )
163+ parts [idx ].append (item )
164+ # Emit to every path (empty lists included) so each input batch contributes
165+ # exactly one list per path, keeping the fan-in merge in lockstep.
166+ for q , part in zip (path_queues , parts ):
167+ await q .put (part )
168+
169+ _route : Callable [[Any ], Awaitable [None ]] = _route_batch if batched else _route_item
122170
123171 @_queue_stage_hook (path_queues )
124172 # pyrefly: ignore [not-callable]
@@ -134,12 +182,38 @@ async def _router() -> None:
134182 await q .put (_EPOCH_END )
135183 continue
136184
137- idx = await arouter (item )
138- if idx < 0 or idx >= num_paths :
139- raise IndexError (
140- f"Router returned index { idx } , but there are only "
141- f"{ num_paths } paths (valid range: [0, { num_paths } ))."
142- )
143- await path_queues [idx ].put (item )
185+ await _route (item )
144186
145187 return _router ()
188+
189+
190+ async def _batched_path_variants_merge (
191+ info : StageInfo ,
192+ input_queues : Sequence [asyncio .Queue ],
193+ output_queue : asyncio .Queue ,
194+ ) -> None :
195+ """Fan-in merge for a ``batched`` path_variants stage.
196+
197+ Each input batch was partitioned by the router into one sub-batch (list) per
198+ path, so the queues stay in lockstep: reading one list from every input queue
199+ yields the sub-batches of a single original batch. They are concatenated (in
200+ path order) back into one batch and emitted, so the stage is batch-in /
201+ batch-out and downstream sees whole batches -- not the individual items.
202+
203+ A fully-empty recombined batch (every path routed nothing, or every element
204+ was dropped) is skipped rather than emitted downstream.
205+
206+ ``_EPOCH_END`` and ``_EOF`` are broadcast by the router to every path queue,
207+ so they surface on all input queues together; the merge forwards one epoch
208+ boundary and stops on end-of-stream.
209+ """
210+ while True :
211+ items = [await q .get () for q in input_queues ]
212+ if any (is_eof (it ) for it in items ):
213+ return
214+ if any (is_epoch_end (it ) for it in items ):
215+ await output_queue .put (_EPOCH_END )
216+ continue
217+ combined = [item for sub_batch in items for item in sub_batch ]
218+ if combined :
219+ await output_queue .put (combined )
0 commit comments