Repository navigation
[TRTLLM-13419][refactor] Batch multimodal encoder inputs by item partitions - #21052
Draft
yechank-nvidia wants to merge 5 commits into
Draft
yechank-nvidia wants to merge 5 commits into
yechank-nvidia wants to merge 5 commits into
Conversation
yechank-nvidia
force-pushed
the
yechank/mm-common-encoder-batching
branch
3 times, most recently
from
November 5, 2026 04:28
eb64b08 to
7832960
Compare
Problem: Cross-request batching of multimodal encoder inputs is written per model: each model packs its own payload fields for one encoder call. The input processors already declare how items occupy those fields (MultimodalItemMetadata.input_partitions), which item scheduling and the encoder cache use to cut items out. The same declarations are enough to batch the items of several requests, so the packing can be shared. The shared path has to stay cheap on the host, because a batch that a model already encodes in one call gains nothing from it. A draft that batched each key with helper calls per payload made 474 Python calls for 16 Qwen2.5-VL image requests where main makes 14, and added 0.118 ms of host time per batch, most of it before the first tower call, which the GPU waits for. Solution: - batch_by_item_partitions batches pieces of one field (whole requests or single items) by their partitions, the inverse of select_multimodal_fields. A shared value (item_axis=None) is returned once. Other pieces are concatenated along the item axis; tensors whose other dims differ are padded at the end of those dims with the partition's padding_value, a new MultimodalItemPartition field (zero, False for bool, by default; item selection keeps it). The returned partition lists every batched item, so select gets each piece back. - build_batched_input_by_partitions is the common builder. How a key is batched (item axis, padding value) is part of the partition that the input processor declares, so it is decided once and travels with the data. The builder gathers every declared key in one pass over the payloads (each modality of the group, then each request) and batches each key once. Only declared keys reach the encoder. A key that several modalities declare batches their items in modality order. - The input checks stay, without a helper call per payload: the payloads of a key must share item axis and padding value, a packed value's size must match its item lengths, item tensors must share dtype and rank (checked before torch.cat, which also accepts a 1-D empty tensor next to tensors of another rank), and the item record must be a MultimodalItemMetadata. - EncoderGroup.build_batched_input is optional. When it is None, the group binds the common builder for its modalities. A group that passes its own builder works as before. - encode_multimodal_by_groups: with the common builder, payloads whose shared value or tensor dtype for a key differs (for example raw uint8 pixels next to normalized ones), or whose keys differ within one modality, are encoded in separate calls, and the rows are put back in the group layout. Normally this is one call: one check per key over the gathered payloads finds it, and only a batch that has to split is looked at payload by payload. The prompt-order restore reads the item layouts without a helper call per request. Groups with their own builder keep one call, since their payloads may not declare partitions. For every group the encoder rows must now match the item metadata; otherwise the encode raises MultimodalEncoderContractError instead of slicing silently. No model uses the common builder yet; models move to it in the following commits. Performance: This commit alone: not measured, since no model uses the common builder yet. Host side (screening, not a formal benchmark): one GB200 GPU on a shared node; the towers are replaced by stubs that only allocate their outputs, so the time is the host work of one batched encode plus its copy kernels, with a CUDA synchronize before and after; payloads are built and moved to the GPU outside the timer; 5 warm-up and 60 timed encodes per process, 2 processes per side, median of the 120 samples; warm means no gc.collect between encodes, cold means gc.collect before each. Baseline: upstream main bae7da4. With the following model commits applied, the three controls, whose tower-call count does not change, read (main -> this change): - Qwen2.5-VL, 16 image requests: Python calls 14 -> 32; host time 0.072 -> 0.125 ms warm (+0.053 ms), 0.328 -> 0.468 ms cold (+0.140 ms) - Qwen3-VL, 16 image requests: Python calls 116 -> 37; host time 0.141 -> 0.166 ms warm (+0.025 ms), 0.484 -> 0.532 ms cold (+0.048 ms) - Gemma4, 8 image requests: Python calls 21 -> 26; host time 0.081 -> 0.098 ms warm (+0.016 ms), 0.412 -> 0.428 ms cold (+0.016 ms) Kernel launches are the same as main and no encode makes a stream synchronize. The draft above made 474, 472 and 384 Python calls for these controls and added 0.118, 0.081 and 0.184 ms warm against its baseline (upstream main 66ce99b). Between two runs of this pass, main's own warm time for a control moved by up to 0.021 ms, so differences of that size are run-to-run variation. The input checks are cheap: in a CPU microbenchmark of the host path up to the encoder call for 16 Qwen image requests, they take 7.6 us of 38.1 us. Real-tower timings are in the model commits. Validation: - New CPU tests in tests/unittest/inputs/test_multimodal.py, next to the select tests: batching shared values, one row per item, packed lengths, ragged and bool padding, a ragged middle piece, a padding value, a non-leading item axis, one piece, lists, select returning each piece, whole requests mixed with single items, item selection keeping padding values, and the errors, including a 1-D empty piece between 2-D pieces. - New CPU tests in tests/unittest/_torch/multimodal/test_encoder_group.py: the common builder's modality-then-request order, declared keys only, a key shared by image and video, a padding value reaching the encoder, payloads that disagree on how a key is batched, a payload without partitions, a packed value whose size does not match its item lengths and an item record of another type (both rejected by the builder and by the grouped encode); one call for compatible payloads with prompt order restored, one call for equal records held by different requests, separate calls for different shared values and dtypes, own builders getting the original params in one call, and the row-count check with one call and with split calls. - Multimodal CPU suite at this commit (-m cpu_only over tests/unittest/_torch/multimodal, the multimodal model test files and tests/unittest/inputs/test_multimodal.py): 1877 passed, 77 skipped, 4 failed; main bae7da4: 1843 / 77 / 4. No test changed status and none was removed; the 34 new tests pass. The 4 failures also fail on main (Nemotron Nano V2 VL input processor, checkpoint missing on the test machine). The CPU grouping tests that carry no cpu_only marker, run with all markers: no status change at any commit of the series. The test machine's native build is older than main; six operators it lacks are left unregistered, and no selected test uses them. - pre-commit hooks pass on the changed files. - Reproduce: pytest -q -m cpu_only tests/unittest/inputs/test_multimodal.py tests/unittest/_torch/multimodal/test_encoder_group.py - GPU and end-to-end serving: not run for this commit alone, since no model's encode path changes in it. Signed-off-by: Yechan Kim <161688079+yechank-nvidia@users.noreply.github.com>
Problem: Gemma4 packs its encoder inputs in encode_multimodal_inputs. It splits the batch into runs of adjacent requests with the same modality and calls itself once per run. Within a run it normalizes raw pixels per payload, batches the image or video payloads for one tower call, and runs the audio tower once per request. The processors already declare the partitions of these payload keys for item scheduling, so the common partition builder can do the packing. Vision payloads with different patch counts need padding, and patch positions use -1 to mark padding. Padding them with zeros and restoring the -1 afterwards needs the per-frame lengths on the GPU: in a draft, that restore made one pageable host-to-device copy and one stream synchronize per vision call (8 image requests: 2 copies, 1 synchronize and 5 kernel launches, against 1, 0 and 2 on its baseline, upstream main 66ce99b, with the towers stubbed). Solution: - Gemma4 and Gemma4 Unified use three EncoderGroups with the common builder: image, video and audio. The vision encoder normalizes raw uint8 patches with the processor's record after batching and keeps the rule that per-frame lengths are used only when their count matches the frames. The audio encoder takes the batched features and masks. The per-run recursion and the packing are removed; requests with more than one modality are still rejected. - The processors also declare vision_normalization as a shared value. When they build item metadata, they fill the per-item inputs a processor may omit (an all-valid audio mask, square-grid patch positions and their lengths), so payloads of different lengths can be padded into one call. Patch positions declare the processors' -1 padding sentinel as their padding value, so batching itself marks added patches as padding: the vision encoder needs no restore pass, no host-to-device copy and no synchronize for it. Declaring partitions at run time leaves the payload unchanged. - The profiling dummy carries item metadata with output rows and partitions. Gemma4 Unified counts the output rows of its items when a request has no item metadata yet (no multimodal hashing). Behaviour: - Image-only and video-only batches of adjacent requests give the tower the same input as before, so their outputs are unchanged. - Requests of one modality that are not adjacent in the batch share one call per modality instead of one call per run. - The audio clips of all requests go to one padded, masked tower call instead of one call per request. Outputs can differ from before only in floating-point order. - Raw uint8 pixels next to normalized pixels, or different normalization records, are encoded in one call per compatible set instead of one call, and the rows are put back in prompt order. This happens when some requests pass mm_processor_kwargs (host normalization) and others do not while GPU normalization is on. - Vision payloads with different patch counts can share a call (before, batching them failed). Performance: Screening, not a formal benchmark: one GB200 GPU on a shared node, bf16, TP=1, reduced random-weight Gemma4 towers (26B vision widths with 4 layers, default audio tower with 12 layers, TRTLLM attention). Baseline: upstream main bae7da4. Payloads come from the real image, video and audio processors. Each round runs 3 warm-up and then 15 timed batched encodes of all requests, one at a time; the timer wraps the whole encode with a CUDA synchronize before and after, and leaves out building the payloads and moving them to the GPU. Two rounds (main, this change, main, this change); each value is that round's median, as before -> after; difference; change in elapsed time. This is neither a kernel-only nor a serving-throughput number. - 8 audio requests of one 1-30 s clip each (8 audio calls before, one call of [8, 2707, 128] features after): round 1: 192.55 -> 30.88 ms; -161.67 ms; -84.0% round 2: 193.32 -> 30.92 ms; -162.40 ms; -84.0% - 12 requests cycling one image, one 6 s audio clip and one 4-frame video (12 calls before; after, 3 calls: images [4, 2520, 768], video frames [16, 630, 768], audio [4, 599, 128]): round 1: 149.63 -> 41.76 ms; -107.87 ms; -72.1% round 2: 151.54 -> 41.07 ms; -110.47 ms; -72.9% - 8 single-image requests, raw uint8 (GPU normalization) and host normalized alternating (one call of [8, 2520, 768] before, two calls of [4, 2520, 768] after): round 1: 15.16 -> 16.44 ms; +1.29 ms; +8.5% round 2: 15.21 -> 16.48 ms; +1.26 ms; +8.3% - 8 single-image requests (one call of [8, 2520, 768] in both): round 1: 14.85 -> 14.89 ms; +0.05 ms; +0.3% round 2: 14.85 -> 14.91 ms; +0.07 ms; +0.4% With the towers stubbed (method in the first commit), the 8-image control makes 2 kernel launches as main does, no copies (main: 1) and no stream synchronize; Python calls 21 -> 26, host time 0.081 -> 0.098 ms warm (+0.016 ms). With only the tower bodies stubbed (wrapper and embedder live, cold), the per-call work goes away with the calls: 8 audio requests take 4.063 -> 1.332 ms of host time and the 12 mixed requests 6.397 -> 2.368 ms. Validation: - New CPU tests against a copy of the previous encode path: per-request and batched equality and call counts for single and mixed batches, including non-adjacent requests, selected items, the image, video and audio profiling dummies, raw and normalized pixel splits, ragged vision with explicit or implicit positions, ragged audio with and without masks, and the Gemma4 Unified wrappers and output-row counting. Vision inputs equal the previous ones (values, dtype, device); a reduced random-weight audio tower matches per-request outputs within 1e-5 (fp32). A runtime partition lookup leaves the payload unchanged. - Multimodal CPU suite at this commit (-m cpu_only over tests/unittest/_torch/multimodal, the multimodal model test files and tests/unittest/inputs/test_multimodal.py): 1950 passed, 77 skipped, 4 failed; main bae7da4: 1843 / 77 / 4. No test changed status and none was removed; the 107 new tests pass. The 4 failures also fail on main (Nemotron Nano V2 VL input processor, checkpoint missing on the test machine). - GPU, reduced random-weight Gemma4 towers and Gemma4 Unified front end (bf16, TRTLLM attention, TF32 off), payloads from the real processors, 18 cases: every request alone equals the previous output bitwise; batched image, video, raw uint8, split and non-adjacent batches equal the previous batched output bitwise, except Unified non-adjacent image requests, which now share one call and stay within the bf16 floating-point-order bound. Batched audio is within that bound (rel L2 8.2e-3); in fp32 with TF32 off it differs from per-request encoding by rel L2 1.6e-6, so the difference is floating-point order only. - GPU, test_gemma4_multimodal.py with all markers (whole series applied): 170 passed, 20 skipped, 0 failed (main: 146 / 20 / 0); no test changed status; its ragged-vision sentinel tests run on CPU only. - pre-commit hooks pass on the changed files. - Reproduce: pytest -q -m cpu_only tests/unittest/_torch/modeling/test_gemma4_multimodal.py - End-to-end serving and real-checkpoint accuracy: not measured. Exclusive-node formal performance and TP > 1: not measured. GPU validation used reduced random-weight towers. Signed-off-by: Yechan Kim <161688079+yechank-nvidia@users.noreply.github.com>
Problem: Qwen3-VL packs its encoder inputs in a private builder, _qwen3vl_build_batched_input. It concatenates the image rows, then the video rows, of all requests and normalizes raw uint8 pixels per payload with the processor's record; Cosmos3 Edge imports the same builder. The Qwen processor already declares how items occupy these payload keys (packed patch rows, one grid row per item) for item scheduling, so the common partition builder can do the packing. Solution: - The Qwen vision partitions also declare vision_normalization as a shared value, so the record reaches the encoder and payloads with different records are encoded apart. - Qwen3VisionModelBase (Qwen3-VL, Qwen3-VL-MoE, Qwen3.5-VL, Qwen4Exp, Cosmos3) and Cosmos3 Edge use the common builder. Their encode_batched takes the payload keys, normalizes raw uint8 keys with the record and concatenates the image and video rows, image rows first, for one ViT call. The private builder is removed. Behaviour: - Batches whose payloads agree in pixel dtype and normalization record (the normal case) make one ViT call with the same input as before, so the outputs are unchanged. - A batch that mixes raw uint8 and normalized pixels of one modality, or different normalization records, is encoded in one call per compatible set instead of one call, and the rows are put back in prompt order. Its outputs can differ from before only in floating-point order. This happens when some requests pass mm_processor_kwargs (host normalization) and others do not while GPU normalization is on. - A call that holds both images and videos copies the pixel rows once more, to concatenate the two payload keys. Performance: Screening, not a formal benchmark: one GB200 GPU on a shared node, bf16, TP=1, a reduced random-weight Qwen3-VL tower (8 layers of width 1152, patch size 16, TRTLLM attention). Baseline: upstream main bae7da4. 16 image requests of one image each, patch grid (1, 32, 32), 1024 patch rows per image. Each round runs 3 warm-up and then 15 timed batched encodes of all requests, one at a time; the timer wraps the whole encode with a CUDA synchronize before and after, and leaves out building the payloads and moving them to the GPU. Two rounds (main, this change, main, this change); each value is that round's median, as before -> after; difference; change in elapsed time. This is neither a kernel-only nor a serving-throughput number. - All images normalized (one ViT call of 16384 rows in both): round 1: 10.08 -> 10.15 ms; +0.07 ms; +0.6% round 2: 10.14 -> 10.20 ms; +0.06 ms; +0.6% - Raw uint8 and normalized images alternating (one call of 16384 rows before, two calls of 8192 rows after): round 1: 10.41 -> 15.65 ms; +5.24 ms; +50.3% round 2: 10.49 -> 15.85 ms; +5.36 ms; +51.1% The alternating batch pays for the second ViT call. With the tower stubbed (method in the first commit), host time is 0.141 -> 0.166 ms warm (+0.025 ms) for the normalized control and 0.362 -> 0.378 ms warm (+0.016 ms) for the alternating batch, which makes 10 -> 6 kernel launches since raw pixels are normalized once per call instead of once per payload. Validation: - New CPU tests in test_modeling_qwen3vl.py against copies of the removed builder and the previous encode_batched, with item metadata from the Qwen processor: the ViT input and the output are equal for one image, image only, video only, mixed and video-first batches, with raw uint8 and normalized pixels, for selected-item views and for the profiling dummy; batched output equals per-request output; different records split the call; the encoders take exactly the declared keys. In test_modeling_cosmos3_edge.py, Cosmos3 Edge gets the same ViT input as before. The shared Qwen request helpers are defined in test_modeling_qwen3vl.py. - Multimodal CPU suite at this commit (-m cpu_only over tests/unittest/_torch/multimodal, the multimodal model test files and tests/unittest/inputs/test_multimodal.py): 1896 passed, 77 skipped, 4 failed; main bae7da4: 1843 / 77 / 4. No test changed status and none was removed; the 53 new tests pass. The 4 failures also fail on main (Nemotron Nano V2 VL input processor, checkpoint missing on the test machine). - GPU, reduced random-weight Qwen3-VL tower (bf16, TRTLLM attention, TF32 off), 13 cases: per-request and batched rows equal the previous code's bitwise for normalized and raw uint8 pixels, including a batch encoded in two calls. - GPU, test_modeling_qwen3vl.py and test_modeling_cosmos3_edge.py with all markers (whole series applied): 516 passed, 39 skipped, 8 failed (main: 482 / 39 / 8); no test changed status; the 8 failures (test_qwen3_vision_cuda_graph_replay_matches_eager with the FLASHINFER backend) fail the same way on main on the test machine. - pre-commit hooks pass on the changed files. - Reproduce: pytest -q -m cpu_only tests/unittest/_torch/modeling/test_modeling_qwen3vl.py tests/unittest/_torch/modeling/test_modeling_cosmos3_edge.py - End-to-end serving and real-checkpoint accuracy: not measured. Exclusive-node formal performance and TP > 1: not measured. GPU validation used reduced random-weight towers. Signed-off-by: Yechan Kim <161688079+yechank-nvidia@users.noreply.github.com>
Problem: Qwen2-VL, Qwen2.5-VL and Exaone4.5 pack their encoder inputs in a private helper that concatenates the image rows and the video rows of all requests (or passes one request's tensors through). The encoder then calls the ViT once for the images and once for the videos and returns two tensors, image rows first. A batch that holds both images and videos therefore hands its callers two modality-major tensors. The encoder-only runner rejects such a batch. The aggregated path rejects it unless it has exactly two requests, and then pairs the tensors with the requests by position, so a video request before an image request gets the image rows. Solution: The Qwen processor already declares the partitions of these payload keys, so Qwen2VisionModelBase now uses one image and video EncoderGroup with the common partition builder, as Qwen3-VL does. Its encode_batched concatenates the image and video rows, image rows first, and runs the ViT once; the ViT tells items apart only by their grid rows. forward returns the shared encode result. Exaone4.5 inherits the encoder. The private helper is removed. Behaviour: - Image-only and video-only batches give the ViT the same input as before, so their outputs are unchanged. A single request's pixels are copied once, as the other batch sizes already were. - Batches with both images and videos make one ViT call instead of two, so their outputs can differ only in floating-point order. They now reach the language model and the encoder-only runner as one tensor in request order, then prompt order, so these batches are served correctly. Performance: Screening, not a formal benchmark: one GB200 GPU on a shared node, bf16, TP=1, a reduced random-weight Qwen2.5-VL tower (8 layers of width 1280, patch size 14, TRTLLM attention). Baseline: upstream main bae7da4. 16 requests of one item each; an image has patch grid (1, 32, 32) (1024 patch rows), a video (4, 24, 24) (2304 patch rows). Each round runs 3 warm-up and then 15 timed batched encodes of all requests, one at a time; the timer wraps the whole encode with a CUDA synchronize before and after, and leaves out building the payloads and moving them to the GPU. Two rounds (main, this change, main, this change); each value is that round's median, as before -> after; difference; change in elapsed time. This is neither a kernel-only nor a serving-throughput number. - 8 image and 8 video requests alternating (two ViT calls of 8192 and 18432 rows before, one call of 26624 rows after): round 1: 18.79 -> 15.85 ms; -2.94 ms; -15.6% round 2: 18.84 -> 15.87 ms; -2.96 ms; -15.7% - 16 image requests (one call of 16384 rows in both): round 1: 10.80 -> 10.84 ms; +0.04 ms; +0.4% round 2: 10.80 -> 10.87 ms; +0.07 ms; +0.7% With the tower stubbed (method in the first commit), host time is 0.072 -> 0.125 ms warm (+0.053 ms) for the image control and 0.083 -> 0.177 ms warm (+0.093 ms) for the mixed batch: main returned the two modality tensors as they were, while the common path checks the item layouts and puts the rows in request order. Validation: - New CPU tests in test_modeling_qwen3vl.py, next to the Qwen3-VL ones and with the same requests, against copies of the removed helper and the previous forward, with item metadata from the Qwen processor: one ViT call whose input equals the previous image input followed by the previous video input, for one image, image only, video only, mixed and video-first batches and selected-item views; one output tensor equal to per-item encoding in prompt order, exactly with a stub ViT and within 1e-5 with a tiny random-weight Qwen2-VL ViT, and equal to the previous output for single-modality batches; the profiling dummy encodes through the group; the Qwen2-VL and Exaone4.5 encoders take exactly the declared keys. - Multimodal CPU suite at this commit (-m cpu_only over tests/unittest/_torch/multimodal, the multimodal model test files and tests/unittest/inputs/test_multimodal.py): 1911 passed, 77 skipped, 4 failed; main bae7da4: 1843 / 77 / 4. No test changed status and none was removed; the 68 new tests pass. The 4 failures also fail on main (Nemotron Nano V2 VL input processor, checkpoint missing on the test machine). - GPU, reduced random-weight Qwen2.5-VL tower (bf16, TRTLLM attention, TF32 off), 6 cases: per-request and batched rows equal the previous code's bitwise, also with images and videos in one call. - GPU, test_modeling_qwen3vl.py and test_modeling_qwen2vl.py with all markers (whole series applied): 515 passed, 36 skipped, 8 failed (main: 483 / 36 / 8); no test changed status; the 8 failures (test_qwen3_vision_cuda_graph_replay_matches_eager with the FLASHINFER backend) fail the same way on main on the test machine. - pre-commit hooks pass on the changed files. - Reproduce: pytest -q -m cpu_only tests/unittest/_torch/modeling/test_modeling_qwen3vl.py -k qwen2_vision - End-to-end serving and real-checkpoint accuracy: not measured. Exclusive-node formal performance and TP > 1: not measured. GPU validation used reduced random-weight towers. Exaone4.5 was not run with its own vision weights; it shares the Qwen2.5-VL encoder class. Signed-off-by: Yechan Kim <161688079+yechank-nvidia@users.noreply.github.com>
…ions Problem: Mistral3 and Pixtral pack their encoder inputs in _vision_forward: it pads every request's pixel_values to the largest image_sizes of the batch with batch_pixel_values and runs the Pixtral tower once. The processors declare no input partitions, so item scheduling and the encoder cache cut selected images with the legacy selector, which also crops the trailing height and width. Solution: - The HF and native processors (MistralPixtralEncoderContract) declare pixel_values and image_sizes with one row per image. The profiling dummy carries the same item metadata (output rows and partitions). - Mistral3VLM uses one image EncoderGroup with the common partition builder. Its encoder takes the batched pixel_values and image_sizes, crops the pixels to the largest image size of the call, as batch_pixel_values did, and runs the unchanged tower and projector. _vision_forward is removed; batch_pixel_values stays for the native processor. - image_sizes stay on the host (multimodal_data_device_paths), so the crop reads them without a device sync. - A comment in the legacy item selector no longer names Mistral, which now declares its partitions. Behaviour: - A batch gives the tower the same input as before in one call, so the outputs are unchanged, also for selected-item views. - Payloads whose pixel dtype differs are encoded in separate calls, and their rows are put back in request order. Performance: Not measured: the tower call and its input are unchanged. Validation: - New CPU tests against a copy of the previous packer, with stub tower and projector: the tower input equals the previous one (values, dtype, device) for ragged image sizes given as lists or tensors, for selected and reordered items and for the HF and native profiling dummies; batched output equals per-request output; different dtypes split the call. The profiling-dummy contract test now drives the encoder group, which also checks the output rows against the item metadata. - Multimodal CPU suite at this commit (-m cpu_only over tests/unittest/_torch/multimodal, the multimodal model test files and tests/unittest/inputs/test_multimodal.py): 1926 passed, 77 skipped, 4 failed; main bae7da4: 1843 / 77 / 4. No test changed status and none was removed; the 83 new tests pass. The 4 failures also fail on main (Nemotron Nano V2 VL input processor, checkpoint missing on the test machine). - GPU, reduced random-weight Pixtral tower and projector (bf16, TRTLLM attention, TF32 off), 8 cases: per-request and batched rows equal the previous code's bitwise, with image sizes as lists and as tensors. - GPU, test_modeling_mistral.py with all markers (whole series applied): 52 passed, 0 skipped, 0 failed (main: 37 / 0 / 0); no test changed status. - pre-commit hooks pass on the changed files. - Reproduce: pytest -q -m cpu_only tests/unittest/_torch/modeling/test_modeling_mistral.py; the dummy contract test runs without the marker: tests/unittest/_torch/modeling/test_modeling_mistral.py::test_dummy_mm_data_satisfies_the_encoder_input_contract - End-to-end serving and real-checkpoint accuracy: not measured. Exclusive-node formal performance and TP > 1: not measured. GPU validation used reduced random-weight towers. Signed-off-by: Yechan Kim <161688079+yechank-nvidia@users.noreply.github.com>
yechank-nvidia
force-pushed
the
yechank/mm-common-encoder-batching
branch
from
November 11, 2026 05:09
7832960 to
4c70ba0
Compare
yechank-nvidia
force-pushed
the
yechank/mm-common-encoder-batching
branch
from
November 11, 2026 06:06
4c70ba0 to
2ea1188
Compare
Collaborator
|
Could you please rebase this branch onto the latest upstream |
1 task done
This branch has not been deployed
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.
@coderabbitai summary
Description
Why: When the multimodal encoder runs several requests together, each model packs its own encoder inputs across requests in its own code. These copies drift apart: some models call the tower once per request or per run of requests, and Qwen2-VL could not serve image and video requests in one batch. The input processors already declare how every item sits in its payload fields (
MultimodalItemMetadata.input_partitions), and item scheduling and the encoder cache already use these declarations to cut items out. The same declarations are enough to batch the items of several requests. So this PR adds one common batching path that is driven by those partitions and is the inverse of item selection, and moves the core models to it.What:
batch_by_item_partitions(pieces)intensorrt_llm/inputs/multimodal.py, next toselect_multimodal_fields. It batches the pieces of one payload key (whole requests or single items) by their partitions. A shared value (item_axis=None) is returned once. Other pieces are concatenated along the item axis, and other dims that differ are padded at their end with the partition'spadding_value, a newMultimodalItemPartitionfield (zero,Falsefor bool, by default; item selection keeps it). The returned partition lists every batched item, soselectgets each piece back.build_batched_input_by_partitions. How a key is batched (item axis, padding value) is part of the partition that the input processor declares, so it is decided once and travels with the data. One pass over the payloads (each modality of the group, then each request) gathers every declared key, one check per key decides whether the payloads can share a call, and each key is batched once. Only declared keys reach the encoder, as keyword arguments named after the payload keys.MultimodalItemMetadata.EncoderGroup.build_batched_inputis now optional. Without it, the group binds the default builder, so a model writesEncoderGroup(("image", "video"), self.encode_batched). A model whose packing is different still passes its own builder, and that path works as before.encode_multimodal_by_groupsputs payloads in separate encoder calls when a shared value (such as a normalization record) or a tensor dtype differs, or when payloads of one modality give different keys, and then puts the rows back in prompt order. Normally there is one call. For every group, the encoder must now return as many rows as the item metadata expects; otherwise the encode raisesMultimodalEncoderContractErrorinstead of slicing silently.Models moved in this PR (one commit each): the Qwen3-VL family (Qwen3-VL, Qwen3-VL-MoE, Qwen3.5-VL, Qwen4Exp, Cosmos3) and Cosmos3 Edge; Qwen2-VL, Qwen2.5-VL and Exaone4.5; Mistral3 and Pixtral; Gemma4 and Gemma4 Unified. Each model drops its own packing code and declares every key its encoder needs (for example
vision_normalizationas a shared value).Behaviour changes:
mm_processor_kwargs. Batches that agree on pixel dtype and normalization record, the normal case, still make one call with the same input as before.image_sizeson the host (multimodal_data_device_paths), so its encoder crops the pixels without a device sync.Compatibility: Python only. No C++ change and no new config field, environment variable or CLI flag.
EncoderGroupkeeps its fields and their order, and groups that pass their ownbuild_batched_inputwork as before.MultimodalItemPartitiongains the optional third fieldpadding_value(default 0), so existing partitions keep their meaning.Note for earlier reviewers: this PR was rewritten. The earlier version added a new
EncoderGroup.input_fieldsmapping and moved every model at once. This version reuses the partition declarations that are already on main and moves only the core models.Performance
Screening only, not a formal benchmark: one GB200 GPU on a shared node, bf16, TP=1, TRTLLM attention, random-weight towers with real widths and fewer layers (Qwen2.5-VL: 8 layers of width 1280; Qwen3-VL: 8 layers of width 1152; Gemma4: 26B vision widths with 4 layers and the default audio tower with 12 layers). Baseline: upstream main
bae7da432c. Each round runs 3 warm-up and then 15 timed batched encodes of all requests, one at a time. The timer wraps the whole encode with a CUDA synchronize before and after, and leaves out building the payloads and moving them to the GPU. Two rounds per side (main, PR, main, PR); each cell shows the round's median time, main → PR, and the change. Qwen images have patch grid (1, 32, 32) and Qwen2.5-VL videos (4, 24, 24); Gemma4 payloads come from the real image, video and audio processors.The gain comes where the number of tower calls drops: Gemma4 audio -84%, the mixed Gemma4 batch -72% and the mixed Qwen2.5-VL batch -16% (round 1). When the call count does not change (the controls), the PR adds at most +0.07 ms per batch (+0.7%). A batch that mixes raw and normalized pixels pays for one extra call. Mistral3 was not timed, because its call count does not change. End-to-end serving was not measured.
Host work of the common path, measured with the towers replaced by stubs that only allocate their outputs (120 warm encodes per side, main → PR): Qwen2.5-VL control: 14 → 32 Python calls, 0.072 → 0.125 ms (+0.053 ms); Qwen3-VL control: 116 → 37 Python calls, 0.141 → 0.166 ms (+0.025 ms); Gemma4 control: 21 → 26 Python calls, 0.081 → 0.098 ms (+0.016 ms). Kernel launches are the same as main, no encode makes a stream sync, and the Gemma4 control makes no copies (main: 1). Between two runs of this pass, main's own time for a control moved by up to 0.021 ms, so differences of that size are run-to-run variation. The input checks take 7.6 us of the 38.1 us host path for 16 Qwen image requests (CPU microbenchmark).
Test Coverage
tests/unittest/inputs/test_multimodal.py(-m cpu_only):batch_by_item_partitionsnext to the select tests: shared values, one row per item, packed lengths, ragged and bool padding, padding values, a non-leading item axis, one piece, lists, select returning each piece, whole requests mixed with single items, and the errors (146 tests, 18 new).tests/unittest/_torch/multimodal/test_encoder_group.py(-m cpu_only): default builder order and declared keys, padding values, packed sizes, the item record type, one call for equal records, split calls for different shared values, dtypes and keys, own builders getting the original params in one call, and the row-count check.test_modeling_qwen3vl.py(Qwen3-VL, Qwen2-VL / Qwen2.5-VL and the shared Qwen requests),test_modeling_cosmos3_edge.py,test_modeling_mistral.pyandtest_gemma4_multimodal.py, all undertests/unittest/_torch/modeling/.-m cpu_onlyovertests/unittest/_torch/multimodal, the multimodal model test files andtests/unittest/inputs/test_multimodal.py): 1950 passed, 77 skipped, 4 failed, against 1843 / 77 / 4 on main. No test changed status. The 4 failures are the Nemotron Nano V2 VL input-processor tests that fail on main too because the checkpoint is missing on the test machine. Every commit of the PR passes the same check on its own.Follow-up: the other multimodal models (for example Gemma3, LLaVA-NeXT, Llama4, Kimi VL, GLM5 Next, Nemotron-H, Phi-4 multimodal, MiniCPM-V, MiniMax-M3, Step3p7, HyperCLOVA X, DeepSeek-V4.1 and MuseSpark) keep their current code in this PR and move to the default builder in later PRs.
PR Checklist
Please review the following before submitting your PR:
PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.
PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.
Test cases are provided for new code paths (see test instructions)
If PR introduces API changes, an appropriate PR label is added - either
api-compatibleorapi-breaking. Forapi-breaking, includeBREAKINGin the PR title.Any new dependencies have been scanned for license and vulnerabilities
CODEOWNERS updated if ownership changes
Documentation updated as needed
Update tava architecture diagram if there is a significant design change in PR.
The reviewers assigned automatically/manually are appropriate for the PR.
Please check this after reviewing the above items as appropriate for this PR.
GitHub Bot Help
To see a list of available CI bot commands, please comment
/bot help.