Skip to content

Commit fc9b5d2

Browse files
furionwclaude
andcommitted
feat(multimodal): CUDA-graph bucket ladder (Dynamo-side target_bucket) + Qwen3-VL ViT example
PR4 of the custom vision-encoder series, stacked on PR3. Introduces bucket-wise graph support on the Dynamo side and a real graphed encoder example. - ThreadedMicroBatcher: optional `buckets` ladder. When set, the batcher rounds a batch's packed sum(cost) UP to the nearest rung and passes it as `target_bucket` to fn(items, target_bucket). With max_batch_cost=None it derives the ceiling as max(buckets); buckets=None stays eager / pass-through (PR3 behavior unchanged). - AsyncVisionEncoder passes backend.buckets to the batcher. - Qwen3VLViTEncoder example: loads the real Qwen3-VL vision tower (build(model_id), picks its own device), captures one CUDA graph per rung via torch.compile(reduce-overhead), and in forward_batch pads sum(cost) up to target_bucket, replays, slices the real images back out (CPU). Hardcodes image_token_id via the Qwen base. The author owns padding; Dynamo only picks the rung. `buckets`/`target_bucket` were forward-compat in the contract since PR1; this PR makes them live. Tests: target_bucket rounding (boundary + eager None + buckets-derive + ladder-covers-budget). Graph smoke validated on the integration branch. Deepstack limitation: Qwen3-VL deepstack features are not carried by the one-tensor-per-image contract — this exercises the mechanism, not accuracy. 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 4f361b7 commit fc9b5d2

5 files changed

