Skip to content

feat(multimodal): ThreadedMicroBatcher + batcher-backed AsyncVisionEncoder (cross-request batching)#11037

Open
furionw wants to merge 21 commits into
qiwa/partial-embed-assemblyfrom
qiwa/vision-encoder-batcher
Open

feat(multimodal): ThreadedMicroBatcher + batcher-backed AsyncVisionEncoder (cross-request batching)#11037
furionw wants to merge 21 commits into
qiwa/partial-embed-assemblyfrom
qiwa/vision-encoder-batcher

Conversation

@furionw

@furionw furionw commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Based on #10832.

Why

#10832 drives the custom encoder serially — one encode() call's images at a time on the actor thread, with no cross-request coalescing — so under concurrent load the encoder GPU sits idle between requests. This change swaps AsyncVisionEncoder's internals for a ThreadedMicroBatcher that coalesces images from concurrent encode() calls into batched forward_batch calls, while keeping the public surface (load / encode / get_image_placeholder_token_id / shutdown) unchanged — so the worker integration from #10832 doesn't move. Batching is eager by default (drain whatever is queued whenever the worker is free, no timer); passing max_batch_cost instead caps each batch by a summed per-item cost budget.

What Change

  • Add ThreadedMicroBatcher (generic, torch-free): one dedicated worker thread runs build / every fn / close; eager batching by default, optional cost-bounded micro-batching; exactly-once per-request finalization; a crash supervisor never hangs an awaiter.
  • Swap AsyncVisionEncoder internals to the batcher; its public surface and the preprocess-atomicity barrier are unchanged.

Test Plan

  • Batcher concurrency + batcher-backed glue unit tests, plus a concurrency stress harness (coalescing, shutdown-under-load, concurrent double-start) and an agg_custom e2e that splices to "42" under a 30-way burst.
  • pytest components/src/dynamo/vllm/tests/multimodal_utils/

🤖 Generated with Claude Code

@furionw furionw temporarily deployed to external_collaborator June 29, 2026 04:53 — with GitHub Actions Inactive
@github-actions github-actions Bot added feat backend::vllm Relates to the vllm backend multimodal labels Jun 29, 2026
@datadog-official

This comment has been minimized.

@furionw furionw force-pushed the qiwa/partial-embed-assembly branch from 768e5c8 to 90cc67e Compare June 29, 2026 19:42
@furionw furionw force-pushed the qiwa/vision-encoder-batcher branch from fa42f16 to 4be49c4 Compare June 29, 2026 19:44
@furionw furionw force-pushed the qiwa/partial-embed-assembly branch from 90cc67e to 1c5be47 Compare June 30, 2026 07:04
@furionw furionw force-pushed the qiwa/vision-encoder-batcher branch from 4be49c4 to 3c1f269 Compare June 30, 2026 07:11
@furionw furionw force-pushed the qiwa/partial-embed-assembly branch from 1c5be47 to ada3d9a Compare June 30, 2026 16:55
@furionw furionw force-pushed the qiwa/vision-encoder-batcher branch from 3c1f269 to 729398f Compare June 30, 2026 16:58
@furionw furionw force-pushed the qiwa/partial-embed-assembly branch from ada3d9a to b3a7c13 Compare June 30, 2026 17:08
@furionw furionw force-pushed the qiwa/vision-encoder-batcher branch from 729398f to 4f361b7 Compare June 30, 2026 17:08
@furionw furionw force-pushed the qiwa/vision-encoder-batcher branch from 4f361b7 to b86a07f Compare June 30, 2026 17:27
@furionw furionw force-pushed the qiwa/partial-embed-assembly branch from b3a7c13 to 5677c09 Compare June 30, 2026 18:09
@furionw furionw force-pushed the qiwa/vision-encoder-batcher branch from b86a07f to 7d148e3 Compare June 30, 2026 18:15
furionw added 2 commits June 30, 2026 22:01
…IXL/SHM path

