Skip to content

Commit 90cc67e

Browse files
furionwclaude
andcommitted
feat(multimodal): eager handler integration + serial AsyncVisionEncoder (no batcher) + e2e
PR2 of the custom vision-encoder series, stacked on PR1. Wires the contract into the aggregated dynamo.vllm worker and proves the mixed-embeds splice path end to end with a trivial backend and a DIRECT serial call — no micro-batcher yet. - AsyncVisionEncoder (serial glue): off-thread preprocess pool + the A5 all-or-nothing preprocess barrier + a single actor thread running build / forward_batch / close. Forwards are serialized (no cross-request coalescing) — that lands in PR3. The public surface (load / encode / get_image_placeholder_token_id / shutdown) is the stable handler boundary. - handlers.py: resolve a VisionEncoderBackend subclass from --custom-encoder-class, wrap it in AsyncVisionEncoder, assemble the mixed EmbedsPrompt; requires --enable-prompt-embeds; image-only with a text-only fallback. - Examples: QwenVisionEncoderBackend base + HitchhikersVisionEncoder (answers "42"). - agg_custom.sh launcher + a minimal qwen_vl.jinja template. - e2e: the agg_custom topology profile + the "42" semantic payload. Tests: serial-glue unit tests (encode, A5 barrier, single actor thread, NO coalescing, fail-fast load + thread reaping, shutdown). The integrated "42" e2e is validated on the series' integration branch. Note: hand-set GPU utilization (memory reservation is a later milestone). 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 221e473 commit 90cc67e

11 files changed

Lines changed: 1012 additions & 15 deletions

File tree

components/src/dynamo/vllm/handlers.py

