Skip to content

Commit 7ec1a35

Browse files
authored
Improve threading defaults, docs, and sys_info (#14103)
1 parent e1da4c0 commit 7ec1a35

10 files changed

Lines changed: 219 additions & 16 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,4 @@ venv/
103103
.hypothesis/
104104
.ruff_cache/
105105
.ipynb_checkpoints/
106+
/.claude/

doc/changes/dev/14103.other.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Decomposition-heavy operations such as :func:`mne.preprocessing.maxwell_filter`, :func:`mne.compute_covariance` and :meth:`mne.preprocessing.ICA.fit` now limit how many BLAS threads they use, which is often much faster; see :ref:`faq_cpu` to control this yourself, by `Eric Larson`_.

doc/help/faq.rst

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -174,27 +174,43 @@ support multithreading:
174174
- `OpenBLAS <http://www.openblas.net/>`_
175175
- `Intel Math Kernel Library (MKL) <https://www.intel.com/content/www/us/en/developer/tools/oneapi/onemkl.html>`_,
176176
which uses `OpenMP <https://www.openmp.org/>`_
177+
- `Accelerate <https://developer.apple.com/accelerate/>`_, used by the NumPy and
178+
SciPy wheels on PyPI for macOS on Apple silicon
177179

178180
To control how many cores are used for linear-algebra-heavy functions like
179-
:func:`mne.preprocessing.maxwell_filter`, you can set the ``OMP_NUM_THREADS``
180-
or ``OPENBLAS_NUM_THREADS`` environment variable to the desired number of cores
181-
for MKL or OpenBLAS, respectively.
181+
:func:`mne.preprocessing.maxwell_filter`, you can set an environment variable
182+
to the desired number of cores. **Which variable works depends on the threading
183+
layer your BLAS was built against, not just on which BLAS it is.**
184+
:func:`mne.sys_info` reports both, for example
185+
``OpenBLAS 0.3.33 with 10 threads (openmp threading layer)``.
186+
187+
- OpenBLAS with the ``pthreads`` threading layer, typically shipped by the
188+
NumPy and SciPy wheels on PyPI: use ``OPENBLAS_NUM_THREADS``.
189+
- OpenBLAS with the ``openmp`` threading layer, typically shipped by
190+
``conda-forge`` and by the :ref:`standalone installers <installers>`: use
191+
``OMP_NUM_THREADS``. On these builds ``OPENBLAS_NUM_THREADS`` is silently
192+
ignored, because OpenBLAS delegates threading to OpenMP.
193+
- MKL: use ``MKL_NUM_THREADS``, or ``OMP_NUM_THREADS`` for any OpenMP-based
194+
threading layer.
195+
- Accelerate: there is no effective setting, and none is needed. Accelerate
196+
manages its own threads and ignores all of the variables above;
197+
``VECLIB_MAXIMUM_THREADS`` is advisory at best. Its performance is also
198+
essentially flat with respect to thread settings, so there is nothing to tune.
182199

183200
Using more threads is not always faster. In particular, decomposition-heavy
184201
operations such as :func:`mne.preprocessing.maxwell_filter` and
185-
:func:`mne.compute_covariance` with ``method="shrunk"`` can be slower when
186-
OpenBLAS uses all logical CPU cores on Linux. If one of these operations is
187-
unexpectedly slow, use :func:`mne.sys_info` to check the BLAS library and its
188-
current thread count. Compare a few settings, for example 1, 2, 4, and the
189-
number of physical CPU cores, using a representative part of your analysis.
190-
The best setting depends on the operation, CPU, and BLAS library.
202+
:func:`mne.compute_covariance` with ``method="shrunk"`` can be much slower when
203+
OpenBLAS or MKL uses all logical CPU cores. If one of these operations is
204+
unexpectedly slow, compare a few settings, for example 1, 2, 3, 4, and the
205+
number of physical CPU cores, using a representative part of your analysis. The
206+
best setting depends on the operation, CPU, and BLAS library.
191207

192208
Set the environment variable before importing MNE-Python, NumPy, or SciPy. For
193209
example, start the analysis from the shell with:
194210

195211
.. code-block:: console
196212
197-
$ OPENBLAS_NUM_THREADS=4 python analysis.py
213+
$ OMP_NUM_THREADS=4 python analysis.py
198214
199215
Changes made after the linear algebra library has been loaded might have no
200216
effect in the same Python session.

mne/cov.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
_check_fname,
5959
_check_on_missing,
6060
_check_option,
61+
_limit_blas_threads,
6162
_on_missing,
6263
_pl,
6364
_scaled_array,
@@ -1259,6 +1260,7 @@ def _compute_rank_raw_array(
12591260
)
12601261

12611262

1263+
@_limit_blas_threads()
12621264
def _compute_covariance_auto(
12631265
data,
12641266
method,

mne/preprocessing/ica.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
_check_preload,
6969
_ensure_int,
7070
_get_inst_data,
71+
_limit_blas_threads,
7172
_on_missing,
7273
_pl,
7374
_reject_data_segments,
@@ -887,6 +888,7 @@ def _pre_whiten(self, data):
887888
data = self.pre_whitener_ @ data
888889
return data
889890

891+
@_limit_blas_threads()
890892
def _fit(self, data, fit_type):
891893
"""Aux function."""
892894
if not np.isfinite(data).all():

mne/preprocessing/maxwell.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
_check_option,
5252
_clean_names,
5353
_ensure_int,
54+
_limit_blas_threads,
5455
_pl,
5556
_time_mask,
5657
_validate_type,
@@ -713,6 +714,7 @@ def _prep_maxwell_filter(
713714
return params
714715

715716

717+
@_limit_blas_threads()
716718
def _run_maxwell_filter(
717719
raw,
718720
skip_by_annotation,

mne/utils/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ __all__ = [
8989
"_is_numeric",
9090
"_is_vtk",
9191
"_julian_to_date",
92+
"_limit_blas_threads",
9293
"_mask_to_onsets_offsets",
9394
"_on_missing",
9495
"_open_lock",
@@ -317,6 +318,7 @@ from .docs import (
317318
from .fetching import _url_to_local_path
318319
from .linalg import (
319320
_get_blas_funcs,
321+
_limit_blas_threads,
320322
_repeated_svd,
321323
_svd_lwork,
322324
_sym_mat_pow,

mne/utils/config.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -633,25 +633,51 @@ def _get_root_dir():
633633
return root_dir
634634

635635

636+
_blas_rename = dict(
637+
openblas="OpenBLAS",
638+
mkl="MKL",
639+
accelerate="Accelerate",
640+
)
641+
642+
636643
def _get_numpy_libs():
637644
bad_lib = "unknown linalg bindings"
638645
try:
639646
from threadpoolctl import threadpool_info
640647
except Exception as exc:
641648
return bad_lib + f" (threadpoolctl module not found: {exc})"
642649
pools = threadpool_info()
643-
rename = dict(
644-
openblas="OpenBLAS",
645-
mkl="MKL",
646-
)
647650
for pool in pools:
648651
if pool["internal_api"] in ("openblas", "mkl"):
652+
layer = pool.get("threading_layer")
653+
layer = f" via {layer}" if layer else ""
654+
name = pool["internal_api"]
655+
name = _blas_rename.get(name, name)
649656
return (
650-
f"{rename[pool['internal_api']]} "
657+
f"{name} "
651658
f"{pool['version']} with "
652659
f"{pool['num_threads']} thread{_pl(pool['num_threads'])}"
660+
f"{layer}"
653661
)
654-
return bad_lib
662+
return _get_numpy_build_blas() or bad_lib
663+
664+
665+
def _get_numpy_build_blas():
666+
"""Name the BLAS from the build config, for backends threadpoolctl can't see."""
667+
# Accelerate has no threadpoolctl controller, so macOS wheels otherwise report only
668+
# "unknown linalg bindings"
669+
import numpy as np
670+
671+
try:
672+
blas = np.show_config(mode="dicts")["Build Dependencies"]["blas"]
673+
name = blas["name"].lower()
674+
except Exception:
675+
return None
676+
version = blas.get("version", "")
677+
name = _blas_rename.get(name, name)
678+
if version and version != "unknown": # Accelerate reports a literal "unknown"
679+
name = f"{name} {version}"
680+
return f"{name}, threads not introspectable"
655681

656682

657683
_gpu_cmd = """\

mne/utils/linalg.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,90 @@
2222
# License: BSD-3-Clause
2323
# Copyright the MNE-Python contributors.
2424

25+
import contextlib
2526
import functools
27+
import os
2628

2729
import numpy as np
2830
from scipy import linalg
2931
from scipy._lib._util import _asarray_validated
3032

3133
from ..fixes import _safe_svd
3234

35+
###############################################################################
36+
# BLAS thread limiting
37+
38+
# Setting any of these means the user has already expressed a preference
39+
_BLAS_THREAD_ENV_VARS = (
40+
"OMP_NUM_THREADS",
41+
"OPENBLAS_NUM_THREADS",
42+
"MKL_NUM_THREADS",
43+
"BLIS_NUM_THREADS",
44+
"VECLIB_MAXIMUM_THREADS",
45+
)
46+
# Measured optima across the machines in gh-13766 range from 1 to 8 depending on CPU and
47+
# BLAS, but 3 is within ~11% of the best on all of them, and unlike a larger cap it
48+
# leaves a core free on 4-core machines.
49+
_MAX_BLAS_THREADS = 3
50+
51+
52+
@functools.cache
53+
def _blas_thread_controller():
54+
"""Get a cached controller, or None if we should not touch thread counts."""
55+
# Constructing one rescans the process's shared libraries (~120 us); reusing it
56+
# drops enter/exit to ~3 us, hence the cache. It still reports live thread counts,
57+
# so caching does not stale the checks in _limit_blas_threads.
58+
try:
59+
from threadpoolctl import ThreadpoolController
60+
except Exception: # not a required dependency
61+
return None
62+
controller = ThreadpoolController()
63+
# Accelerate has no working control, so there is nothing to limit
64+
if not any(
65+
pool["internal_api"] in ("openblas", "mkl") for pool in controller.info()
66+
):
67+
return None
68+
return controller
69+
70+
71+
def _n_available_cpus():
72+
"""Get the number of CPUs this process may actually use."""
73+
# TODO VERSION: drop both fallbacks once Python 3.13 is the minimum
74+
if (process_cpu_count := getattr(os, "process_cpu_count", None)) is not None:
75+
# preferred: affinity-aware everywhere, and honors -X cpu_count and
76+
# PYTHON_CPU_COUNT, which gives users a documented override
77+
return process_cpu_count() or 1
78+
if (sched_getaffinity := getattr(os, "sched_getaffinity", None)) is not None:
79+
return len(sched_getaffinity(0)) # Linux only
80+
return os.cpu_count() or 1
81+
82+
83+
@contextlib.contextmanager
84+
def _limit_blas_threads():
85+
"""Cap BLAS threads around decomposition-heavy code (gh-13766).
86+
87+
Works as a context manager or as a decorator. Yields immediately, leaving thread
88+
counts untouched, whenever the user has expressed their own preference or the BLAS
89+
in use cannot be controlled.
90+
"""
91+
controller = _blas_thread_controller()
92+
if controller is None or any(os.getenv(var) for var in _BLAS_THREAD_ENV_VARS):
93+
yield
94+
return
95+
n_cpus = _n_available_cpus()
96+
# a pool already below the machine size means something else is managing threads
97+
if any(pool["num_threads"] < n_cpus for pool in controller.info()):
98+
yield
99+
return
100+
# limit every API rather than just user_api="blas": OpenBLAS built against
101+
# OpenMP silently ignores the BLAS-level call before OpenBLAS 0.3.34
102+
with controller.limit(limits=min(_MAX_BLAS_THREADS, n_cpus)):
103+
yield
104+
105+
106+
###############################################################################
107+
# Fast linalg helpers
108+
33109
# For efficiency, names should be str or tuple of str, dtype a builtin
34110
# NumPy dtype
35111

mne/utils/tests/test_linalg.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
# License: BSD-3-Clause
55
# Copyright the MNE-Python contributors.
66

7+
from contextlib import contextmanager
8+
79
import numpy as np
810
import pytest
911
from numpy.testing import assert_allclose, assert_array_equal
@@ -101,3 +103,76 @@ def test_pos_semidef_inv(ndim, dtype, n, deficient, reduce_rank, psdef, func):
101103
want = np.repeat(want[np.newaxis], n_extra, axis=0)
102104
assert_allclose(np.matmul(mat_symv, mat), want, **kwargs)
103105
assert_allclose(np.matmul(mat, mat_symv), want, **kwargs)
106+
107+
108+
class _FakeController:
109+
"""Minimal stand-in for threadpoolctl.ThreadpoolController."""
110+
111+
def __init__(self, num_threads):
112+
self.num_threads = num_threads
113+
self.applied = []
114+
115+
def info(self):
116+
return [dict(internal_api="openblas", num_threads=self.num_threads)]
117+
118+
@contextmanager
119+
def limit(self, *, limits):
120+
self.applied.append(limits)
121+
yield
122+
123+
124+
def test_limit_blas_threads_logic(monkeypatch):
125+
"""Test when BLAS thread limiting engages and when it defers."""
126+
pytest.importorskip("threadpoolctl")
127+
from mne.utils import linalg
128+
129+
controller = _FakeController(8)
130+
monkeypatch.setattr(linalg, "_blas_thread_controller", lambda: controller)
131+
monkeypatch.setattr(linalg, "_n_available_cpus", lambda: 8)
132+
for var in linalg._BLAS_THREAD_ENV_VARS:
133+
monkeypatch.delenv(var, raising=False)
134+
with linalg._limit_blas_threads():
135+
pass
136+
assert controller.applied == [3]
137+
138+
# defer to an explicit user preference
139+
monkeypatch.setenv("OMP_NUM_THREADS", "2")
140+
with linalg._limit_blas_threads():
141+
pass
142+
assert controller.applied == [3]
143+
144+
# defer when something already limited us (outer limits, joblib worker, ...)
145+
monkeypatch.delenv("OMP_NUM_THREADS")
146+
controller.num_threads = 2
147+
with linalg._limit_blas_threads():
148+
pass
149+
assert controller.applied == [3]
150+
151+
# but do not oversubscribe a machine smaller than the cap
152+
monkeypatch.setattr(linalg, "_n_available_cpus", lambda: 2)
153+
with linalg._limit_blas_threads():
154+
pass
155+
assert controller.applied == [3, 2]
156+
157+
# no-op when threads cannot be controlled at all (no threadpoolctl, Accelerate)
158+
monkeypatch.setattr(linalg, "_blas_thread_controller", lambda: None)
159+
with linalg._limit_blas_threads():
160+
pass
161+
assert controller.applied == [3, 2]
162+
163+
164+
def test_limit_blas_threads(monkeypatch):
165+
"""Test that BLAS threads are actually limited and restored."""
166+
threadpool_info = pytest.importorskip("threadpoolctl").threadpool_info
167+
from mne.utils import linalg
168+
169+
if linalg._blas_thread_controller() is None:
170+
pytest.skip("BLAS threads are not controllable (e.g. Accelerate)")
171+
for var in linalg._BLAS_THREAD_ENV_VARS:
172+
monkeypatch.delenv(var, raising=False)
173+
# 1 so that no ambient limit (e.g. from conftest) counts as already-limited
174+
monkeypatch.setattr(linalg, "_n_available_cpus", lambda: 1)
175+
before = [pool["num_threads"] for pool in threadpool_info()]
176+
with linalg._limit_blas_threads():
177+
assert [pool["num_threads"] for pool in threadpool_info()] == [1] * len(before)
178+
assert [pool["num_threads"] for pool in threadpool_info()] == before

0 commit comments

Comments
 (0)