Source code for nemo_rl.distributed.numa_utils
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""NUMA-aware CPU affinity and memory binding for GPU workers.
Uses a GPU→cpulist mapping file written by topology_probe.sh (in ray.sub)
at node startup. The file path is communicated via the NRL_GPU_CPU_AFFINITY_FILE
environment variable. See ray.sub for the writer side.
Disable all binding with NRL_DISABLE_NUMA_BINDING=1.
Disable only memory policy with NRL_DISABLE_NUMA_MEMBIND=1.
"""
import ctypes
import ctypes.util
import logging
import os
logger = logging.getLogger(__name__)
# IMPORTANT: This default path must stay in sync with topology_probe.sh in ray.sub.
# The canonical path is set via the NRL_GPU_CPU_AFFINITY_FILE env var exported by ray.sub.
GPU_CPU_AFFINITY_PATH = os.environ.get(
"NRL_GPU_CPU_AFFINITY_FILE", "/tmp/nrl_gpu_cpu_affinity"
)
[docs]
def bind_to_gpu_numa(gpu_id: int) -> bool:
"""Pin the current process to the NUMA-local CPUs and memory of the given GPU.
Reads the GPU→cpulist mapping written by topology_probe.sh at node
startup, then calls os.sched_setaffinity() for CPU pinning and
numa_set_membind() for memory policy. Best-effort: failures are
logged, never raised.
Args:
gpu_id: Node-global physical GPU index (``nvidia-smi`` numbering), which
is how the affinity file is keyed. Passed explicitly because
``CUDA_VISIBLE_DEVICES`` lists all devices on the node under
``RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES=1`` and so does not
identify a single worker's GPU. In a Ray actor this is
``int(ray.get_gpu_ids()[0])``.
Returns True if CPU binding succeeded, False if skipped or failed.
Memory binding is attempted independently and logged separately.
"""
if os.environ.get("NRL_DISABLE_NUMA_BINDING") == "1":
return False
gpu = str(gpu_id)
try:
with open(GPU_CPU_AFFINITY_PATH) as f:
for line in f:
line = line.strip()
if not line:
continue
idx, cpulist = line.split(":", 1)
if idx == gpu:
cpus = _parse_cpulist(cpulist)
os.sched_setaffinity(0, cpus)
logger.info("NUMA CPU binding: GPU %s → CPUs %s", gpu, cpulist)
_set_numa_membind(cpus)
return True
logger.debug("NUMA binding: GPU %s not found in %s", gpu, GPU_CPU_AFFINITY_PATH)
except FileNotFoundError:
logger.debug("NUMA binding skipped: %s not found", GPU_CPU_AFFINITY_PATH)
except Exception as exc:
logger.debug("NUMA binding skipped: %s", exc)
return False
[docs]
def resolve_visible_gpu_id(local_index: int) -> int | None:
"""Map a process-local CUDA device index to its node-global physical GPU id.
``CUDA_VISIBLE_DEVICES`` lists the physical GPU ids visible to this process
in device-index order, and ``local_index`` (e.g.
``torch.cuda.current_device()``) indexes into that list. The affinity file is
keyed by the physical id, so return ``CUDA_VISIBLE_DEVICES[local_index]``.
``CUDA_VISIBLE_DEVICES`` contents depend on the worker:
- vLLM TP>1 (``RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES=1``): the
per-instance device subset, e.g. ``"4,5"``.
- vLLM TP=1: a single isolated device, so ``local_index`` is 0.
Returns the physical GPU id, or None if it cannot be resolved (unset CVD,
index out of range, or non-integer entries such as MIG UUIDs).
"""
cvd = os.environ.get("CUDA_VISIBLE_DEVICES", "")
if not cvd:
return None
devices = cvd.split(",")
if local_index < 0 or local_index >= len(devices):
return None
try:
return int(devices[local_index])
except ValueError:
return None
[docs]
def _load_libnuma() -> ctypes.CDLL | None:
"""Load libnuma, returning None if unavailable."""
try:
return ctypes.CDLL("libnuma.so.1")
except OSError:
return None
[docs]
def _get_numa_node(libnuma: ctypes.CDLL, cpus: set[int]) -> int:
"""Return the NUMA node for the given CPU set, or -1 on failure."""
libnuma.numa_node_of_cpu.restype = ctypes.c_int
return libnuma.numa_node_of_cpu(min(cpus))
[docs]
def _set_numa_membind(cpus: set[int]) -> bool:
"""Hard-bind memory allocations to the NUMA node of the given CPUs."""
if os.environ.get("NRL_DISABLE_NUMA_MEMBIND") == "1":
return False
libnuma = _load_libnuma()
if libnuma is None:
logger.debug("NUMA membind skipped: libnuma.so.1 not available")
return False
try:
numa_node = _get_numa_node(libnuma, cpus)
if numa_node < 0:
logger.debug(
"NUMA membind skipped: numa_node_of_cpu(%d) returned %d",
min(cpus),
numa_node,
)
return False
libnuma.numa_allocate_nodemask.restype = ctypes.c_void_p
libnuma.numa_bitmask_setbit.argtypes = [ctypes.c_void_p, ctypes.c_uint]
libnuma.numa_bitmask_setbit.restype = ctypes.c_void_p
libnuma.numa_set_membind.argtypes = [ctypes.c_void_p]
libnuma.numa_bitmask_free.argtypes = [ctypes.c_void_p]
nodemask = libnuma.numa_allocate_nodemask()
if not nodemask:
logger.debug("NUMA membind skipped: numa_allocate_nodemask returned NULL")
return False
try:
libnuma.numa_bitmask_setbit(nodemask, numa_node)
libnuma.numa_set_membind(nodemask)
finally:
libnuma.numa_bitmask_free(nodemask)
logger.info(
"NUMA membind: hard-bound to node %d (from CPU %d)", numa_node, min(cpus)
)
return True
except Exception as exc:
logger.debug("NUMA membind skipped: %s", exc)
return False
[docs]
def _parse_cpulist(cpulist: str) -> set[int]:
"""Parse a Linux cpulist string like '0-71' into a set of ints."""
cpus: set[int] = set()
for part in cpulist.split(","):
part = part.strip()
if "-" in part:
lo, hi = part.split("-", 1)
cpus.update(range(int(lo), int(hi) + 1))
else:
cpus.add(int(part))
return cpus