main
py 512 lines 18 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import os
5 from types import SimpleNamespace
6
7 import pytest
8
9 from vllm.config import ParallelConfig
10 from vllm.utils import numa_utils
11
12
13 @pytest.fixture(autouse=True)
14 def _disable_pct_by_default(monkeypatch):
15 """Force PCT detection OFF unless a test opts in via ``_patch_pct_gates``.
16
17 The CI / dev machines themselves can be Xeon 6776P with PCT enabled, so a
18 plain ``cache_clear`` would let the gate auto-detect ``True`` from the
19 live filesystem and silently re-route "baseline" tests through the PCT
20 path. Stub ``/proc/cpuinfo`` and ``acpi_cppc/highest_perf`` to a state
21 that fails the gate; ``_patch_pct_gates`` re-stubs on top when needed.
22 """
23 from io import StringIO
24
25 real_open = open
26
27 def _no_pct_open(path, *args, **kwargs):
28 if path == numa_utils._PROC_CPUINFO_PATH:
29 return StringIO("processor\t: 0\nmodel name\t: Generic Test CPU\n")
30 if path == numa_utils._PCT_HIGHEST_PERF_PATH:
31 raise OSError("PCT disabled by autouse fixture")
32 return real_open(path, *args, **kwargs)
33
34 monkeypatch.setattr("builtins.open", _no_pct_open)
35 numa_utils._pct_sku_config.cache_clear()
36 yield
37 numa_utils._pct_sku_config.cache_clear()
38
39
40 def _make_config(**parallel_kwargs):
41 parallel_defaults = dict(
42 numa_bind=False,
43 numa_bind_nodes=None,
44 numa_bind_cpus=None,
45 distributed_executor_backend="mp",
46 data_parallel_backend="mp",
47 nnodes_within_dp=1,
48 data_parallel_rank_local=0,
49 data_parallel_index=0,
50 pipeline_parallel_size=1,
51 tensor_parallel_size=1,
52 )
53 parallel_defaults.update(parallel_kwargs)
54 parallel_config = SimpleNamespace(**parallel_defaults)
55 return SimpleNamespace(parallel_config=parallel_config)
56
57
58 def test_get_numactl_args_with_node_binding():
59 vllm_config = _make_config(numa_bind=True, numa_bind_nodes=[0, 1])
60 assert (
61 numa_utils._get_numactl_worker_args(vllm_config.parallel_config, local_rank=1)
62 == "--cpunodebind=1 --membind=1"
63 )
64
65
66 def test_get_numactl_args_with_cpu_binding():
67 vllm_config = _make_config(
68 numa_bind=True,
69 numa_bind_nodes=[0, 1],
70 numa_bind_cpus=["0-3", "4-7"],
71 )
72 assert (
73 numa_utils._get_numactl_worker_args(vllm_config.parallel_config, local_rank=1)
74 == "--physcpubind=4-7 --membind=1"
75 )
76
77
78 def _patch_pct_gates(
79 monkeypatch,
80 *,
81 model_match: bool,
82 highest_perf: int | None,
83 cpulist: str | None = "0-31,64-95",
84 cpulist_by_node: dict[int, str | None] | None = None,
85 sku: str = "6776P",
86 ):
87 """Force `_pct_sku_config` and node cpulist read to deterministic state.
88
89 ``cpulist`` is the default returned for any node not present in
90 ``cpulist_by_node``. ``cpulist_by_node`` lets a test return different
91 cpulists for different NUMA nodes (e.g. node 0 vs node 1). ``sku`` lets
92 the test pick which Granite Rapids SKU appears in the fake
93 ``/proc/cpuinfo`` ``model name`` (only used when ``model_match=True``).
94 """
95 import pathlib
96 from io import StringIO
97
98 import regex as re
99
100 cpuinfo = (
101 f"processor\t: 0\nmodel name\t: Intel(R) Xeon(R) Platinum {sku} CPU @ 2.40GHz\n"
102 if model_match
103 else "processor\t: 0\nmodel name\t: Intel(R) Xeon(R) Platinum 8480+\n"
104 )
105
106 real_open = open
107
108 def fake_open(path, *args, **kwargs):
109 if path == numa_utils._PROC_CPUINFO_PATH:
110 return StringIO(cpuinfo)
111 if path == numa_utils._PCT_HIGHEST_PERF_PATH:
112 if highest_perf is None:
113 raise OSError("missing")
114 return StringIO(f"{highest_perf}\n")
115 return real_open(path, *args, **kwargs)
116
117 real_read_text = pathlib.Path.read_text
118 cpulist_by_node = cpulist_by_node or {}
119
120 def fake_read_text(self, *args, **kwargs):
121 path_str = str(self)
122 if path_str.endswith("/cpulist") and "/sys/devices/system/node" in path_str:
123 match = re.search(r"/node(\d+)/cpulist$", path_str)
124 if match:
125 node_id = int(match.group(1))
126 if node_id in cpulist_by_node:
127 val = cpulist_by_node[node_id]
128 if val is None:
129 raise OSError(f"missing cpulist for node{node_id}")
130 return val
131 if cpulist is None:
132 raise OSError("missing cpulist")
133 return cpulist
134 return real_read_text(self, *args, **kwargs)
135
136 monkeypatch.setattr("builtins.open", fake_open)
137 monkeypatch.setattr("pathlib.Path.read_text", fake_read_text)
138 numa_utils._pct_sku_config.cache_clear()
139
140
141 def test_pct_binding_filters_cpus(monkeypatch):
142 _patch_pct_gates(monkeypatch, model_match=True, highest_perf=46)
143 assert numa_utils._maybe_get_pct_cpu_binding([0]) == [0, 1, 16, 17, 64, 65, 80, 81]
144
145
146 @pytest.mark.parametrize(
147 "sku,expected_cpus",
148 [
149 # 64-core SKUs (stride 16): cpus from "0-31,64-95" with cpu_id % 16
150 # in (0, 1) -> 0, 1, 16, 17, 64, 65, 80, 81.
151 ("6776P", [0, 1, 16, 17, 64, 65, 80, 81]),
152 ("6774P", [0, 1, 16, 17, 64, 65, 80, 81]),
153 # 72-core SKU (stride 18): cpus from "0-31,64-95" with cpu_id % 18
154 # in (0, 1) -> 0, 1, 18, 19, 72, 73, 90, 91.
155 ("6962P", [0, 1, 18, 19, 72, 73, 90, 91]),
156 ],
157 )
158 def test_pct_binding_fires_on_every_capable_sku(monkeypatch, sku, expected_cpus):
159 """Each SKU in ``_PCT_CAPABLE_SKUS`` engages the gate at its own
160 expected ``highest_perf`` and uses its own priority-core stride."""
161 sku_config = numa_utils._PCT_CAPABLE_SKUS[sku]
162 _patch_pct_gates(
163 monkeypatch,
164 model_match=True,
165 highest_perf=sku_config.highest_perf,
166 sku=sku,
167 )
168 assert numa_utils._maybe_get_pct_cpu_binding([0]) == expected_cpus
169
170
171 def test_pct_binding_fails_closed_when_sku_perf_mismatch(monkeypatch):
172 """6962P with 6776P's highest_perf (46 vs expected 44) must fail closed."""
173 _patch_pct_gates(monkeypatch, model_match=True, highest_perf=46, sku="6962P")
174 assert numa_utils._maybe_get_pct_cpu_binding([0]) is None
175
176
177 def test_pct_binding_disabled_when_cpu_model_mismatch(monkeypatch):
178 _patch_pct_gates(monkeypatch, model_match=False, highest_perf=46)
179 assert numa_utils._maybe_get_pct_cpu_binding([0]) is None
180
181
182 def test_pct_binding_disabled_when_highest_perf_does_not_match(monkeypatch):
183 _patch_pct_gates(monkeypatch, model_match=True, highest_perf=42)
184 assert numa_utils._maybe_get_pct_cpu_binding([0]) is None
185
186
187 def test_pct_binding_disabled_when_files_missing(monkeypatch):
188 _patch_pct_gates(monkeypatch, model_match=True, highest_perf=None)
189 assert numa_utils._maybe_get_pct_cpu_binding([0]) is None
190
191
192 def test_pct_binding_returns_none_when_node_cpulist_filter_empty(monkeypatch):
193 _patch_pct_gates(
194 monkeypatch,
195 model_match=True,
196 highest_perf=46,
197 cpulist="2-15,18-31",
198 )
199 assert numa_utils._maybe_get_pct_cpu_binding([0]) is None
200
201
202 def test_pct_binding_returns_none_when_node_cpulist_missing(monkeypatch):
203 _patch_pct_gates(monkeypatch, model_match=True, highest_perf=46, cpulist=None)
204 assert numa_utils._maybe_get_pct_cpu_binding([0]) is None
205
206
207 def test_get_numactl_args_uses_pct_when_user_did_not_specify_cpus(monkeypatch):
208 _patch_pct_gates(monkeypatch, model_match=True, highest_perf=46)
209 vllm_config = _make_config(numa_bind=True, numa_bind_nodes=[0, 1])
210 assert (
211 numa_utils._get_numactl_worker_args(vllm_config.parallel_config, local_rank=1)
212 == "--physcpubind=0,1,16,17,64,65,80,81 --membind=1"
213 )
214
215
216 def test_get_numactl_args_engine_core_baseline_single_node_shard():
217 """Baseline (no PCT): single-NUMA shard -> single-node bind."""
218 vllm_config = _make_config(numa_bind=True, numa_bind_nodes=[0, 1])
219 assert (
220 numa_utils._get_numactl_enginecore_args(
221 vllm_config.parallel_config, local_rank=0
222 )
223 == "--cpunodebind=0 --membind=0"
224 )
225
226
227 def test_get_numactl_args_engine_core_baseline_spans_shard_numa_nodes():
228 """Baseline (no PCT): a TP=4 shard spanning both NUMA nodes -> bind to both."""
229 vllm_config = _make_config(
230 numa_bind=True,
231 numa_bind_nodes=[0, 0, 1, 1],
232 tensor_parallel_size=4,
233 )
234 assert (
235 numa_utils._get_numactl_enginecore_args(
236 vllm_config.parallel_config, local_rank=0
237 )
238 == "--cpunodebind=0,1 --membind=0,1"
239 )
240
241
242 def test_get_numactl_args_engine_core_pct_spans_shard_numa_nodes(monkeypatch):
243 """PCT: EngineCore for a multi-NUMA shard binds to the union of priority
244 cores across all shard nodes, so worker `--physcpubind` is always a
245 subset of EngineCore's `cpus_allowed`."""
246 _patch_pct_gates(
247 monkeypatch,
248 model_match=True,
249 highest_perf=46,
250 cpulist_by_node={0: "0-31,128-159", 1: "64-95,192-223"},
251 )
252 vllm_config = _make_config(
253 numa_bind=True,
254 numa_bind_nodes=[0, 0, 1, 1],
255 tensor_parallel_size=4,
256 )
257 assert numa_utils._get_numactl_enginecore_args(
258 vllm_config.parallel_config, local_rank=0
259 ) == (
260 "--physcpubind="
261 "0,1,16,17,64,65,80,81,128,129,144,145,192,193,208,209"
262 " --membind=0,1"
263 )
264
265
266 def test_get_numactl_args_engine_core_pct_dp_shard_picks_local_nodes(monkeypatch):
267 """With DP=2, each shard's EngineCore binds only to its own NUMA nodes."""
268 _patch_pct_gates(
269 monkeypatch,
270 model_match=True,
271 highest_perf=46,
272 cpulist_by_node={0: "0-31,128-159", 1: "64-95,192-223"},
273 )
274 vllm_config = _make_config(
275 numa_bind=True,
276 numa_bind_nodes=[0, 0, 1, 1],
277 tensor_parallel_size=2,
278 data_parallel_rank_local=1,
279 )
280 # Shard 1 owns gpu_indices 2 and 3 -> nodes [1, 1] -> {1}.
281 assert (
282 numa_utils._get_numactl_enginecore_args(
283 vllm_config.parallel_config, local_rank=0
284 )
285 == "--physcpubind=64,65,80,81,192,193,208,209 --membind=1"
286 )
287
288
289 def test_get_numactl_args_engine_core_pct_external_launcher_spans_local_nodes(
290 monkeypatch,
291 ):
292 """external_launcher (or multi-node-within-DP, or Ray) hits the
293 fallback branch. EngineCore must still span every local NUMA node
294 so it can mp-spawn its local workers without ``--physcpubind``
295 strict-validation failures."""
296 _patch_pct_gates(
297 monkeypatch,
298 model_match=True,
299 highest_perf=46,
300 cpulist_by_node={0: "0-31,128-159", 1: "64-95,192-223"},
301 )
302 vllm_config = _make_config(
303 numa_bind=True,
304 numa_bind_nodes=[0, 0, 0, 0, 1, 1, 1, 1],
305 distributed_executor_backend="external_launcher",
306 tensor_parallel_size=8,
307 )
308 assert numa_utils._get_numactl_enginecore_args(
309 vllm_config.parallel_config, local_rank=0
310 ) == (
311 "--physcpubind="
312 "0,1,16,17,64,65,80,81,128,129,144,145,192,193,208,209"
313 " --membind=0,1"
314 )
315
316
317 def test_get_numactl_args_engine_core_baseline_multi_node_within_dp_spans_locals():
318 """Multi-node-within-DP fallback: bind EngineCore to all local NUMA
319 nodes that the visible ``numa_bind_nodes`` reference."""
320 vllm_config = _make_config(
321 numa_bind=True,
322 numa_bind_nodes=[0, 0, 0, 0, 1, 1, 1, 1],
323 nnodes_within_dp=2,
324 tensor_parallel_size=8,
325 )
326 assert (
327 numa_utils._get_numactl_enginecore_args(
328 vllm_config.parallel_config, local_rank=0
329 )
330 == "--cpunodebind=0,1 --membind=0,1"
331 )
332
333
334 def test_get_numactl_args_engine_core_skips_user_cpu_list(monkeypatch):
335 """EngineCore ignores ``--numa-bind-cpus``.
336
337 Those are per-worker lists; binding EngineCore to any of them would
338 shrink its ``cpus_allowed`` below the strict-superset workers'
339 ``--physcpubind`` spawns need. We fall back to ``--cpunodebind`` over
340 the shard's NUMA nodes instead. PCT auto-detect is also bypassed when
341 the user is explicit (its priority-core union may not be a superset
342 of the user's per-worker cores)."""
343 _patch_pct_gates(monkeypatch, model_match=True, highest_perf=46)
344 vllm_config = _make_config(
345 numa_bind=True,
346 numa_bind_nodes=[0, 0, 1, 1],
347 numa_bind_cpus=["0-3", "4-7", "64-67", "68-71"],
348 tensor_parallel_size=4,
349 )
350 assert (
351 numa_utils._get_numactl_enginecore_args(
352 vllm_config.parallel_config, local_rank=0
353 )
354 == "--cpunodebind=0,1 --membind=0,1"
355 )
356
357
358 def test_get_numactl_args_user_cpus_override_pct(monkeypatch):
359 _patch_pct_gates(monkeypatch, model_match=True, highest_perf=46)
360 vllm_config = _make_config(
361 numa_bind=True,
362 numa_bind_nodes=[0, 1],
363 numa_bind_cpus=["0-3", "4-7"],
364 )
365 assert (
366 numa_utils._get_numactl_worker_args(vllm_config.parallel_config, local_rank=1)
367 == "--physcpubind=4-7 --membind=1"
368 )
369
370
371 def test_get_numactl_args_uses_dp_offset():
372 vllm_config = _make_config(
373 numa_bind=True,
374 numa_bind_nodes=[0, 0, 1, 1],
375 data_parallel_rank_local=1,
376 pipeline_parallel_size=1,
377 tensor_parallel_size=2,
378 )
379 assert (
380 numa_utils._get_numactl_worker_args(vllm_config.parallel_config, local_rank=1)
381 == "--cpunodebind=1 --membind=1"
382 )
383
384
385 def test_get_numactl_args_requires_detectable_nodes(monkeypatch):
386 vllm_config = _make_config(numa_bind=True)
387 monkeypatch.setattr(numa_utils, "get_auto_numa_nodes", lambda: None)
388 with pytest.raises(RuntimeError):
389 numa_utils._get_numactl_worker_args(vllm_config.parallel_config, local_rank=0)
390
391
392 def test_configure_subprocess_rejects_unknown_process_kind():
393 """configure_subprocess only knows 'worker' and 'EngineCore'; anything
394 else must raise ValueError instead of silently routing to the worker
395 path."""
396 vllm_config = _make_config(numa_bind=True, numa_bind_nodes=[0])
397 with (
398 pytest.raises(ValueError, match="process_kind"),
399 numa_utils.configure_subprocess(
400 vllm_config, local_rank=0, process_kind="bogus"
401 ),
402 ):
403 pass
404
405
406 def test_log_numactl_show(monkeypatch):
407 log_lines = []
408
409 def fake_debug(msg, *args):
410 log_lines.append(msg % args)
411
412 monkeypatch.setattr(numa_utils.logger, "debug", fake_debug)
413 monkeypatch.setattr(
414 numa_utils.subprocess,
415 "run",
416 lambda *args, **kwargs: SimpleNamespace(
417 stdout="policy: bind\nphyscpubind: 0 1 2 3\n", returncode=0
418 ),
419 )
420
421 assert numa_utils._log_numactl_show("Worker_0") is True
422 assert log_lines == [
423 "Worker_0 affinity: policy: bind, physcpubind: 0 1 2 3",
424 ]
425
426
427 def test_get_numactl_executable_points_to_fixed_wrapper(monkeypatch):
428 monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/numactl")
429 executable, debug_str = numa_utils._get_numactl_executable()
430 assert executable.endswith("/vllm/utils/numa_wrapper.sh")
431 assert "_VLLM_INTERNAL_NUMACTL_ARGS" in debug_str
432
433
434 def test_set_numa_wrapper_env_restores_previous_values():
435 os.environ[numa_utils._NUMACTL_ARGS_ENV] = "old-args"
436 os.environ[numa_utils._NUMACTL_PYTHON_EXECUTABLE_ENV] = "old-python"
437
438 with numa_utils._set_numa_wrapper_env("new-args", "new-python"):
439 assert os.environ[numa_utils._NUMACTL_ARGS_ENV] == "new-args"
440 assert os.environ[numa_utils._NUMACTL_PYTHON_EXECUTABLE_ENV] == "new-python"
441
442 assert os.environ[numa_utils._NUMACTL_ARGS_ENV] == "old-args"
443 assert os.environ[numa_utils._NUMACTL_PYTHON_EXECUTABLE_ENV] == "old-python"
444
445
446 def test_set_numa_wrapper_env_clears_values_when_unset():
447 os.environ.pop(numa_utils._NUMACTL_ARGS_ENV, None)
448 os.environ.pop(numa_utils._NUMACTL_PYTHON_EXECUTABLE_ENV, None)
449
450 with numa_utils._set_numa_wrapper_env("new-args", "new-python"):
451 assert os.environ[numa_utils._NUMACTL_ARGS_ENV] == "new-args"
452 assert os.environ[numa_utils._NUMACTL_PYTHON_EXECUTABLE_ENV] == "new-python"
453
454 assert numa_utils._NUMACTL_ARGS_ENV not in os.environ
455 assert numa_utils._NUMACTL_PYTHON_EXECUTABLE_ENV not in os.environ
456
457
458 def test_parallel_config_validates_numa_bind_nodes():
459 with pytest.raises(ValueError, match="non-negative"):
460 ParallelConfig(numa_bind_nodes=[0, -1])
461
462
463 @pytest.mark.parametrize("cpuset", ["", "abc", "1-", "4-1", "1,,2", "1:2"])
464 def test_parallel_config_rejects_invalid_numa_bind_cpus(cpuset):
465 with pytest.raises(ValueError, match="numa_bind_cpus"):
466 ParallelConfig(numa_bind_cpus=[cpuset])
467
468
469 def _fake_numactl_run(rejected_args):
470 """Fake ``numactl`` that fails when any of ``rejected_args`` is present."""
471
472 def run(cmd, *args, **kwargs):
473 arg_str = " ".join(cmd[1:-1])
474 ok = not any(bad in arg_str for bad in rejected_args)
475 return SimpleNamespace(returncode=0 if ok else 1)
476
477 return run
478
479
480 def test_configure_subprocess_numa_fallback(monkeypatch):
481 import multiprocessing
482
483 monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/numactl")
484 monkeypatch.setattr(numa_utils.envs, "VLLM_WORKER_MULTIPROC_METHOD", "spawn")
485 node_config = _make_config(numa_bind=True, numa_bind_nodes=[0])
486
487 monkeypatch.setattr(numa_utils.subprocess, "run", _fake_numactl_run([]))
488 with numa_utils.configure_subprocess(node_config, local_rank=0):
489 assert os.environ[numa_utils._NUMACTL_ARGS_ENV] == "--cpunodebind=0 --membind=0"
490
491 membind_fails = _fake_numactl_run(["--membind="])
492 monkeypatch.setattr(numa_utils.subprocess, "run", membind_fails)
493 with numa_utils.configure_subprocess(node_config, local_rank=0):
494 assert os.environ[numa_utils._NUMACTL_ARGS_ENV] == "--cpunodebind=0"
495
496 cpu_config = _make_config(
497 numa_bind=True,
498 numa_bind_nodes=[0],
499 numa_bind_cpus=["0-3"],
500 )
501 with numa_utils.configure_subprocess(cpu_config, local_rank=0):
502 assert os.environ[numa_utils._NUMACTL_ARGS_ENV] == "--physcpubind=0-3"
503
504 before = multiprocessing.spawn.get_executable()
505 monkeypatch.setattr(
506 numa_utils.subprocess,
507 "run",
508 _fake_numactl_run(["--cpunodebind=", "--membind="]),
509 )
510 with numa_utils.configure_subprocess(node_config, local_rank=0):
511 assert multiprocessing.spawn.get_executable() == before
512 assert numa_utils._NUMACTL_ARGS_ENV not in os.environ