[ModelRunner v2] Enable decoder token-wise pooling (#50931)

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

Taneem Ibrahim committed Aug 7, 2026 at 00:21 UTC 946452961265514622b95ea170839b214f39c2a0
8 files changed +86 -58
tests/model_executor/layers/test_pooler_methods.py
+30 -6
@@ -256,6 +256,7 @@ class TestGetSeqPoolingMethod:
256 @dataclass
257 class _FakeSchedulerConfig:
258 enable_chunked_prefill: bool = False
259 + async_scheduling: bool = False
260
261
262 @dataclass
@@ -265,10 +266,13 @@ class _FakeVllmConfig:
266
267 class TestAllPool:
268 @staticmethod
268 - def _make_all_pool(*, chunked: bool = False) -> AllPool:
269 + def _make_all_pool(
270 + *, chunked: bool = False, async_scheduling: bool = False
271 + ) -> AllPool:
272 fake_config = _FakeVllmConfig(
273 scheduler_config=_FakeSchedulerConfig(
274 enable_chunked_prefill=chunked,
275 + async_scheduling=async_scheduling,
276 ),
277 )
278 with patch(
@@ -319,6 +323,8 @@ class TestAllPool:
323 )
324 out1 = pooler(chunk1, metadata1)
325 assert out1[0] is None
326 + expected_chunk1 = chunk1.clone()
327 + chunk1.zero_()
328
329 chunk2 = torch.tensor([[5.0, 6.0], [7.0, 8.0]])
330 metadata2 = _make_metadata(
@@ -329,19 +335,37 @@ class TestAllPool:
335 metadata2.pooling_states = metadata1.pooling_states
336 out2 = pooler(chunk2, metadata2)
337 assert out2[0] is not None
332 - expected = torch.cat([chunk1, chunk2], dim=0)
338 + expected = torch.cat([expected_chunk1, chunk2], dim=0)
339 assert torch.equal(out2[0], expected)
340
335 - def test_chunked_prefill_single_shot_matches_non_chunked(self):
336 - pooler = self._make_all_pool(chunked=True)
341 + @pytest.mark.parametrize("async_scheduling", [False, True])
342 + def test_chunked_prefill_single_shot_matches_non_chunked(
343 + self, async_scheduling: bool
344 + ):
345 + pooler = self._make_all_pool(chunked=True, async_scheduling=async_scheduling)
346 hidden = torch.tensor(
347 [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0], [9.0, 10.0]]
348 )
349 + expected = [hidden[:2].clone(), hidden[2:].clone()]
350 metadata = _make_metadata([2, 3])
351 out = pooler(hidden, metadata)
352 + if async_scheduling:
353 + # Finished outputs must own their storage under async scheduling.
354 + hidden.zero_()
355 assert len(out) == 2
343 - assert torch.equal(out[0], hidden[:2])
344 - assert torch.equal(out[1], hidden[2:])
356 + assert torch.equal(out[0], expected[0])
357 + assert torch.equal(out[1], expected[1])
358 +
359 + def test_non_chunked_owns_output_under_async_scheduling(self):
360 + pooler = self._make_all_pool(async_scheduling=True)
361 + hidden = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
362 + expected = [hidden[:2].clone(), hidden[2:].clone()]
363 + metadata = _make_metadata([2, 1])
364 + out = pooler(hidden, metadata)
365 + hidden.zero_()
366 + assert len(out) == 2
367 + assert torch.equal(out[0], expected[0])
368 + assert torch.equal(out[1], expected[1])
369
370 def test_chunked_prefill_mixed_finished_unfinished(self):
371 pooler = self._make_all_pool(chunked=True)
tests/models/language/pooling/test_all_pooling_plus_chunked_prefill.py
+5 -3
@@ -15,9 +15,10 @@ from vllm.config import PoolerConfig
15 ["Qwen/Qwen3-Embedding-0.6B"],
16 )
17 @torch.inference_mode
18 -def test_embed_models(hf_runner, vllm_runner, monkeypatch, model: str):
19 - # Keep token_embed on MRV1 when sequence pooling becomes MRV2 by default.
20 - monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "0")
18 +def test_decoder_token_embed_model_runner_v2(
19 + hf_runner, vllm_runner, monkeypatch, model: str
20 +):
21 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
22 chunk_size = 10
23 n_prompt_tokens = [55, 56, 57]
24 token_prompts = [[1024 + i for i in range(n)] for n in n_prompt_tokens]
@@ -33,6 +34,7 @@ def test_embed_models(hf_runner, vllm_runner, monkeypatch, model: str):
34 enable_chunked_prefill=True,
35 enable_prefix_caching=True,
36 ) as vllm_model:
37 + assert vllm_model.llm.llm_engine.vllm_config.use_v2_model_runner
38 vllm_outputs = vllm_model.token_embed(
39 [TokensPrompt(prompt_token_ids=t) for t in token_prompts],
40 )
tests/models/language/pooling/test_jina_reranker_v3.py
+3 -1
@@ -45,8 +45,10 @@ INSTRUCTION = (
45 )
46
47
48 -def test_offline(vllm_runner):
48 +def test_offline(vllm_runner, monkeypatch):
49 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
50 with vllm_runner(model_name, runner="pooling") as llm_runner:
51 + assert llm_runner.llm.llm_engine.vllm_config.use_v2_model_runner
52 llm = llm_runner.get_llm()
53 _test_offline_1_v_1(llm)
54 _test_offline_1_v_n(llm)
tests/models/language/pooling/test_reward.py
+10
@@ -98,6 +98,7 @@ def test_prm_models(
98 hf_runner,
99 vllm_runner,
100 math_step_prompts,
101 + monkeypatch,
102 model: str,
103 dtype: str,
104 ) -> None:
@@ -107,12 +108,14 @@ def test_prm_models(
108 if current_platform.is_cpu():
109 pytest.skip("CPU only supports V1")
110
111 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
112 with vllm_runner(
113 model,
114 max_model_len=1024,
115 dtype=dtype,
116 kv_cache_memory_bytes=64 * MiB_bytes,
117 ) as vllm_model:
118 + assert vllm_model.llm.llm_engine.vllm_config.use_v2_model_runner
119 vllm_outputs = vllm_model.token_classify(math_step_prompts)
120
121 with hf_runner(model, dtype=dtype, auto_cls=AutoModel) as hf_model:
@@ -145,18 +148,25 @@ def test_prm_models(
148 def test_prm_models_with_golden_outputs(
149 vllm_runner,
150 math_step_prompts,
151 + monkeypatch,
152 model: str,
153 dtype: str,
154 ) -> None:
155 if not FIXTURE_REWARD_RESULT.get(model):
156 pytest.skip(f"No available golden outputs for {model}.")
157
158 + use_v2_model_runner = not current_platform.is_cpu()
159 + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1" if use_v2_model_runner else "0")
160 with vllm_runner(
161 model,
162 max_model_len=1024,
163 dtype=dtype,
164 kv_cache_memory_bytes=64 * MiB_bytes,
165 ) as vllm_model:
166 + assert (
167 + vllm_model.llm.llm_engine.vllm_config.use_v2_model_runner
168 + is use_v2_model_runner
169 + )
170 vllm_outputs = vllm_model.token_classify(math_step_prompts)
171
172 golden_outputs = load_reward_outputs(FIXTURE_REWARD_RESULT[model])
tests/models/language/pooling/test_splade_sparse_pooler.py
+8 -15
@@ -231,7 +231,7 @@ def test_pooling_runner_supports_encoder_token_embedding() -> None:
231 assert runner.supported_tasks == {"token_embed"}
232
233
234 -def test_pooling_runner_rejects_decoder_token_classification() -> None:
234 +def test_pooling_runner_supports_decoder_token_classification() -> None:
235 model = MagicMock()
236 model.pooler.get_supported_tasks.return_value = {"token_classify"}
237 vllm_config = MagicMock()
@@ -239,13 +239,12 @@ def test_pooling_runner_rejects_decoder_token_classification() -> None:
239 vllm_config.model_config.attn_type = "decoder"
240 vllm_config.model_config.get_pooling_task.return_value = "token_classify"
241
242 - with pytest.raises(ValueError, match="selects 'token_classify'") as exc_info:
243 - PoolingRunner(model, vllm_config)
242 + runner = PoolingRunner(model, vllm_config)
243
245 - assert "Set an explicitly supported task" not in str(exc_info.value)
244 + assert runner.supported_tasks == {"token_classify"}
245
246
248 -def test_pooling_runner_filters_decoder_token_classification() -> None:
247 +def test_pooling_runner_keeps_decoder_token_classification() -> None:
248 model = MagicMock()
249 model.pooler.get_supported_tasks.return_value = {"embed", "token_classify"}
250 vllm_config = MagicMock()
@@ -255,10 +254,10 @@ def test_pooling_runner_filters_decoder_token_classification() -> None:
254
255 runner = PoolingRunner(model, vllm_config)
256
258 - assert runner.supported_tasks == {"embed"}
257 + assert runner.supported_tasks == {"embed", "token_classify"}
258
259
261 -def test_pooling_runner_filters_decoder_token_embedding() -> None:
260 +def test_pooling_runner_keeps_decoder_token_embedding() -> None:
261 model = MagicMock()
262 model.pooler.get_supported_tasks.return_value = {"embed", "token_embed"}
263 vllm_config = MagicMock()
@@ -268,12 +267,10 @@ def test_pooling_runner_filters_decoder_token_embedding() -> None:
267
268 runner = PoolingRunner(model, vllm_config)
269
271 - assert runner.supported_tasks == {"embed"}
270 + assert runner.supported_tasks == {"embed", "token_embed"}
271
272
274 -def test_pooling_runner_gates_embed_token_classification_to_encoder() -> None:
275 - # BGE-M3's combined task emits per-token weights alongside the embedding,
276 - # so it is enabled only where prefill is unchunked.
273 +def test_pooling_runner_supports_embed_token_classification() -> None:
274 model = MagicMock()
275 model.pooler.get_supported_tasks.return_value = {"embed", "embed&token_classify"}
276 vllm_config = MagicMock()
@@ -285,7 +282,3 @@ def test_pooling_runner_gates_embed_token_classification_to_encoder() -> None:
282 "embed",
283 "embed&token_classify",
284 }
288 -
289 - vllm_config.model_config.attn_type = "decoder"
290 - vllm_config.model_config.get_pooling_task.return_value = "embed"
291 - assert PoolingRunner(model, vllm_config).supported_tasks == {"embed"}
vllm/model_executor/layers/pooler/tokwise/methods.py
+21 -4
@@ -40,9 +40,15 @@ class AllPool(TokenPoolingMethod):
40 scheduler_config = vllm_config.scheduler_config
41
42 self.enable_chunked_prefill = scheduler_config.enable_chunked_prefill
43 + # With async scheduling, the next step may overwrite the hidden states
44 + # buffer before outputs are copied to CPU, so they must own their storage.
45 + self.clone_finished = bool(scheduler_config.async_scheduling)
46
47 def extra_repr(self) -> str:
45 - return f"enable_chunked_prefill={self.enable_chunked_prefill}"
48 + return (
49 + f"enable_chunked_prefill={self.enable_chunked_prefill}, "
50 + f"clone_finished={self.clone_finished}"
51 + )
52
53 def forward(
54 self,
@@ -58,18 +64,29 @@ class AllPool(TokenPoolingMethod):
64 )
65
66 if not self.enable_chunked_prefill:
67 + if self.clone_finished:
68 + return [hs.clone() for hs in hidden_states_lst]
69 return hidden_states_lst
70
71 pooling_states = pooling_metadata.pooling_states
72 + finished_mask = pooling_cursor.is_finished().tolist()
73
74 # If chunked_prefill is enabled
75 # 1. first store the chunked hidden_states in pooling_states.hidden_states_cache
67 - for p, hs_chunk in zip(pooling_states, hidden_states_lst):
68 - p.hidden_states_cache.append(hs_chunk)
76 + for p, hs_chunk, finished in zip(
77 + pooling_states, hidden_states_lst, finished_mask
78 + ):
79 + # Own data retained across steps or returned without concatenation.
80 + needs_owned_storage = not finished or (
81 + self.clone_finished and not p.hidden_states_cache
82 + )
83 + p.hidden_states_cache.append(
84 + hs_chunk.clone() if needs_owned_storage else hs_chunk
85 + )
86
87 # 2. Once prefill is finished, send hidden_states_cache to PoolerHead
88 output_list = list[TokenPoolingMethodOutputItem]()
72 - for p, finished in zip(pooling_states, pooling_cursor.is_finished()):
89 + for p, finished in zip(pooling_states, finished_mask):
90 if finished:
91 hidden_states_cache = p.hidden_states_cache
92 if len(hidden_states_cache) == 1:
vllm/v1/worker/gpu/model_runner.py
+1 -3
@@ -313,9 +313,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
313 # Do not rely on pooling_runner here, since this information is needed
314 # on the first PP rank, while pooling_runner is only initialized
315 # on the last PP rank.
316 - tasks.extend(
317 - PoolingRunner.get_supported_tasks(self.model, self.model_config)
318 - )
316 + tasks.extend(PoolingRunner.get_supported_tasks(self.model))
317 return tuple(tasks)
318
319 def load_model(self, load_dummy_weights: bool = False, *args, **kwargs) -> None:
vllm/v1/worker/gpu/pool/pooling_runner.py
+8 -26
@@ -6,7 +6,7 @@ import numpy as np
6 import torch
7 import torch.nn as nn
8
9 -from vllm.config import ModelConfig, VllmConfig
9 +from vllm.config import VllmConfig
10 from vllm.model_executor.models import VllmModelForPooling, is_pooling_model
11 from vllm.pooling_params import PoolingParams
12 from vllm.tasks import PoolingTask
@@ -20,11 +20,6 @@ from vllm.v1.worker.gpu.states import RequestState
20 _SUPPORTED_TASKS: frozenset[PoolingTask] = frozenset(
21 {"embed", "classify", "token_embed", "token_classify", "embed&token_classify"}
22 )
23 -# `embed&token_classify` concatenates a fixed-size embedding with per-token
24 -# weights, so its output length scales with the prompt like the others here.
25 -_TOKEN_TASKS: frozenset[PoolingTask] = frozenset(
26 - {"token_embed", "token_classify", "embed&token_classify"}
27 -)
23
24
25 class PoolingRunner:
@@ -33,26 +28,23 @@ class PoolingRunner:
28 self.model_config = vllm_config.model_config
29 self.max_num_reqs = vllm_config.scheduler_config.max_num_seqs
30 model_tasks = tuple(sorted(self.model.pooler.get_supported_tasks()))
36 - enabled_tasks = self._get_enabled_tasks(self.model_config)
31 selected_task = self.model_config.get_pooling_task(model_tasks)
38 - if selected_task not in enabled_tasks:
32 + if selected_task not in _SUPPORTED_TASKS:
33 hint = (
34 "Set an explicitly supported task or VLLM_USE_V2_MODEL_RUNNER=0."
41 - if enabled_tasks.intersection(model_tasks)
35 + if _SUPPORTED_TASKS.intersection(model_tasks)
36 else "Set VLLM_USE_V2_MODEL_RUNNER=0 to use this model."
37 )
38 raise ValueError(
39 "Model Runner V2 supports pooling tasks "
46 - f"{sorted(enabled_tasks)}, but this model selects "
40 + f"{sorted(_SUPPORTED_TASKS)}, but this model selects "
41 f"{selected_task!r} from {list(model_tasks)}. {hint}"
42 )
49 - self.supported_tasks = frozenset(
50 - self.get_supported_tasks(model, self.model_config)
51 - )
43 + self.supported_tasks = frozenset(self.get_supported_tasks(model))
44 if not self.supported_tasks:
45 raise ValueError(
46 "Model Runner V2 supports pooling tasks "
55 - f"{sorted(enabled_tasks)}, but this model supports "
47 + f"{sorted(_SUPPORTED_TASKS)}, but this model supports "
48 f"{list(model_tasks)}. "
49 "Set VLLM_USE_V2_MODEL_RUNNER=0 to use this model."
50 )
@@ -62,20 +54,10 @@ class PoolingRunner:
54 self.late_interaction_runner = LateInteractionRunner()
55
56 @staticmethod
65 - def _get_enabled_tasks(model_config: ModelConfig) -> frozenset[PoolingTask]:
66 - # Token-wise pooling is validated only for unchunked encoder-only prefill.
67 - if model_config.attn_type == "encoder_only":
68 - return _SUPPORTED_TASKS
69 - return _SUPPORTED_TASKS - _TOKEN_TASKS
70 -
71 - @staticmethod
72 - def get_supported_tasks(
73 - model: nn.Module, model_config: ModelConfig
74 - ) -> list[PoolingTask]:
57 + def get_supported_tasks(model: nn.Module) -> list[PoolingTask]:
58 if not is_pooling_model(model):
59 return []
77 - enabled_tasks = PoolingRunner._get_enabled_tasks(model_config)
78 - return sorted(model.pooler.get_supported_tasks() & enabled_tasks)
60 + return sorted(model.pooler.get_supported_tasks() & _SUPPORTED_TASKS)
61
62 def add_request(
63 self,