|
4 | 4 | # License: BSD-3-Clause |
5 | 5 | # Copyright the MNE-Python contributors. |
6 | 6 |
|
| 7 | +from contextlib import contextmanager |
| 8 | + |
7 | 9 | import numpy as np |
8 | 10 | import pytest |
9 | 11 | 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): |
101 | 103 | want = np.repeat(want[np.newaxis], n_extra, axis=0) |
102 | 104 | assert_allclose(np.matmul(mat_symv, mat), want, **kwargs) |
103 | 105 | 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