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
2629from __future__ import annotations
2730
2831import asyncio
2932import logging
3033from concurrent .futures import ThreadPoolExecutor
31- from typing import Generic , List
34+ from typing import Generic , List , Optional
3235
3336import torch
3437
38+ from dynamo .vllm .multimodal_utils .threaded_micro_batcher import ThreadedMicroBatcher
3539from dynamo .vllm .multimodal_utils .vision_encoder_backend import (
3640 ItemT ,
3741 Preprocessed ,
4347
4448
4549class 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