Skip to content

Commit 4f361b7

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 to the author's max_batch_cost token budget (scalar cost, one-dimensional packing — no shape buckets, no graph ladder; that lands in PR4). 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(items) / on_stop (close); eager drain-on-completion (no timer; max_wait_ms is an opt-in knob); coalesce by scalar cost up to max_batch_cost (None = pass-through). Per-item idempotent finalizer; cancellation + repeat-cancel retirement; worker supervisor; optional max_outstanding_cost admission; cross-loop-safe shutdown. submit(items, costs). - AsyncVisionEncoder now routes preprocessed items through the batcher (was a single-worker serial executor); the A5 preprocess barrier is unchanged; build is build(model_id) and the hardcoded image_token_id is validated at load. Tests: batcher concurrency suite (eager-drain, cost budget, pass-through, cancellation/retirement, supervisor, lifecycle, on_stop) + batcher-backed glue. 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 b3a7c13 commit 4f361b7

4 files changed

Lines changed: 1096 additions & 92 deletions

File tree

Lines changed: 68 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,41 @@
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 between the worker's event loop and a ``VisionEncoderBackend``.
5-
6-
This is the **eager** 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).
19-
20-
Request-level atomicity: a gather-barrier sits between preprocess and
21-
the forward — ``encode`` waits for *every* image's preprocess to settle and runs
22-
the forward only if **all** succeed; on any failure it does no GPU work and raises
23-
the request-level error, so a text-only LM never sees a partial result.
4+
"""Async glue 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 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**: 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 scalar ``cost``)
19+
to a ``ThreadedMicroBatcher``, which coalesces across concurrent ``encode`` calls
20+
by cost and runs ``backend.forward_batch`` on the single actor thread.
21+
22+
The backend's ``build`` runs on the batcher's actor thread (so a CUDA graph it
23+
captures is replayed on the same thread) and its ``close`` runs there at
24+
teardown. ``load`` fails fast: it re-raises a build error and resolves the image
25+
placeholder id once, so a misconfigured encoder errors at startup, not on the
26+
first request.
2427
"""
2528

2629
from __future__ import annotations
2730

2831
import asyncio
2932
import logging
3033
from concurrent.futures import ThreadPoolExecutor
31-
from typing import Generic, List
34+
from typing import Generic, List, Optional
3235

3336
import torch
3437

