[Perf][Multimodal] Sort multimodal encoder inputs by modality (#60196)

Signed-off-by: Jaebok Lee <jaebok9541@naver.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
zzaebok
2026-10-09 10:55:36 +08:00
committed by GitHub
co-authored by mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
parent 295c062db8
commit 702ce313f4
5 changed files with 112 additions and 27 deletions
+16 -1
View File
@@ -78,7 +78,7 @@ def test_encoder_graph_unsupported_modality_uses_eager_output():
"vllm.v1.worker.gpu.mm.encoder_runner.group_and_batch_mm_kwargs",
return_value=[("audio", 1, {"audio_values": torch.zeros(2)})],
):
result = runner.execute_mm_encoder([])
result = runner.execute_mm_encoder([("audio", MagicMock())])
assert len(result) == 1
assert result[0] is output
manager.execute.assert_not_called()
@@ -290,6 +290,21 @@ def test_execute_mm_encoder_caches_outputs_without_gathering():
state.encoder_runner.gather_mm_embeddings.assert_not_called()
def test_execute_mm_encoder_sorts_by_modality():
"""Items are encoded sorted by modality; outputs keep the input order."""
mm_kwargs = [("video", 0), ("image", 1), ("video", 2), ("image", 3)]
runner = _make_runner([], [])
runner.model = MagicMock()
runner.model.embed_multimodal.side_effect = lambda x: [torch.full((1, 1), x)]
with patch(
"vllm.v1.worker.gpu.mm.encoder_runner.group_and_batch_mm_kwargs",
side_effect=lambda items, **_: [(m, 1, {"x": x}) for m, x in items],
) as group:
outputs = runner.execute_mm_encoder(mm_kwargs)
assert group.call_args.args[0] == [mm_kwargs[i] for i in (1, 3, 0, 2)]
assert [int(o) for o in outputs] == [0, 1, 2, 3]
def test_execute_mm_encoder_is_a_noop_without_scheduled_items():
"""A step that schedules no encoder input must not touch the encoder."""
cache = EncoderCache()
+64
View File
@@ -624,6 +624,70 @@ def test_set_active_mm_loras_builds_tower_and_connector_mappings():
assert connector_mapping.index_mapping == ((7,) * 14 + (7,) * 13 + (0,) * 12)
def test_set_active_mm_loras_follows_modality_sorted_encoder_order():
model = Mock()
model.get_mm_lora_token_counts.side_effect = (
lambda *, modality, mm_kwargs, num_mm_embeds: (num_mm_embeds, None)
)
lora_manager = Mock()
lora_manager.supports_tower_connector_lora.return_value = True
encoder_cache = EncoderCache()
for req_id, modality, length in [("req-a", "video", 1), ("req-b", "image", 2)]:
encoder_cache.mm_features[req_id] = [
MultiModalFeatureSpec(
data=None,
modality=modality,
identifier=f"{req_id}-0",
mm_position=PlaceholderRange(offset=0, length=length),
)
]
lora_state = LoraState(max_num_reqs=2)
lora_request = LoRARequest("vision-lora", 7, "/tmp/vision-lora")
lora_state.add_request("req-a", 0, lora_request)
lora_state.add_request("req-b", 1, None)
set_active_mm_loras(
model=model,
lora_manager=lora_manager,
encoder_cache=encoder_cache,
req_id_to_index={"req-a": 0, "req-b": 1},
lora_state=lora_state,
scheduled_encoder_inputs={"req-a": [0], "req-b": [0]},
)
# The image is encoded before the video, so its tokens come first.
_, tower_mapping = lora_manager.set_active_adapters.call_args_list[0].args
assert tower_mapping.index_mapping == (0, 0, 7)
def test_batch_mm_inputs_from_scheduler_sorts_by_modality():
features = {
f"req{i}": MultiModalFeatureSpec(
data=Mock(),
modality=m,
identifier=f"hash{i}",
mm_position=PlaceholderRange(offset=i, length=1),
)
for i, m in enumerate(["video", "image", "video"])
}
runner = SimpleNamespace(
requests={r: SimpleNamespace(mm_features=[f]) for r, f in features.items()}
)
scheduler_output = SimpleNamespace(
scheduled_encoder_inputs={r: [0] for r in features}
)
mm_hashes, mm_kwargs, mm_lora_refs = GPUModelRunner._batch_mm_inputs_from_scheduler(
runner, scheduler_output
)
order = ["req1", "req0", "req2"]
assert mm_hashes == [features[r].identifier for r in order]
assert mm_kwargs == [(features[r].modality, features[r].data) for r in order]
assert mm_lora_refs == [(r, features[r].mm_position) for r in order]
def test_update_states_new_request(model_runner, dist_init):
req_id = "req_0"
+5 -2
View File
@@ -150,9 +150,12 @@ class EncoderRunner:
def execute_mm_encoder(
self, mm_kwargs: list[tuple[str, MultiModalKwargsItem]]
) -> list[torch.Tensor]:
# Stable-sort items by modality so each modality is encoded in as few
# batches as possible. The returned outputs are restored to input order.
order = sorted(range(len(mm_kwargs)), key=lambda i: mm_kwargs[i][0])
encoder_outputs: list[torch.Tensor] = []
for modality, num_items, mm_kwargs_batch in group_and_batch_mm_kwargs(
mm_kwargs, device=self.device, pin_memory=PIN_MEMORY
[mm_kwargs[i] for i in order], device=self.device, pin_memory=PIN_MEMORY
):
cg_manager = self.cudagraph_manager
cudagraph_output = (
@@ -169,7 +172,7 @@ class EncoderRunner:
)
sanity_check_mm_encoder_outputs(batch_outputs, expected_num_items=num_items)
encoder_outputs.extend(batch_outputs)
return encoder_outputs
return [encoder_outputs[i] for i in np.argsort(order)]
@contextmanager
def timed_encoder_operation(self, request_ids: Collection[str]):
+19 -16
View File
@@ -6,6 +6,7 @@ import numpy as np
from vllm.lora.layers import LoRAMapping, LoRAMappingType
from vllm.lora.worker_manager import WorkerLoRAManager
from vllm.multimodal.inputs import MultiModalFeatureSpec
from vllm.v1.worker.gpu.lora_utils import LoraState
from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
@@ -31,7 +32,7 @@ def set_active_mm_loras(
encoder_token_counts: list[int] = []
connector_token_counts: list[int] = []
# iterate through images
mm_items: list[tuple[int, MultiModalFeatureSpec]] = []
for req_id, encoder_input_ids in scheduled_encoder_inputs.items():
req_idx = req_id_to_index.get(req_id)
if req_idx is None:
@@ -39,27 +40,29 @@ def set_active_mm_loras(
lora_id = int(lora_state.lora_ids[req_idx])
mm_features = encoder_cache.mm_features[req_id]
# iterate through visual tokens
for mm_input_id in encoder_input_ids:
pos_info = mm_features[mm_input_id].mm_position
tower_tokens, connector_tokens = model.get_mm_lora_token_counts(
modality=mm_features[mm_input_id].modality,
mm_kwargs=mm_features[mm_input_id].data,
num_mm_embeds=pos_info.get_num_embeds(),
)
prompt_lora_mapping.append(lora_id)
token_lora_mapping.extend([lora_id] * tower_tokens)
encoder_token_counts.append(tower_tokens)
connector_token_counts.append(connector_tokens)
mm_items.extend((lora_id, mm_features[i]) for i in encoder_input_ids)
if lora_id > 0:
lora_request = lora_state.lora_requests.get(req_id)
if lora_request is not None:
lora_requests.add(lora_request)
# Runs items sorted by modality.
mm_items.sort(key=lambda x: x[1].modality)
for lora_id, mm_feature in mm_items:
pos_info = mm_feature.mm_position
tower_tokens, connector_tokens = model.get_mm_lora_token_counts(
modality=mm_feature.modality,
mm_kwargs=mm_feature.data,
num_mm_embeds=pos_info.get_num_embeds(),
)
prompt_lora_mapping.append(lora_id)
token_lora_mapping.extend([lora_id] * tower_tokens)
encoder_token_counts.append(tower_tokens)
connector_token_counts.append(connector_tokens)
if not prompt_lora_mapping:
return
+8 -8
View File
@@ -2929,7 +2929,14 @@ class GPUModelRunner(
mm_kwargs.append((mm_feature.modality, mm_feature.data))
mm_lora_refs.append((req_id, mm_feature.mm_position))
return mm_hashes, mm_kwargs, mm_lora_refs
# Stable-sort by modality so each modality is encoded in as few batches
# as possible. Encoder outputs are cached by mm_hash, so order is free.
order = sorted(range(len(mm_kwargs)), key=lambda i: mm_kwargs[i][0])
return (
[mm_hashes[i] for i in order],
[mm_kwargs[i] for i in order],
[mm_lora_refs[i] for i in order],
)
def _cache_encoder_output(
self,
@@ -2990,13 +2997,6 @@ class GPUModelRunner(
and scheduler_output.scheduled_encoder_inputs
)
# Batch mm inputs as much as we can: if a request in the batch has
# multiple modalities or a different modality than the previous one,
# we process it separately to preserve item order.
# FIXME(ywang96): This is a hacky way to deal with multiple modalities
# in the same batch while still being able to benefit from batching
# multimodal inputs. The proper solution should be reordering the
# encoder outputs.
model = cast(SupportsMultiModal, self.model)
if self.lora_config and self.lora_manager.supports_tower_connector_lora():