A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 2, 02:15 UTC
[Perf] Improve multimodal item handling from O(n) to O(log n) per step (#44212)
Signed-off-by: Andy Lo <andy@mistral.ai>
Andy Lo committed
Jun 3, 2026 at 12:00 UTC
95b1615ec97dac31e670ed71e5cd2f3134d67ba2
6 files changed
+92
-44
tests/v1/core/test_encoder_cache_manager.py
+30
@@ -43,6 +43,7 @@ def test_basic_allocate_and_reuse():
43
44
assert cache.check_and_update_cache(req, 0)
45
assert "r1" in cache.cached["imgA"]
46
+ assert cache.request_cached_ids["r1"] == {0}
47
assert cache.num_free_slots == 6
48
49
# Free twice to bring refcount to 0.
@@ -50,6 +51,7 @@ def test_basic_allocate_and_reuse():
51
cache.free_encoder_input(req, 0)
52
53
assert not cache.cached["imgA"]
54
+ assert "r1" not in cache.request_cached_ids
55
assert "imgA" in cache.freeable
56
assert cache.num_freeable_slots == 10
57
assert cache.num_free_slots == 6
@@ -63,10 +65,12 @@ def test_freeing_decreases_refcount_and_moves_to_freeable():
65
manager.allocate(req, 0)
66
67
assert len(manager.cached["img3"]) == 1
68
+ assert manager.request_cached_ids["req2"] == {0}
69
70
manager.free_encoder_input(req, 0)
71
72
assert not manager.cached["img3"]
73
+ assert "req2" not in manager.request_cached_ids
74
assert "img3" in manager.freeable
75
assert manager.num_freeable_slots == 10
76
@@ -83,11 +87,13 @@ def test_free_request_frees_all_inputs():
87
88
assert len(manager.cached["a"]) == 1
89
assert len(manager.cached["b"]) == 1
90
+ assert manager.request_cached_ids["req3"] == {0, 1}
91
92
manager.free(req)
93
94
assert not manager.cached["a"]
95
assert not manager.cached["b"]
96
+ assert "req3" not in manager.request_cached_ids
97
assert "a" in manager.freeable
98
assert "b" in manager.freeable
99
assert manager.num_freeable_slots == 10
@@ -108,6 +114,7 @@ def test_eviction_when_cache_is_full():
114
115
# 'x' should have been evicted.
116
assert "x" not in manager.cached
117
+ assert "req1" not in manager.request_cached_ids
118
assert "x" in manager.get_freed_mm_hashes()
119
120
@@ -137,6 +144,7 @@ def test_has_cache_restores_from_freeable():
144
# Should restore from freeable.
145
assert manager.check_and_update_cache(req, 0)
146
assert len(manager.cached["imgZ"]) == 1
147
+ assert manager.request_cached_ids["reqY"] == {0}
148
assert "imgZ" not in manager.freeable
149
assert manager.num_freeable_slots == 6
150
@@ -205,6 +213,7 @@ def test_encoder_cache_with_is_embed_mask():
213
214
assert manager.num_free_slots == 92
215
assert "img1" in manager.cached
216
+ assert manager.request_cached_ids["r1"] == {0}
217
218
old_size = 100
219
new_size = request.mm_features[0].mm_position.get_num_embeds()
@@ -276,6 +285,7 @@ def test_reset_clears_all_state():
285
manager.reset()
286
287
assert len(manager.cached) == 0
288
+ assert len(manager.request_cached_ids) == 0
289
assert len(manager.freeable) == 0
290
assert len(manager.freed) == 0
291
assert manager.num_free_slots == 20
@@ -298,6 +308,26 @@ def test_reset_allows_fresh_allocations():
308
assert manager.num_free_slots == 2
309
assert "img2" in manager.cached
310
assert "img1" not in manager.cached
311
+ assert manager.request_cached_ids["req2"] == {0}
312
+ assert "req1" not in manager.request_cached_ids
313
+
314
+
315
+def test_free_request_with_duplicate_mm_hashes():
316
+ """Freeing a request whose two inputs share the same mm_hash must fully
317
+ clean up request_cached_ids. After the first free_encoder_input call,
318
+ cached[mm_hash] becomes empty; the second call must still remove the
319
+ remaining input_id from request_cached_ids."""
320
+ manager = EncoderCacheManager(cache_size=20)
321
+
322
+ req = MockRequest("r1", ["imgA", "imgA"], [4, 4])
323
+
324
+ manager.allocate(req, 0)
325
+ # input 1 has the same hash, so it's already cached.
326
+ assert manager.check_and_update_cache(req, 1)
327
+ assert manager.request_cached_ids["r1"] == {0, 1}
328
+
329
+ manager.free(req)
330
+ assert "r1" not in manager.request_cached_ids
331
332
333
def test_encoder_decoder_cache_manager_reset():
vllm/multimodal/utils.py
+25
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import bisect
5
import mimetypes
6
from collections import defaultdict
7
from collections.abc import Generator, Sequence
@@ -18,6 +19,7 @@ from vllm.utils.import_utils import LazyLoader
19
from .hasher import MultiModalHasher
20
from .inputs import (
21
BatchedTensorInputs,
22
+ MultiModalFeatureSpec,
23
MultiModalFieldElem,
24
MultiModalKwargsItem,
25
MultiModalSharedField,
@@ -109,6 +111,29 @@ def encode_video_url(
111
return f"data:{mimetype};base64,{video_b64}"
112
113
114
+def get_mm_features_in_window(
115
+ mm_features: list[MultiModalFeatureSpec],
116
+ start: int,
117
+ end: int,
118
+) -> tuple[int, int]:
119
+ """Return (lo, hi) indices for features overlapping [start, end).
120
+
121
+ Assumes mm_features are sorted by offset and non-overlapping, so
122
+ offset + length is also sorted.
123
+ """
124
+ lo = bisect.bisect_left(
125
+ mm_features,
126
+ start + 1,
127
+ key=lambda f: f.mm_position.offset + f.mm_position.length,
128
+ )
129
+ hi = bisect.bisect_left(
130
+ mm_features,
131
+ end,
132
+ key=lambda f: f.mm_position.offset,
133
+ )
134
+ return lo, hi
135
+
136
+
137
def argsort_mm_positions(
138
mm_positions: MultiModalPlaceholders,
139
) -> list[tuple[str, int]]:
vllm/transformers_utils/processors/voxtral.py
+1
-1
@@ -44,7 +44,7 @@ class MistralCommonFeatureExtractor:
44
if not self.audio_encoder.audio_config.is_streaming:
45
audio = self.audio_encoder.pad(audio, self.sampling_rate)
46
47
- audios_processed.append(torch.tensor(audio))
47
+ audios_processed.append(torch.from_numpy(audio))
48
49
return BatchFeature(
50
{"audio_arrays": audios_processed}, tensor_type=return_tensors
vllm/v1/core/encoder_cache_manager.py
+14
-14
@@ -71,6 +71,8 @@ class EncoderCacheManager:
71
72
# mm_hash of mm_data => ids of requests that reference the mm_data
73
self.cached: dict[str, set[str]] = {}
74
+ # request_id => set of input_ids cached for that request
75
+ self.request_cached_ids: dict[str, set[int]] = {}
76
77
# mm_hash of mm_data => num_encoder_embeds of the mm_data
78
self.freeable: OrderedDict[str, int] = OrderedDict()
@@ -83,6 +85,7 @@ class EncoderCacheManager:
85
Called when model weights are updated to invalidate stale embeddings.
86
"""
87
self.cached.clear()
88
+ self.request_cached_ids.clear()
89
self.freeable.clear()
90
self.freed.clear()
91
self.num_free_slots = self.cache_size
@@ -114,6 +117,7 @@ class EncoderCacheManager:
117
self.num_freeable_slots -= num_encoder_embeds
118
119
self.cached[mm_hash].add(request.request_id)
120
+ self.request_cached_ids.setdefault(request.request_id, set()).add(input_id)
121
return True
122
123
def can_allocate(
@@ -201,22 +205,13 @@ class EncoderCacheManager:
205
assert self.num_freeable_slots >= num_encoder_embeds
206
207
self.cached[mm_hash].add(request_id)
208
+ self.request_cached_ids.setdefault(request_id, set()).add(input_id)
209
self.num_free_slots -= num_encoder_embeds
210
self.num_freeable_slots -= num_encoder_embeds
211
212
def get_cached_input_ids(self, request: Request) -> set[int]:
208
- """Get all cached multimodal input IDs for a request.
209
-
210
- Returns the set of input IDs whose `mm_hash` exists in the cache map.
211
- This includes entries that are currently unreferenced (and thus present
212
- in `freeable`); for such entries, freeing for this request will be a
213
- no-op.
214
- """
215
- return {
216
- input_id
217
- for input_id in range(len(request.mm_features))
218
- if request.mm_features[input_id].identifier in self.cached
219
- }
213
+ """Get all cached multimodal input IDs for a request."""
214
+ return self.request_cached_ids.get(request.request_id, set())
215
216
def free_encoder_input(self, request: Request, input_id: int) -> None:
217
"""Free the request's reference to the encoder input (`mm_data`)
@@ -230,6 +225,12 @@ class EncoderCacheManager:
225
"""
226
req_id = request.request_id
227
mm_hash = request.mm_features[input_id].identifier
228
+ # Always clean up request_cached_ids, even if the mm_hash was
229
+ # already evicted from cache (e.g. by can_allocate).
230
+ if req_id in self.request_cached_ids:
231
+ self.request_cached_ids[req_id].discard(input_id)
232
+ if not self.request_cached_ids[req_id]:
233
+ del self.request_cached_ids[req_id]
234
# The mm_hash not in cache or the req_id set is empty
235
if not self.cached.get(mm_hash, None):
236
return
@@ -248,8 +249,7 @@ class EncoderCacheManager:
249
250
Typically called when a request is finished, cancelled, or aborted.
251
"""
251
- input_ids = self.get_cached_input_ids(request)
252
- for input_id in input_ids:
252
+ for input_id in list(self.get_cached_input_ids(request)):
253
self.free_encoder_input(request, input_id)
254
255
def get_freed_mm_hashes(self) -> list[str]:
vllm/v1/core/sched/scheduler.py
+13
-15
@@ -29,6 +29,7 @@ from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
29
)
30
from vllm.multimodal import MULTIMODAL_REGISTRY, MultiModalRegistry
31
from vllm.multimodal.encoder_budget import MultiModalBudget
32
+from vllm.multimodal.utils import get_mm_features_in_window
33
from vllm.v1.core.encoder_cache_manager import (
34
EncoderCacheManager,
35
EncoderDecoderCacheManager,
@@ -1163,22 +1164,23 @@ class Scheduler(SchedulerInterface):
1164
# trackers for accounting at the encoder input level.
1165
mm_hashes_to_schedule = set()
1166
num_embeds_to_schedule = 0
1166
- for i, mm_feature in enumerate(mm_features):
1167
+
1168
+ lo, hi = get_mm_features_in_window(
1169
+ mm_features,
1170
+ start=num_computed_tokens,
1171
+ end=num_computed_tokens + num_new_tokens + shift_computed_tokens,
1172
+ )
1173
+ # For encoder-decoder, all inputs sit at start_pos=0, so lo=0 always.
1174
+ if self.is_encoder_decoder:
1175
+ lo = 0
1176
+
1177
+ for i in range(lo, hi):
1178
+ mm_feature = mm_features[i]
1179
start_pos = mm_feature.mm_position.offset
1180
num_encoder_tokens = mm_feature.mm_position.length
1181
num_encoder_embeds = mm_feature.mm_position.get_num_embeds()
1182
item_identifier = mm_feature.identifier
1183
1172
- # The encoder output is needed if the two ranges overlap:
1173
- # [num_computed_tokens, num_computed_tokens + num_new_tokens) and
1174
- # [start_pos, start_pos + num_encoder_tokens)
1175
- if (
1176
- start_pos
1177
- >= num_computed_tokens + num_new_tokens + shift_computed_tokens
1178
- ):
1179
- # The encoder input is not needed in this step.
1180
- break
1181
-
1184
if self.is_encoder_decoder and num_computed_tokens > 0:
1185
assert start_pos == 0, (
1186
"Encoder input should be processed at the beginning of "
@@ -1194,10 +1196,6 @@ class Scheduler(SchedulerInterface):
1196
# decoder tokens (num_computed_tokens > 0), then we know we
1197
# already calculated encoder inputs and can skip here.
1198
continue
1197
- elif start_pos + num_encoder_tokens <= num_computed_tokens:
1198
- # The encoder input is already computed and stored
1199
- # in the decoder's KV cache.
1200
- continue
1199
1200
if not self.is_encoder_decoder:
1201
# We are not using the encoder cache for encoder-decoder models,
vllm/v1/worker/gpu_model_runner.py
+9
-14
@@ -104,7 +104,7 @@ from vllm.multimodal.inputs import (
104
MultiModalKwargsItem,
105
PlaceholderRange,
106
)
107
-from vllm.multimodal.utils import group_and_batch_mm_kwargs
107
+from vllm.multimodal.utils import get_mm_features_in_window, group_and_batch_mm_kwargs
108
from vllm.platforms import current_platform
109
from vllm.pooling_params import PoolingParams
110
from vllm.sampling_params import SamplingType
@@ -3100,23 +3100,18 @@ class GPUModelRunner(
3100
req_state = self.requests[req_id]
3101
num_computed_tokens = req_state.num_computed_tokens + shift_computed_tokens
3102
3103
- for mm_feature in req_state.mm_features:
3103
+ mm_features = req_state.mm_features
3104
+ lo, hi = get_mm_features_in_window(
3105
+ mm_features,
3106
+ start=num_computed_tokens,
3107
+ end=num_computed_tokens + num_scheduled_tokens,
3108
+ )
3109
+ for i in range(lo, hi):
3110
+ mm_feature = mm_features[i]
3111
pos_info = mm_feature.mm_position
3112
start_pos = pos_info.offset
3113
num_encoder_tokens = pos_info.length
3114
3108
- # The encoder output is needed if the two ranges overlap:
3109
- # [num_computed_tokens,
3110
- # num_computed_tokens + num_scheduled_tokens) and
3111
- # [start_pos, start_pos + num_encoder_tokens)
3112
- if start_pos >= num_computed_tokens + num_scheduled_tokens:
3113
- # The encoder output is not needed in this step.
3114
- break
3115
- if start_pos + num_encoder_tokens <= num_computed_tokens:
3116
- # The encoder output is already processed and stored
3117
- # in the decoder's KV cache.
3118
- continue
3119
-
3115
start_idx = max(num_computed_tokens - start_pos, 0)
3116
end_idx = min(
3117
num_computed_tokens - start_pos + num_scheduled_tokens,