Reword the confusing 'would otherwise silently win' aside: the point is simply
that a configured encoder owns the image path, so route there instead of the
normal NIXL/SHM pre-render + HF path.
@furionw furionw force-pushed the qiwa/partial-embed-assembly branch from d8118b4 to 1c6d658 Compare July 1, 2026 05:02
furionw and others added 7 commits June 30, 2026 22:07
…er path

In _assemble_custom_encoder_prompt, non-image modalities are already
rejected earlier, so when no usable image URLs remain there is nothing
left to load — _extract_multimodal_data can only walk empty lists and
return an empty/None result. Return (None, None, None) directly for the
text-only case and drop the now-unused mm_processor_kwargs parameter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
image_urls is image_items filtered to dicts with a 'Url' key, so the old
`image_items and not image_urls` check only fired when ALL items were
malformed — a mixed list (some 'Url', some 'Decoded'/malformed) slipped
through and was encoded with the bad items silently dropped. Compare
lengths instead: reject the request if any item lacks a usable 'Url'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…coder (cross-request batching)

PR3 of the custom vision-encoder series, stacked on PR2. Replaces the serial
direct-call glue with cross-request batching to the author's max_batch_cost token
budget (scalar cost, one-dimensional packing — no shape buckets, no graph ladder;
that lands in PR4). handlers.py is unchanged — the AsyncVisionEncoder public
surface is the stable boundary.

- ThreadedMicroBatcher (generic, torch-free): one pinned non-daemon actor thread
  running on_start (build) / every fn(items) / on_stop (close); eager
  drain-on-completion (no timer; max_wait_ms is an opt-in knob); coalesce by
  scalar cost up to max_batch_cost (None = pass-through). Per-item idempotent
  finalizer; cancellation + repeat-cancel retirement; worker supervisor; optional
  max_outstanding_cost admission; cross-loop-safe shutdown. submit(items, costs).
- AsyncVisionEncoder now routes preprocessed items through the batcher (was a
  single-worker serial executor); the A5 preprocess barrier is unchanged; build is
  build(model_id) and the hardcoded image_token_id is validated at load.

Tests: batcher concurrency suite (eager-drain, cost budget, pass-through,
cancellation/retirement, supervisor, lifecycle, on_stop) + batcher-backed glue.

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>
…Batcher

shutdown() returned early when a slow fn was still in flight and relied on a
later call to reap — but the worker already self-exits once its fn returns and
reads the stop sentinel. Make the drain sentinel-safe (re-put it instead of
consuming it) so shutdown can finalize in one linear pass: signal, bounded join,
mark closed, drain; warn if a slow fn is still finishing (it exits on its own).
…cher (TODO)

Cancellation added real surface — a per-request `cancelled` flag + `retired`
future, a CancelledError/shield/repeat-cancel block in submit(), and cancelled
checks in tombstoning/finalisation — for an optimization, not correctness. Drop
it for v0 with a TODO: a cancelled await now abandons its result but its items
still run through fn (admission frees when they finish). Safe (Python refcounting
keeps the items alive; the abandoned future no-ops via InvalidStateError).
Failure tombstoning (a sibling batch's error skips a request's remaining items)
is kept — that's request atomicity, not cancellation.
…ckpressure

- Merge CLOSING/CLOSED into one CLOSED state. Nothing branched on the difference
  (submit, the shutdown idempotency guard, and _run_batch all treated them the
  same); only a bookkeeping transition distinguished them.
- Drop the unused max_outstanding_cost admission ceiling and its machinery
  (_outstanding, cost_total, BatcherOverloaded): AsyncVisionEncoder never sets
  it, so the check never fired. TODO left to add real backpressure if a producer
  can outrun the encoder. on_start/on_stop stay -- they pin build/close to the
  actor thread (CUDA-graph affinity).
furionw and others added 3 commits June 30, 2026 23:07
Codex-review round 1 (behaviour-preserving simplifications):
- Collapse the startup handshake — a ready Event, a start-error field, and a
  write-only _terminated Event — into one concurrent.futures.Future that
  start() blocks on and re-raises from.
