Skip to content

Commit 4be49c4

Browse files
furionwclaude
andcommitted
feat(multimodal): ThreadedMicroBatcher + batcher-backed AsyncVisionEncoder (cross-request batching)
PR3 of the custom vision-encoder series, stacked on PR2. Replaces the serial direct-call glue with cross-request batching. handlers.py is unchanged — the AsyncVisionEncoder public surface is the stable boundary. - ThreadedMicroBatcher (generic, torch-free): one pinned non-daemon actor thread running on_start (build) / every fn / on_stop (close); eager drain-on-completion (no timer); coalesce by cost within bucket_key. max_batch_cost is optional — None (default) = pass-through (no cap; the whole drained bucket runs as one fn call), an int caps the batch cost. Per-item idempotent finalizer; cancellation + repeat-cancel retirement; worker supervisor; optional max_outstanding_cost admission; cross-loop-safe shutdown. submit(items, costs, bucket_keys) — cost/bucket_key precomputed off-thread (A4). - AsyncVisionEncoder now routes preprocessed items through the batcher (was a single-worker serial executor); the A5 preprocess barrier is unchanged. No graph ladder yet (batches to the cost budget, or passes through) — the buckets/target_bucket machinery lands in PR4. Tests: batcher concurrency suite (eager-drain, cost budget, pass-through, bucket_key isolation, cancellation/retirement, supervisor, lifecycle, on_stop) + batcher-backed glue (A5 barrier, single actor thread, fail-fast load + pool reaping). Note: based on the series' validated merge-base; rebase onto main before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 90cc67e commit 4be49c4

4 files changed

Lines changed: 1113 additions & 90 deletions

File tree

