[Bugfix][V1] Reject encoder-cache hits with mismatched embedding counts (#57696)

Signed-off-by: jackLei0901 <42642542+jackLei0901@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

jackLei committed Sep 24, 2026 at 01:40 UTC bbc4ddeca9ece9851aad26f03afc4bab37c6e8df
5 files changed +298 -4
tests/v1/core/test_async_scheduler.py
+1
@@ -353,6 +353,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
353 scheduler.finished_req_ids = set()
354 scheduler.finished_req_ids_dict = None
355 scheduler.grammar_compile_error_reqs = set()
356 + scheduler.encoder_cache_mismatch_reqs = set()
357 scheduler.vllm_config = Mock()
358 scheduler.aux_output_connector = None
359 scheduler.return_sampling_mask = False
tests/v1/core/test_encoder_cache_manager.py
+27
@@ -114,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 "x" not in manager.num_embeds_by_hash
118 assert "req1" not in manager.request_cached_ids
119 assert "x" in manager.get_freed_mm_hashes()
120
@@ -273,6 +274,31 @@ def test_encoder_cache_with_is_embed_mask():
274 assert savings_ratio == 12.5
275
276
277 +def test_encoder_cache_tracks_embedding_count_by_identifier():
278 + class MockRequestWithMask(MockRequest):
279 + def get_num_encoder_embeds(self, input_id: int) -> int:
280 + return self.mm_features[input_id].mm_position.get_num_embeds()
281 +
282 + is_embed = torch.tensor([False, True, True, False, True, False])
283 + request = MockRequestWithMask("owner", ["img1"], [len(is_embed)])
284 + request.mm_features[0] = MultiModalFeatureSpec(
285 + data=None,
286 + modality="image",
287 + identifier="img1",
288 + mm_position=PlaceholderRange(offset=0, length=6, is_embed=is_embed),
289 + )
290 +
291 + manager = EncoderCacheManager(cache_size=10)
292 + manager.allocate(request, 0)
293 + assert manager.get_cached_num_encoder_embeds(request, 0) == 3
294 +
295 + manager.free(request)
296 + assert manager.get_cached_num_encoder_embeds(request, 0) == 3
297 +
298 + manager.reset()
299 + assert manager.get_cached_num_encoder_embeds(request, 0) is None
300 +
301 +
302 def test_encoder_cache_mask_based_retrieval():
303 class MockRequestWithMask(MockRequest):
304 def get_num_encoder_embeds(self, input_id: int) -> int:
@@ -336,6 +362,7 @@ def test_reset_clears_all_state():
362 manager.reset()
363
364 assert len(manager.cached) == 0
365 + assert len(manager.num_embeds_by_hash) == 0
366 assert len(manager.request_cached_ids) == 0
367 assert len(manager.freeable) == 0
368 assert len(manager.freed) == 0
tests/v1/core/test_scheduler.py
+196
@@ -3896,6 +3896,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
3896 scheduler.finished_req_ids_dict = None
3897 scheduler.aux_output_connector = None
3898 scheduler.grammar_compile_error_reqs = set()
3899 + scheduler.encoder_cache_mismatch_reqs = set()
3900 scheduler.vllm_config = Mock()
3901 scheduler.return_sampling_mask = False
3902 scheduler.recompute_kv_load_failures = False
@@ -6089,6 +6090,201 @@ def test_unavailable_encoder_input_fails_the_request_as_retryable():
6090 assert [o.finish_reason for o in engine_outputs] == [FinishReason.ERROR]
6091
6092
6093 +def _create_mm_request_with_embedding_positions(
6094 + request_id: str, identifier: str, is_embed: list[bool]
6095 +) -> Request:
6096 + return create_requests(
6097 + num_requests=1,
6098 + num_tokens=len(is_embed),
6099 + mm_hashes_list=[[identifier]],
6100 + mm_positions=[
6101 + [
6102 + PlaceholderRange(
6103 + offset=0,
6104 + length=len(is_embed),
6105 + is_embed=torch.tensor(is_embed),
6106 + )
6107 + ]
6108 + ],
6109 + req_ids=[request_id],
6110 + )[0]
6111 +
6112 +
6113 +def test_encoder_cache_accepts_matching_embed_count():
6114 + """An actively referenced entry is reused when embedding counts match."""
6115 + scheduler = create_scheduler(model="llava-hf/llava-1.5-7b-hf")
6116 + identifier = "reused-client-uuid"
6117 + owner = _create_mm_request_with_embedding_positions(
6118 + "owner", identifier, [True, True, True, False, False, False]
6119 + )
6120 + compatible = _create_mm_request_with_embedding_positions(
6121 + "compatible", identifier, [True, False, True, False, True, False, False]
6122 + )
6123 + scheduler.encoder_cache_manager.allocate(owner, 0)
6124 + scheduler.add_request(compatible)
6125 +
6126 + scheduler_output = scheduler.schedule()
6127 +
6128 + assert compatible.request_id in scheduler_output.num_scheduled_tokens
6129 + assert compatible.request_id not in scheduler.encoder_cache_mismatch_reqs
6130 + assert compatible.request_id not in scheduler_output.scheduled_encoder_inputs
6131 +
6132 +
6133 +def test_encoder_cache_rejects_mismatched_embed_count(caplog_vllm):
6134 + """A completed request's freeable entry rejects an incompatible reuse."""
6135 + scheduler = create_scheduler(model="llava-hf/llava-1.5-7b-hf")
6136 + identifier = "reused-client-uuid"
6137 + owner = _create_mm_request_with_embedding_positions(
6138 + "owner", identifier, [True, True, True, False, False, False]
6139 + )
6140 + mismatched = _create_mm_request_with_embedding_positions(
6141 + "mismatched", identifier, [True, True, True, True, False, False]
6142 + )
6143 + scheduler.encoder_cache_manager.allocate(owner, 0)
6144 + scheduler.encoder_cache_manager.free(owner)
6145 + freeable_slots_before = scheduler.encoder_cache_manager.num_freeable_slots
6146 + scheduler.add_request(mismatched)
6147 +
6148 + scheduler_output = scheduler.schedule()
6149 + assert not scheduler_output.num_scheduled_tokens
6150 + assert mismatched.request_id in scheduler.encoder_cache_mismatch_reqs
6151 + assert (
6152 + "multimodal identifier 'reused-client-uuid' holds 3 embeddings"
6153 + in caplog_vllm.text
6154 + and "expects 4" in caplog_vllm.text
6155 + )
6156 +
6157 + outputs = scheduler.update_from_output(
6158 + scheduler_output,
6159 + ModelRunnerOutput(req_ids=[], req_id_to_index={}),
6160 + )
6161 +
6162 + assert mismatched.status == RequestStatus.FINISHED_ERROR
6163 + assert not scheduler.encoder_cache_mismatch_reqs
6164 + assert mismatched.request_id not in scheduler.requests
6165 + assert scheduler.encoder_cache_manager.cached[identifier] == set()
6166 + assert (
6167 + mismatched.request_id not in scheduler.encoder_cache_manager.request_cached_ids
6168 + )
6169 + assert scheduler.encoder_cache_manager.freeable[identifier] == 3
6170 + assert scheduler.encoder_cache_manager.num_freeable_slots == freeable_slots_before
6171 + output = outputs[mismatched.client_index].outputs[0]
6172 + assert output.request_id == mismatched.request_id
6173 + assert output.finish_reason == FinishReason.ERROR
6174 +
6175 + healthy = create_requests(num_requests=1, req_ids=["healthy"], num_tokens=6)[0]
6176 + scheduler.add_request(healthy)
6177 + next_output = scheduler.schedule()
6178 + assert healthy.request_id in next_output.num_scheduled_tokens
6179 +
6180 +
6181 +def test_encoder_cache_embed_count_mismatch_restores_prior_hit_state():
6182 + """A prior hit is released when a later mismatch fails the request."""
6183 + scheduler = create_scheduler(model="llava-hf/llava-1.5-7b-hf")
6184 + manager = scheduler.encoder_cache_manager
6185 + valid_identifier = "valid-reuse"
6186 + mismatch_identifier = "mismatched-reuse"
6187 + valid_owner = _create_mm_request_with_embedding_positions(
6188 + "valid-owner", valid_identifier, [True, True, False]
6189 + )
6190 + mismatch_owner = _create_mm_request_with_embedding_positions(
6191 + "mismatch-owner", mismatch_identifier, [True, True, True, False]
6192 + )
6193 + manager.allocate(valid_owner, 0)
6194 + manager.free(valid_owner)
6195 + manager.allocate(mismatch_owner, 0)
6196 + manager.free(mismatch_owner)
6197 + freeable_slots_before = manager.num_freeable_slots
6198 +
6199 + request = create_requests(
6200 + num_requests=1,
6201 + num_tokens=7,
6202 + mm_hashes_list=[[valid_identifier, mismatch_identifier]],
6203 + mm_positions=[
6204 + [
6205 + PlaceholderRange(
6206 + offset=0,
6207 + length=3,
6208 + is_embed=torch.tensor([True, True, False]),
6209 + ),
6210 + PlaceholderRange(
6211 + offset=3,
6212 + length=4,
6213 + is_embed=torch.tensor([True, True, True, True]),
6214 + ),
6215 + ]
6216 + ],
6217 + req_ids=["partially-mutated"],
6218 + )[0]
6219 + scheduler.add_request(request)
6220 +
6221 + scheduler_output = scheduler.schedule()
6222 + assert not scheduler_output.num_scheduled_tokens
6223 + assert request.request_id in scheduler.encoder_cache_mismatch_reqs
6224 + assert request.request_id in manager.cached[valid_identifier]
6225 + assert valid_identifier not in manager.freeable
6226 + assert manager.num_freeable_slots == freeable_slots_before - 2
6227 +
6228 + outputs = scheduler.update_from_output(
6229 + scheduler_output,
6230 + ModelRunnerOutput(req_ids=[], req_id_to_index={}),
6231 + )
6232 +
6233 + assert request.status == RequestStatus.FINISHED_ERROR
6234 + assert request.request_id not in scheduler.requests
6235 + assert request.request_id not in manager.request_cached_ids
6236 + assert manager.cached[valid_identifier] == set()
6237 + assert manager.freeable[valid_identifier] == 2
6238 + assert manager.freeable[mismatch_identifier] == 3
6239 + assert manager.num_freeable_slots == freeable_slots_before
6240 + output = outputs[request.client_index].outputs[0]
6241 + assert output.finish_reason == FinishReason.ERROR
6242 +
6243 +
6244 +def test_encoder_cache_rejects_mismatched_embed_count_within_request():
6245 + """One request cannot reuse an identifier with a different embed count."""
6246 + scheduler = create_scheduler(model="llava-hf/llava-1.5-7b-hf")
6247 + identifier = "reused-within-request"
6248 + request = create_requests(
6249 + num_requests=1,
6250 + num_tokens=8,
6251 + mm_hashes_list=[[identifier, identifier]],
6252 + mm_positions=[
6253 + [
6254 + PlaceholderRange(
6255 + offset=0,
6256 + length=4,
6257 + is_embed=torch.tensor([True, True, False, False]),
6258 + ),
6259 + PlaceholderRange(
6260 + offset=4,
6261 + length=4,
6262 + is_embed=torch.tensor([True, True, True, False]),
6263 + ),
6264 + ]
6265 + ],
6266 + req_ids=["mismatched-within-request"],
6267 + )[0]
6268 + scheduler.add_request(request)
6269 +
6270 + scheduler_output = scheduler.schedule()
6271 + assert not scheduler_output.num_scheduled_tokens
6272 + assert request.request_id in scheduler.encoder_cache_mismatch_reqs
6273 + assert identifier not in scheduler.encoder_cache_manager.cached
6274 +
6275 + outputs = scheduler.update_from_output(
6276 + scheduler_output,
6277 + ModelRunnerOutput(req_ids=[], req_id_to_index={}),
6278 + )
6279 +
6280 + assert request.status == RequestStatus.FINISHED_ERROR
6281 + assert not scheduler.encoder_cache_mismatch_reqs
6282 + assert request.request_id not in scheduler.requests
6283 + output = outputs[request.client_index].outputs[0]
6284 + assert output.request_id == request.request_id
6285 + assert output.finish_reason == FinishReason.ERROR
6286 +
6287 +
6288 def test_free_encoder_inputs_defers_for_eagle_lookahead():
6289 """With EAGLE speculative decoding, the encoder input is retained one extra
6290 position so the drafter's +1 look-ahead mm-embedding gather (which reads one
vllm/v1/core/encoder_cache_manager.py
+20 -1
@@ -59,6 +59,8 @@ class EncoderCacheManager:
59 reference the cached entry. If the set is empty, the entry exists
60 but is not referenced by any request and is eligible for
61 reclamation.
62 + num_embeds_by_hash: Mapping from mm_hash to the number of encoder
63 + embeddings stored for that cache entry.
64 freeable: List of tuples (mm_hash, num_encoder_embeds) representing entries
65 whose no current running request is needed and that can be freed to
66 make space when needed.
@@ -80,6 +82,8 @@ class EncoderCacheManager:
82
83 # mm_hash of mm_data => ids of requests that reference the mm_data
84 self.cached: dict[str, set[str]] = {}
85 + # mm_hash of mm_data => number of cached encoder embeddings
86 + self.num_embeds_by_hash: dict[str, int] = {}
87 # request_id => set of input_ids cached for that request
88 self.request_cached_ids: dict[str, set[int]] = {}
89
@@ -94,6 +98,7 @@ class EncoderCacheManager:
98 Called when model weights are updated to invalidate stale embeddings.
99 """
100 self.cached.clear()
101 + self.num_embeds_by_hash.clear()
102 self.request_cached_ids.clear()
103 self.freeable.clear()
104 self.freed.clear()
@@ -130,6 +135,13 @@ class EncoderCacheManager:
135 self.request_cached_ids.setdefault(request.request_id, set()).add(input_id)
136 return True
137
138 + def get_cached_num_encoder_embeds(
139 + self, request: Request, input_id: int
140 + ) -> int | None:
141 + """Return the encoder-embedding count stored for a cached input."""
142 + mm_hash = request.mm_features[input_id].identifier
143 + return self.num_embeds_by_hash.get(mm_hash)
144 +
145 def can_allocate(
146 self,
147 request: Request,
@@ -188,6 +200,7 @@ class EncoderCacheManager:
200 while num_embeds > self.num_free_slots:
201 mm_hash, num_free_embeds = self.freeable.popitem(last=False)
202 del self.cached[mm_hash]
203 + del self.num_embeds_by_hash[mm_hash]
204 self.freed.append(mm_hash)
205 self.num_free_slots += num_free_embeds
206 return True
@@ -209,6 +222,12 @@ class EncoderCacheManager:
222 self.cached[mm_hash] = set()
223
224 num_encoder_embeds = request.get_num_encoder_embeds(input_id)
225 + cached_num_encoder_embeds = self.num_embeds_by_hash.setdefault(
226 + mm_hash, num_encoder_embeds
227 + )
228 + # _try_schedule_encoder_inputs rejects local-hit and same-step
229 + # identifier/count mismatches before allocation.
230 + assert cached_num_encoder_embeds == num_encoder_embeds
231
232 # NOTE: Encoder cache should always have enough space for encoder inputs
233 # that are scheduled since eviction takes place at can_allocate().
@@ -258,7 +277,7 @@ class EncoderCacheManager:
277 return
278 self.cached[mm_hash].discard(req_id)
279 if not self.cached[mm_hash]:
261 - num_encoder_embeds = request.get_num_encoder_embeds(input_id)
280 + num_encoder_embeds = self.num_embeds_by_hash[mm_hash]
281 self.freeable[mm_hash] = num_encoder_embeds
282 self.num_freeable_slots += num_encoder_embeds
283
vllm/v1/core/sched/scheduler.py
+54 -3
@@ -234,6 +234,7 @@ class Scheduler(SchedulerInterface):
234 # Grammar compilation failures to finish as per-request errors in
235 # update_from_output.
236 self.grammar_compile_error_reqs: set[str] = set()
237 + self.encoder_cache_mismatch_reqs: set[str] = set()
238
239 # Encoder-related.
240 # Calculate encoder cache size if applicable
@@ -1714,6 +1715,29 @@ class Scheduler(SchedulerInterface):
1715 num_output_tokens=num_output_tokens,
1716 )
1717
1718 + def _reject_on_encoder_cache_embed_mismatch(
1719 + self,
1720 + request: Request,
1721 + input_id: int,
1722 + identifier: str,
1723 + cached_num_encoder_embeds: int,
1724 + requested_num_encoder_embeds: int,
1725 + ) -> bool:
1726 + if cached_num_encoder_embeds == requested_num_encoder_embeds:
1727 + return False
1728 + logger.warning(
1729 + "Encoder cache entry for multimodal identifier %r holds %d "
1730 + "embeddings, but request %s input %d expects %d. A client-provided "
1731 + "multimodal UUID may have been reused for different content.",
1732 + identifier,
1733 + cached_num_encoder_embeds,
1734 + request.request_id,
1735 + input_id,
1736 + requested_num_encoder_embeds,
1737 + )
1738 + self.encoder_cache_mismatch_reqs.add(request.request_id)
1739 + return True
1740 +
1741 def _try_schedule_encoder_inputs(
1742 self,
1743 request: Request,
@@ -1752,7 +1776,7 @@ class Scheduler(SchedulerInterface):
1776 # NOTE: since scheduler operates on the request level (possibly with
1777 # multiple encoder inputs per request), we need to create temporary
1778 # trackers for accounting at the encoder input level.
1755 - mm_hashes_to_schedule = set()
1779 + mm_hashes_to_schedule: dict[str, int] = {}
1780 num_embeds_to_schedule = 0
1781
1782 encoder_window_end = (
@@ -1794,10 +1818,35 @@ class Scheduler(SchedulerInterface):
1818 # We are not using the encoder cache for encoder-decoder models,
1819 # yet.
1820 if item_identifier in mm_hashes_to_schedule:
1821 + scheduled_num_encoder_embeds = mm_hashes_to_schedule[
1822 + item_identifier
1823 + ]
1824 + if self._reject_on_encoder_cache_embed_mismatch(
1825 + request,
1826 + i,
1827 + item_identifier,
1828 + scheduled_num_encoder_embeds,
1829 + num_encoder_embeds,
1830 + ):
1831 + return [], 0, encoder_compute_budget, []
1832 # The same encoder input has already been scheduled in the
1833 # current step.
1834 continue
1835
1836 + cached_num_encoder_embeds = (
1837 + self.encoder_cache_manager.get_cached_num_encoder_embeds(request, i)
1838 + )
1839 + if cached_num_encoder_embeds is not None and (
1840 + self._reject_on_encoder_cache_embed_mismatch(
1841 + request,
1842 + i,
1843 + item_identifier,
1844 + cached_num_encoder_embeds,
1845 + num_encoder_embeds,
1846 + )
1847 + ):
1848 + return [], 0, encoder_compute_budget, []
1849 +
1850 if self.encoder_cache_manager.check_and_update_cache(request, i):
1851 # The encoder input is already computed and cached from a
1852 # previous step.
@@ -1857,14 +1906,14 @@ class Scheduler(SchedulerInterface):
1906 if self.ec_connector is not None and self.ec_connector.has_cache_item(
1907 item_identifier
1908 ):
1860 - mm_hashes_to_schedule.add(item_identifier)
1909 + mm_hashes_to_schedule[item_identifier] = num_encoder_embeds
1910 external_load_encoder_input.append(i)
1911 num_embeds_to_schedule += num_encoder_embeds
1912 continue
1913
1914 num_embeds_to_schedule += num_encoder_embeds
1915 encoder_compute_budget -= num_encoder_embeds
1867 - mm_hashes_to_schedule.add(item_identifier)
1916 + mm_hashes_to_schedule[item_identifier] = num_encoder_embeds
1917 encoder_inputs_to_schedule.append(i)
1918
1919 return (
@@ -2185,6 +2234,8 @@ class Scheduler(SchedulerInterface):
2234
2235 error_req_ids = set(self.grammar_compile_error_reqs)
2236 self.grammar_compile_error_reqs.clear()
2237 + error_req_ids.update(self.encoder_cache_mismatch_reqs)
2238 + self.encoder_cache_mismatch_reqs.clear()
2239 if failed_kv_load_req_ids and not self.recompute_kv_load_failures:
2240 error_req_ids.update(failed_kv_load_req_ids)
2241 if self.ec_connector is not None: