[None][feat] Enforce multimodal encoder runtime budgets with budgeted output storage - #16051
Open
yechank-nvidia wants to merge 67 commits into
Open
[None][feat] Enforce multimodal encoder runtime budgets with budgeted output storage#16051yechank-nvidia wants to merge 67 commits into
yechank-nvidia wants to merge 67 commits into
Conversation
yechank-nvidia
commented
Jul 9, 2026
2ez4bz
reviewed
Jul 9, 2026
2ez4bz
reviewed
Jul 10, 2026
yechank-nvidia
force-pushed
the
multimodal-encoder-runtime-scheduling
branch
from
July 10, 2026 09:14
cb85bc0 to
f2242dd
Compare
2ez4bz
reviewed
Jul 15, 2026
yechank-nvidia
force-pushed
the
multimodal-encoder-runtime-scheduling
branch
from
July 15, 2026 09:17
c552067 to
9b9ee3f
Compare
2ez4bz
reviewed
Jul 15, 2026
yechank-nvidia
force-pushed
the
multimodal-encoder-runtime-scheduling
branch
3 times, most recently
from
July 20, 2026 08:15
8daa248 to
602b402
Compare
yechank-nvidia
marked this pull request as ready for review
July 20, 2026 08:26
yechank-nvidia
requested review from
QiJune,
arysef,
chienchunhung and
pcastonguay
July 20, 2026 08:26
…ding an arg Drop the 'del max_num_tokens' line and say in the docstring why the default ignores it: it sizes from the item budget, and encoders that split one item across several sequences override this and do use the token budget. Also record that the returned keys name each encoder's own attention metadata objects -- one 'attention' here, 'full_attention' and 'window_attention' for a windowed encoder -- so there is no fixed superset to promote to a TypedDict. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
The argument already names the destination at every call site, so the suffix
was not carrying its weight:
request.py_mm_encoder_state.finalize(request.py_multimodal_data)
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Per-item partial cache hits (NVIDIA#16817) landed the same primitives this branch grew independently. Converge on the upstream ones and generalize them where item scheduling needs more than the full-request path does. Cache lookup: the executor's hand-rolled hit/miss loop becomes `partition_encoder_cache`, which gains two parameters so it can serve both callers without regressing either: - `item_indices` scopes the lookup. Probing an item the budget cannot encode this iteration would still refresh its LRU recency and reorder eviction against items actually in flight, so the scheduler passes only what it selected. `EncoderCachePartition.looked_up` records the scope, and the full-hit/full-miss predicates read it rather than `keys`. - `keys` lets the caller supply precomputed keys. The executor builds params from `py_multimodal_data` alone while the content hashes live on the `LlmRequest`, so param-derived keys are not available there. Key derivation: `_encoder_cache_keys` now delegates to `build_encoder_cache_item_keys` when the request carries atomic-item metadata, taking each key's modality from its `item_refs` entry. This removes the "mixed-modality params are not cacheable" limitation for both paths -- that metadata is exactly what the comment said was missing. Slicing: drop this branch's `_build_multimodal_encoder_input` for upstream's `build_multimodal_encoder_input` (three layouts including audio, sibling-field slicing) plus `_apply_metadata_slice`. It takes an optional `modality` so a caller that already knows an item's modality can slice one item out of an interleaved request; the residual now keeps only that modality's payload, since leaving a sibling's unsliced tensors on it makes the residual look mixed-modality to `_lengths_by_modality`. Adds junction tests. The two features each had coverage but nothing ran them together, and the pairing has real edges: the metadata re-slice is load-bearing for `encode_multimodal_by_groups`, which splits encoder output by `multimodal_embedding_lengths` and yields zero rows without it. `MultimodalEncoderRequestState.record` deliberately does not move to `assemble_full_embedding`. That helper builds the buffer from a complete item dict; items here arrive across iterations, and holding per-item tensors until the request completes would pin each contributing encoder batch instead of copying into the single pre-sized buffer. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Two entry points were building the same thing: a request's per-item encoder outputs copied into one exact-size contiguous buffer. `assemble_full_embedding` took every item at once for the full-request path; `MultimodalEncoderRequestState` filled the same buffer incrementally for item scheduling, which receives items across iterations. Same buffer, same validation, two APIs. Keep the incremental owner, since it is the one that cannot be expressed in terms of the other, and add a classmethod for the all-at-once case that routes through `record`. Callers keep the name they had; the mixin's copy is deleted. This also drops the single-item shortcut that returned the item tensor unchanged. Its inputs are cache entries, and `TensorLRUCache.get` documents its return as an alias of the cache-owned tensor, so a one-item request ended up sharing storage with the cache: eviction could no longer free those bytes, and the cache's accounting said otherwise. `record` already copied for exactly this reason, so the unified path inherits it. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
`record()` located each item's row range with `sum(self.embedding_lengths[:item_idx])`, re-adding the prefix on every call and making a request's assembly quadratic in its item count. The declared lengths are fixed at construction, so accumulate the offsets once in `__post_init__`. This matters more than it did: folding `assemble_full_embedding` into this class routed the full-request encoder path through `record` too, so both callers paid it. `resident_output_bytes` reuses the same total rather than summing again. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
`prepare_multimodal_encoder_inputs` invoked the raw-tensor slicer once per selected item. For the packed layout that slicer splits the request's entire pixel payload and concatenates the chosen pieces, so per-item calls re-split the payload N times and `torch.cat` copies each item separately -- 67 ms per 8-item request at Qwen2.5-VL 1024x1024 shapes, against 0.03 ms for the direct row-range slice this replaced. Batch adjacent same-request, same-modality items into one call, which is what the plural `item_indices` parameter is for, and take a view instead of concatenating when the indices are a contiguous run. Both callers benefit: a scheduler picks items in order, and cache misses cluster. Back to 0.032 ms per request, with one slicer call instead of eight and no payload copy. `prepare_multimodal_encoder_inputs` now returns per-tuple length lists; `forward_multimodal_encoder_items` still emits one tensor per item, so the engine contract is unchanged. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Two host-side costs repeated work whose inputs are fixed at admission. `_runs_by_request_modality` called `get_multimodal_encoder_item_metadata` once per selected item, and each call re-validates the whole record. At 32 requests x 8 items that is 256 calls, ~460 us per iteration. Fetch it when the request changes instead, and yield it with the run so `prepare_multimodal_encoder_inputs` reuses it rather than fetching again -- 256 calls become 32, no new state. `get_mm_encoder_item_keys` rebuilt a request's keys on every iteration that scheduled any of its items. The keys derive from the content hashes and item metadata, both fixed at admission, so memoize them on the encoder request state, which has exactly that lifetime. `None` is a real result -- the request cannot participate in the cache -- so an `_UNSET` sentinel separates it from "not computed yet"; without that an unkeyable request would retry every iteration, the case the second test pins. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
yechank-nvidia
force-pushed
the
multimodal-encoder-runtime-scheduling
branch
from
August 10, 2026 02:33
0c73853 to
79c5886
Compare
Collaborator
Author
|
/bot run --disable-fail-fast |
Collaborator
|
PR_Github #64923 [ run ] triggered by Bot. Commit: |
Collaborator
|
PR_Github #64923 [ run ] completed with state
|
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
Collaborator
Author
|
/bot run --disable-fail-fast |
Collaborator
|
PR_Github #65236 [ run ] triggered by Bot. Commit: |
Collaborator
|
PR_Github #65236 [ run ] completed with state
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Multimodal encoder work is unbounded at runtime today: the scheduler admits on
LLM/KV capacity alone, so one iteration can submit more encoder items — or more
encoder attention tokens — than the workspace was sized for, and the embeddings
held between encode and prefill accumulate with no cap. KV-cache sizing at
startup does not know any of it exists.
This PR makes encoder execution a scheduled, budgeted resource:
encoder_max_num_items×encoder_max_num_tokensareenforced per iteration over atomic items (one image or video), selected FCFS;
a request left partial resumes later. The executor's encoder step is the
single encode site.
prefill. A request's first scheduled item allocates one contiguous buffer for
all of its items, so a started request can always finish; KV estimation
reserves that budget at startup. A request that could never fit is rejected at
admission with a message naming the knob, instead of becoming a CUDA OOM.
multimodal_config.encoder_scheduling_policyselectsDEFAULT,EAGER(advance encoder work for capacity-rejected requests), or
DISABLED(legacyinline encode).
Models: Qwen2-VL / Qwen2.5-VL, Qwen3-VL (deepstack-widened rows), Mistral3 /
Pixtral.
Measurements
Qwen3-VL-8B-Instruct, 1×H200,
aiperf, n=8 per policy, policy the onlydifference and
kv_cache_config.max_tokenspinned equal. Caches off.Availability — past the headroom, removing the cap does not degrade: it
loses essentially every request and the server process dies.
DISABLEDDEFAULTReproduced on two hosts. At 105 GiB
DISABLEDis non-deterministic.Peak memory —
DEFAULThas zero variance across repetitions and barelymoves as the workload gets 7× heavier (136.1 / 135.9 / 135.1 GiB at 1 / 4 / 7
images per request);
DISABLEDtracks the traffic (131.7 → 138.0 → 138.1).Latency — a trade, and only under heavy load:
At 7 images both are real (non-overlapping ranges).
DEFAULTraises the fastITL percentiles and lowers the slow ones (p50 +17%, p99 −2.0%) — spreading
encoder work across iterations. Quote both halves or neither.
Test coverage
test_multimodal_scheduler.py(atomic packing, byte-budgetallocate-before-compute, whole-request charging, admission fail-fast, per-item
cache read-through, contiguous-buffer ownership, per-rank resolution under PP),
test_kv_cache_estimation.py(encoder profiled at its own budget; reservationof unmaterialized capacity),
test_modeling_qwen2_5vl.py/test_modeling_mistral.py(capacity from processor geometry, window countsmatching the encoder's padding, dummy tensors satisfying the encoder contract),
test_scheduler_serializable_output.py(item schedule survives rankdistribution).
Follow-ups
Unify the remaining full-request consumers (side-stream prefetch,
mm_encoder_only/ disagg, non-item models) onto the item path andsingle-source the item manifest; TODO markers are anchored at the migration
sites.
Dev Engineer Review
DEFAULT,EAGER, andDISABLEDscheduling policies.encoder_max_batch_sizewithencoder_max_num_itemsacross APIs, configuration, telemetry, manifests, and documentation.setup_attn_metadatacallers, budget calculations, admission behavior, cache reuse, and partial-progress handling.QA Engineer Review
get_dummy_mm_data_for_tokenstests with coverage forget_dummy_mm_data.tests/integration/test_lists/were modified.test-db/orqa/based on the provided changes.