From 126b24fc863125677e6e6d974f91039a8e445c22 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 22 Oct 2026 16:31:59 -0400 Subject: [PATCH 1/9] Sketch of limiting OpenBLAS pthreads to being controlled from one thread only. --- conftest.py | 10 +++++++++ threadpoolctl.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index 11855652..1067297c 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,16 @@ +import pytest + collect_ignore = [ "tests/empirical_scope_observation.py", "tests/_openmp_test_helper.py", "tests/_limit_blas.py", "tests/_dl_iterate_phdr_deadlock.py", ] + +@pytest.fixture(autouse=True) +def reset_openblas_controlling_thread(): + from threadpoolctl import OpenBLASController + try: + yield + finally: + OpenBLASController.clear_controlling_thread() diff --git a/threadpoolctl.py b/threadpoolctl.py index 2eab17c3..e2461ee7 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -22,7 +22,7 @@ from collections.abc import Callable from contextlib import ContextDecorator from functools import lru_cache -from threading import Thread +from threading import Thread, Lock, current_thread from typing import Literal, final # ctypes.util is not imported on Linux: on CPython 3.14 it allocates a @@ -290,7 +290,21 @@ def _get_symbol(self, name): class OpenBLASController(LibController): - """Controller class for OpenBLAS""" + """Controller class for OpenBLAS. + + When OpenMP threading layer is not used, there is a process-wide shared + thread pool, so having multiple Python threads change the limit at the same + time makes no sense. Therefore: + + 1. Thread pool size can only be set by a single thread, the first + thread to call ``set_num_threads()``. In practice this will usually + be the main thread. + + 2. Other threads silently do nothing. + + 3. ``clear_controlling_thread()`` can be used to reset the thread that + can meaningfully use the limiting API. + """ user_api = "blas" internal_api = "openblas" @@ -332,7 +346,45 @@ def get_num_threads(self): return get_num_threads_func() return None + class _SHARED_STATE: + """Shared state across instances.""" + controlling_thread: Thread | None = None + lock = Lock() + + @classmethod + def clear_controlling_thread(cls): + """Reset which thread can set limits (on non-OpenMP threading layer). + + It's up the caller to ensure the current controlling thread is done. + """ + with cls._SHARED_STATE.lock: + cls._SHARED_STATE.controlling_thread = None + def set_num_threads(self, num_threads): + if self.threading_layer == "openmp": + # Per-thread limits, so no need to be constraining: + return self._real_set_num_threads(num_threads) + + # A shared process-wide thread pool, so don't want different Python + # threads fighting over limits: + this_thread = current_thread() + + match self._SHARED_STATE.controlling_thread: + case None: + with self._SHARED_STATE.lock: + # Another thread might have changed it, so ensure that's + # not the case: + if self._SHARED_STATE.controlling_thread is None: + self._SHARED_STATE.controlling_thread = this_thread + # Controlling thread should now be set, to either this thread + # or another, so try again: + return self.set_num_threads(num_threads) + case controlling_thread if controlling_thread is this_thread: + return self._real_set_num_threads(num_threads) + case _: + return + + def _real_set_num_threads(self, num_threads): # The OpenBLAS limiting API is process-wide, and we want current thread # limit if possible. When OpenBLAS is backed by OpenMP, using the # OpenMP API allows for current thread limiting when OpenMP has that From 2d58a1124f00d32eafda4dfbe186e3291b155d16 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 22 Oct 2026 18:30:30 -0400 Subject: [PATCH 2/9] More useful semantics for joblib's expected interaction pattern --- conftest.py | 2 +- threadpoolctl.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/conftest.py b/conftest.py index 1067297c..c175f9ca 100644 --- a/conftest.py +++ b/conftest.py @@ -13,4 +13,4 @@ def reset_openblas_controlling_thread(): try: yield finally: - OpenBLASController.clear_controlling_thread() + OpenBLASController.clear_controlling_thread(force=True) diff --git a/threadpoolctl.py b/threadpoolctl.py index e2461ee7..fe3064e0 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -352,13 +352,18 @@ class _SHARED_STATE: lock = Lock() @classmethod - def clear_controlling_thread(cls): + def clear_controlling_thread(cls, force: bool=False): """Reset which thread can set limits (on non-OpenMP threading layer). It's up the caller to ensure the current controlling thread is done. + + By default, only the controlling thread has an effect when calling this + moethod. You can have any thread clear the controlling thread by + setting ``force=True``. """ with cls._SHARED_STATE.lock: - cls._SHARED_STATE.controlling_thread = None + if force or cls._SHARED_STATE.controlling_thread is current_thread(): + cls._SHARED_STATE.controlling_thread = None def set_num_threads(self, num_threads): if self.threading_layer == "openmp": From 3c155c24017b246b464baefe5bd68f83a37e307d Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 22 Oct 2026 18:32:48 -0400 Subject: [PATCH 3/9] Explanations --- threadpoolctl.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/threadpoolctl.py b/threadpoolctl.py index fe3064e0..a905eb61 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -376,6 +376,7 @@ def set_num_threads(self, num_threads): match self._SHARED_STATE.controlling_thread: case None: + # No thread controls OpenBLAS, so try to take control: with self._SHARED_STATE.lock: # Another thread might have changed it, so ensure that's # not the case: @@ -385,8 +386,10 @@ def set_num_threads(self, num_threads): # or another, so try again: return self.set_num_threads(num_threads) case controlling_thread if controlling_thread is this_thread: + # This thread controls OpenBLAS: return self._real_set_num_threads(num_threads) case _: + # Some other thread controlls OpenBLAS: return def _real_set_num_threads(self, num_threads): From 5689e7e311f2c40dc5f1605bdc1e252a61d7ee24 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 22 Oct 2026 18:35:19 -0400 Subject: [PATCH 4/9] Tests pass. --- threadpoolctl.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/threadpoolctl.py b/threadpoolctl.py index a905eb61..b3131af7 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -154,6 +154,7 @@ def get_and_set() -> None: thread = Thread(target=get_and_set) thread.start() thread.join() + OpenBLASController.clear_controlling_thread(force=True) # First, getting in the same thread as a set should always give same # number, if it's a number in a reasonable range. A possible exception @@ -179,6 +180,7 @@ def get_and_set() -> None: return "unknown" finally: set_n_threads(previous) + OpenBLASController.clear_controlling_thread(force=True) class LibController(ABC): From fb9494dd638aaad4fb2273cdd3366ca5bd6b7b66 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 23 Oct 2026 06:19:38 -0400 Subject: [PATCH 5/9] Update docs to document new required idiom --- README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 73a6c1f6..f4f363b6 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,12 @@ libraries) that can take some time. To prevent the performance cost of doing this work in all the threads, you can reuse a `ThreadpoolController` object across the threads. +OpenBLAS with pthreads or Windows threading layer, affects the whole process, +and is therefore constrained to only work from the first thread that uses it, +the controlling thread. Other third party libraries allow setting the limit +per-thread. So we set limits both in the main thread (in case you're using +OpenBLAS+pthreads) and in each worker thread. + ```python from threadpoolctl import ThreadpoolController @@ -241,11 +247,10 @@ from threadpoolctl import ThreadpoolController controller = ThreadpoolController() with ( - # This top-level limiter doesn't actually change the limits initially; it - # is there to ensure the limits are reset _after_ the Python thread pool is - # done. This is necessary because some underlying limiting APIs operate on - # a process-wide basis. - controller.limit(), + # Limit on main thread. This is necessary because some underlying limiting + # APIs operate on a process-wide basis. Because it's a context manager, it + # will restore the limits after the ``with`` is done. + controller.limit(limits=1), # Make sure each Python worker thread also calls threadpool_limits(). If # you're using another thread pool class, you will need to do so some other # way. @@ -257,7 +262,7 @@ with ( # Later... controller = ThreadpoolController() with ( - controller.limit(), + controller.limit(limits=2), ThreadPoolExecutor(4, initializer=lambda: controller.limit(limits=2)) as pool, ): # ... run some BLAS-using code in the thread pool ... @@ -271,7 +276,7 @@ from threadpoolctl import ThreadpoolController controller = ThreadpoolController() try: - limiter = controller.limit() + limiter = controller.limit(limits=1) with ThreadPoolExecutor(4, initializer=lambda: controller.limit(limits=1)) as pool: # ... run some BLAS-using code in the thread pool ... pool.map(somefunc, someargs) From f2e8e6a2cb6ee6d2b194ce606c03fdf3456f9724 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 23 Oct 2026 06:19:52 -0400 Subject: [PATCH 6/9] Reformat --- conftest.py | 2 ++ threadpoolctl.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index c175f9ca..10e1dd32 100644 --- a/conftest.py +++ b/conftest.py @@ -7,9 +7,11 @@ "tests/_dl_iterate_phdr_deadlock.py", ] + @pytest.fixture(autouse=True) def reset_openblas_controlling_thread(): from threadpoolctl import OpenBLASController + try: yield finally: diff --git a/threadpoolctl.py b/threadpoolctl.py index b3131af7..a628707a 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -350,11 +350,12 @@ def get_num_threads(self): class _SHARED_STATE: """Shared state across instances.""" + controlling_thread: Thread | None = None lock = Lock() @classmethod - def clear_controlling_thread(cls, force: bool=False): + def clear_controlling_thread(cls, force: bool = False): """Reset which thread can set limits (on non-OpenMP threading layer). It's up the caller to ensure the current controlling thread is done. From efd104f15682acbe862657d8a1656b2886e4e34a Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 23 Oct 2026 13:38:11 -0400 Subject: [PATCH 7/9] Unit tests --- tests/test_threadpoolctl.py | 86 +++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 2b7d6065..88251d66 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -7,6 +7,7 @@ import shutil import subprocess import sys +from threading import Thread import pytest @@ -683,6 +684,91 @@ def test_openblas_threading_layer(): assert threading_layer in expected_openblas_threading_layers +def run_in_thread(f, *args, **kwargs): + thread = Thread(target=lambda: f(*args, **kwargs)) + thread.start() + thread.join() + + +def test_openblas_non_openmp_restricted_to_controlling_thread(): + """Only one thread can set number of threads for non-OpenMP OpenBLAS.""" + openblas_controller = ThreadpoolController().select(internal_api="openblas") + + if not (openblas_controller): + pytest.skip("requires OpenBLAS.") + + for openblas_lib in openblas_controller.lib_controllers: + if openblas_lib.get_num_threads() > 1: + if openblas_lib.threading_layer == "openmp": + check_openblas_openmp_no_controlling_thread(openblas_lib) + else: + check_openblas_non_openmp_restricted_to_controlling_thread(openblas_lib) + + +def check_openblas_openmp_no_controlling_thread(openblas_lib): + orig_num_threads = openblas_lib.get_num_threads() + assert orig_num_threads > 1 + + # This thread can change num threads: + openblas_lib.set_num_threads(1) + assert openblas_lib.get_num_threads() == 1 + + # Another thread's attempts change _that_ thread: + result = [] + + def another_thread(): + openblas_lib.set_num_threads(orig_num_threads) + result.append(openblas_lib.get_num_threads()) + + run_in_thread(another_thread) + assert result[-1] == orig_num_threads + + # This thread is unchanged: + assert openblas_lib.get_num_threads() == 1 + + +def check_openblas_non_openmp_restricted_to_controlling_thread(openblas_lib): + orig_num_threads = openblas_lib.get_num_threads() + assert orig_num_threads > 1 + + # This thread can change num threads: + openblas_lib.set_num_threads(1) + assert openblas_lib.get_num_threads() == 1 + + # Another thread's attempts are no-op: + result = [] + + def another_thread(): + openblas_lib.set_num_threads(orig_num_threads) + result.append(openblas_lib.get_num_threads()) + + run_in_thread(another_thread) + assert result[-1] == 1 + assert openblas_lib.get_num_threads() == 1 + + # This thread can still make changes: + openblas_lib.set_num_threads(orig_num_threads) + assert openblas_lib.get_num_threads() == orig_num_threads + + # Another thread clearing the controlling thread has no effect: + def clear_and_set(): + openblas_lib.clear_controlling_thread() + openblas_lib.set_num_threads(1) + + run_in_thread(clear_and_set) + assert openblas_lib.get_num_threads() == orig_num_threads + + # The controlling thread can clear, and then another thread can set: + clear_and_set() + run_in_thread(openblas_lib.set_num_threads, 1) + assert openblas_lib.get_num_threads() == 1 + + # clear_controlling_thread(Force=True) allows any thread to clear: + openblas_lib.clear_controlling_thread(force=True) + openblas_lib.set_num_threads(orig_num_threads) + assert openblas_lib.get_num_threads() == orig_num_threads + + # skip test if not run in a azure pipelines job since it relies on a specific flexiblas # installation. @pytest.mark.skipif( From e3209a983c8e704414279ba6b21c05af6e0903a4 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 23 Oct 2026 13:49:33 -0400 Subject: [PATCH 8/9] Changelog entry --- CHANGES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 0e643c66..6e0e0ab5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,14 @@ quite a bit of overhead. https://github.com/joblib/threadpoolctl/pull/250 +- When OpenBLAS uses a non-OpenMP threading layer, you can only set the size of + its thread pool from a single controlling thread; since it has a shared + process-wide thread pool, having multiple Python threads fight over control + wouldn't work well. A new API + ``OpenBLASController.clear_controlling_thread()`` allows resetting which + thread controlls OpenBLAS in these cases. + https://github.com/joblib/threadpoolctl/pull/275 + - Added official support for Python 3.15, and declared support for Python 3.14 in package metadata. https://github.com/joblib/threadpoolctl/issues/251 From 162de4dcb48aac80fc18317643c91d6ac10ba418 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 23 Oct 2026 13:50:05 -0400 Subject: [PATCH 9/9] Ruff lints --- threadpoolctl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index a628707a..e442b264 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -22,7 +22,7 @@ from collections.abc import Callable from contextlib import ContextDecorator from functools import lru_cache -from threading import Thread, Lock, current_thread +from threading import Lock, Thread, current_thread from typing import Literal, final # ctypes.util is not imported on Linux: on CPython 3.14 it allocates a