Skip to content

Commit 4fc5ed8

Browse files
authored
[pipeline] Add batched routing to path_variants (facebookresearch#1595)
Add a `batched` option to `PathVariants` and `PipelineBuilder.path_variants`. When set, the router receives a whole batch (a list) and returns one path index per element; the batch is partitioned into per-path sub-batches (order preserved), each path processes its sub-batch as a list, and the fan-in merge concatenates the sub-batches back into one batch. Per-item routing pays the router + fan-out/fan-in machinery once per element. For workloads that route in bulk -- e.g. a cache hit/miss split over an already-aggregated batch -- that fixed per-item cost dominates. Batched routing amortizes it over the whole batch: the router runs once per batch, each branch runs its ops once per batch, and one list (not one item) crosses each queue per batch. Semantics: - Each input batch contributes exactly one sub-batch (possibly empty) to every path queue, so the queues stay in lockstep and the merge recombines an input batch's sub-batches by reading one list from each path. - A branch may drop elements by returning a shorter list, and must tolerate an empty input list (a path that received no elements for a batch). - A batched router must return exactly one index per element; otherwise the stage fails. `batched` defaults to `False`, so existing per-item behavior is byte-identical (not backward-incompatible).
1 parent eef7060 commit 4fc5ed8

5 files changed

Lines changed: 381 additions & 41 deletions

File tree

src/spdl/pipeline/_builder.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -386,17 +386,30 @@ def path_variants(
386386
router: Callable,
387387
paths: Sequence,
388388
name: str | None = None,
389+
batched: bool = False,
389390
) -> "PipelineBuilder[T, U]":
390391
"""Route items to different processing paths based on a router function.
391392
393+
.. versionadded:: 0.6.0
394+
The ``batched`` argument.
395+
392396
Args:
393-
router: A callable that takes an item and returns an int index
394-
selecting which path the item should be routed to.
397+
router: A callable that selects the path for each input. In per-item
398+
mode (default) it takes an item and returns an int index. In
399+
``batched`` mode it takes a batch (list) and returns one int index
400+
per element (same length as the batch).
395401
paths: A sequence of paths, where each path is a sequence of
396402
pipe configs.
397403
name: Optional name for the stage.
404+
batched: If True, route whole batches instead of single items: the
405+
batch is partitioned into per-path sub-batches (each path op takes
406+
and returns a list) and merged back into one batch. Aggregate the
407+
source into batches upstream of this stage. See
408+
:py:func:`~spdl.pipeline.defs.PathVariants`.
398409
"""
399-
self._process_args.append(PathVariants(router, paths, name=name))
410+
self._process_args.append(
411+
PathVariants(router, paths, name=name, batched=batched)
412+
)
400413
return self
401414

402415
def add_sink(

src/spdl/pipeline/_components/_node.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from ._sink import _sink
4747
from ._source import _source, _source_continuous
4848
from ._subprocess_pipe import _subprocess_pipeline
49-
from ._variants import _path_variants_router
49+
from ._variants import _batched_path_variants_merge, _path_variants_router
5050

5151
T = TypeVar("T")
5252
S = TypeVar("S")
@@ -69,14 +69,17 @@
6969
# pyre-strict
7070

7171

72+
@dataclass
7273
class _PathVariantsMergeConfig:
7374
"""Internal config for the fan-in merge node of PathVariants.
7475
7576
This is not user-facing. It is used as the ``cfg`` of the merge ``_Node``
7677
so that ``_build_node`` can dispatch to the correct coroutine builder.
7778
"""
7879

79-
pass
80+
batched: bool = False
81+
"""Whether the parent PathVariants stage routes batches (see
82+
:py:func:`~spdl.pipeline._components._variants._batched_path_variants_merge`)."""
8083

8184

8285
# Used to express the upstream relation ship of coroutines,
@@ -434,7 +437,7 @@ def _convert_path_variants(
434437
merge_out_q = q_class(merge_info, buffer_size=_BUFFER_SIZE)
435438
merge_node = _FanInNode(
436439
merge_info,
437-
_PathVariantsMergeConfig(),
440+
_PathVariantsMergeConfig(batched=cfg.batched),
438441
end_nodes,
439442
input_queues=[n.output_queue for n in end_nodes],
440443
output_queue=merge_out_q,
@@ -570,6 +573,7 @@ def _build_node(
570573
node.output_queues,
571574
node.cfg.router,
572575
hooks,
576+
batched=node.cfg.batched,
573577
)
574578
case _FanInNode():
575579
hooks = task_hook_factory(node.info)
@@ -585,8 +589,16 @@ def _build_node(
585589
cfg.op,
586590
)
587591
case _PathVariantsMergeConfig():
592+
# A batched stage recombines each input batch's per-path
593+
# sub-batches into one batch; the per-item stage passes items
594+
# through in arrival order (default merge).
588595
node._coro = _merge(
589-
node.info, node.input_queues, node.output_queue, fc, hooks, None
596+
node.info,
597+
node.input_queues,
598+
node.output_queue,
599+
fc,
600+
hooks,
601+
_batched_path_variants_merge if cfg.batched else None,
590602
)
591603
case _: # pragma: no cover
592604
raise ValueError(

src/spdl/pipeline/_components/_variants.py

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# pyre-strict
1010

1111
__all__ = [
12+
"_batched_path_variants_merge",
1213
"_path_variants_router",
1314
]
1415

@@ -21,17 +22,20 @@
2122

2223
from 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
2526
from ._hook import _stage_hooks, TaskHook
2627
from ._queue import AsyncQueue
2728

2829
_LG: logging.Logger = logging.getLogger(__name__)
2930

3031

3132
def _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
8892
def _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

Comments
 (0)