- Fix a start() thread leak: a retry after a failed thread spawn passed the
  old `_thread is None` guard and spawned a second, orphaned worker. Guard on
  state (reject any non-NEW) instead.
- Drop the unreachable post-join queue re-drain in shutdown() (admission is
  already closed and the queue drained under the lock) and inline the now
  single-use _consume_all helper.
- Drop a dead logging import/logger in async_vision_encoder and a redundant
  buckets=None override (matches the ABC default) in the fake test backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…path

Codex-review round 2 (behaviour-preserving simplifications):
- Remove the _NO_RESULT sentinel: bare _consume() only ever accounts an
  already-tombstoned item (error set → result write skipped), and result
  slots default to None, so a real None result is indistinguishable — default
  result=None and assign whenever no error is set.
- Drop the always-true `if batch` guard on the final flush in _dispatch (live
  is non-empty and every work is appended after any mid-loop flush).
- Stop _drain_queue_locked from re-queueing _SHUTDOWN: both callers own the
  stop signal (shutdown() re-enqueues one; the crash supervisor drains as the
  sole worker exits), so a seen sentinel is stale — drop it and keep draining.
- Build the batcher before the preprocess pool in load(), so a config the
  batcher ctor rejects fails before any pool is spawned (nothing to reap);
  update the corresponding test.
- Drop the write-only _Recorder.batches field in the batcher tests.

Kept _Request.done (not folded into remaining==0): _abort() sets done with
remaining>0, so it distinguishes a force-aborted request from a cleanly
completed one — an explicit finalized-flag, not redundant state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex-review round 3:
- Fix a concurrent double-start race the round-1 guard change opened: `_state`
  stays NEW while the first start() waits on on_start, so a second start() could
  pass a state-only guard, spawn a second worker, and race on _started. Guard on
  both `_thread is not None` and non-NEW state (covers double-start AND
  failed-spawn retry).
- shutdown() now snapshots `_thread` under the lock instead of reading it
  unlocked, so it can't race a concurrent start()'s publish.
- encode(): drop the `errors` list comprehension and the `list(settled)` copy —
  scan settled in order, raise the first exception, and alias (not copy) the
  already-returned list.
- Strengthen the passthrough test to assert the raws reach forward_batch
  unchanged and in order (count+shape alone missed substitution/reorder).
- Simplify the module header: dedicated worker thread, eager batching by
  default, optional micro-batching when max_batch_cost is given.

Kept _State.FAILED as an explicit terminal state (not folded into CLOSED +
_terminal_error): "shut down" and "worker crashed" are distinct conditions and
the enum keeps the state machine legible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@furionw furionw marked this pull request as ready for review July 1, 2026 06:42
@furionw furionw requested review from a team as code owners July 1, 2026 06:42
The existing test_double_start_raises only covers a sequential second start().
Add a concurrent one: two threads race into start(), asserting exactly one wins
and on_start runs once (no second worker). Guards the fix for the race where
_state stays NEW while the first start() blocks on on_start, so the guard must
also key on _thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

furionw and others added 2 commits July 1, 2026 00:00
Codex round 4: after the concurrent-start guard, only the winning start() ever
sets RUNNING, so the post-start "already RUNNING → skip" arm is unreachable —
reject non-NEW, then set RUNNING. Likewise submit()'s `if RUNNING: pass` is
vacuous — check FAILED, then reject non-RUNNING, else proceed. Behavior
unchanged (unit + concurrency stress green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex round 5: the timing-based 40-run version could pass a buggy state-only
guard if the first start() reached RUNNING before the second was scheduled.
Park the winner inside on_start (via an event) so `_state` is provably still NEW
when the loser races in, and assert the loser is rejected while the winner is
still parked — forcing the exact double-start window every run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@furionw furionw force-pushed the qiwa/partial-embed-assembly branch from 99e2a74 to 0509d8a Compare July 6, 2026 20:00
@furionw furionw requested review from a team as code owners July 6, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant