Skip to content

Commit 5677c09

Browse files
furionwclaude
andcommitted
feat(multimodal): honor optional preprocess in the serial AsyncVisionEncoder
Read preprocess_concurrency from the backend (driver arg overrides for tuning); 0 ⇒ no pool — raws go straight to forward_batch with no A5 barrier (the single forward is already all-or-nothing for the request). Make the example HitchhikersVisionEncoder a passthrough (drop its identity preprocess override) and document the pool opt-in in the Qwen base. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2654718 commit 5677c09

4 files changed

Lines changed: 96 additions & 43 deletions

File tree

components/src/dynamo/vllm/multimodal_utils/async_vision_encoder.py

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -47,24 +47,32 @@ class AsyncVisionEncoder(Generic[RawT, ItemT]):
4747
4848
The worker calls ``load`` once at startup and ``await``s ``encode`` per
4949
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``.
50+
this class owns the optional preprocess pool, the A5 barrier, and the single
51+
actor thread that runs ``build`` / ``forward_batch`` / ``close``.
5252
"""
5353

5454
def __init__(
5555
self,
5656
backend: VisionEncoderBackend[RawT, ItemT],
5757
*,
58-
preprocess_concurrency: int = 4,
58+
preprocess_concurrency: int | None = None,
5959
name: str = "vision-encoder",
6060
) -> None:
61-
if preprocess_concurrency < 1:
62-
raise ValueError("preprocess_concurrency must be >= 1")
61+
# The backend declares whether it needs off-loop preprocessing; the
62+
# explicit arg is an override for tuning. 0 ⇒ no pool (raws pass straight
63+
# to forward_batch).
64+
conc = (
65+
backend.preprocess_concurrency
66+
if preprocess_concurrency is None
67+
else preprocess_concurrency
68+
)
69+
if conc < 0:
70+
raise ValueError("preprocess_concurrency must be >= 0")
6371
self._backend = backend
64-
self._preprocess_concurrency = preprocess_concurrency
72+
self._preprocess_concurrency = conc
6573
self._name = name
6674
self._actor: ThreadPoolExecutor | None = None # build + every forward
67-
self._pool: ThreadPoolExecutor | None = None # off-loop preprocess
75+
self._pool: ThreadPoolExecutor | None = None # off-loop preprocess (or None)
6876

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

@@ -75,17 +83,22 @@ def load(self, model_id: str) -> None:
7583
so a misconfigured encoder errors at startup. Single-shot: a second
7684
``load()`` raises rather than orphaning the first actor thread and model.
7785
"""
78-
if self._actor is not None or self._pool is not None:
86+
if self._actor is not None:
7987
raise RuntimeError("AsyncVisionEncoder.load() called twice")
8088
try:
8189
# One actor thread so build + every forward share a thread; a single
8290
# worker also serializes forwards (FIFO) — no cross-request batching.
8391
self._actor = ThreadPoolExecutor(
8492
max_workers=1, thread_name_prefix=f"{self._name}-actor"
8593
)
86-
self._pool = ThreadPoolExecutor(
87-
max_workers=self._preprocess_concurrency,
88-
thread_name_prefix=f"{self._name}-pre",
94+
# No pool when concurrency is 0 — preprocess is skipped (passthrough).
95+
self._pool = (
96+
ThreadPoolExecutor(
97+
max_workers=self._preprocess_concurrency,
98+
thread_name_prefix=f"{self._name}-pre",
99+
)
100+
if self._preprocess_concurrency > 0
101+
else None
89102
)
90103
self._actor.submit(self._backend.build, model_id).result()
91104
self.validate()
@@ -110,30 +123,38 @@ def get_image_placeholder_token_id(self) -> int:
110123
# ---- request path ------------------------------------------------------
111124

112125
async def encode(self, raws: List[RawT]) -> List[torch.Tensor]:
113-
"""Preprocess (off-loop, A5 barrier) then run a single serial forward.
114-
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.
126+
"""Optionally preprocess (off-loop, A5 barrier) then run a single serial
127+
forward.
128+
129+
With no preprocess pool (``preprocess_concurrency == 0``) raws go straight
130+
to ``forward_batch`` (the backend folds any prep in there). Returns one
131+
``(n_visual_tokens, lm_hidden_dim)`` CPU tensor per raw input, in order.
132+
Raises if any image's preprocess fails (no GPU work) or if the forward
133+
fails.
118134
"""
119-
if self._actor is None or self._pool is None:
135+
if self._actor is None:
120136
raise RuntimeError("AsyncVisionEncoder.encode() called before load()")
121137
if not raws:
122138
return []
123139
loop = asyncio.get_running_loop()
124-
# 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).
127-
tasks = [
128-
loop.run_in_executor(self._pool, self._backend.preprocess, raw)
129-
for raw in raws
130-
]
131-
settled = await asyncio.gather(*tasks, return_exceptions=True)
132-
errors = [r for r in settled if isinstance(r, BaseException)]
133-
if errors:
134-
raise errors[0]
135-
preprocessed: List[Preprocessed] = list(settled) # type: ignore[arg-type]
136-
items = [p.item for p in preprocessed]
140+
if self._pool is None:
141+
# No preprocess phase: raw IS the item. No A5 barrier needed — the
142+
# single forward is already all-or-nothing for the request.
143+
items: List[ItemT] = list(raws) # type: ignore[arg-type] # ItemT==RawT
144+
else:
145+
# A5 barrier: preprocess all images concurrently, wait for EVERY one to
146+
# settle, run the forward only if all succeeded. return_exceptions=True
147+
# makes the gather a true barrier (no short-circuit).
148+
tasks = [
149+
loop.run_in_executor(self._pool, self._backend.preprocess, raw)
150+
for raw in raws
151+
]
152+
settled = await asyncio.gather(*tasks, return_exceptions=True)
153+
errors = [r for r in settled if isinstance(r, BaseException)]
154+
if errors:
155+
raise errors[0]
156+
preprocessed: List[Preprocessed] = list(settled) # type: ignore[arg-type]
157+
items = [p.item for p in preprocessed]
137158
# Direct, serialized forward on the actor thread (eager; target_bucket
138159
# defaults to None — there is no graph ladder until CUDA-graph batching
139160
# is supported).

components/src/dynamo/vllm/tests/multimodal_utils/test_vllm_async_vision_encoder.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ class _FakeBackend(VisionEncoderBackend):
3535
"""A CPU-only fake backend; records its threads and forward-call boundaries."""
3636

3737
image_token_id = 151655
38+
# This fake overrides preprocess, so opt into the off-loop pool (the default
39+
# is 0 ⇒ no pool); the passthrough path is covered by its own test below.
40+
preprocess_concurrency = 4
3841

3942
def __init__(self, *, fail_on=None):
4043
self.fail_on = set(fail_on or ())
@@ -185,6 +188,37 @@ def test_shutdown_before_load_is_safe():
185188
AsyncVisionEncoder(_FakeBackend()).shutdown() # no-op, no raise
186189

187190

188-
def test_preprocess_concurrency_must_be_positive():
191+
async def test_passthrough_skips_preprocess_when_no_pool():
192+
"""With no pool (preprocess_concurrency=0) preprocess is skipped and raws go
193+
straight to forward_batch — no A5 barrier, no pool thread."""
194+
195+
class _PassthroughBackend(_FakeBackend):
196+
preprocess_concurrency = 0
197+
198+
def preprocess(self, raw):
199+
raise AssertionError("preprocess must not run in passthrough mode")
200+
201+
be = _PassthroughBackend()
202+
enc = AsyncVisionEncoder(be)
203+
enc.load("m")
204+
try:
205+
assert enc._pool is None # no pool created
206+
out = await enc.encode(["a", "bb"])
207+
assert len(out) == 2
208+
assert be.forward_calls == [["a", "bb"]] # raws passed straight through
209+
finally:
210+
enc.shutdown()
211+
212+
213+
def test_preprocess_concurrency_zero_disables_pool():
214+
enc = AsyncVisionEncoder(_FakeBackend(), preprocess_concurrency=0)
215+
enc.load("m")
216+
try:
217+
assert enc._pool is None
218+
finally:
219+
enc.shutdown()
220+
221+
222+
def test_preprocess_concurrency_rejects_negative():
189223
with pytest.raises(ValueError, match="preprocess_concurrency"):
190-
AsyncVisionEncoder(_FakeBackend(), preprocess_concurrency=0)
224+
AsyncVisionEncoder(_FakeBackend(), preprocess_concurrency=-1)

examples/custom_encoder/hitchhikers_vision_encoder.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
from safetensors import safe_open
4545
from transformers.utils import cached_file
4646

47-
from dynamo.vllm.multimodal_utils.vision_encoder_backend import Preprocessed
4847
from examples.custom_encoder.qwen_vision_encoder import QwenVisionEncoderBackend
4948

5049
logger = logging.getLogger(__name__)
@@ -97,7 +96,9 @@ class HitchhikersVisionEncoder(QwenVisionEncoderBackend):
9796
so the spliced prompt reads as a coherent sentence.
9897
"""
9998

100-
# Eager: no graph ladder. Count-based budget (cost == 1 per image).
99+
# Eager: no graph ladder. Count-based budget (cost == 1 per image). The URL is
100+
# ignored, so there is nothing to preprocess: it inherits the identity-default
101+
# preprocess + preprocess_concurrency=0, and raws go straight to forward_batch.
101102
buckets = None
102103
max_batch_cost = 8
103104

@@ -112,12 +113,6 @@ def build(self, model_id: str) -> None:
112113
_PHRASE,
113114
)
114115

