Source code for unilab.base.cpu_runtime
"""Process-local CPU confinement for envs that own an explicit CPU block.
Multi-rank off-policy data-parallel runs partition host CPUs so each rank's
collector owns one contiguous block (``training.dp_collector_cpu_ids``, routed
into ``EnvCfg.cpu_ids``). The MuJoCo BatchEnvPool already pins its physics
workers to that block, but the collector's existing host-side native pools did
not follow and could drift across rank boundaries to compete with sibling
ranks' pinned physics workers.
``apply_cpu_confinement`` is the generic env-level counterpart, applied once on
the env-construction cold path, before managers and backend materialization:
- ``os.sched_setaffinity`` pins the calling thread to the block, and every
already-running thread (e.g. BLAS pools spawned by a backend dependency) is
pinned individually via ``/proc/self/task``; threads spawned later inherit
the mask.
The function is backend-agnostic: ``EnvCfg.cpu_ids`` is the single source of
truth, so any backend whose env declares a block gets the same confinement.
"""
from __future__ import annotations
import os
import warnings
from collections.abc import Sequence
_PROC_TASK_DIR = "/proc/self/task"
def _confine_existing_threads(ids: set[int]) -> None:
"""Pin already-running threads; later threads inherit the caller's mask.
Native pools spawned before env construction (for example, OpenBLAS workers
started by a backend dependency) would otherwise keep the host-wide mask and
compete with sibling ranks' pinned CPUs. Threads may exit between listing
and pinning; those races are ignored.
"""
# Resolved at call time (not import) so tests can monkeypatch the seam;
# ``getattr`` keeps this checkable on platforms where typeshed hides the
# Linux-only symbol (mypy ``attr-defined`` on darwin).
sched_setaffinity = getattr(os, "sched_setaffinity", None)
if sched_setaffinity is None or not os.path.isdir(_PROC_TASK_DIR):
return
for entry in os.listdir(_PROC_TASK_DIR):
try:
sched_setaffinity(int(entry), ids)
except OSError:
continue
[docs]
def apply_cpu_confinement(cpu_ids: Sequence[int] | None) -> None:
"""Confine this process's host-side compute to the env-owned CPU block.
Cold path only: call from env construction, before the backend pool or
managers exist. ``None`` (the single-rank default) is a no-op so the
default path keeps default OS scheduling.
Structural validation (non-empty, unique, non-negative ints) is owned by
``EnvCfg.validate``; this function fails closed on CPU ids that are not
available to the process.
"""
if cpu_ids is None:
return
ids = {int(cpu_id) for cpu_id in cpu_ids}
sched_setaffinity = getattr(os, "sched_setaffinity", None)
sched_getaffinity = getattr(os, "sched_getaffinity", None)
if sched_setaffinity is not None and sched_getaffinity is not None:
available = set(sched_getaffinity(0))
missing = sorted(ids - available)
if missing:
raise ValueError(
f"EnvCfg.cpu_ids entries {missing} are not available to this process "
f"(sched_getaffinity={sorted(available)})"
)
sched_setaffinity(0, ids)
_confine_existing_threads(ids)
else:
warnings.warn(
"EnvCfg.cpu_ids process confinement requires os.sched_setaffinity "
"(Linux); CPU confinement is unavailable",
stacklevel=2,
)