Lines changed: 377 additions & 36 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ def load(self, model_id: str) -> None:
8787
if self._batcher is not None or self._pool is not None:
8888
raise RuntimeError("AsyncVisionEncoder.load() called twice")
8989
# 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.
90+
# (e.g. a backend exposing a misconfigured buckets / max_batch_cost the
91+
# batcher rejects) still reaps the pool via shutdown() instead of leaking
92+
# it. shutdown() is None-safe on the not-yet-assigned member.
9393
try:
9494
self._pool = ThreadPoolExecutor(
9595
max_workers=self._preprocess_concurrency,
@@ -98,6 +98,7 @@ def load(self, model_id: str) -> None:
9898
self._batcher = ThreadedMicroBatcher(
9999
self._backend.forward_batch,
100100
max_batch_cost=self._backend.max_batch_cost,
101+
buckets=self._backend.buckets,
101102
on_start=lambda: self._backend.build(model_id),
102103
on_stop=self._backend.close,
103104
name=self._name,

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

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@
1515
pooled and split into batches whose summed ``cost`` stays within
1616
``max_batch_cost`` (a compute/token budget, not a raw count). Packing is
1717
**one-dimensional** — by scalar ``cost`` alone; the batcher never inspects item
18-
shape.
18+
shape;
19+
- a **graph ladder** (optional ``buckets``): when set, the batcher rounds each
20+
batch's ``sum(cost)`` **up to the nearest rung** and passes it as
21+
``target_bucket`` so ``fn`` can pad to that rung and replay its captured graph.
22+
``buckets=None`` ⇒ eager: ``target_bucket=None``.
1923
2024
The caller speaks in opaque items plus a per-item scalar ``cost`` (int), computed
2125
once off-thread (see ``Preprocessed``); the batcher never interprets the items, so
22-
all model knowledge stays in the caller.
26+
all model knowledge stays in the caller. Final padding of a batch to a captured
27+
CUDA-graph shape is the ``fn``'s job (it owns
28+
the model), not the batcher's — the batcher only decides *which rung*.
2329
2430
Coalescing window — **eager drain-on-completion, no timer** (the design default):
2531
whenever the worker is free it pulls everything queued and runs it, then repeats.
@@ -56,7 +62,7 @@
5662
import time
5763
from dataclasses import dataclass
5864
from enum import Enum, auto
59-
from typing import Callable, Generic, List, Optional, TypeVar
65+
from typing import Callable, Generic, List, Optional, Sequence, TypeVar
6066

6167
logger = logging.getLogger(__name__)
6268

@@ -113,18 +119,25 @@ class _Work(Generic[T]):
113119

114120

115121
class ThreadedMicroBatcher(Generic[T, R]):
116-
"""Run ``fn(list[item]) -> list[result]`` on a dedicated thread, coalescing
117-
concurrent ``submit()`` calls into cost-bounded batches.
122+
"""Run ``fn(list[item], target_bucket) -> list[result]`` on a dedicated thread,
123+
coalescing concurrent ``submit()`` calls into cost-bounded batches.
118124
119125
Args:
120-
fn: Batched work; one result per item, in order. Runs on the worker thread.
126+
fn: Batched work; one result per item, in order. Called as
127+
``fn(items, target_bucket)`` on the worker thread — ``target_bucket``
128+
is the ladder rung to pad to (``None`` in eager mode).
121129
max_batch_cost: Max summed ``cost`` of a single ``fn`` batch (>= 1).
122130
``None`` (default) ⇒ **pass-through**: no cap — the whole drained set
123-
runs as one ``fn`` call (``cost`` ignored).
131+
runs as one ``fn`` call (``cost`` ignored). With ``buckets`` set,
132+
``None`` derives the ceiling as ``max(buckets)``.
133+
buckets: Optional sorted graph ladder. When set, the batcher rounds a
134+
batch's ``sum(cost)`` up to the nearest rung and passes it as
135+
``target_bucket``; ``None``/empty ⇒ eager (``target_bucket=None``).
124136
max_wait_ms: Opt-in coalescing hold after the first item arrives. Default
125137
``0`` ⇒ eager drain-on-completion (no timer).
126138
on_start: Optional callable run once on the worker thread before serving
127-
(model build / warmup); its failure surfaces from ``start()``.
139+
(model build / warmup / graph capture); its failure surfaces from
140+
``start()``.
128141
on_stop: Optional callable run once on the worker thread at teardown (after
129142
the serving loop ends), iff ``on_start`` succeeded. Its failure is
130143
logged, never raised.
@@ -136,9 +149,10 @@ class ThreadedMicroBatcher(Generic[T, R]):
136149

137150
def __init__(
138151
self,
139-
fn: Callable[[List[T]], List[R]],
152+
fn: Callable[[List[T], Optional[int]], List[R]],
140153
*,
141154
max_batch_cost: Optional[int] = None,
155+
buckets: Optional[Sequence[int]] = None,
142156
max_wait_ms: float = 0.0,
143157
on_start: Optional[Callable[[], None]] = None,
144158
on_stop: Optional[Callable[[], None]] = None,
@@ -150,6 +164,11 @@ def __init__(
150164
raise ValueError("max_batch_cost must be >= 1 (or None for pass-through)")
151165
if max_outstanding_cost is not None and max_outstanding_cost < 1:
152166
raise ValueError("max_outstanding_cost must be >= 1")
167+
self._buckets = self._validate_buckets(buckets, max_batch_cost)
168+
# Graph mode needs a bounded ceiling, so derive it from the ladder when the
169+
# author left it None (pass-through is only meaningful in eager mode).
170+
if self._buckets is not None and max_batch_cost is None:
171+
max_batch_cost = self._buckets[-1]
153172
self._fn = fn
154173
self._max_batch_cost = max_batch_cost
155174
self._max_wait_s = max_wait_ms / 1000.0
@@ -173,6 +192,37 @@ def __init__(
173192
self._live: set[_Request] = set()
174193
self._thread: Optional[threading.Thread] = None
175194

195+
@staticmethod
196+
def _validate_buckets(
197+
buckets: Optional[Sequence[int]], max_batch_cost: Optional[int]
198+
) -> Optional[tuple]:
199+
"""Normalise the ladder to a sorted tuple of positive ints (or None).
200+
201+
When ``max_batch_cost`` is set, the ladder must cover it so every packed
202+
batch has a rung to round up to: ``max(buckets) >= max_batch_cost``. When
203+
it is ``None``, the ceiling is derived as ``max(buckets)`` by the caller."""
204+
if not buckets:
205+
return None
206+
rungs = tuple(sorted(int(b) for b in buckets))
207+
if any(b < 1 for b in rungs):
208+
raise ValueError("buckets must be positive ints")
209+
if max_batch_cost is not None and rungs[-1] < max_batch_cost:
210+
raise ValueError(
211+
f"max(buckets)={rungs[-1]} < max_batch_cost={max_batch_cost}; the "
212+
"ladder must cover the dispatch ceiling (set max_batch_cost to "
213+
"max(buckets) for a graphed encoder)"
214+
)
215+
return rungs
216+
217+
def _target_bucket(self, batch_cost: int) -> Optional[int]:
218+
"""Round a batch's summed cost up to the nearest ladder rung (None=eager)."""
219+
if self._buckets is None:
220+
return None
221+
for rung in self._buckets: # sorted ascending
222+
if rung >= batch_cost:
223+
return rung
224+
return self._buckets[-1] # unreachable: max(buckets) >= max_batch_cost
225+
176226
# ---- lifecycle ---------------------------------------------------------
177227

178228
def start(self) -> None:
@@ -457,8 +507,9 @@ def _run_batch(self, batch: List[_Work]) -> None:
457507
)
458508
return
459509
items = [w.item for w in runnable]
510+
target_bucket = self._target_bucket(sum(w.cost for w in runnable))
460511
try:
461-
results = self._fn(items)
512+
results = self._fn(items, target_bucket)
462513
except (
463514
BaseException
464515
) as exc: # noqa: BLE001 — a bad batch must not hang awaiters

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,14 @@ def test_preprocess_concurrency_must_be_positive():
174174

175175

176176
def test_load_reaps_pool_if_batcher_ctor_fails():
177-
"""A backend exposing a max_batch_cost the batcher rejects must not leak the pool."""
177+
"""A backend exposing a ladder the batcher rejects must not leak the pool."""
178178

179-
class _BadBudget(_FakeBackend):
180-
max_batch_cost = 0 # ThreadedMicroBatcher requires >= 1 → ctor raises
179+
class _BadBuckets(_FakeBackend):
180+
max_batch_cost = 8
181+
buckets = [2, 4] # max(buckets) < max_batch_cost → batcher ctor raises
181182

182-
enc = AsyncVisionEncoder(_BadBudget())
183-
with pytest.raises(ValueError, match="max_batch_cost"):
183+
enc = AsyncVisionEncoder(_BadBuckets())
184+
with pytest.raises(ValueError, match="ladder must cover"):
184185
enc.load("m")
185186
assert enc._pool is not None and enc._pool._shutdown is True
186187
assert enc._batcher is None

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

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
"""Unit tests for dynamo.vllm.multimodal_utils.threaded_micro_batcher.
55
66
Pin the execution contract: on_start + every fn call (+ on_stop) run on one
7-
dedicated thread (so CUDA-graph capture/replay share a thread), concurrent submits
8-
coalesce into cost-bounded batches up to max_batch_cost (or pass-through when
9-
None), eager-drain pulls all queued work when free, errors reach every awaiting
10-
caller, and the shutdown lifecycle behaves.
7+
dedicated thread (so CUDA-graph capture/replay share a thread), concurrent
8+
submits coalesce into cost-bounded same-bucket batches, the graph ladder rounds a
9+
batch's cost up to a rung (target_bucket), eager-drain pulls all queued work when
10+
free, errors reach every awaiting caller, and the shutdown lifecycle behaves.
1111
12-
``fn`` is ``fn(items)``; ``cost`` is a precomputed scalar that rides on
13-
``submit(items, costs)`` (one-dimensional packing — no bucket_key, no ladder).
12+
``fn`` is ``fn(items, target_bucket)``; ``cost`` is a precomputed scalar that rides
13+
on ``submit(items, costs)`` (one-dimensional packing — no bucket_key).
1414
"""
1515

1616
import asyncio
@@ -32,16 +32,17 @@
3232
]
3333

3434

35-
def _echo(items):
35+
def _echo(items, target_bucket=None):
3636
return list(items)
3737

3838

3939
class _Recorder:
40-
"""fn that records the threads it ran on and the batches it received."""
40+
"""fn that records the threads it ran on and the batches / target_buckets it saw."""
4141

4242
def __init__(self):
4343
self.threads: list[int] = []
4444
self.batches: list[list] = []
45+
self.target_buckets: list = []
4546
self.start_thread: int | None = None
4647
self.stop_thread: int | None = None
4748

@@ -51,9 +52,10 @@ def on_start(self):
5152
def on_stop(self):
5253
self.stop_thread = threading.get_ident()
5354

54-
def fn(self, items):
55+
def fn(self, items, target_bucket=None):
5556
self.threads.append(threading.get_ident())
5657
self.batches.append(list(items))
58+
self.target_buckets.append(target_bucket)
5759
return [("r", x) for x in items]
5860

5961

@@ -117,7 +119,7 @@ async def test_eager_drain_pulls_all_queued_when_free():
117119
release = threading.Event()
118120
batches: list[list] = []
119121

120-
def fn(items):
122+
def fn(items, target_bucket=None):
121123
batches.append(list(items))
122124
if "block" in items:
123125
entered.set()
@@ -155,9 +157,48 @@ async def test_cost_budget_caps_each_batch():
155157
b.shutdown()
156158

157159

160+
async def test_target_bucket_rounds_packed_cost_up_to_nearest_rung():
161+
"""Graph mode: the batcher rounds a batch's sum(cost) up to the nearest rung
162+
and passes it as target_bucket."""
163+
rec = _Recorder()
164+
b = ThreadedMicroBatcher(
165+
rec.fn, max_batch_cost=8, buckets=[2, 4, 8], max_wait_ms=200.0
166+
)
167+
b.start()
168+
try:
169+
# One coalesced batch of cost 3 → rounds up to rung 4.
170+
await b.submit(["x", "y", "z"], costs=[1, 1, 1])
171+
assert rec.batches == [["x", "y", "z"]]
172+
assert rec.target_buckets == [4]
173+
finally:
174+
b.shutdown()
175+
176+
177+
async def test_eager_mode_passes_none_target_bucket():
178+
rec = _Recorder()
179+
b = ThreadedMicroBatcher(rec.fn) # buckets=None ⇒ eager
180+
b.start()
181+
try:
182+
await b.submit(["a"])
183+
assert rec.target_buckets == [None]
184+
finally:
185+
b.shutdown()
186+
187+
188+
def test_buckets_below_max_batch_cost_rejected():
189+
with pytest.raises(ValueError, match="ladder must cover"):
190+
ThreadedMicroBatcher(_echo, max_batch_cost=8, buckets=[2, 4])
191+
192+
193+
def test_buckets_derive_max_batch_cost_when_none():
194+
"""Graph mode with no explicit budget derives the ceiling from the ladder."""
195+
b = ThreadedMicroBatcher(_echo, buckets=[2, 4, 8]) # max_batch_cost=None
196+
assert b._max_batch_cost == 8
197+
198+
158199
async def test_max_batch_cost_none_is_passthrough():
159200
"""Default (max_batch_cost=None): no cap and no per-item ceiling — the whole
160-
drained set runs as ONE fn call regardless of summed cost."""
201+
drained same-bucket group runs as ONE fn call regardless of summed cost."""
161202
rec = _Recorder()
162203
b = ThreadedMicroBatcher(rec.fn, max_wait_ms=200.0) # max_batch_cost=None
163204
b.start()
@@ -166,12 +207,13 @@ async def test_max_batch_cost_none_is_passthrough():
166207
out = await b.submit([1, 2, 3, 4], costs=[1000, 1000, 1000, 1000])
167208
assert len(out) == 4
168209
assert rec.batches == [[1, 2, 3, 4]] # one un-split batch
210+
assert rec.target_buckets == [None] # eager (no ladder)
169211
finally:
170212
b.shutdown()
171213

172214

173215
async def test_error_reaches_every_caller():
174-
def boom(items):
216+
def boom(items, target_bucket=None):
175217
raise ValueError("boom")
176218

177219
b = ThreadedMicroBatcher(boom, max_wait_ms=50.0)
@@ -186,7 +228,7 @@ def boom(items):
186228

187229

188230
async def test_wrong_result_count_raises():
189-
b = ThreadedMicroBatcher(lambda items: [], max_wait_ms=10.0)
231+
b = ThreadedMicroBatcher(lambda items, target_bucket=None: [], max_wait_ms=10.0)
190232
b.start()
191233
try:
192234
with pytest.raises(RuntimeError, match="one result per item"):
@@ -233,7 +275,7 @@ async def test_shutdown_fails_queued_items():
233275
entered = threading.Event()
234276
release = threading.Event()
235277

236-
def blocking(items):
278+
def blocking(items, target_bucket=None):
237279
entered.set()
238280
release.wait(timeout=5.0)
239281
return [("r", x) for x in items]
@@ -275,7 +317,7 @@ async def test_cancelled_submit_is_retired_and_releases_admission():
275317
entered = threading.Event()
276318
release = threading.Event()
277319

278-
def blocking(items):
320+
def blocking(items, target_bucket=None):
279321
entered.set()
280322
release.wait(timeout=5.0)
281323
return [("r", x) for x in items]
@@ -309,7 +351,7 @@ async def test_max_outstanding_cost_rejects_when_full():
309351
entered = threading.Event()
310352
release = threading.Event()
311353

312-
def blocking(items):
354+
def blocking(items, target_bucket=None):
313355
entered.set()
314356
release.wait(timeout=5.0)
315357
return [("r", x) for x in items]
@@ -379,7 +421,7 @@ async def test_partial_batch_failure_fails_request_once_and_releases():
379421
later sibling item is tombstoned — it never reaches fn."""
380422
seen: list = []
381423

382-
def fn(items):
424+
def fn(items, target_bucket=None):
383425
seen.extend(items)
384426
if "bad" in items:
385427
raise ValueError("boom")
@@ -407,7 +449,7 @@ async def test_no_fn_after_shutdown_for_collected_items():
407449
release = threading.Event()
408450
seen: list = []
409451

410-
def fn(items):
452+
def fn(items, target_bucket=None):
411453
seen.extend(items)
412454
if "a" in items:
413455
entered.set()

0 commit comments

Comments
 (0)