main
py 364 lines 11.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from dataclasses import dataclass
5 from enum import Enum
6
7 import pytest
8
9 from vllm.config.cache import CacheConfig
10 from vllm.config.scheduler import SchedulerConfig
11 from vllm.config.utils import (
12 get_attr_docs,
13 get_hash_factors,
14 hash_factors,
15 normalize_value,
16 )
17
18 # Helpers
19
20
21 def endswith_fqname(obj, suffix: str) -> bool:
22 # normalize_value(type) returns fully-qualified name
23 # Compare suffix to avoid brittle import paths.
24 out = normalize_value(obj)
25 return isinstance(out, str) and out.endswith(suffix)
26
27
28 def expected_path(p_str: str = ".") -> str:
29 import pathlib
30
31 p = pathlib.Path(p_str)
32 return p.expanduser().resolve().as_posix()
33
34
35 # Minimal dataclass to test get_hash_factors.
36 # Avoid importing heavy vLLM configs.
37 @dataclass
38 class SimpleConfig:
39 a: object
40 b: object | None = None
41
42
43 class DummyLogprobsMode(Enum):
44 RAW_LOGITS = "raw_logits"
45
46
47 def test_hash_factors_deterministic():
48 """Test that hash_factors produces consistent SHA-256 hashes."""
49 factors = {"a": 1, "b": "test"}
50 hash1 = hash_factors(factors)
51 hash2 = hash_factors(factors)
52
53 assert hash1 == hash2
54 # Dict key insertion order should not affect the hash.
55 factors_reordered = {"b": "test", "a": 1}
56 assert hash_factors(factors_reordered) == hash1
57 assert len(hash1) == 64
58 assert all(c in "0123456789abcdef" for c in hash1)
59
60
61 @pytest.mark.parametrize(
62 "inp, expected",
63 [
64 (None, None),
65 (True, True),
66 (1, 1),
67 (1.0, 1.0),
68 ("x", "x"),
69 (b"ab", "6162"),
70 (bytearray(b"ab"), "6162"),
71 ([1, 2], (1, 2)),
72 ({"b": 2, "a": 1}, (("a", 1), ("b", 2))),
73 ],
74 )
75 def test_normalize_value_matrix(inp, expected):
76 """Parametric input→expected normalization table."""
77 assert normalize_value(inp) == expected
78
79
80 def test_normalize_value_enum():
81 # Enums normalize to (module.QualName, value).
82 # DummyLogprobsMode uses a string payload.
83 out = normalize_value(DummyLogprobsMode.RAW_LOGITS)
84 assert isinstance(out, tuple)
85 assert out[0].endswith("DummyLogprobsMode")
86 # Expect string payload 'raw_logits'.
87 assert out[1] == "raw_logits"
88
89
90 def test_normalize_value_set_order_insensitive():
91 # Sets are unordered; normalize_value sorts elements for determinism.
92 assert normalize_value({3, 1, 2}) == normalize_value({1, 2, 3})
93
94
95 def test_normalize_value_path_normalization():
96 from pathlib import Path # local import to avoid global dependency
97
98 # Paths expand/resolve to absolute strings.
99 # Stabilizes hashing across working dirs.
100 assert normalize_value(Path(".")) == expected_path(".")
101
102
103 def test_normalize_value_uuid_and_to_json():
104 # Objects may normalize via uuid() or to_json_string().
105 class HasUUID:
106 def uuid(self):
107 return "test-uuid"
108
109 class ToJson:
110 def to_json_string(self):
111 return '{"x":1}'
112
113 assert normalize_value(HasUUID()) == "test-uuid"
114 assert normalize_value(ToJson()) == '{"x":1}'
115
116
117 @pytest.mark.parametrize(
118 "bad",
119 [
120 (lambda x: x),
121 (type("CallableInstance", (), {"__call__": lambda self: 0}))(),
122 (lambda: lambda: 0)(), # nested function instance
123 ],
124 )
125 def test_error_cases(bad):
126 """Inputs expected to raise TypeError."""
127 # Reject functions/lambdas/callable instances
128 # to avoid under-hashing.
129 with pytest.raises(TypeError):
130 normalize_value(bad)
131
132
133 def test_enum_vs_int_disambiguation():
134 # int stays primitive
135 nf_int = normalize_value(1)
136 assert nf_int == 1
137
138 # enum becomes ("module.QualName", value)
139 nf_enum = normalize_value(DummyLogprobsMode.RAW_LOGITS)
140 assert isinstance(nf_enum, tuple) and len(nf_enum) == 2
141 enum_type, enum_val = nf_enum
142 assert enum_type.endswith(".DummyLogprobsMode")
143 assert enum_val == "raw_logits"
144
145 # Build factor dicts from configs with int vs enum
146 f_int = get_hash_factors(SimpleConfig(1), set())
147 f_enum = get_hash_factors(SimpleConfig(DummyLogprobsMode.RAW_LOGITS), set())
148 # The int case remains a primitive value
149 assert f_int["a"] == 1
150 # The enum case becomes a tagged tuple ("module.QualName", "raw_logits")
151 assert isinstance(f_enum["a"], tuple) and f_enum["a"][1] == "raw_logits"
152 # Factor dicts must differ so we don't collide primitives with Enums.
153 assert f_int != f_enum
154 # Hash digests must differ correspondingly
155 assert hash_factors(f_int) != hash_factors(f_enum)
156
157 # Hash functions produce stable hex strings
158 h_int = hash_factors(f_int)
159 h_enum = hash_factors(f_enum)
160 assert isinstance(h_int, str) and len(h_int) == 64
161 assert isinstance(h_enum, str) and len(h_enum) == 64
162
163
164 def test_classes_are_types():
165 """Types normalize to FQNs; include real vLLM types."""
166 # Only classes allowed; functions/lambdas are rejected.
167 # Canonical form is the fully-qualified name.
168 assert isinstance(normalize_value(str), str)
169
170 class LocalDummy:
171 pass
172
173 assert endswith_fqname(LocalDummy, ".LocalDummy")
174
175
176 def test_ir_config_hash_initializes_provider_and_tracks_implementation(monkeypatch):
177 from types import SimpleNamespace
178
179 from vllm.config.kernel import IrOpPriorityConfig
180 from vllm.ir.op import IrOp
181 from vllm.platforms import current_platform
182
183 op = IrOp.registry["rms_norm"]
184 implementation = SimpleNamespace(uuid=lambda: "first-implementation")
185
186 def import_ir_kernels():
187 monkeypatch.setitem(op.impls, "test_lazy_provider", implementation)
188
189 monkeypatch.setattr(current_platform, "import_ir_kernels", import_ir_kernels)
190 config = IrOpPriorityConfig(rms_norm=["test_lazy_provider"])
191 first_hash = config.compute_hash()
192 assert config.compute_hash() == first_hash
193
194 implementation.uuid = lambda: "changed-implementation"
195 assert config.compute_hash() != first_hash
196
197
198 def test_get_attr_docs_includes_inherited_fields():
199 """get_attr_docs must collect attribute docstrings inherited from base
200 classes, not just those declared in the class's own body."""
201
202 @dataclass
203 class Base:
204 inherited: object = None
205 """doc for inherited (base)."""
206 overridden: object = None
207 """base version of overridden."""
208
209 @dataclass
210 class Derived(Base):
211 own: object = None
212 """doc for own (derived)."""
213 overridden: object = None
214 """derived override of overridden."""
215
216 docs = get_attr_docs(Derived)
217
218 assert docs["own"] == "doc for own (derived)."
219 assert docs["inherited"] == "doc for inherited (base)."
220 assert docs["overridden"] == "derived override of overridden."
221
222
223 def test_envs_compile_factors_stable():
224 """Test that envs.compile_factors() hash is stable across fresh initializations.
225
226 Uses subprocesses to ensure env vars with dynamic defaults (like UUIDs)
227 are freshly generated each time, verifying they're properly ignored.
228 """
229 import subprocess
230 import sys
231
232 code = """
233 import sys
234 import logging
235 logging.disable(logging.CRITICAL)
236 from vllm import envs
237 from vllm.config.utils import hash_factors
238 print(hash_factors(envs.compile_factors()))
239 """
240
241 def get_hash_in_subprocess():
242 result = subprocess.run(
243 [sys.executable, "-c", code],
244 capture_output=True,
245 text=True,
246 check=True,
247 env={**dict(__import__("os").environ), "VLLM_LOGGING_LEVEL": "ERROR"},
248 )
249 return result.stdout.strip()
250
251 hash1 = get_hash_in_subprocess()
252 hash2 = get_hash_in_subprocess()
253
254 assert hash1 == hash2, (
255 "compile_factors hash differs between fresh initializations - "
256 "dynamic env vars may not be properly ignored"
257 )
258
259
260 def test_cache_config_hash_ignores_kv_cache_sizing_knobs():
261 """kv_cache_memory_bytes only sizes the KV cache allocation (like
262 gpu_memory_utilization, which is already ignored); it does not affect
263 the compiled computation graph. If it leaks into the hash, setting the
264 documented fast-boot knob silently invalidates the torch.compile cache
265 and forces a full recompile.
266 """
267 base_hash = CacheConfig().compute_hash()
268 assert CacheConfig(kv_cache_memory_bytes=1 << 30).compute_hash() == base_hash
269 assert CacheConfig(gpu_memory_utilization=0.5).compute_hash() == base_hash
270 config = CacheConfig()
271 config.effective_attention_block_size = 64
272 assert config.compute_hash() == base_hash
273
274
275 def test_scheduler_config_hash_includes_max_num_seqs():
276 """Per-request workspace sizes must invalidate compiled graphs."""
277 base_hash = SchedulerConfig(
278 max_model_len=8192,
279 is_encoder_decoder=False,
280 max_num_batched_tokens=8192,
281 max_num_seqs=128,
282 ).compute_hash()
283 larger_batch_hash = SchedulerConfig(
284 max_model_len=8192,
285 is_encoder_decoder=False,
286 max_num_batched_tokens=8192,
287 max_num_seqs=1024,
288 ).compute_hash()
289
290 assert larger_batch_hash != base_hash
291
292
293 def test_cache_config_hash_ignores_prefix_cache_retention_interval():
294 base_hash = CacheConfig().compute_hash()
295 assert CacheConfig(prefix_cache_retention_interval=64).compute_hash() == base_hash
296
297
298 def test_envs_compile_factors_relocation_invariant(tmp_path):
299 """Relocating HOME or the XDG roots must not change the compile-cache
300 env hash.
301
302 Location-derived env vars (VLLM_XLA_CACHE_PATH from XDG_CACHE_HOME,
303 VLLM_CONFIG_ROOT from XDG_CONFIG_HOME/HOME) carry no information about
304 compiled artifacts, only about where directories live. When they leak
305 into compile_factors(), a cache produced under one HOME/XDG layout
306 silently misses under another - which defeats copying or pre-baking a
307 compile cache into a container image.
308 """
309 import os
310 import subprocess
311 import sys
312
313 code = """
314 import sys
315 import logging
316 logging.disable(logging.CRITICAL)
317 from vllm import envs
318 from vllm.config.utils import hash_factors
319 print(hash_factors(envs.compile_factors()))
320 """
321
322 def hash_with(extra_env):
323 env = {**dict(os.environ), "VLLM_LOGGING_LEVEL": "ERROR"}
324 # Drop explicit overrides so the derived defaults are what is
325 # exercised, then apply the relocation under test.
326 for key in ("VLLM_XLA_CACHE_PATH", "VLLM_CONFIG_ROOT", "VLLM_CACHE_ROOT"):
327 env.pop(key, None)
328 env.update(extra_env)
329 result = subprocess.run(
330 [sys.executable, "-c", code],
331 capture_output=True,
332 text=True,
333 check=True,
334 env=env,
335 )
336 return result.stdout.strip()
337
338 xdg_cache = tmp_path / "relocated-xdg-cache"
339 xdg_config = tmp_path / "relocated-xdg-config"
340 new_home = tmp_path / "relocated-home"
341 for d in (xdg_cache, xdg_config, new_home):
342 d.mkdir()
343
344 base = hash_with({})
345 relocated_xdg = hash_with(
346 {"XDG_CACHE_HOME": str(xdg_cache), "XDG_CONFIG_HOME": str(xdg_config)}
347 )
348 relocated_home = hash_with({"HOME": str(new_home)})
349
350 assert relocated_xdg == base, (
351 "XDG_CACHE_HOME/XDG_CONFIG_HOME relocation changed the compile-cache "
352 "env hash - a location-only derived var is leaking into the key"
353 )
354 assert relocated_home == base, (
355 "HOME relocation changed the compile-cache env hash - a "
356 "location-only derived var is leaking into the key"
357 )
358
359
360 def test_get_attr_docs_rejects_non_class():
361 """The annotation forbids it, but the runtime guard still protects
362 untyped callers, so keep asserting the documented TypeError."""
363 with pytest.raises(TypeError, match="Given object was not a class."):
364 get_attr_docs(object()) # type: ignore[arg-type]