main
py 228 lines 7.37 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """CPU-only tests for the V2 custom logits processor loader.
4
5 Load-time validation is pure Python (importlib + issubclass), so it runs
6 without CUDA. Pipeline behavior is covered by test_gpu_logits_processors.py.
7 """
8
9 import subprocess
10 import sys
11 from enum import Enum, auto
12 from types import SimpleNamespace
13 from typing import Any
14
15 import pytest
16 import torch
17
18 import vllm.v1.worker.gpu.sample.logits_processor.loader as loader
19 from vllm.exceptions import VLLMValidationError
20 from vllm.sampling_params import SamplingParams
21 from vllm.v1.sample.logits_processor import (
22 LogitsProcessor as V1LogitsProcessor,
23 )
24 from vllm.v1.worker.gpu.sample.logits_processor import (
25 LogitsContext,
26 LogitsProcessor,
27 )
28
29
30 class DummyV2Processor(LogitsProcessor):
31 """Records its constructor args so tests can assert how it was built."""
32
33 def __init__(self, vllm_config: Any, req_state: Any):
34 self.ctor_args = (vllm_config, req_state)
35
36 def apply(self, logits: torch.Tensor, ctx: LogitsContext) -> torch.Tensor:
37 return logits
38
39
40 class AltV2Processor(DummyV2Processor):
41 """Second V2 processor, used to assert plugin/FQCN load ordering."""
42
43
44 class DummyV1Processor(V1LogitsProcessor):
45 """A V1-interface processor. Never instantiated: the loader must reject
46 it at the issubclass check, before any constructor runs."""
47
48
49 def not_a_processor() -> None:
50 """FQCN target that resolves but is not a class."""
51
52
53 def _fake_entrypoint(cls: type) -> SimpleNamespace:
54 return SimpleNamespace(
55 name=cls.__name__.lower(),
56 value=f"fake:{cls.__name__}",
57 load=lambda: cls,
58 )
59
60
61 @pytest.fixture(autouse=True)
62 def no_installed_plugins(monkeypatch: pytest.MonkeyPatch):
63 """Isolate tests from entry points registered in the environment."""
64 monkeypatch.setattr(loader, "entry_points", lambda group: [])
65
66
67 class ProcSource(Enum):
68 CLASS = auto()
69 FQCN = auto()
70 ENTRYPOINT = auto()
71
72
73 @pytest.mark.parametrize("source", list(ProcSource))
74 def test_loads_v2_processors(monkeypatch: pytest.MonkeyPatch, source: ProcSource):
75 """A V2 subclass loads via class object, FQCN string, or entrypoint."""
76 fake_req_states = SimpleNamespace(
77 device="cpu",
78 max_num_reqs=4,
79 vocab_size=128,
80 all_token_ids=None,
81 prompt_len=None,
82 prefill_len=None,
83 total_len=None,
84 )
85 custom: list[Any]
86 if source is ProcSource.CLASS:
87 custom = [DummyV2Processor]
88 elif source is ProcSource.FQCN:
89 custom = [f"{__name__}:DummyV2Processor"]
90 else:
91 monkeypatch.setattr(
92 loader, "entry_points", lambda group: [_fake_entrypoint(DummyV2Processor)]
93 )
94 # Plugins load before user-specified processors.
95 custom = [f"{__name__}:AltV2Processor"]
96
97 procs = loader.build_custom_logits_processors(None, fake_req_states, False, custom)
98
99 if source is ProcSource.ENTRYPOINT:
100 assert [type(p) for p in procs] == [DummyV2Processor, AltV2Processor]
101 else:
102 assert [type(p) for p in procs] == [DummyV2Processor]
103 vllm_config, req_state = procs[0].ctor_args
104 assert vllm_config is None
105 assert isinstance(req_state, loader.LogitsProcRequestState)
106 assert req_state.max_num_reqs == 4
107
108
109 @pytest.mark.parametrize(
110 ("custom", "plugin_cls", "exc_type", "msg_fragment"),
111 [
112 pytest.param(
113 [DummyV1Processor],
114 None,
115 ValueError,
116 "not a subclass",
117 id="v1-class-object",
118 ),
119 pytest.param(
120 [f"{__name__}:DummyV1Processor"],
121 None,
122 ValueError,
123 "not a subclass",
124 id="v1-fqcn",
125 ),
126 pytest.param(
127 [],
128 DummyV1Processor,
129 ValueError,
130 "V1-interface plugins are not supported",
131 id="v1-entrypoint",
132 ),
133 pytest.param(
134 [f"{__name__}:not_a_processor"],
135 None,
136 ValueError,
137 "must be a type",
138 id="non-type-fqcn",
139 ),
140 pytest.param(
141 ["no.such.module:Nope"],
142 None,
143 RuntimeError,
144 "no.such.module:Nope",
145 id="missing-module",
146 ),
147 pytest.param(
148 ["no_colon_here"],
149 None,
150 ValueError,
151 "Expected format",
152 id="fqcn-missing-colon",
153 ),
154 pytest.param(
155 ["too:many:colons"],
156 None,
157 ValueError,
158 "Expected format",
159 id="fqcn-extra-colons",
160 ),
161 ],
162 )
163 def test_rejects_invalid(
164 monkeypatch: pytest.MonkeyPatch,
165 custom: list,
166 plugin_cls: type | None,
167 exc_type: type[Exception],
168 msg_fragment: str,
169 ):
170 """V1 processors and malformed references fail at load time, not at
171 sampling time, with a message naming the offending reference."""
172 if plugin_cls is not None:
173 monkeypatch.setattr(
174 loader, "entry_points", lambda group: [_fake_entrypoint(plugin_cls)]
175 )
176
177 with pytest.raises(exc_type, match=msg_fragment):
178 loader.build_custom_logits_processors(None, None, False, custom)
179
180
181 def test_pooling_model_rejects_custom_logitsprocs():
182 """Pooling models reject custom processors instead of ignoring them."""
183 with pytest.raises(ValueError, match="Pooling models do not support"):
184 loader.build_custom_logits_processors(None, None, True, [DummyV2Processor])
185 assert loader.build_custom_logits_processors(None, None, True, []) == []
186
187
188 class ValidatingProcessor(DummyV2Processor):
189 """Rejects target_token=-1 via the optional validate_params hook."""
190
191 @classmethod
192 def validate_params(cls, sampling_params: SamplingParams) -> None:
193 if (sampling_params.extra_args or {}).get("target_token") == -1:
194 raise ValueError("target_token must not be -1")
195
196
197 def test_validate_params_runs_at_admission():
198 """The factory loads classes up front; the returned validator runs
199 validate_params per request and wraps rejections as VLLMValidationError."""
200 loader._cached_load_v2_logitsprocs.cache_clear()
201 validate = loader.build_custom_logits_processors_params_validator(
202 [ValidatingProcessor]
203 )
204 validate(SamplingParams(extra_args={"target_token": 1}))
205 with pytest.raises(VLLMValidationError, match="target_token"):
206 validate(SamplingParams(extra_args={"target_token": -1}))
207
208
209 def test_validator_factory_loads_classes_eagerly():
210 """A bad reference fails when the validator is built (at startup), not on
211 the first request."""
212 loader._cached_load_v2_logitsprocs.cache_clear()
213 with pytest.raises(RuntimeError, match="no.such.module:Nope"):
214 loader.build_custom_logits_processors_params_validator(["no.such.module:Nope"])
215
216
217 def test_loader_import_stays_frontend_safe():
218 """The frontend process imports the loader to validate params; importing
219 it must not pull in model-runner side modules."""
220 code = (
221 "import sys\n"
222 "import vllm.v1.worker.gpu.sample.logits_processor.loader\n"
223 "assert 'vllm.sampling_params' not in sys.modules\n"
224 "assert 'vllm.v1.sample.logits_processor' not in sys.modules\n"
225 "assert 'vllm.v1.worker.gpu.states' not in sys.modules\n"
226 "assert 'vllm.v1.worker.gpu.buffer_utils' not in sys.modules\n"
227 )
228 subprocess.run([sys.executable, "-c", code], check=True)