38+
from dynamo.vllm.multimodal_utils.threaded_micro_batcher import ThreadedMicroBatcher
3539
from dynamo.vllm.multimodal_utils.vision_encoder_backend import (
3640
ItemT,
3741
Preprocessed,
@@ -43,12 +47,16 @@
4347

4448

4549
class AsyncVisionEncoder(Generic[RawT, ItemT]):
46-
"""Drive a ``VisionEncoderBackend`` from the async request path, serially.
50+
"""Drive a ``VisionEncoderBackend`` from the worker's async request path.
4751
4852
The worker calls ``load`` once at startup and ``await``s ``encode`` per
4953
request; ``shutdown`` on teardown. All model knowledge lives in ``backend``;
50-
this class owns the preprocess pool, the A5 barrier, and the single actor
51-
thread that runs ``build`` / ``forward_batch`` / ``close``.
54+
this class owns the preprocess pool, the A5 barrier, and the micro-batcher.
55+
56+
Args:
57+
backend: The author-written ``VisionEncoderBackend``.
58+
preprocess_concurrency: Worker threads for off-loop ``preprocess``.
59+
name: Base name for the actor thread / preprocess pool.
5260
"""
5361

5462
def __init__(
@@ -63,31 +71,38 @@ def __init__(
6371
self._backend = backend
6472
self._preprocess_concurrency = preprocess_concurrency
6573
self._name = name
66-
self._actor: ThreadPoolExecutor | None = None # build + every forward
67-
self._pool: ThreadPoolExecutor | None = None # off-loop preprocess
74+
self._batcher: Optional[ThreadedMicroBatcher] = None
75+
self._pool: Optional[ThreadPoolExecutor] = None
6876

6977
# ---- lifecycle ---------------------------------------------------------
7078

7179
def load(self, model_id: str) -> None:
72-
"""Run ``backend.build`` on the actor thread and fail fast.
80+
"""Start the actor thread (running ``backend.build`` on it) and fail fast.
7381
74-
Re-raises any build error, then ``validate``s the hardcoded image token id
75-
so a misconfigured encoder errors at startup. Single-shot: a second
76-
``load()`` raises rather than orphaning the first actor thread and model.
82+
Re-raises any build error, then ``validate``s the placeholder id so a
83+
misconfigured encoder errors at startup instead of on the first request.
84+
Single-shot: a second ``load()`` raises rather than orphaning the first
85+
batcher's (non-daemon) worker thread and model.
7786
"""
78-
if self._actor is not None or self._pool is not None:
87+
if self._batcher is not None or self._pool is not None:
7988
raise RuntimeError("AsyncVisionEncoder.load() called twice")
89+
# Construct the pool + batcher INSIDE the try so a constructor failure
90+
# (e.g. a backend exposing a max_batch_cost the batcher rejects) still
91+
# reaps the pool via shutdown() instead of leaking it. shutdown() is
92+
# None-safe on the not-yet-assigned member.
8093
try:
81-
# One actor thread so build + every forward share a thread; a single
82-
# worker also serializes forwards (FIFO) — no cross-request batching.
83-
self._actor = ThreadPoolExecutor(
84-
max_workers=1, thread_name_prefix=f"{self._name}-actor"
85-
)
8694
self._pool = ThreadPoolExecutor(
8795
max_workers=self._preprocess_concurrency,
8896
thread_name_prefix=f"{self._name}-pre",
8997
)
90-
self._actor.submit(self._backend.build, model_id).result()
98+
self._batcher = ThreadedMicroBatcher(
99+
self._backend.forward_batch,
100+
max_batch_cost=self._backend.max_batch_cost,
101+
on_start=lambda: self._backend.build(model_id),
102+
on_stop=self._backend.close,
103+
name=self._name,
104+
)
105+
self._batcher.start() # runs backend.build() on the actor thread
91106
self.validate()
92107
except BaseException:
93108
self.shutdown()
@@ -110,48 +125,40 @@ def get_image_placeholder_token_id(self) -> int:
110125
# ---- request path ------------------------------------------------------
111126

112127
async def encode(self, raws: List[RawT]) -> List[torch.Tensor]:
113-
"""Preprocess (off-loop, A5 barrier) then run a single serial forward.
128+
"""Preprocess (off-loop, A5 barrier) then batched-encode; all-or-nothing.
114129
115-
Returns one ``(n_visual_tokens, lm_hidden_dim)`` CPU tensor per raw input,
116-
in order. Raises if any image's preprocess fails (no GPU work) or if the
117-
forward fails.
130+
Returns one ``(n_visual_tokens, lm_hidden_dim)`` tensor per raw input, in
131+
order. Raises if any image's preprocess fails (submitting nothing) or if
132+
the batched forward fails.
118133
"""
119-
if self._actor is None or self._pool is None:
134+
if self._batcher is None or self._pool is None:
120135
raise RuntimeError("AsyncVisionEncoder.encode() called before load()")
121136
if not raws:
122137
return []
123138
loop = asyncio.get_running_loop()
124139
# A5 barrier: preprocess all images concurrently, wait for EVERY one to
125-
# settle, and run the forward only if all succeeded. return_exceptions=True
126-
# makes the gather a true barrier (no short-circuit).
140+
# settle, and submit only if all succeeded. return_exceptions=True makes
141+
# the gather a true barrier (it never short-circuits), so a failed sibling
142+
# cannot leave a half-submitted request — we submit nothing on any error.
127143
tasks = [
128144
loop.run_in_executor(self._pool, self._backend.preprocess, raw)
129145
for raw in raws
130146
]
131147
settled = await asyncio.gather(*tasks, return_exceptions=True)
132148
errors = [r for r in settled if isinstance(r, BaseException)]
133149
if errors:
150+
# Fail the whole request atomically; no item was submitted (no GPU
151+
# work). Surface the first failure.
134152
raise errors[0]
135153
preprocessed: List[Preprocessed] = list(settled) # type: ignore[arg-type]
136154
items = [p.item for p in preprocessed]
137-
# Direct, serialized forward on the actor thread (eager; target_bucket
138-
# defaults to None — there is no graph ladder until CUDA-graph batching
139-
# is supported).
140-
return await loop.run_in_executor(
141-
self._actor, self._backend.forward_batch, items
142-
)
155+
costs = [p.cost for p in preprocessed]
156+
return await self._batcher.submit(items, costs)
143157

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

0 commit comments

Comments
 (0)