Lines changed: 58 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
"""Serial async glue (L3) between the worker's event loop and a ``VisionEncoderBackend``.
5-
6-
This is the **eager-milestone** glue: it proves the splice path end to end with a
7-
**direct call — no micro-batcher**. Per request it preprocesses the images off the
8-
event loop, enforces request-level atomicity, and runs the author's
9-
``forward_batch`` on a single dedicated **actor thread**, serialized — there is no
10-
cross-request coalescing. A follow-up swaps this body for a
11-
``ThreadedMicroBatcher`` (cross-request batching); the public surface
12-
(``load`` / ``encode`` / ``get_image_placeholder_token_id`` / ``shutdown``) is
13-
identical, so the worker integration does not change.
14-
15-
Why a single actor thread (not ``asyncio.to_thread``): build and every
16-
``forward_batch`` run on the **same** thread, so an author that captures a CUDA
17-
graph in ``build`` can replay it from ``forward_batch`` — the affinity the batched
18-
version also guarantees. ``max_workers=1`` serializes forwards (FIFO), so
19-
concurrent ``encode`` calls run one forward at a time without interleaving.
20-
21-
Request-level atomicity (design A5): a gather-barrier sits between preprocess and
22-
the forward — ``encode`` waits for *every* image's preprocess to settle and runs
23-
the forward only if **all** succeed; on any failure it does no GPU work and raises
24-
the request-level error, so a text-only LM never sees a partial result.
4+
"""Async glue (L3) between the worker's event loop and a ``VisionEncoderBackend``.
5+
6+
``AsyncVisionEncoder`` is the **Dynamo-owned** layer the worker talks to. It
7+
turns the author's synchronous, thread-affine backend (L2) into an awaitable
8+
``encode(raws) -> list[tensor]`` by:
9+
10+
- running ``backend.preprocess`` **off the event loop** on a bounded
11+
``ThreadPoolExecutor`` (CPU-heavy fetch / resize / patchify must not serialize
12+
on the GPU actor thread);
13+
- enforcing **request-level atomicity** (A5): a gather-barrier between preprocess
14+
and submit — ``encode`` waits for *every* image's preprocess to settle and only
15+
submits if **all** succeed; on any failure it submits nothing (zero GPU work)
16+
and raises the request-level error, so a text-only LM never sees a partial
17+
result;
18+
- handing the preprocessed items (with their off-thread-computed ``cost`` /
19+
``bucket_key``) to a ``ThreadedMicroBatcher``, which **coalesces across
20+
concurrent ``encode`` calls** up to the backend's ``max_batch_cost`` token
21+
budget and runs ``backend.forward_batch`` on the single actor thread.
22+
23+
This replaces the earlier serial (direct-call) glue with cross-request batching;
24+
the public surface (``load`` / ``encode`` / ``get_image_placeholder_token_id`` /
25+
``shutdown``) is unchanged, so the worker integration does not move.
2526
"""
2627

2728
from __future__ import annotations
@@ -33,6 +34,7 @@
3334

3435
import torch
3536

37+
from dynamo.vllm.multimodal_utils.threaded_micro_batcher import ThreadedMicroBatcher
3638
from dynamo.vllm.multimodal_utils.vision_encoder_backend import (
3739
ItemT,
3840
Preprocessed,
@@ -44,12 +46,11 @@
4446

4547

4648
class AsyncVisionEncoder(Generic[RawT, ItemT]):
47-
"""Drive a ``VisionEncoderBackend`` from the async request path, serially.
49+
"""Drive a ``VisionEncoderBackend`` from the worker's async request path.
4850
4951
The worker calls ``load`` once at startup and ``await``s ``encode`` per
5052
request; ``shutdown`` on teardown. All model knowledge lives in ``backend``;
51-
this class owns the preprocess pool, the A5 barrier, and the single actor
52-
thread that runs ``build`` / ``forward_batch`` / ``close``.
53+
this class owns the preprocess pool, the A5 barrier, and the micro-batcher.
5354
5455
Args:
5556
backend: The author-written ``VisionEncoderBackend``.
@@ -69,32 +70,38 @@ def __init__(
6970
self._backend = backend
7071
self._preprocess_concurrency = preprocess_concurrency
7172
self._name = name
72-
self._actor: Optional[ThreadPoolExecutor] = None # build + every forward
73-
self._pool: Optional[ThreadPoolExecutor] = None # off-loop preprocess
73+
self._batcher: Optional[ThreadedMicroBatcher] = None
74+
self._pool: Optional[ThreadPoolExecutor] = None
7475

7576
# ---- lifecycle ---------------------------------------------------------
7677

7778
def load(self, model_id: str, device: str) -> None:
78-
"""Run ``backend.build`` on the actor thread and fail fast.
79+
"""Start the actor thread (running ``backend.build`` on it) and fail fast.
7980
8081
Re-raises any build error, then ``validate``s the placeholder id so a
8182
misconfigured encoder errors at startup instead of on the first request.
8283
Single-shot: a second ``load()`` raises rather than orphaning the first
83-
actor thread and model.
84+
batcher's (non-daemon) worker thread and model.
8485
"""
85-
if self._actor is not None or self._pool is not None:
86+
if self._batcher is not None or self._pool is not None:
8687
raise RuntimeError("AsyncVisionEncoder.load() called twice")
88+
# Construct the pool + batcher INSIDE the try so a constructor failure
89+
# (e.g. a backend exposing a max_batch_cost the batcher rejects) still
90+
# reaps the pool via shutdown() instead of leaking it. shutdown() is
91+
# None-safe on the not-yet-assigned member.
8792
try:
88-
# One actor thread so build + every forward share a thread; a single
89-
# worker also serializes forwards (FIFO) — no cross-request batching.
90-
self._actor = ThreadPoolExecutor(
91-
max_workers=1, thread_name_prefix=f"{self._name}-actor"
92-
)
9393
self._pool = ThreadPoolExecutor(
9494
max_workers=self._preprocess_concurrency,
9595
thread_name_prefix=f"{self._name}-pre",
9696
)
97-
self._actor.submit(self._backend.build, model_id, device).result()
97+
self._batcher = ThreadedMicroBatcher(
98+
self._backend.forward_batch,
99+
max_batch_cost=self._backend.max_batch_cost,
100+
on_start=lambda: self._backend.build(model_id, device),
101+
on_stop=self._backend.close,
102+
name=self._name,
103+
)
104+
self._batcher.start() # runs backend.build() on the actor thread
98105
self.validate()
99106
except BaseException:
100107
self.shutdown()
@@ -112,48 +119,41 @@ def get_image_placeholder_token_id(self) -> int:
112119
# ---- request path ------------------------------------------------------
113120

