main
py 603 lines 21 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """NUMA binding utilities for vLLM worker processes.
4
5 Adapted in part from SGLang's NUMA helper implementation:
6 https://github.com/sgl-project/sglang/blob/ba6d54d0f08f82f42b8224908ae2459a496b31b3/python/sglang/srt/utils/numa_utils.py
7 """
8
9 import ctypes
10 import logging
11 import multiprocessing
12 import os
13 import subprocess
14 from contextlib import contextmanager
15 from functools import cache
16 from pathlib import Path
17 from typing import TYPE_CHECKING, NamedTuple
18
19 import psutil
20
21 from vllm import envs
22
23 if TYPE_CHECKING:
24 from vllm.config import VllmConfig
25
26 logger = logging.getLogger(__name__)
27 _NUMACTL_ARGS_ENV = "_VLLM_INTERNAL_NUMACTL_ARGS"
28 _NUMACTL_PYTHON_EXECUTABLE_ENV = "_VLLM_INTERNAL_NUMACTL_PYTHON_EXECUTABLE"
29
30
31 @cache
32 def get_libnuma():
33 libnuma = None
34 for libnuma_so in ["libnuma.so", "libnuma.so.1"]:
35 try:
36 libnuma = ctypes.CDLL(libnuma_so)
37 except OSError:
38 libnuma = None
39 if libnuma is not None:
40 break
41 return libnuma
42
43
44 def _can_set_mempolicy() -> bool:
45 """Check whether the current process can use NUMA memory policy syscalls."""
46 try:
47 libnuma = get_libnuma()
48 if libnuma is None or libnuma.numa_available() < 0:
49 return False
50 mode = ctypes.c_int()
51 ret = libnuma.get_mempolicy(
52 ctypes.byref(mode), None, ctypes.c_ulong(0), None, ctypes.c_ulong(0)
53 )
54 return ret == 0
55 except Exception:
56 return False
57
58
59 def _is_auto_numa_available() -> bool:
60 """Check whether automatic GPU-to-NUMA detection should be attempted."""
61 from vllm.platforms import current_platform
62
63 if not current_platform.is_cuda_alike():
64 return False
65
66 if not os.path.isdir("/sys/devices/system/node/node1"):
67 return False
68
69 try:
70 process = psutil.Process(os.getpid())
71 cpu_affinity = process.cpu_affinity()
72 cpu_count = psutil.cpu_count()
73 if cpu_count is not None and cpu_affinity != list(range(cpu_count)):
74 logger.warning(
75 "CPU affinity is already constrained for this process. "
76 "Skipping automatic NUMA binding; pass --numa-bind-nodes "
77 "explicitly to override."
78 )
79 return False
80 except (AttributeError, NotImplementedError, psutil.Error):
81 pass
82
83 if not _can_set_mempolicy():
84 logger.warning(
85 "User lacks permission to set NUMA memory policy. "
86 "Automatic NUMA detection may not work; if you are using Docker, "
87 "try adding --cap-add SYS_NICE."
88 )
89 return False
90
91 if not hasattr(current_platform, "get_all_device_numa_nodes"):
92 logger.warning(
93 "Platform %s does not support automatic NUMA detection",
94 type(current_platform).__name__,
95 )
96 return False
97
98 return True
99
100
101 @cache
102 def get_auto_numa_nodes() -> list[int] | None:
103 """Auto-detect NUMA nodes for all visible GPUs."""
104 from vllm.platforms import current_platform
105
106 if not _is_auto_numa_available():
107 return None
108
109 numa_nodes = current_platform.get_all_device_numa_nodes()
110 if numa_nodes is not None:
111 logger.info("Auto-detected NUMA nodes for GPUs: %s", numa_nodes)
112 return numa_nodes
113
114
115 # PCT (Priority Core Turbo) auto-detection workaround for Granite Rapids
116 # Xeon SKUs.
117 #
118 # Background:
119 # * The Linux kernel does not expose PCT priority-core membership via any
120 # unprivileged sysfs path. The official interface
121 # (/dev/isst_interface, used by `intel-speed-select`) is root-only,
122 # which is a non-starter in most production deployments (shared
123 # clusters, prebuilt containers, managed cloud).
124 # * Even recent stable kernels (e.g. 6.14, March 2025) do not yet
125 # preferentially schedule work on PCT priority cores, so vLLM cannot
126 # just "let the scheduler handle it".
127 #
128 # Empirical heuristic (DGX B300 / Xeon 6776P, the SKU we measured):
129 # * /proc/cpuinfo `model name` contains the SKU number.
130 # * cpu0 is a PCT priority core on these SKUs, so it reports the
131 # priority-cohort CPPC `highest_perf` (the value matches the SKU's
132 # "Max PCT core frequency" in 100 MHz units, e.g. 4.6 GHz -> 46).
133 # * Priority cores within each NUMA node satisfy `cpu_id % S in (0, 1)`
134 # intersected with the node's cpulist, where `S` is the SKU's logical
135 # CPUs per priority "group" (= total threads / 8 priority cores; 16 on
136 # 64-core SKUs, 18 on 72-core SKUs).
137 #
138 # SKU table:
139 # ``_PCT_CAPABLE_SKUS`` maps each known PCT-capable Granite Rapids part
140 # to a ``_PctSku(highest_perf, priority_stride)`` config:
141 # * highest_perf is the expected ``acpi_cppc/highest_perf`` on cpu0,
142 # derived from Intel ARK's "Max PCT core frequency" * 10 (CPPC max
143 # ratio reports in 100 MHz units).
144 # * priority_stride is the SKU's "Total Cores" / 4 (= total HT threads
145 # / 8 priority cores), used in the ``cpu_id % stride`` filter above.
146 # Values:
147 # * 6776P - 4.6 GHz, 64C/128T -> (46, 16) measured on DGX B300
148 # * 6774P - 4.6 GHz, 64C/128T -> (46, 16) per Intel ARK, not measured
149 # * 6962P - 4.4 GHz, 72C/144T -> (44, 18) per Intel ARK, not measured
150 # The non-measured SKUs are listed best-effort: the gate fails closed
151 # (no PCT engagement) if a host's actual highest_perf doesn't match the
152 # table value, so adding entries is safe. If you have access to a 6962P
153 # or 6774P box and find a different value or cpu-id pattern, update the
154 # table below.
155 #
156 # This whole block is a stop-gap until the kernel exposes PCT membership
157 # in an unprivileged way; see the tracking issue linked from the PR.
158
159
160 class _PctSku(NamedTuple):
161 """Per-SKU config used by the PCT auto-detection gate."""
162
163 highest_perf: int
164 priority_stride: int
165
166
167 _PCT_CAPABLE_SKUS: dict[str, _PctSku] = {
168 "6776P": _PctSku(highest_perf=46, priority_stride=16),
169 "6774P": _PctSku(highest_perf=46, priority_stride=16),
170 "6962P": _PctSku(highest_perf=44, priority_stride=18),
171 }
172 _PCT_HIGHEST_PERF_PATH = "/sys/devices/system/cpu/cpu0/acpi_cppc/highest_perf"
173 _PROC_CPUINFO_PATH = "/proc/cpuinfo"
174
175
176 def _pct_sku_from_cpuinfo() -> _PctSku | None:
177 """Return the ``_PctSku`` config for this host's SKU, or None.
178
179 Reads ``/proc/cpuinfo``'s ``model name`` and looks the SKU up in
180 ``_PCT_CAPABLE_SKUS``. Returns ``None`` when the host is not a known
181 PCT-capable Granite Rapids Xeon (or when ``/proc/cpuinfo`` is
182 unreadable).
183 """
184 try:
185 with open(_PROC_CPUINFO_PATH) as f:
186 for line in f:
187 if not line.lstrip().lower().startswith("model name"):
188 continue
189 for sku, config in _PCT_CAPABLE_SKUS.items():
190 if sku in line:
191 return config
192 except OSError:
193 return None
194 return None
195
196
197 @cache
198 def _pct_sku_config() -> _PctSku | None:
199 """Detect a PCT-capable Granite Rapids Xeon with PCT enabled.
200
201 See the comment block above ``_PCT_CAPABLE_SKUS`` for the full context
202 (why we hard-code SKUs, why we read CPPC ``highest_perf``, etc.).
203
204 Returns the matching ``_PctSku`` config when both gates hold:
205 * ``/proc/cpuinfo`` ``model name`` contains an SKU listed in
206 ``_PCT_CAPABLE_SKUS``.
207 * ``/sys/devices/system/cpu/cpu0/acpi_cppc/highest_perf`` matches
208 that SKU's expected ``highest_perf``.
209 Otherwise returns ``None`` and the caller falls back to the default
210 NUMA-node bind.
211 """
212 sku = _pct_sku_from_cpuinfo()
213 if sku is None:
214 return None
215
216 try:
217 with open(_PCT_HIGHEST_PERF_PATH) as f:
218 actual = int(f.read().strip())
219 except (OSError, ValueError):
220 return None
221 if actual != sku.highest_perf:
222 return None
223 return sku
224
225
226 def _get_gpu_index(
227 parallel_config, local_rank: int, dp_local_rank: int | None = None
228 ) -> int:
229 """Compute the physical GPU index used for NUMA lookup."""
230 if (
231 parallel_config.distributed_executor_backend not in ("ray", "external_launcher")
232 and parallel_config.data_parallel_backend != "ray"
233 and parallel_config.nnodes_within_dp == 1
234 ):
235 if dp_local_rank is None:
236 dp_local_rank = parallel_config.data_parallel_rank_local
237 if dp_local_rank is None:
238 dp_local_rank = parallel_config.data_parallel_index
239
240 tp_pp_world_size = (
241 parallel_config.pipeline_parallel_size
242 * parallel_config.tensor_parallel_size
243 )
244 return local_rank + dp_local_rank * tp_pp_world_size
245
246 return local_rank
247
248
249 def _get_numa_node(parallel_config, gpu_index: int) -> int:
250 numa_nodes = parallel_config.numa_bind_nodes
251 if numa_nodes is None:
252 numa_nodes = get_auto_numa_nodes()
253 if numa_nodes is None:
254 raise RuntimeError(
255 "NUMA binding was requested, but vLLM could not detect the "
256 "GPU-to-NUMA topology automatically. Pass --numa-bind-nodes "
257 "explicitly or disable --numa-bind."
258 )
259 parallel_config.numa_bind_nodes = numa_nodes
260
261 if gpu_index >= len(numa_nodes):
262 raise ValueError(
263 f"GPU index {gpu_index} exceeds numa_bind_nodes size {len(numa_nodes)}. "
264 "Ensure the binding lists cover every visible GPU."
265 )
266
267 return numa_nodes[gpu_index]
268
269
270 def _maybe_get_pct_cpu_binding(numa_nodes: list[int]) -> list[int] | None:
271 """Return the union of PCT priority cores across ``numa_nodes`` (or None).
272
273 PCT (Priority Core Turbo) lets a subset of cores boost above the rest;
274 we want workers and the EngineCore on those cores. The Linux kernel does
275 not expose PCT membership without root, so we use the empirical heuristic
276 documented above ``_PCT_CAPABLE_SKUS``: priority cores within each NUMA
277 node satisfy ``cpu_id % stride in (0, 1)`` intersected with the node's
278 ``cpulist``, where ``stride`` is the SKU's logical CPUs per priority
279 group (16 on 64-core SKUs, 18 on 72-core SKUs). Only triggers on the
280 SKUs in ``_PCT_CAPABLE_SKUS`` with the expected CPPC ``highest_perf``
281 signal; on any other host it returns None and the caller falls back to
282 the default NUMA-node bind.
283
284 Returns the sorted CPU ids as a ``list[int]``; the caller is expected
285 to format them for the chosen tool (e.g. comma-joined for
286 ``numactl --physcpubind``).
287 """
288 sku = _pct_sku_config()
289 if sku is None:
290 return None
291
292 from vllm.utils.cpu_resource_utils import parse_id_list
293
294 stride = sku.priority_stride
295 union_cpus: set[int] = set()
296 for numa_node in numa_nodes:
297 cpulist_path = Path(f"/sys/devices/system/node/node{numa_node}/cpulist")
298 try:
299 cpulist_raw = cpulist_path.read_text().strip()
300 except OSError:
301 continue
302 if not cpulist_raw:
303 continue
304 try:
305 node_cpus = parse_id_list(cpulist_raw)
306 except ValueError:
307 continue
308
309 priority = [cpu for cpu in node_cpus if cpu % stride in (0, 1)]
310 if not priority:
311 continue
312 union_cpus.update(priority)
313 logger.info(
314 "Detected PCT-capable Granite Rapids Xeon (stride=%d); "
315 "NUMA node %d priority cores: %s",
316 stride,
317 numa_node,
318 ",".join(str(c) for c in priority),
319 )
320
321 if not union_cpus:
322 return None
323 return sorted(union_cpus)
324
325
326 def _get_cpu_binding(
327 parallel_config, gpu_index: int, numa_nodes: list[int]
328 ) -> str | None:
329 """Return the CPU list a process should be pinned to (or None)."""
330 cpu_bindings = parallel_config.numa_bind_cpus
331 if cpu_bindings is None:
332 pct_cpus = _maybe_get_pct_cpu_binding(numa_nodes)
333 if pct_cpus is None:
334 return None
335 return ",".join(str(c) for c in pct_cpus)
336
337 if gpu_index >= len(cpu_bindings):
338 raise ValueError(
339 f"GPU index {gpu_index} exceeds numa_bind_cpus size "
340 f"{len(cpu_bindings)}. Ensure the binding lists cover every visible GPU."
341 )
342 return cpu_bindings[gpu_index]
343
344
345 def _get_numactl_worker_args(
346 parallel_config, local_rank: int, dp_local_rank: int | None = None
347 ) -> str:
348 """Compute the numactl args for a single TP/PP worker subprocess."""
349 gpu_index = _get_gpu_index(parallel_config, local_rank, dp_local_rank)
350 numa_node = _get_numa_node(parallel_config, gpu_index)
351 cpu_binding = _get_cpu_binding(parallel_config, gpu_index, [numa_node])
352
353 if cpu_binding is not None:
354 logger.info(
355 "Binding worker subprocess (local_rank=%s, gpu_index=%s) to CPUs %s and NUMA node %s", # noqa: E501
356 local_rank,
357 gpu_index,
358 cpu_binding,
359 numa_node,
360 )
361 return f"--physcpubind={cpu_binding} --membind={numa_node}"
362
363 logger.info(
364 "Binding worker subprocess (local_rank=%s, gpu_index=%s) to NUMA node %s",
365 local_rank,
366 gpu_index,
367 numa_node,
368 )
369 return f"--cpunodebind={numa_node} --membind={numa_node}"
370
371
372 def _get_enginecore_numa_nodes(
373 parallel_config, dp_local_rank: int | None = None
374 ) -> list[int]:
375 """Return the sorted, unique NUMA nodes of the EngineCore's DP shard."""
376 numa_nodes = parallel_config.numa_bind_nodes
377 if numa_nodes is None:
378 # Trigger auto-detection (it caches into parallel_config).
379 _get_numa_node(parallel_config, 0)
380 numa_nodes = parallel_config.numa_bind_nodes
381
382 if (
383 parallel_config.distributed_executor_backend not in ("ray", "external_launcher")
384 and parallel_config.data_parallel_backend != "ray"
385 and parallel_config.nnodes_within_dp == 1
386 ):
387 if dp_local_rank is None:
388 dp_local_rank = parallel_config.data_parallel_rank_local
389 if dp_local_rank is None:
390 dp_local_rank = parallel_config.data_parallel_index
391
392 tp_pp_world_size = (
393 parallel_config.pipeline_parallel_size
394 * parallel_config.tensor_parallel_size
395 )
396 shard_start = dp_local_rank * tp_pp_world_size
397 shard_end = min(shard_start + tp_pp_world_size, len(numa_nodes))
398 shard_indices: range | tuple[int, ...] = range(shard_start, shard_end)
399 else:
400 shard_indices = range(len(numa_nodes))
401
402 if not shard_indices:
403 return [numa_nodes[0]]
404 return sorted({numa_nodes[i] for i in shard_indices})
405
406
407 def _get_numactl_enginecore_args(
408 parallel_config, local_rank: int, dp_local_rank: int | None = None
409 ) -> str:
410 """Compute the numactl args for an EngineCore subprocess.
411
412 ``--numa-bind-cpus`` is deliberately ignored here: the user provides a
413 per-worker CPU list, and binding EngineCore to any of those entries
414 would shrink its ``cpus_allowed`` below the strict-superset that the
415 workers' ``--physcpubind`` spawns require. We fall back to
416 ``--cpunodebind=<shard nodes>`` instead, which is always a safe
417 superset. PCT auto-detection still applies when the user did not pass
418 ``--numa-bind-cpus`` (its priority-core union across the shard nodes
419 is also a safe superset by construction).
420 """
421 shard_nodes = _get_enginecore_numa_nodes(parallel_config, dp_local_rank)
422 membind_arg = ",".join(str(n) for n in shard_nodes)
423
424 pct_cpus = (
425 None
426 if parallel_config.numa_bind_cpus is not None
427 else _maybe_get_pct_cpu_binding(shard_nodes)
428 )
429
430 if pct_cpus is not None:
431 cpu_binding = ",".join(str(c) for c in pct_cpus)
432 logger.info(
433 "Binding EngineCore subprocess (local_rank=%s) to CPUs %s "
434 "and NUMA nodes %s",
435 local_rank,
436 cpu_binding,
437 membind_arg,
438 )
439 return f"--physcpubind={cpu_binding} --membind={membind_arg}"
440
441 logger.info(
442 "Binding EngineCore subprocess (local_rank=%s) to NUMA nodes %s",
443 local_rank,
444 membind_arg,
445 )
446 return f"--cpunodebind={membind_arg} --membind={membind_arg}"
447
448
449 def _log_numactl_show(label: str) -> bool:
450 try:
451 result = subprocess.run(
452 ["numactl", "--show"],
453 check=True,
454 capture_output=True,
455 text=True,
456 )
457 except (FileNotFoundError, subprocess.CalledProcessError) as e:
458 logger.warning("Failed to run `numactl --show` for %s: %s", label, e)
459 return False
460
461 output = result.stdout.strip()
462 if not output:
463 logger.warning("`numactl --show` returned no output for %s", label)
464 return False
465
466 summary = ", ".join(line.strip() for line in output.splitlines() if line.strip())
467 logger.debug("%s affinity: %s", label, summary)
468 return True
469
470
471 def log_current_affinity_state(label: str) -> None:
472 """Log the process's effective NUMA affinity state."""
473 _log_numactl_show(label)
474
475
476 def _probe_numactl_args(numactl_args: str) -> bool:
477 """Whether ``numactl <args> true`` succeeds in this (parent) environment."""
478 try:
479 result = subprocess.run(
480 ["numactl", *numactl_args.split(), "true"],
481 capture_output=True,
482 timeout=10,
483 )
484 except (OSError, subprocess.SubprocessError):
485 return False
486 return result.returncode == 0
487
488
489 def _resolve_numactl_args(numactl_args: str) -> str:
490 """Drop ``--membind`` if the container rejects it, keeping CPU binding."""
491 cpu_only = " ".join(
492 t for t in numactl_args.split() if not t.startswith("--membind=")
493 )
494 for candidate in (numactl_args, cpu_only, ""):
495 if _probe_numactl_args(candidate):
496 if candidate != numactl_args:
497 logger.warning(
498 "numactl args %r rejected; falling back to %r. Add "
499 "--cap-add SYS_NICE for full NUMA binding.",
500 numactl_args,
501 candidate or "no binding",
502 )
503 return candidate
504 return ""
505
506
507 @contextmanager
508 def configure_subprocess(
509 vllm_config: "VllmConfig",
510 local_rank: int,
511 dp_local_rank: int | None = None,
512 process_kind: str = "worker",
513 ):
514 """Temporarily replace the multiprocessing executable with a numactl wrapper."""
515 parallel_config = vllm_config.parallel_config
516 if not parallel_config.numa_bind:
517 yield
518 return
519
520 if process_kind == "EngineCore":
521 numactl_args = _get_numactl_enginecore_args(
522 parallel_config, local_rank, dp_local_rank
523 )
524 elif process_kind == "worker":
525 numactl_args = _get_numactl_worker_args(
526 parallel_config, local_rank, dp_local_rank
527 )
528 else:
529 raise ValueError(
530 f"Unknown process_kind {process_kind!r}; expected 'worker' or 'EngineCore'."
531 )
532
533 executable, debug_str = _get_numactl_executable()
534 numactl_args = _resolve_numactl_args(numactl_args)
535 if not numactl_args:
536 # No NUMA binding possible here; launch without the wrapper.
537 yield
538 return
539 python_executable = os.fsdecode(multiprocessing.spawn.get_executable())
540 with (
541 _set_numa_wrapper_env(numactl_args, python_executable),
542 _mp_set_executable(executable, debug_str),
543 ):
544 yield
545
546
547 def _get_numactl_executable() -> tuple[str, str]:
548 """Return the fixed wrapper executable used to launch numactl."""
549 from shutil import which
550
551 if which("numactl") is None:
552 raise RuntimeError(
553 "numactl is required for NUMA binding but is not installed or "
554 "not available on PATH."
555 )
556
557 script_path = Path(__file__).with_name("numa_wrapper.sh")
558 return str(script_path), f"{script_path} via {_NUMACTL_ARGS_ENV}"
559
560
561 @contextmanager
562 def _set_numa_wrapper_env(numactl_args: str, python_executable: str):
563 old_numactl_args = os.environ.get(_NUMACTL_ARGS_ENV)
564 old_python_executable = os.environ.get(_NUMACTL_PYTHON_EXECUTABLE_ENV)
565 os.environ[_NUMACTL_ARGS_ENV] = numactl_args
566 os.environ[_NUMACTL_PYTHON_EXECUTABLE_ENV] = python_executable
567 try:
568 yield
569 finally:
570 if old_numactl_args is None:
571 os.environ.pop(_NUMACTL_ARGS_ENV, None)
572 else:
573 os.environ[_NUMACTL_ARGS_ENV] = old_numactl_args
574
575 if old_python_executable is None:
576 os.environ.pop(_NUMACTL_PYTHON_EXECUTABLE_ENV, None)
577 else:
578 os.environ[_NUMACTL_PYTHON_EXECUTABLE_ENV] = old_python_executable
579
580
581 @contextmanager
582 def _mp_set_executable(executable: str, debug_str: str):
583 start_method = envs.VLLM_WORKER_MULTIPROC_METHOD
584 if start_method != "spawn":
585 logger.warning(
586 "NUMA binding requires spawn method but got '%s'. "
587 "NUMA binding will be ineffective. "
588 "Set VLLM_WORKER_MULTIPROC_METHOD=spawn to enable NUMA binding.",
589 start_method,
590 )
591 yield
592 return
593
594 old_executable = os.fsdecode(multiprocessing.spawn.get_executable())
595 multiprocessing.spawn.set_executable(executable)
596 try:
597 yield
598 finally:
599 assert os.fsdecode(multiprocessing.spawn.get_executable()) == executable, (
600 "Executable was changed during NUMA binding context: "
601 f"expected {executable}, got {multiprocessing.spawn.get_executable()}"
602 )
603 multiprocessing.spawn.set_executable(old_executable)