[Model Runner V2] Enable encoder token embedding (#50574)

Signed-off-by: Taneem Ibrahim <taneem.ibrahim@gmail.com>

Taneem Ibrahim committed Aug 1, 2026 at 06:55 UTC 652ba59229499eb65fc4115b7feadeddf9bcb75d
4 files changed +106 -7
tests/models/language/pooling/test_colbert.py
+13 -2
@@ -379,8 +379,16 @@ def test_colbert_embed_not_supported(
379 vllm_model.embed([TEXTS_1[0]])
380
381
382 -@pytest.mark.parametrize("backend", list(COLBERT_MODELS.keys()))
383 -def test_colbert_hf_comparison(vllm_runner, backend):
382 +@pytest.mark.parametrize(
383 + ("backend", "use_v2"),
384 + [
385 + pytest.param("bert", True, id="bert-v2"),
386 + pytest.param("modernbert", True, id="modernbert-v2"),
387 + pytest.param("jina", False, id="jina-v1"),
388 + pytest.param("lfm2", False, id="lfm2-v1"),
389 + ],
390 +)
391 +def test_colbert_hf_comparison(vllm_runner, monkeypatch, backend, use_v2):
392 """Test that vLLM ColBERT embeddings match HuggingFace for each backend."""
393 from transformers import AutoTokenizer
394
@@ -393,6 +401,8 @@ def test_colbert_hf_comparison(vllm_runner, backend):
401 assert isinstance(extra_kwargs, dict)
402 test_texts = [TEXTS_1[0], TEXTS_2[0]]
403
404 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1" if use_v2 else "0")
405 +
406 with vllm_runner(
407 model_name,
408 runner="pooling",
@@ -401,6 +411,7 @@ def test_colbert_hf_comparison(vllm_runner, backend):
411 enforce_eager=True,
412 **extra_kwargs,
413 ) as vllm_model:
414 + assert vllm_model.llm.llm_engine.vllm_config.use_v2_model_runner == use_v2
415 vllm_outputs = vllm_model.token_embed(test_texts)
416
417 device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tests/models/language/pooling/test_splade_sparse_pooler.py
+67 -1
@@ -15,8 +15,10 @@ from vllm.model_executor.models.bert import (
15 from vllm.platforms import current_platform
16 from vllm.pooling_params import PoolingParams
17 from vllm.utils.torch_utils import PIN_MEMORY
18 +from vllm.v1.pool.late_interaction_runner import LateInteractionRunner
19 from vllm.v1.pool.metadata import PoolingMetadata, PoolingStates
20 from vllm.v1.worker.gpu.input_batch import InputBatch
21 +from vllm.v1.worker.gpu.model_runner import GPUModelRunner
22 from vllm.v1.worker.gpu.pool.pooling_runner import PoolingRunner
23 from vllm.v1.worker.gpu.states import RequestState
24
@@ -138,9 +140,11 @@ def test_pooling_runner_stores_only_required_token_ids() -> None:
140 runner.pooling_params = {}
141 runner.pooling_states = {}
142 runner.prompt_token_ids = {}
143 + runner.late_interaction_runner = MagicMock()
144
142 - runner.add_request(1, PoolingParams(task="embed"), [101, 102])
145 + runner.add_request("req-1", 1, PoolingParams(task="embed"), [101, 102])
146 runner.add_request(
147 + "req-2",
148 2,
149 PoolingParams(task="embed", requires_token_ids=True),
150 [101, 11, 102],
@@ -150,6 +154,42 @@ def test_pooling_runner_stores_only_required_token_ids() -> None:
154 torch.testing.assert_close(runner.prompt_token_ids[2], torch.tensor([101, 11, 102]))
155
156
157 +def test_pooling_runner_releases_aborted_late_interaction_doc() -> None:
158 + runner = PoolingRunner.__new__(PoolingRunner)
159 + runner.late_interaction_runner = LateInteractionRunner()
160 +
161 + query_key = "query-abort"
162 + late_interaction_runner = runner.late_interaction_runner
163 + late_interaction_runner._query_cache[query_key] = torch.ones(2, 4)
164 + late_interaction_runner._query_uses[query_key] = 1
165 + late_interaction_runner._doc_query_keys["doc-req"] = query_key
166 +
167 + runner.on_requests_finished({"doc-req"})
168 +
169 + assert not late_interaction_runner._query_cache
170 + assert not late_interaction_runner._query_uses
171 + assert not late_interaction_runner._doc_query_keys
172 +
173 +
174 +def test_encoder_cache_reset_clears_late_interaction_state() -> None:
175 + # Cached query embeddings are only invalidated by weight reloads, which
176 + # reach the runner via reset_encoder_cache. Resetting the multi-modal
177 + # cache is unrelated and must leave them intact.
178 + runner = GPUModelRunner.__new__(GPUModelRunner)
179 + runner.encoder_cache = MagicMock()
180 + runner.pooling_runner = MagicMock()
181 +
182 + runner.reset_mm_cache()
183 +
184 + runner.encoder_cache.reset_mm_cache.assert_called_once_with()
185 + runner.pooling_runner.clear.assert_not_called()
186 +
187 + runner.reset_encoder_cache()
188 +
189 + runner.encoder_cache.reset_encoder_cache.assert_called_once_with()
190 + runner.pooling_runner.clear.assert_called_once_with()
191 +
192 +
193 def test_pooling_runner_rejects_unsupported_selected_task() -> None:
194 model = MagicMock()
195 model.pooler.get_supported_tasks.return_value = {
@@ -182,6 +222,19 @@ def test_pooling_runner_supports_encoder_token_classification() -> None:
222 assert runner.supported_tasks == {"token_classify"}
223
224
225 +def test_pooling_runner_supports_encoder_token_embedding() -> None:
226 + model = MagicMock()
227 + model.pooler.get_supported_tasks.return_value = {"token_embed"}
228 + vllm_config = MagicMock()
229 + vllm_config.scheduler_config.max_num_seqs = 2
230 + vllm_config.model_config.attn_type = "encoder_only"
231 + vllm_config.model_config.get_pooling_task.return_value = "token_embed"
232 +
233 + runner = PoolingRunner(model, vllm_config)
234 +
235 + assert runner.supported_tasks == {"token_embed"}
236 +
237 +
238 def test_pooling_runner_rejects_decoder_token_classification() -> None:
239 model = MagicMock()
240 model.pooler.get_supported_tasks.return_value = {"token_classify"}
@@ -207,3 +260,16 @@ def test_pooling_runner_filters_decoder_token_classification() -> None:
260 runner = PoolingRunner(model, vllm_config)
261
262 assert runner.supported_tasks == {"embed"}
263 +
264 +
265 +def test_pooling_runner_filters_decoder_token_embedding() -> None:
266 + model = MagicMock()
267 + model.pooler.get_supported_tasks.return_value = {"embed", "token_embed"}
268 + vllm_config = MagicMock()
269 + vllm_config.scheduler_config.max_num_seqs = 2
270 + vllm_config.model_config.attn_type = "decoder"
271 + vllm_config.model_config.get_pooling_task.return_value = "embed"
272 +
273 + runner = PoolingRunner(model, vllm_config)
274 +
275 + assert runner.supported_tasks == {"embed"}
vllm/v1/worker/gpu/model_runner.py
+6
@@ -743,6 +743,8 @@ class GPUModelRunner(LoRAModelRunnerMixin):
743 def reset_encoder_cache(self) -> None:
744 if self.encoder_cache is not None:
745 self.encoder_cache.reset_encoder_cache()
746 + if self.pooling_runner is not None:
747 + self.pooling_runner.clear()
748
749 def profile_cudagraph_memory(self) -> int:
750 # NOTE(woosuk): It is TBD whether we keep this API or not.
@@ -813,6 +815,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
815
816 def finish_requests(self, scheduler_output: SchedulerOutput) -> None:
817 finished_req_ids = scheduler_output.finished_req_ids
818 + if self.pooling_runner is not None:
819 + # Preempted docs keep their query-use reservation until rescheduled.
820 + self.pooling_runner.on_requests_finished(finished_req_ids)
821 preempted_req_ids = scheduler_output.preempted_req_ids
822 if preempted_req_ids:
823 finished_req_ids = finished_req_ids.union(preempted_req_ids)
@@ -857,6 +862,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
862 if self.pooling_runner is not None:
863 assert new_req_data.pooling_params is not None
864 self.pooling_runner.add_request(
865 + req_id,
866 req_index,
867 new_req_data.pooling_params,
868 new_req_data.prompt_token_ids,
vllm/v1/worker/gpu/pool/pooling_runner.py
+20 -4
@@ -12,13 +12,15 @@ from vllm.pooling_params import PoolingParams
12 from vllm.tasks import PoolingTask
13 from vllm.utils.torch_utils import PIN_MEMORY
14 from vllm.v1.outputs import PoolerOutput
15 +from vllm.v1.pool.late_interaction_runner import LateInteractionRunner
16 from vllm.v1.pool.metadata import PoolingMetadata, PoolingStates
17 from vllm.v1.worker.gpu.input_batch import InputBatch
18 from vllm.v1.worker.gpu.states import RequestState
19
20 _SUPPORTED_TASKS: frozenset[PoolingTask] = frozenset(
20 - {"embed", "classify", "token_classify"}
21 + {"embed", "classify", "token_embed", "token_classify"}
22 )
23 +_TOKEN_TASKS: frozenset[PoolingTask] = frozenset({"token_embed", "token_classify"})
24
25
26 class PoolingRunner:
@@ -53,13 +55,14 @@ class PoolingRunner:
55 self.pooling_params: dict[int, PoolingParams] = {}
56 self.pooling_states: dict[int, PoolingStates] = {}
57 self.prompt_token_ids: dict[int, torch.Tensor] = {}
58 + self.late_interaction_runner = LateInteractionRunner()
59
60 @staticmethod
61 def _get_enabled_tasks(model_config: ModelConfig) -> frozenset[PoolingTask]:
59 - # Token classification is validated only for unchunked encoder-only prefill.
62 + # Token-wise pooling is validated only for unchunked encoder-only prefill.
63 if model_config.attn_type == "encoder_only":
64 return _SUPPORTED_TASKS
62 - return _SUPPORTED_TASKS - {"token_classify"}
65 + return _SUPPORTED_TASKS - _TOKEN_TASKS
66
67 @staticmethod
68 def get_supported_tasks(
@@ -72,6 +75,7 @@ class PoolingRunner:
75
76 def add_request(
77 self,
78 + req_id: str,
79 req_index: int,
80 pooling_params: PoolingParams,
81 prompt_token_ids: list[int],
@@ -83,6 +87,7 @@ class PoolingRunner:
87 f"Supported tasks: {sorted(self.supported_tasks)}"
88 )
89 self.model.pooler.get_pooling_updates(task).apply(pooling_params)
90 + self.late_interaction_runner.register_request(req_id, pooling_params)
91 self.pooling_params[req_index] = pooling_params
92 self.pooling_states[req_index] = PoolingStates()
93 if pooling_params.requires_token_ids:
@@ -96,6 +101,12 @@ class PoolingRunner:
101 state.clean()
102 self.prompt_token_ids.pop(req_index, None)
103
104 + def on_requests_finished(self, req_ids: set[str]) -> None:
105 + self.late_interaction_runner.on_requests_finished(req_ids)
106 +
107 + def clear(self) -> None:
108 + self.late_interaction_runner.clear()
109 +
110 def _get_pooling_metadata(
111 self,
112 input_batch: InputBatch,
@@ -153,8 +164,13 @@ class PoolingRunner:
164 query_start_loc_gpu=input_batch.query_start_loc[: num_reqs + 1],
165 )
166 pooler_output = self.model.pooler(hidden_states, pooling_metadata)
156 -
167 finished_mask = pooling_metadata.get_pooling_cursor().is_finished().tolist()
168 + pooler_output = self.late_interaction_runner.postprocess_pooler_output(
169 + raw_pooler_output=pooler_output,
170 + pooling_params=pooling_metadata.pooling_params,
171 + req_ids=input_batch.req_ids,
172 + finished_mask=finished_mask,
173 + )
174 return pooler_output, finished_mask
175
176 def _dummy_pooler_run_task(