114121
async def encode(self, raws: List[RawT]) -> List[torch.Tensor]:
115-
"""Preprocess (off-loop, A5 barrier) then run a single serial forward.
122+
"""Preprocess (off-loop, A5 barrier) then batched-encode; all-or-nothing.
116123
117124
Returns one ``(n_visual_tokens, lm_hidden_dim)`` tensor per raw input, in
118-
order. Raises if any image's preprocess fails (no GPU work) or if the
119-
forward fails.
125+
order. Raises if any image's preprocess fails (submitting nothing) or if
126+
the batched forward fails.
120127
"""
121-
if self._actor is None or self._pool is None:
128+
if self._batcher is None or self._pool is None:
122129
raise RuntimeError("AsyncVisionEncoder.encode() called before load()")
123130
if not raws:
124131
return []
125132
loop = asyncio.get_running_loop()
126133
# A5 barrier: preprocess all images concurrently, wait for EVERY one to
127-
# settle, and run the forward only if all succeeded. return_exceptions=True
128-
# makes the gather a true barrier (no short-circuit), so a failed sibling
129-
# cannot leave a half-run request.
134+
# settle, and submit only if all succeeded. return_exceptions=True makes
135+
# the gather a true barrier (it never short-circuits), so a failed sibling
136+
# cannot leave a half-submitted request — we submit nothing on any error.
130137
tasks = [
131138
loop.run_in_executor(self._pool, self._backend.preprocess, raw)
132139
for raw in raws
133140
]
134141
settled = await asyncio.gather(*tasks, return_exceptions=True)
135142
errors = [r for r in settled if isinstance(r, BaseException)]
136143
if errors:
144+
# Fail the whole request atomically; no item was submitted (no GPU
145+
# work). Surface the first failure.
137146
raise errors[0]
138147
preprocessed: List[Preprocessed] = list(settled) # type: ignore[arg-type]
139148
items = [p.item for p in preprocessed]
140-
# Direct, serialized forward on the actor thread (eager; target_bucket
141-
# defaults to None — there is no graph ladder in this milestone).
142-
return await loop.run_in_executor(
143-
self._actor, self._backend.forward_batch, items
144-
)
149+
costs = [p.cost for p in preprocessed]
150+
bucket_keys = [p.bucket_key for p in preprocessed]
151+
return await self._batcher.submit(items, costs, bucket_keys)
145152

146153
def shutdown(self) -> None:
147-
"""Run ``backend.close`` on the actor thread, then stop both pools. Safe
148-
before ``load`` and idempotent."""
149-
if self._actor is not None:
150-
try:
151-
self._actor.submit(self._backend.close).result(timeout=10)
152-
except BaseException: # noqa: BLE001 — teardown best-effort
153-
logger.exception(
154-
"AsyncVisionEncoder(%s): backend.close raised during teardown",
155-
self._name,
156-
)
157-
self._actor.shutdown(wait=False)
154+
"""Stop the actor thread (running ``backend.close`` on it) and the
155+
preprocess pool. Safe before ``load`` and idempotent."""
156+
if self._batcher is not None:
157+
self._batcher.shutdown() # runs backend.close() on the actor thread
158158
if self._pool is not None:
159159
self._pool.shutdown(wait=False)

0 commit comments

Comments
 (0)