"""Backend, worker, and tie-break selection.
``backend`` sets a preference for NumPy or PyTorch and the device it runs on; ``num_workers``
controls parallel execution; ``tie_break`` sets how the searches order tied choices.
``docs/docs/configuration.md`` describes every value. Each operation
supports a subset of the backends, including the always-available ``numpy`` reference. A backend
the operation does not support falls back to automatic selection without a warning
(:func:`dispatch_stats` records which one ran). A supported backend unavailable on the machine
warns once and uses automatic selection. PyTorch, provided by the ``torch`` extra, is imported
only when needed.
A setting resolves from the call's argument, then ``with andrey.config(...)``, then the
:data:`config` object, then ``ANDREY_BACKEND`` / ``ANDREY_NUM_WORKERS`` / ``ANDREY_TIE_BREAK``
(read live), then the default. ``with`` uses context variables, so its overrides do not reach
threads started inside the block.
"""
from __future__ import annotations
import contextlib
import contextvars
import importlib.util
import logging
import os
import sys
import threading
from collections import Counter
from dataclasses import dataclass
from functools import lru_cache
import numpy as np
from andrey.core import warning_policy
from . import env
logger = logging.getLogger(__name__)
# Per-op auto-preference orders (best first) and the CPU fallback.
_TORCH_PINNABLE = ("numpy", "cpu", "cuda", "mps")
_TORCH_AUTO = ("cuda", "numpy") # cpu-torch and mps are pin-only, never auto-selected
# What to install when a pinned-but-unavailable backend falls back (surfaced in the warning).
_UNAVAILABLE_HINT = {
"cpu": " (torch-on-CPU; install the [torch] extra)",
"cuda": " (needs a CUDA GPU and the [torch] extra)",
"mps": " (needs Apple MPS and the [torch] extra)",
}
# Scoped overrides set by the ``andrey.config(...)`` context manager (thread- / task-safe).
_backend_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"andrey_backend", default=None
)
_num_workers_var: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"andrey_num_workers", default=None
)
_tie_break_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"andrey_tie_break", default=None
)
def _warn_once(message: str) -> None:
warning_policy.warn_once(message, warning_policy.BackendFallbackWarning)
# ---- optional accelerators ----------------------------------------------------------------------
@lru_cache(maxsize=1)
def torch():
"""The ``torch`` module, or ``None`` if the ``[torch]`` extra is not installed."""
try:
import torch as _torch
except ImportError:
return None
return _torch
def _is_available(b: str) -> bool:
"""True iff backend ``b`` can run on this machine right now."""
if b == "numpy":
return True
t = torch()
if t is None:
return False
if b == "cpu":
return True
if b == "cuda":
return bool(t.cuda.is_available())
if b == "mps":
mps = getattr(t.backends, "mps", None)
return mps is not None and bool(mps.is_available())
return False
def _is_live(b: str) -> bool:
"""Return whether backend ``b`` already has a live runtime in this process.
Torch must be present in ``sys.modules``, and the device runtime must have an initialized CUDA
context or an active Metal allocation. The check does not import torch or touch the driver.
"""
t = sys.modules.get("torch")
if t is None:
return False
try:
if b == "cuda":
return bool(t.cuda.is_initialized())
if b == "mps":
return bool(t.mps.is_available() and t.mps.current_allocated_memory() > 0)
except Exception: # noqa: BLE001 - a probe of optional state must not break dispatch
return False
return True
# ---- the config object + context manager --------------------------------------------------------
class _Config:
"""Process-wide backend, worker, and tie-break settings, also usable as a ``with`` block.
Assigning ``andrey.config.backend``, ``andrey.config.num_workers``, or
``andrey.config.tie_break`` changes the default for later calls; assigning ``None`` returns to
``ANDREY_BACKEND``, ``ANDREY_NUM_WORKERS``, and ``ANDREY_TIE_BREAK``.
``with andrey.config(backend=..., num_workers=..., tie_break=...):`` changes them inside the
block only, and does not reach threads started inside it. The order, most specific first:
``with`` block, these attributes, the environment variables, the defaults.
``backend="auto"`` (the default) considers ``"cuda"`` when the operation meets its size and
runtime-startup requirements and a compatible GPU is available; otherwise it uses ``"numpy"``.
``"numpy"`` uses NumPy on the CPU in float64. The ``torch`` extra provides ``"cpu"`` for
PyTorch on the CPU, ``"cuda"`` for a CUDA-compatible GPU, and ``"mps"`` for an Apple GPU in
unvalidated float32. ``"none"`` means ``"cpu"`` and ``"metal"`` means ``"mps"``. An explicit
choice bypasses the size and startup requirements. An operation without the requested backend
uses ``"auto"``; a supported but unavailable backend also warns once with
``BackendFallbackWarning`` before using ``"auto"``. Unknown names raise ``ValueError``.
``num_workers`` counts worker processes (threads for ``granger``): ``-1`` uses every usable
core, ``0`` or ``1`` runs serially (the default is ``1``), and larger values request that many
workers.
``tie_break`` sets how a method chooses between candidates that tie, such as two moves with
equal rounded score changes. ``"rank"`` (the default) orders them by a rank computed from each
column's own data (:func:`andrey.search.hc.tie_ranks`), so reordering the columns relabels the
result. ``"position"`` orders them by column position instead, which skips computing the ranks,
and the result can then depend on the column order. ``pc`` and ``cdnod`` take their colliders'
``collider_order``, and ``fci`` and ``gfci`` their ``possible_dsep``, from it when the call
gives none: ``"p_value"`` and ``"order_free"`` under ``"rank"``, ``"column"`` and ``"fast"``
under ``"position"``. ``pc``, ``cdnod``, ``fci``, and ``gfci`` also fire their orientation
rules one at a time in column order under ``"position"``, where ``"rank"`` runs them in
order-free rounds. The speed difference is small, except for ``fci`` and ``gfci`` on large
dense graphs. Each fit reads the setting once, when it starts. The
:doc:`column-order guide </docs/guides/column-order>` gives the numbers and lists the methods
it reaches.
"""
_backend: str | None = None
_num_workers: int | None = None
_tie_break: str | None = None
@property
def backend(self) -> str:
"""The backend preference in effect; assigning an unknown name raises ``ValueError``."""
return _resolve_backend(None)
@backend.setter
def backend(self, value: str | None) -> None:
self._backend = None if value is None else env.parse_backend(value, "config.backend")
@property
def num_workers(self) -> int:
"""The worker count in effect; assigning a value below ``-1`` raises ``ValueError``."""
return _resolve_num_workers(None)
@num_workers.setter
def num_workers(self, value: int | None) -> None:
self._num_workers = (
None if value is None else env.parse_num_workers(value, "config.num_workers")
)
@property
def tie_break(self) -> str:
"""The tie-break rule in effect; assigning an unknown name raises ``ValueError``."""
return _resolve_tie_break()
@tie_break.setter
def tie_break(self, value: str | None) -> None:
self._tie_break = None if value is None else env.parse_tie_break(value, "config.tie_break")
def __call__(
self,
*,
backend: str | None = None,
num_workers: int | None = None,
tie_break: str | None = None,
):
"""Return a context manager that sets the given settings for a ``with`` block.
Parameters
----------
backend : str or None, default=None
Backend preference for the block; ``None`` keeps the current one.
num_workers : int or None, default=None
Worker count for the block, ``-1`` or more; ``None`` keeps the current one.
tie_break : {"rank", "position"} or None, default=None
Tie-break rule for the block; ``None`` keeps the current one.
Returns
-------
contextlib.AbstractContextManager
The context manager for the ``with`` statement.
Raises
------
ValueError
On entering the block, if ``backend`` or ``tie_break`` is not a known name or
``num_workers`` is not an integer of at least ``-1``; the message lists the accepted
values.
"""
return _scope(backend, num_workers, tie_break)
config = _Config()
@contextlib.contextmanager
def _scope(backend: str | None, num_workers: int | None, tie_break: str | None):
b = None if backend is None else env.parse_backend(backend, "backend")
w = None if num_workers is None else env.parse_num_workers(num_workers, "num_workers")
r = None if tie_break is None else env.parse_tie_break(tie_break, "tie_break")
bt = _backend_var.set(b) if b is not None else None
wt = _num_workers_var.set(w) if w is not None else None
rt = _tie_break_var.set(r) if r is not None else None
try:
yield
finally:
if bt is not None:
_backend_var.reset(bt)
if wt is not None:
_num_workers_var.reset(wt)
if rt is not None:
_tie_break_var.reset(rt)
# ---- resolution ---------------------------------------------------------------------------------
def _resolve_backend(kwarg: str | None) -> str:
"""Resolve the backend preference by precedence: kwarg > context > config > env > ``auto``."""
if kwarg is not None:
return env.parse_backend(kwarg, "backend")
scoped = _backend_var.get()
if scoped is not None:
return scoped
if config._backend is not None:
return config._backend
# Parse both, so a bad alias raises even when ANDREY_BACKEND takes precedence.
backend, device = env.BACKEND.read(), env.DEVICE.read()
return backend or device or "auto"
def _resolve_num_workers(kwarg: int | None) -> int:
"""Resolve num_workers by precedence: kwarg > context > config > env > ``1`` (serial)."""
if kwarg is not None:
return env.parse_num_workers(kwarg, "num_workers")
scoped = _num_workers_var.get()
if scoped is not None:
return scoped
if config._num_workers is not None:
return config._num_workers
n = env.NUM_WORKERS.read()
return 1 if n is None else n
def _resolve_tie_break() -> str:
"""Resolve the tie-break rule by precedence: context > config > env > ``rank``."""
scoped = _tie_break_var.get()
if scoped is not None:
return scoped
if config._tie_break is not None:
return config._tie_break
return env.TIE_BREAK.read() or "rank"
# ---- the dispatch counts: every select() decision, recorded per process -------------------------
_dispatch_lock = threading.Lock()
_dispatch_counts: Counter[tuple[str, str]] = Counter()
_recording = contextvars.ContextVar("andrey_dispatch_recording", default=True)
def _reset_after_fork() -> None:
"""Give a forked child an unlocked dispatch-count lock and enable recording.
A child forked while another thread holds the lock inherits it locked, with no thread left to
release it. Without this reset, a child forked inside :func:`unrecorded_dispatch` would keep
recording disabled.
"""
global _dispatch_lock
_dispatch_lock = threading.Lock()
_recording.set(True)
if hasattr(os, "register_at_fork"):
os.register_at_fork(after_in_child=_reset_after_fork)
@contextlib.contextmanager
def unrecorded_dispatch():
"""Keep decisions made inside this block out of :func:`dispatch_stats`.
Calibration times real operations, but those probes are not user dispatches and would distort
the counts.
"""
token = _recording.set(False)
try:
yield
finally:
_recording.reset(token)
def _record_dispatch(op: str | None, chosen: str) -> str:
if op is not None and _recording.get():
with _dispatch_lock:
_dispatch_counts[(op, chosen)] += 1
return chosen
def dispatch_stats() -> dict[tuple[str, str], int]:
"""A snapshot of this process's dispatch decisions: ``{(op, backend): count}``.
Every labeled :func:`select` call is counted. For example,
``dispatch_stats()[("entropy", "cuda")]`` is the number of offloaded entropy calls. Each
process keeps its own counts.
"""
with _dispatch_lock:
return dict(_dispatch_counts)
def select(
pinnable: tuple[str, ...],
auto_order: tuple[str, ...],
*,
metric: int | None = None,
thresholds: dict[str, int | None] | None = None,
backend: str | None = None,
amortized: bool = True,
op: str | None = None,
) -> str:
"""Pick the backend an op should run on, from the ``pinnable`` set it supports.
``backend`` overrides the active preference for this call. A pinned preference the op supports
and is available wins (a device pin overrides the size threshold *and* the bootstrap rule
below). A preference the op does not support falls back to ``auto`` silently; a
supported-but-unavailable one warns once and falls back.
``auto`` picks the first device in ``auto_order`` that clears *its own* ``thresholds`` entry
(when ``metric`` is given; a ``None`` cutoff means the device never auto-offloads), else the
CPU ``numpy`` path. The cutoff is per-device because the numpy->device crossover depends on the
device. ``amortized`` states whether the op recurs enough per fit to amortize a runtime
bootstrap: an amortized op may be the call that imports torch and creates a CUDA context; a
one-shot op (``amortized=False``) only takes a GPU that is already live in-process
(:func:`_is_live`), so a single millisecond-scale call can never pay the multi-second
torch/CUDA start-up inside a fit. ``op`` labels the decision in :func:`dispatch_stats`.
"""
pref = _resolve_backend(backend)
if pref != "auto":
if pref in pinnable:
if _is_available(pref):
return _record_dispatch(op, pref)
_warn_once(
f"backend {pref!r} is unavailable{_UNAVAILABLE_HINT.get(pref, '')}; "
"falling back to auto-selection"
)
# unsupported by this op -> fall through to auto (silent, by design)
for b in auto_order:
if b == "numpy":
return _record_dispatch(op, "numpy")
if metric is not None and thresholds is not None:
cutoff = thresholds.get(b) # None -> no measured cutoff: never auto-offload to b
if cutoff is None or metric < cutoff:
continue # too small for this device (or unmeasured) -> try the next tier
# Order matters: the liveness check is free; _is_available may import torch. A one-shot op
# checks liveness first so the *decision* to stay on numpy never imports anything.
if b in ("cuda", "mps") and not amortized and not _is_live(b):
continue
if not _is_available(b):
continue
return _record_dispatch(op, b)
return _record_dispatch(op, "numpy")
def resolve(
metric: int,
threshold: int | dict[str, int | None],
*,
amortized: bool = False,
op: str | None = None,
) -> str:
"""Pick ``numpy`` / ``cpu`` / ``cuda`` / ``mps`` for a float torch op of size ``metric``.
``auto`` considers only ``cuda``; ``cpu`` and ``mps`` need a pin. ``threshold`` is either one
cutoff or a ``{device: cutoff}`` mapping. A ``None`` cutoff disables automatic offload for that
device. A pinned backend bypasses the cutoff. Set ``amortized=True`` for repeated operations
that may bootstrap the GPU runtime; the default protects one-shot operations from startup cost.
"""
thresholds: dict[str, int | None] = (
threshold
if isinstance(threshold, dict)
else {b: threshold for b in _TORCH_AUTO if b != "numpy"}
)
dev = select(
_TORCH_PINNABLE,
_TORCH_AUTO,
metric=metric,
thresholds=thresholds,
amortized=amortized,
op=op,
)
logger.debug(
"backend: %s (op=%s, metric=%d, amortized=%s, thresholds=%s)",
dev,
op,
metric,
amortized,
thresholds,
)
return dev
# ---- the executor axis: num_workers over a process pool -----------------------------------------
# CPU limits on Linux can come from both scheduler affinity and cgroups.
#
# cgroups have two layouts:
#
# v2 (modern Linux / Kubernetes)
# One unified hierarchy. CPU quota is stored in:
# cpu.max -> "<quota> <period>" or "max <period>"
#
# v1 (older Linux / many SLURM clusters)
# Separate controller hierarchies. CPU quota is stored in:
# cpu.cfs_quota_us
# cpu.cfs_period_us
# A quota of -1 means unlimited.
#
# A process may live below the cgroup mount root, and limits inherited from any
# parent apply. So the lookup starts at the process's own cgroup path and walks
# upward, taking the tightest quota found.
def _cgroup_rel_paths() -> tuple[str, str]:
"""Return this process's cgroup paths as ``(v2, v1_cpu)``, empty for the unused generation."""
v2 = v1 = ""
try:
with open("/proc/self/cgroup") as fh:
for line in fh:
# v2 writes a single ``0::/path`` line; v1 writes one line per controller group,
# for example, ``4:cpu,cpuacct:/path``.
hid, controllers, path = line.strip().split(":", 2)
if hid == "0":
v2 = path
elif "cpu" in controllers.split(","):
v1 = path
except (OSError, ValueError):
pass
return v2, v1
def _quota_v2(d: str) -> int | None:
"""Read a cgroup v2 CPU quota, or ``None`` if uncapped/unavailable."""
try:
with open(os.path.join(d, "cpu.max")) as fh:
quota, period = fh.read().split()
return None if quota == "max" else max(1, int(int(quota) / int(period)))
except (OSError, ValueError, ZeroDivisionError):
return None
def _quota_v1(d: str) -> int | None:
"""Read a cgroup v1 CPU quota, or ``None`` if uncapped/unavailable."""
try:
with open(os.path.join(d, "cpu.cfs_quota_us")) as fh:
quota = int(fh.read())
with open(os.path.join(d, "cpu.cfs_period_us")) as fh:
period = int(fh.read())
except (OSError, ValueError):
return None
return max(1, int(quota / period)) if quota > 0 and period > 0 else None
def _tightest_quota(root: str, rel: str, read) -> int | None:
"""Return the tightest quota between the process cgroup and mount root."""
best = None
parts = [p for p in rel.split("/") if p]
while True:
n = read(os.path.join(root, *parts))
if n is not None:
best = n if best is None else min(best, n)
if not parts:
return best
parts.pop()
def _cgroup_cpu_quota() -> int | None:
"""Return the CPU quota imposed by cgroups, or ``None`` if uncapped."""
v2, v1 = _cgroup_rel_paths()
for root, rel, read in (
("/sys/fs/cgroup", v2, _quota_v2),
("/sys/fs/cgroup/cpu", v1, _quota_v1),
):
n = _tightest_quota(root, rel, read)
if n is not None:
return n
return None
def usable_cpus() -> int:
"""Return the CPUs this process can actually use.
Uses the tightest applicable limit from machine CPU count, scheduler affinity, and cgroup CPU
quota.
"""
limits = [os.cpu_count() or 1]
if hasattr(os, "sched_getaffinity"):
limits.append(len(os.sched_getaffinity(0)))
quota = _cgroup_cpu_quota()
if quota is not None:
limits.append(quota)
return max(1, min(limits))
def worker_count(num_workers: int | None = None) -> int:
"""Resolve ``num_workers`` to a worker count; ``-1`` means all usable CPUs."""
n = _resolve_num_workers(num_workers)
if n == -1:
return usable_cpus()
return max(1, n)
@lru_cache(maxsize=1)
def _thread_pools():
from threadpoolctl import ThreadpoolController
return ThreadpoolController()
def one_blas_thread() -> contextlib.AbstractContextManager:
"""Limit the loaded BLAS and OpenMP thread pools to one thread inside a ``with`` block.
The libraries are found once per process; one loaded after the first call is not limited.
"""
return _thread_pools().limit(limits=1)
# ---- tensor transfer (torch backends) -----------------------------------------------------------
def as_tensor(data: np.ndarray, dev: str):
"""A torch tensor of ``data`` on ``dev`` -- float32 on ``mps``, else float64."""
t = torch()
dtype = t.float32 if dev == "mps" else t.float64
if any(stride < 0 for stride in data.strides): # torch rejects negative strides
data = np.ascontiguousarray(data)
return t.as_tensor(data, dtype=dtype, device=dev)
def to_numpy(tensor) -> np.ndarray:
"""A ``float64`` numpy array from a torch tensor, whatever the compute precision."""
return tensor.detach().cpu().numpy().astype(np.float64, copy=False)
# ---- introspection ------------------------------------------------------------------------------
@dataclass(frozen=True)
class Backend:
"""A snapshot of the backend + worker settings and what is available on this machine."""
backend: str
num_workers: int
torch: bool
cuda: bool
mps: bool
def __repr__(self) -> str: # a compact, human line
available = ", ".join(
"cpu (torch)" if name == "cpu" else name
for name, ok in self.available_backends().items()
if ok
)
return (
f"Backend(backend={self.backend!r}, num_workers={self.num_workers}, "
f"available=[{available}])"
)
def available_backends(self) -> dict[str, bool]:
"""Availability for each explicit ``ANDREY_BACKEND`` choice; ``cpu`` uses PyTorch."""
return {
"numpy": True,
"cpu": self.torch,
"cuda": self.cuda,
"mps": self.mps,
}
def as_dict(self) -> dict:
"""The machine-readable snapshot. ``andrey config --json`` reports its ``backend`` and
``num_workers`` under ``configuration`` and its ``available_backends`` unchanged."""
return {
"backend": self.backend,
"num_workers": self.num_workers,
"available_backends": self.available_backends(),
}
def _installed(name: str) -> bool:
"""Whether module ``name`` is imported or can be found, without importing it."""
if sys.modules.get(name) is not None:
return True
try:
return importlib.util.find_spec(name) is not None
except (ImportError, ValueError):
return False
def _torch_gpu_build() -> bool:
"""Whether the installed torch was built for CUDA or ROCm, read from ``torch/version.py``.
Runs that one generated file, not the ``torch`` package. A missing or unexpected file counts as
a GPU build.
"""
spec = importlib.util.find_spec("torch")
locations = spec.submodule_search_locations if spec is not None else None
if not locations:
return True
version = importlib.util.spec_from_file_location(
"_andrey_torch_version", os.path.join(locations[0], "version.py")
)
if version is None or version.loader is None:
return True
module = importlib.util.module_from_spec(version)
try:
version.loader.exec_module(module)
except Exception: # noqa: BLE001 - an unknown build is checked by importing torch
return True
return getattr(module, "cuda", True) is not None or getattr(module, "hip", True) is not None
[docs]
def describe() -> Backend:
"""The backend and worker settings in effect, with package and device availability.
Each operation picks its own implementation, so the report gives the preference, not the
backend a given call will use. Checking whether the ``torch`` module is installed does not
import it. GPU checks may import it on macOS or when the build supports CUDA or ROCm; an
unrecognized build is also checked by importing it.
Returns
-------
Backend
A read-only snapshot with the fields ``backend`` (the preference, such as ``"auto"``),
``num_workers`` (``-1`` means every usable core), ``torch`` (whether the module is installed
or already imported), and ``cuda`` and ``mps`` (whether PyTorch reports a usable device).
``available_backends()`` maps ``numpy``, ``cpu``, ``cuda``, and ``mps`` to availability;
``cpu`` uses the ``torch`` field. ``as_dict()`` returns ``backend``, ``num_workers``, and
this availability mapping as a JSON-ready dict.
Raises
------
ValueError
If a setting read from ``ANDREY_BACKEND``, ``ANDREY_DEVICE``, or ``ANDREY_NUM_WORKERS``
holds an invalid value; a context or config override takes precedence over those variables.
"""
has_torch = _installed("torch")
cuda_build = has_torch and ("torch" in sys.modules or _torch_gpu_build())
return Backend(
backend=_resolve_backend(None),
num_workers=_resolve_num_workers(None),
torch=has_torch,
cuda=cuda_build and _is_available("cuda"),
mps=has_torch and sys.platform == "darwin" and _is_available("mps"),
)