| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | import pytest |
| 4 | import torch.nn as nn |
| 5 | |
| 6 | from vllm.platforms import current_platform |
| 7 | |
| 8 | prompt = """ |
| 9 | Generals gathered in their masses |
| 10 | Just like witches at black masses |
| 11 | Evil minds that plot destruction |
| 12 | Sorcerer of death's construction |
| 13 | In the fields, the bodies burning |
| 14 | As the war machine keeps turning |
| 15 | Death and hatred to mankind |
| 16 | Poisoning their brainwashed minds |
| 17 | Oh, Lord, yeah |
| 18 | |
| 19 | Politicians hide themselves away |
| 20 | They only started the war |
| 21 | Why should they go out to fight? |
| 22 | They leave that all to the poor, yeah |
| 23 | Time will tell on their power minds |
| 24 | Making war just for fun |
| 25 | Treating people just like pawns in chess |
| 26 | Wait till their judgment day comes, yeah |
| 27 | |
| 28 | Now, in darkness, world stops turning |
| 29 | Ashes where their bodies burning |
| 30 | No more war pigs have the power |
| 31 | Hand of God has struck the hour |
| 32 | Day of Judgment, God is calling |
| 33 | On their knees, the war pigs crawling |
| 34 | Begging mercies for their sins |
| 35 | Satan, laughing, spreads his wings |
| 36 | Oh, Lord, yeah |
| 37 | """ |
| 38 | |
| 39 | |
| 40 | class WrapperPooler(nn.Module): |
| 41 | def __init__(self, pooler): |
| 42 | super().__init__() |
| 43 | self.pooler = pooler |
| 44 | self.chunks = [] |
| 45 | |
| 46 | def get_pooling_updates(self, task): |
| 47 | return self.pooler.get_pooling_updates(task) |
| 48 | |
| 49 | def forward( |
| 50 | self, |
| 51 | hidden_states, |
| 52 | pooling_metadata, |
| 53 | ): |
| 54 | self.chunks.append(hidden_states.shape[0]) |
| 55 | return self.pooler(hidden_states, pooling_metadata) |
| 56 | |
| 57 | |
| 58 | def inject_pooler(self): |
| 59 | model = self.get_model() |
| 60 | wrapper = WrapperPooler(model.pooler) |
| 61 | model.pooler = wrapper |
| 62 | |
| 63 | |
| 64 | def retrieve_chunks(self): |
| 65 | model = self.get_model() |
| 66 | chunks = model.pooler.chunks |
| 67 | model.pooler.chunks = [] |
| 68 | return chunks |
| 69 | |
| 70 | |
| 71 | @pytest.mark.skipif(not current_platform.is_cuda(), reason="CUDA not available") |
| 72 | def test_pooling_chunked_prefill(vllm_runner, monkeypatch): |
| 73 | """Test chunked prefill for pooling models with LastPool.""" |
| 74 | with monkeypatch.context() as m: |
| 75 | m.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1") |
| 76 | model_id = "Qwen/Qwen3-Embedding-0.6B" |
| 77 | |
| 78 | chunk_size = 10 |
| 79 | |
| 80 | # Cap the token budget to force chunked prefill; the |
| 81 | # long_prefill_token_threshold is not applied to a lone request. |
| 82 | with vllm_runner( |
| 83 | model_id, |
| 84 | runner="pooling", |
| 85 | max_num_batched_tokens=chunk_size, |
| 86 | max_num_seqs=1, |
| 87 | tensor_parallel_size=1, |
| 88 | enforce_eager=True, |
| 89 | enable_chunked_prefill=True, |
| 90 | ) as llm: |
| 91 | llm.get_llm().llm_engine.collective_rpc(inject_pooler) |
| 92 | |
| 93 | tokenizer = llm.get_llm().get_tokenizer() |
| 94 | tokens = tokenizer(prompt)["input_ids"] |
| 95 | prompt_len = len(tokens) |
| 96 | full_chunks, last_chunk = divmod(prompt_len, chunk_size) |
| 97 | expected_chunks = [chunk_size] * full_chunks |
| 98 | if last_chunk: |
| 99 | expected_chunks.append(last_chunk) |
| 100 | llm.embed([prompt]) |
| 101 | chunks = llm.get_llm().llm_engine.collective_rpc(retrieve_chunks)[0] |
| 102 | |
| 103 | # Check that PoolerWrapper was called and chunks were received |
| 104 | assert len(chunks) > 1 |
| 105 | assert chunks == expected_chunks |
| 106 | |
| 107 | # Disable chunked prefill |
| 108 | with vllm_runner( |
| 109 | model_id, |
| 110 | runner="pooling", |
| 111 | tensor_parallel_size=1, |
| 112 | enforce_eager=True, |
| 113 | ) as llm: |
| 114 | llm.get_llm().llm_engine.collective_rpc(inject_pooler) |
| 115 | llm.embed([prompt]) |
| 116 | chunks = llm.get_llm().llm_engine.collective_rpc(retrieve_chunks)[0] |
| 117 | |
| 118 | # Check that PoolerWrapper was called and no chunks were received |
| 119 | assert len(chunks) == 1 |
| 120 | assert chunks[0] == prompt_len |
| 121 | |
| 122 | |
| 123 | @pytest.mark.skipif(not current_platform.is_cuda(), reason="CUDA not available") |
| 124 | def test_pooling_prefix_cache(vllm_runner, monkeypatch): |
| 125 | """Test chunked prefill for pooling models with LastPool.""" |
| 126 | verses = prompt.split("\n\n") |
| 127 | |
| 128 | with monkeypatch.context() as m: |
| 129 | m.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1") |
| 130 | model_id = "Qwen/Qwen3-Embedding-0.6B" |
| 131 | |
| 132 | with vllm_runner( |
| 133 | model_id, |
| 134 | runner="pooling", |
| 135 | enable_prefix_caching=True, |
| 136 | tensor_parallel_size=1, |
| 137 | enforce_eager=True, |
| 138 | ) as llm: |
| 139 | llm.get_llm().llm_engine.collective_rpc(inject_pooler) |
| 140 | tokenizer = llm.get_llm().get_tokenizer() |
| 141 | |
| 142 | prompt1 = "\n\n".join([verses[0], verses[1]]) |
| 143 | prompt2 = "\n\n".join([verses[0], verses[2]]) |
| 144 | tokens1 = tokenizer(prompt1)["input_ids"] |
| 145 | tokens2 = tokenizer(prompt2)["input_ids"] |
| 146 | prompt1_len = len(tokens1) |
| 147 | prompt2_len = len(tokens2) |
| 148 | |
| 149 | llm.embed([prompt1]) |
| 150 | chunks = llm.get_llm().llm_engine.collective_rpc(retrieve_chunks)[0] |
| 151 | |
| 152 | assert len(chunks) == 1 |
| 153 | assert chunks[0] == prompt1_len |
| 154 | |
| 155 | llm.embed([prompt2]) |
| 156 | chunks = llm.get_llm().llm_engine.collective_rpc(retrieve_chunks)[0] |
| 157 | |
| 158 | assert len(chunks) == 1 |
| 159 | assert chunks[0] <= prompt1_len |
| 160 | assert chunks[0] < prompt2_len |
| 161 | |
| 162 | vllm_config = llm.get_llm().llm_engine.vllm_config |
| 163 | cache_config = vllm_config.cache_config |
| 164 | print(f"{cache_config=}") |
| 165 | # Prefixes are cached in blocks |
| 166 | assert (prompt2_len - chunks[0]) % cache_config.block_size == 0 |