Follow-up review on the CPU-only / NUMA hardening: - Restrict the NUMA topology to the cpuset-allowed nodes (/proc/self/status Mems_allowed_list). numactl --interleave=all only spans those, so under a Docker/systemd cpuset that exposes the host's nodes but allows one, the decision no longer sums RAM the child cannot use and wrap a launch that OOMs. - Honor env-forced CPU offload: _extra_args_forces_cpu_offload now also reads the inherited LLAMA_ARG_N_GPU_LAYERS / LLAMA_ARG_DEVICE the child would apply, so a GPU host running with LLAMA_ARG_N_GPU_LAYERS=0 or LLAMA_ARG_DEVICE=none gets the CPU-only safe defaults. CLI extras still win over the env. Unit tests and the offline simulations updated and passing.
193 lines
7.2 KiB
Python
193 lines
7.2 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""NUMA topology detection and an auto-interleave decision for CPU-only launches.
|
|
|
|
Linux's first-touch policy faults a model's pages onto one node; if the GGUF is larger
|
|
than that node's free RAM the load thrashes/fails despite ample total RAM.
|
|
`numactl --interleave=all` spreads pages across nodes (llama.cpp discussion #19102).
|
|
Pure stdlib, Linux-only (no-op decision elsewhere), side-effect free.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
_NODE_ROOT = Path("/sys/devices/system/node")
|
|
|
|
|
|
@dataclass(frozen = True)
|
|
class NumaTopology:
|
|
"""Per-node free memory (MemFree, matching `numactl --hardware`'s 'node N free')."""
|
|
|
|
node_free_mib: dict[int, int] = field(default_factory = dict)
|
|
|
|
@property
|
|
def node_count(self) -> int:
|
|
return len(self.node_free_mib)
|
|
|
|
@property
|
|
def total_free_mib(self) -> int:
|
|
return sum(self.node_free_mib.values())
|
|
|
|
@property
|
|
def largest_node_free_mib(self) -> int:
|
|
return max(self.node_free_mib.values(), default = 0)
|
|
|
|
@property
|
|
def smallest_node_free_mib(self) -> int:
|
|
return min(self.node_free_mib.values(), default = 0)
|
|
|
|
|
|
def _parse_online(spec: str) -> list[int]:
|
|
"""Parse a sysfs cpulist-style range, e.g. '0-1' or '0,2-3', into node ids."""
|
|
out: list[int] = []
|
|
for part in spec.strip().split(","):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
if "-" in part:
|
|
lo, _, hi = part.partition("-")
|
|
try:
|
|
out.extend(range(int(lo), int(hi) + 1))
|
|
except ValueError:
|
|
continue
|
|
else:
|
|
try:
|
|
out.append(int(part))
|
|
except ValueError:
|
|
continue
|
|
return out
|
|
|
|
|
|
def _mems_allowed() -> set[int] | None:
|
|
"""NUMA nodes the process may allocate on (cpuset), from /proc/self/status
|
|
Mems_allowed_list. None when unavailable, so callers fall back to the online set.
|
|
numactl --interleave=all only spans these, so a cpuset-limited container must not
|
|
count host nodes it cannot use."""
|
|
try:
|
|
text = Path("/proc/self/status").read_text()
|
|
except OSError:
|
|
return None
|
|
for line in text.splitlines():
|
|
if line.startswith("Mems_allowed_list:"):
|
|
return set(_parse_online(line.split(":", 1)[1]))
|
|
return None
|
|
|
|
|
|
def _node_memfree_mib(node: int) -> int | None:
|
|
"""MemFree for one node from /sys/.../nodeN/meminfo, in MiB (kB -> MiB)."""
|
|
try:
|
|
text = (_NODE_ROOT / f"node{node}" / "meminfo").read_text()
|
|
except OSError:
|
|
return None
|
|
for line in text.splitlines():
|
|
# "Node 0 MemFree: 465594 kB"
|
|
if "MemFree:" in line:
|
|
parts = line.split()
|
|
try:
|
|
kb = int(parts[parts.index("MemFree:") + 1])
|
|
except (ValueError, IndexError):
|
|
return None
|
|
return kb // 1024
|
|
return None
|
|
|
|
|
|
def read_numa_topology() -> NumaTopology:
|
|
"""Read NUMA node free memory from sysfs. Empty topology on non-Linux / no sysfs /
|
|
single-node (a single node is reported but callers treat node_count <= 1 as 'no
|
|
interleave needed')."""
|
|
try:
|
|
online = (_NODE_ROOT / "online").read_text()
|
|
except OSError:
|
|
return NumaTopology()
|
|
# Restrict to cpuset-allowed nodes: under a Docker/systemd cpuset the child can only
|
|
# allocate on these, so counting other host nodes would overstate the fittable RAM.
|
|
allowed = _mems_allowed()
|
|
free: dict[int, int] = {}
|
|
for node in _parse_online(online):
|
|
if allowed is not None and node not in allowed:
|
|
continue
|
|
mib = _node_memfree_mib(node)
|
|
if mib is not None:
|
|
free[node] = mib
|
|
return NumaTopology(node_free_mib = free)
|
|
|
|
|
|
def numactl_available() -> bool:
|
|
return shutil.which("numactl") is not None
|
|
|
|
|
|
@dataclass(frozen = True)
|
|
class InterleaveDecision:
|
|
interleave: bool
|
|
reason: str
|
|
prefix: tuple[str, ...] = () # argv prefix to apply, e.g. ("numactl", "--interleave=all")
|
|
|
|
|
|
def decide_interleave(
|
|
model_size_bytes: int | None,
|
|
*,
|
|
cpu_only: bool,
|
|
topology: NumaTopology | None = None,
|
|
has_numactl: bool | None = None,
|
|
) -> InterleaveDecision:
|
|
"""Interleave only when CPU-only, multi-node, and the footprint exceeds the smallest
|
|
node's free RAM but fits across all nodes; otherwise leave placement local. The
|
|
smallest node is the bound because the loader is not pinned, so first-touch may land
|
|
on any node. model_size_bytes should be the resident footprint (weights + KV), not
|
|
weights alone, so a model whose weights fit a node but whose footprint does not still
|
|
interleaves."""
|
|
if not cpu_only:
|
|
return InterleaveDecision(False, "not cpu-only; leaving NUMA placement to the OS")
|
|
if not model_size_bytes or model_size_bytes <= 0:
|
|
return InterleaveDecision(False, "model size unknown; not forcing interleave")
|
|
|
|
topo = topology if topology is not None else read_numa_topology()
|
|
if topo.node_count <= 1:
|
|
return InterleaveDecision(False, "single NUMA node; interleave not needed")
|
|
|
|
model_mib = model_size_bytes // (1024 * 1024)
|
|
smallest = topo.smallest_node_free_mib
|
|
total = topo.total_free_mib
|
|
|
|
# Keep local only when the footprint fits EVERY node (the smallest). We don't bind
|
|
# the loader, so first-touch may land on any node; local placement is safe only when
|
|
# any node can hold it. A footprint that fits only the larger node still interleaves.
|
|
if model_mib <= smallest:
|
|
return InterleaveDecision(
|
|
False,
|
|
f"model ~{model_mib} MiB fits every node's free RAM "
|
|
f"(smallest ~{smallest} MiB); keeping local placement",
|
|
)
|
|
|
|
# Impossible across all nodes regardless of numactl: surface the smaller-quant /
|
|
# free-memory path before the numactl hint, so a too-big model is not told to
|
|
# install numactl when interleaving could never make it fit.
|
|
if model_mib > total:
|
|
return InterleaveDecision(
|
|
False,
|
|
f"model ~{model_mib} MiB exceeds total free RAM across all nodes "
|
|
f"(~{total} MiB); interleave cannot help -- free memory or use a smaller quant",
|
|
)
|
|
|
|
avail = numactl_available() if has_numactl is None else has_numactl
|
|
if not avail:
|
|
# Needed but unavailable: surface it; caller decides whether to block.
|
|
return InterleaveDecision(
|
|
False,
|
|
f"model ~{model_mib} MiB exceeds the smallest NUMA node's free RAM "
|
|
f"(~{smallest} MiB) and needs interleaving across {topo.node_count} nodes, "
|
|
f"but `numactl` is not installed. Install numactl (e.g. `apt install "
|
|
f"numactl`) or the model may fail to fit a single node.",
|
|
)
|
|
|
|
return InterleaveDecision(
|
|
True,
|
|
f"model ~{model_mib} MiB exceeds the smallest NUMA node's free RAM "
|
|
f"(~{smallest} MiB) but fits across {topo.node_count} nodes (~{total} MiB total "
|
|
f"free); wrapping with numactl --interleave=all",
|
|
prefix = ("numactl", "--interleave=all"),
|
|
)
|