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
2728from __future__ import annotations
3334
3435import torch
3536
37+ from dynamo .vllm .multimodal_utils .threaded_micro_batcher import ThreadedMicroBatcher
3638from dynamo .vllm .multimodal_utils .vision_encoder_backend import (
3739 ItemT ,
3840 Preprocessed ,
4446
4547
4648class 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