Lines changed: 228 additions & 15 deletions
Large diffs are not rendered by default.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
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.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import asyncio
30+
import logging
31+
from concurrent.futures import ThreadPoolExecutor
32+
from typing import Generic, List, Optional
33+
34+
import torch
35+
36+
from dynamo.vllm.multimodal_utils.vision_encoder_backend import (
37+
ItemT,
38+
Preprocessed,
39+
RawT,
40+
VisionEncoderBackend,
41+
)
42+
43+
logger = logging.getLogger(__name__)
44+
45+
46+
class AsyncVisionEncoder(Generic[RawT, ItemT]):
47+
"""Drive a ``VisionEncoderBackend`` from the async request path, serially.
48+
49+
The worker calls ``load`` once at startup and ``await``s ``encode`` per
50+
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+
54+
Args:
55+
backend: The author-written ``VisionEncoderBackend``.
56+
preprocess_concurrency: Worker threads for off-loop ``preprocess``.
57+
name: Base name for the actor thread / preprocess pool.
58+
"""
59+
60+
def __init__(
61+
self,
62+
backend: VisionEncoderBackend[RawT, ItemT],
63+
*,
64+
preprocess_concurrency: int = 4,
65+
name: str = "vision-encoder",
66+
) -> None:
67+
if preprocess_concurrency < 1:
68+
raise ValueError("preprocess_concurrency must be >= 1")
69+
self._backend = backend
70+
self._preprocess_concurrency = preprocess_concurrency
71+
self._name = name
72+
self._actor: Optional[ThreadPoolExecutor] = None # build + every forward
73+
self._pool: Optional[ThreadPoolExecutor] = None # off-loop preprocess
74+
75+
# ---- lifecycle ---------------------------------------------------------
76+
77+
def load(self, model_id: str, device: str) -> None:
78+
"""Run ``backend.build`` on the actor thread and fail fast.
79+
80+
Re-raises any build error, then ``validate``s the placeholder id so a
81+
misconfigured encoder errors at startup instead of on the first request.
82+
Single-shot: a second ``load()`` raises rather than orphaning the first
83+
actor thread and model.
84+
"""
85+
if self._actor is not None or self._pool is not None:
86+
raise RuntimeError("AsyncVisionEncoder.load() called twice")
87+
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+
)
93+
self._pool = ThreadPoolExecutor(
94+
max_workers=self._preprocess_concurrency,
95+
thread_name_prefix=f"{self._name}-pre",
96+
)
97+
self._actor.submit(self._backend.build, model_id, device).result()
98+
self.validate()
99+
except BaseException:
100+
self.shutdown()
101+
raise
102+
103+
def validate(self) -> None:
104+
"""Fail-fast checks run by ``load`` after ``build`` (resolve the
105+
placeholder id once)."""
106+
self._backend.get_image_placeholder_token_id()
107+
108+
def get_image_placeholder_token_id(self) -> int:
109+
"""The token id marking image positions, from the backend."""
110+
return self._backend.get_image_placeholder_token_id()
111+
112+
# ---- request path ------------------------------------------------------
113+
114+
async def encode(self, raws: List[RawT]) -> List[torch.Tensor]:
115+
"""Preprocess (off-loop, A5 barrier) then run a single serial forward.
116+
117+
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.
120+
"""
121+
if self._actor is None or self._pool is None:
122+
raise RuntimeError("AsyncVisionEncoder.encode() called before load()")
123+
if not raws:
124+
return []
125+
loop = asyncio.get_running_loop()
126+
# 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.
130+
tasks = [
131+
loop.run_in_executor(self._pool, self._backend.preprocess, raw)
132+
for raw in raws
133+
]
134+
settled = await asyncio.gather(*tasks, return_exceptions=True)
135+
errors = [r for r in settled if isinstance(r, BaseException)]
136+
if errors:
137+
raise errors[0]
138+
preprocessed: List[Preprocessed] = list(settled) # type: ignore[arg-type]
139+
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+
)
145+
146+
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)
158+
if self._pool is not None:
159+
self._pool.shutdown(wait=False)
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Unit tests for the serial dynamo.vllm.multimodal_utils.async_vision_encoder.
5+
6+
Pin the serial-glue contract: build / forward / close run on one actor thread;
7+
encode returns one tensor per raw; the A5 preprocess barrier fails a request
8+
atomically (no GPU work) if any image's preprocess fails; concurrent encodes are
9+
**not** coalesced (one forward_batch call each — that is the batched version's
10+
job, added later); load fails fast on a build error or unresolvable placeholder
11+
and reaps its threads.
12+
"""
13+
14+
import threading
15+
16+
import pytest
17+
import torch
18+
19+
from dynamo.vllm.multimodal_utils.async_vision_encoder import AsyncVisionEncoder
20+
from dynamo.vllm.multimodal_utils.vision_encoder_backend import (
21+
Preprocessed,
22+
VisionEncoderBackend,
23+
)
24+
25+
pytestmark = [
26+
pytest.mark.unit,
27+
pytest.mark.pre_merge,
28+
pytest.mark.vllm,
29+
pytest.mark.gpu_0,
30+
pytest.mark.multimodal,
31+
]
32+
33+
34+
class _FakeBackend(VisionEncoderBackend):
35+
"""A CPU-only fake backend; records its threads and forward-call boundaries."""
36+
37+
max_batch_cost = 8
38+
buckets = None
39+
40+
def __init__(self, *, fail_on=None, placeholder_id=151655):
41+
self.fail_on = set(fail_on or ())
42+
self._placeholder_id = placeholder_id
43+
self.build_thread = None
44+
self.close_thread = None
45+
self.closed = False
46+
self.model_id = None
47+
self.forward_threads: list[int] = []
48+
self.forward_calls: list[list] = [] # one entry per forward_batch call
49+
50+
def build(self, model_id, device):
51+
self.build_thread = threading.get_ident()
52+
self.model_id = model_id
53+
54+
def preprocess(self, raw):
55+
if raw in self.fail_on:
56+
raise ValueError(f"bad input {raw}")
57+
return Preprocessed(item=raw, cost=1, bucket_key=None)
58+
59+
def forward_batch(self, items, target_bucket=None):
60+
self.forward_threads.append(threading.get_ident())
61+
self.forward_calls.append(list(items))
62+
return [torch.full((2, 4), float(len(str(it)))) for it in items]
63+
64+
def get_image_placeholder_token_id(self):
65+
return self._placeholder_id
66+
67+
def close(self):
68+
self.close_thread = threading.get_ident()
69+
self.closed = True
70+
71+
72+
async def test_encode_returns_one_tensor_per_raw():
73+
enc = AsyncVisionEncoder(_FakeBackend())
74+
enc.load("m", "cpu")
75+
try:
76+
out = await enc.encode(["a", "bb", "ccc"])
77+
assert len(out) == 3
78+
assert all(t.shape == (2, 4) for t in out)
79+
finally:
80+
enc.shutdown()
81+
82+
83+
async def test_a5_barrier_fails_atomically_with_no_gpu_work():
84+
be = _FakeBackend(fail_on={"bad"})
85+
enc = AsyncVisionEncoder(be)
86+
enc.load("m", "cpu")
87+
try:
88+
with pytest.raises(ValueError, match="bad input"):
89+
await enc.encode(["good", "bad"])
90+
assert be.forward_calls == [] # nothing ran
91+
finally:
92+
enc.shutdown()
93+
94+
95+
async def test_build_and_forward_share_one_non_main_thread():
96+
be = _FakeBackend()
97+
enc = AsyncVisionEncoder(be)
98+
enc.load("m", "cpu")
99+
try:
100+
await enc.encode(["x"])
101+
assert be.build_thread is not None
102+
assert set(be.forward_threads) == {be.build_thread}
103+
assert be.build_thread != threading.get_ident()
104+
finally:
105+
enc.shutdown()
106+
107+
108+
async def test_concurrent_encodes_are_not_coalesced():
109+
"""Serial glue: each encode runs its own forward_batch — no cross-request
110+
batching (that is the batched version's job, added in a later PR)."""
111+
import asyncio
112+
113+
be = _FakeBackend()
114+
enc = AsyncVisionEncoder(be)
115+
enc.load("m", "cpu")
116+
try:
117+
await asyncio.gather(enc.encode(["a"]), enc.encode(["b"]), enc.encode(["c"]))
118+
# Three encodes → three separate single-item forward calls.
119+
assert len(be.forward_calls) == 3
120+
assert all(len(call) == 1 for call in be.forward_calls)
121+
finally:
122+
enc.shutdown()
123+
124+
125+
async def test_load_resolves_placeholder_and_passes_model_id():
126+
be = _FakeBackend()
127+
enc = AsyncVisionEncoder(be)
128+
enc.load("my-model", "cpu")
129+
try:
130+
assert enc.get_image_placeholder_token_id() == 151655
131+
assert be.model_id == "my-model"
132+
finally:
133+
enc.shutdown()
134+
135+
136+
async def test_encode_empty_returns_empty():
137+
enc = AsyncVisionEncoder(_FakeBackend())
138+
enc.load("m", "cpu")
139+
try:
140+
assert await enc.encode([]) == []
141+
finally:
142+
enc.shutdown()
143+
144+
145+
async def test_encode_before_load_raises():
146+
enc = AsyncVisionEncoder(_FakeBackend())
147+
with pytest.raises(RuntimeError, match="before load"):
148+
await enc.encode(["a"])
149+
150+
151+
def test_load_twice_raises():
152+
enc = AsyncVisionEncoder(_FakeBackend())
153+
enc.load("m", "cpu")
154+
try:
155+
with pytest.raises(RuntimeError, match="called twice"):
156+
enc.load("m", "cpu")
157+
finally:
158+
enc.shutdown()
159+
160+
161+
def test_shutdown_runs_backend_close_on_actor_thread():
162+
be = _FakeBackend()
163+
enc = AsyncVisionEncoder(be)
164+
enc.load("m", "cpu")
165+
enc.shutdown()
166+
assert be.closed is True
167+
assert be.close_thread == be.build_thread # close on the actor thread
168+
169+
170+
def test_load_fails_fast_on_build_error_and_reaps_threads():
171+
class _BadBuild(_FakeBackend):
172+
def build(self, model_id, device):
173+
raise RuntimeError("build failed")
174+
175+
enc = AsyncVisionEncoder(_BadBuild())
176+
with pytest.raises(RuntimeError, match="build failed"):
177+
enc.load("m", "cpu")
178+
# Both executors were reaped on the failure path (not leaked).
179+
assert enc._actor is not None and enc._actor._shutdown is True
180+
assert enc._pool is not None and enc._pool._shutdown is True
181+
182+
183+
def test_load_fails_fast_on_unresolvable_placeholder():
184+
class _NoPlaceholder(_FakeBackend):
185+
def get_image_placeholder_token_id(self):
186+
raise ValueError("no placeholder")
187+
188+
enc = AsyncVisionEncoder(_NoPlaceholder())
189+
with pytest.raises(ValueError, match="no placeholder"):
190+
enc.load("m", "cpu")
191+
assert enc._actor is not None and enc._actor._shutdown is True
192+
193+
194+
def test_shutdown_before_load_is_safe():
195+
AsyncVisionEncoder(_FakeBackend()).shutdown() # no-op, no raise
196+
197+
198+
def test_preprocess_concurrency_must_be_positive():
199+
with pytest.raises(ValueError, match="preprocess_concurrency"):
200+
AsyncVisionEncoder(_FakeBackend(), preprocess_concurrency=0)

components/src/dynamo/vllm/tests/test_vllm_worker_handler.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,9 @@ def _make_decode_handler(
662662
handler.input_param_manager = MagicMock()
663663
handler.input_param_manager.get_extra_params.return_value = {}
664664
handler._deferred_aborts = {}
665+
# Real BaseWorkerHandler.__init__ (patched out above) sets this; the
666+
# aggregated branch in _generate_token_mode reads it, so mirror the default.
667+
handler._custom_encoder = None
665668
return handler
666669

667670

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

0 commit comments

Comments
 (0)