| 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] |