115-
def preprocess(self, image_url: str) -> Preprocessed[str]:
116-
"""Pass-through: the URL is ignored, so there is nothing to fetch/decode.
117-
118-
cost=1 (count-based)."""
119-
return Preprocessed(item=image_url, cost=1)
120-
121116
def forward_batch(
122117
self, items: List[str], target_bucket: Optional[int] = None
123118
) -> List[torch.Tensor]:

examples/custom_encoder/qwen_vision_encoder.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
66
Hardcodes the Qwen ``<|image_pad|>`` placeholder id and loads the model tokenizer
77
(handy for subclasses that tokenize text). A concrete Qwen-family encoder
8-
subclasses this and implements only ``preprocess`` + ``forward_batch``.
8+
subclasses this and implements ``forward_batch`` — plus ``preprocess`` (and
9+
``preprocess_concurrency > 0``) only when it needs off-loop fetch/resize.
910
1011
class MyQwenEncoder(QwenVisionEncoderBackend):
12+
preprocess_concurrency = 4 # enable the off-loop pool
1113
def build(self, model_id):
1214
super().build(model_id) # loads self.tokenizer
1315
# ... load ViT + projector (pick the device yourself) ...
@@ -30,8 +32,9 @@ class QwenVisionEncoderBackend(VisionEncoderBackend):
3032
3133
Hardcodes ``image_token_id`` to Qwen3-VL's ``<|image_pad|>`` (151655) — override
3234
it for other versions (e.g. 248056 for Qwen3.5). ``build`` loads the model
33-
tokenizer; ``preprocess`` and ``forward_batch`` stay abstract, so this class
34-
cannot be instantiated directly — subclass it and implement them.
35+
tokenizer; ``forward_batch`` stays abstract, so this class cannot be
36+
instantiated directly — subclass it and implement ``forward_batch`` (and
37+
``preprocess`` only if it needs off-loop prep).
3538
"""
3639

3740
# Qwen3-VL <|image_pad|>; override for other Qwen versions.

0 commit comments

Comments
 (0)