From 126b24fc863125677e6e6d974f91039a8e445c22 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 22 Oct 2026 16:31:59 -0400 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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 09/16] 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 From dc926655ea24f42021e33646cab5989d9523f3a9 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 27 Oct 2026 08:11:29 -0400 Subject: [PATCH 10/16] Apply batched suggestions from code review Co-authored-by: Olivier Grisel --- tests/test_threadpoolctl.py | 4 ++-- threadpoolctl.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 88251d66..01117f05 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -759,11 +759,11 @@ def 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() + openblas_lib.clear_controlling_thread() 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: + # 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 diff --git a/threadpoolctl.py b/threadpoolctl.py index e442b264..044cf537 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -361,7 +361,7 @@ def clear_controlling_thread(cls, force: bool = False): 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 + method. You can have any thread clear the controlling thread by setting ``force=True``. """ with cls._SHARED_STATE.lock: From 59c101b37ffb784fff7da55dbc2c987ad7d79fe7 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 27 Oct 2026 09:26:41 -0400 Subject: [PATCH 11/16] Improve docs --- README.md | 36 ++++++++++++++++++++++++++++++++---- threadpoolctl.py | 11 ++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f4f363b6..3fc4a7c6 100644 --- a/README.md +++ b/README.md @@ -231,12 +231,24 @@ 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 +When setting limits, you want to set limits both in the main thread (in case you're using OpenBLAS+pthreads) and in each worker thread. +> **Why you need both:** When setting a limit on OpenBLAS with pthreads or the +> Windows threading layer, setting the limit affects the whole process, because +> it uses a process-wide shared thread pool. (This in contrast to some other +> BLAS/OpenMP libraries, or OpenBLAS backed by OpenMP, that allow setting the +> limit per-thread.) Having multiple Python threads set limits in this case +> makes no sense, since they would effectively be arguing over a globally shared +> resource. ``threadpoolctl`` therefore constrains the limiting API in the +> OpenBLAS pthreads/Windows case to only work from the first thread that uses +> it, the so-called controlling thread. +> +> For many other libraries, for example OpenMP, the limit is set per thread. As +> a result, you want to set limits both ways in generic code that doesn't know +> the underlying BLAS library: in main thread for the process-wide limits, and +> per-thread for thread-specific limits like OpenMP. + ```python from threadpoolctl import ThreadpoolController @@ -328,6 +340,22 @@ with CONTROLLER.limit(limits=2): ## Usage: Additional APIs and details +### OpenBLAS with pthreads/Windows + +As mentioned above, OpenBLAS with pthreads or Windows threading layer uses a +process-wide shared thread pool. As a result, there are only two limits that +make sense in most circumstances: + +* A limit of `1`, which effectively means no OpenBLAS thread pool: all + operations run inline in the calling thread. This is a good setting when you + have a Python thread pool calling BLAS, and the number of Python threads is + close to the number of available cores. +* A limit equal to the number of available cores. This is a good setting when + you are calling BLAS from the main thread and there are no Python threads. + +While `threadpoolctl` does not limit you to these two values, you may wish to do +so yourself in your code. + ### Switching the FlexiBLAS backend `FlexiBLAS` is a BLAS wrapper for which the BLAS backend can be switched at runtime. diff --git a/threadpoolctl.py b/threadpoolctl.py index 044cf537..b80813b4 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -294,9 +294,14 @@ def _get_symbol(self, name): class OpenBLASController(LibController): """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: + When OpenBLAS uses the OpenMP threading layer, each thread that calls into + OpenBLAS gets its own OpenBLAS thread pool, and correspondingly the limit + set by ``threadpoolctl`` is per-thread. Each Python thread can therefore + set its own limit without consideration for other Python threads. + + In constrast, when the 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 From 438fc6f22ca37ab8f1791800f0464ed8f510053e Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 27 Oct 2026 13:37:38 -0400 Subject: [PATCH 12/16] Automatically clear controlling thread in decorators and wrappers --- tests/test_threadpoolctl.py | 38 ++++++++++++++++++++++++++++++++++++- threadpoolctl.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 1368eaa4..0c75d79a 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -7,7 +7,7 @@ import shutil import subprocess import sys -from threading import Thread +from threading import Thread, current_thread import pytest @@ -16,6 +16,7 @@ _ALL_PREFIXES, _ALL_USER_APIS, LibController, + OpenBLASController, ThreadpoolController, _determine_thread_limit_scope, threadpool_info, @@ -802,6 +803,41 @@ def clear_and_set(): assert openblas_lib.get_num_threads() == orig_num_threads +def _openblas_controlling_thread(): + openblas_controller = ThreadpoolController().select(internal_api="openblas") + + if not (openblas_controller): + pytest.skip("requires OpenBLAS.") + + return OpenBLASController._SHARED_STATE.controlling_thread + + +def test_openblas_controlling_thread_cleared_on_limiter_context_manager_exit(): + + with threadpool_limits(limits=1): + assert _openblas_controlling_thread() is current_thread() + with threadpool_limits(limits=1): + assert _openblas_controlling_thread() is current_thread() + assert _openblas_controlling_thread() is current_thread() + assert _openblas_controlling_thread() is None + + +def test_openblas_controlling_thread_cleared_on_decorator_exit(): + + @threadpool_limits.wrap(limits=1) + def inner(): + assert _openblas_controlling_thread() is current_thread() + + @threadpool_limits.wrap(limits=1) + def outer(): + assert _openblas_controlling_thread() is current_thread() + inner() + assert _openblas_controlling_thread() is current_thread() + + outer() + assert _openblas_controlling_thread() is None + + # skip test if not run in a azure pipelines job since it relies on a specific flexiblas # installation. @pytest.mark.skipif( diff --git a/threadpoolctl.py b/threadpoolctl.py index 8ae96574..e00503be 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -23,6 +23,7 @@ from contextlib import ContextDecorator from functools import lru_cache from threading import Lock, Thread, current_thread +from threading import local as thread_local from typing import Literal, final # ctypes.util is not imported on Linux: on CPython 3.14 it allocates a @@ -311,6 +312,10 @@ class OpenBLASController(LibController): 3. ``clear_controlling_thread()`` can be used to reset the thread that can meaningfully use the limiting API. + + ``with threadpool_limits()`` and the function decorator + ``threadpool_limits.wrap()`` will automatically clear the controlling + thread when they're done, including when nested. """ user_api = "blas" @@ -456,6 +461,28 @@ def _get_architecture(self): return None +class _OpenBLASOwnership(thread_local): + """Count how many paired limit-unlimits have modified OpenBLAS. + + Clear controlling thread if it hits zero. + """ + + count = 0 + + def increment(self): + """Increment ownership.""" + self.count += 1 + + def decrement(self): + """Decrement ownership.""" + self.count -= 1 + if self.count <= 0: + OpenBLASController.clear_controlling_thread() + + +_OPENBLAS_OWNERSHIP = _OpenBLASOwnership() + + class BLISController(LibController): """Controller class for BLIS""" @@ -830,10 +857,12 @@ def __init__(self, controller, *, limits=None, user_api=None): self._set_threadpool_limits() def __enter__(self): + _OPENBLAS_OWNERSHIP.increment() return self def __exit__(self, type, value, traceback): self.restore_original_limits() + _OPENBLAS_OWNERSHIP.decrement() @classmethod def wrap(cls, controller, *, limits=None, user_api=None): @@ -979,6 +1008,7 @@ def __enter__(self): # decorator. self._original_info = self._controller.info() self._set_threadpool_limits() + _OPENBLAS_OWNERSHIP.increment() return self From 43ef17cdef41b7bfe28631703e689d4e786da072 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 27 Oct 2026 14:03:34 -0400 Subject: [PATCH 13/16] Skip if only OpenMP OpenBLAS is available --- tests/test_threadpoolctl.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 0c75d79a..d0683108 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -808,7 +808,8 @@ def _openblas_controlling_thread(): if not (openblas_controller): pytest.skip("requires OpenBLAS.") - + if {lc.threading_layer for lc in openblas_controller.lib_controllers} == {"openmp"}: + pytest.skip("require non-OpenMP OpenBLAS.") return OpenBLASController._SHARED_STATE.controlling_thread From ba642b1094ed1cb220f85a78dfa319df9482c65f Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 28 Oct 2026 11:31:44 -0400 Subject: [PATCH 14/16] Make the controlling thread singleton public and test it directly. --- tests/test_threadpoolctl.py | 14 ++++++++++++++ threadpoolctl.py | 13 ++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index d0683108..f4bbe197 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -15,6 +15,7 @@ from threadpoolctl import ( _ALL_PREFIXES, _ALL_USER_APIS, + CONTROLLING_THREAD_OWNERSHIP, LibController, OpenBLASController, ThreadpoolController, @@ -839,6 +840,19 @@ def outer(): assert _openblas_controlling_thread() is None +def test_openblas_controlling_thread_manual_increment_decrement_api(): + threadpool_limits(limits=1) + CONTROLLING_THREAD_OWNERSHIP.increment() + assert _openblas_controlling_thread() is current_thread() + threadpool_limits(limits=1) + CONTROLLING_THREAD_OWNERSHIP.increment() + assert _openblas_controlling_thread() is current_thread() + CONTROLLING_THREAD_OWNERSHIP.decrement() + assert _openblas_controlling_thread() is current_thread() + CONTROLLING_THREAD_OWNERSHIP.decrement() + assert _openblas_controlling_thread() is None + + # skip test if not run in a azure pipelines job since it relies on a specific flexiblas # installation. @pytest.mark.skipif( diff --git a/threadpoolctl.py b/threadpoolctl.py index e00503be..6a89b69f 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -461,10 +461,13 @@ def _get_architecture(self): return None -class _OpenBLASOwnership(thread_local): +class _ControllingThreadOwnership(thread_local): """Count how many paired limit-unlimits have modified OpenBLAS. Clear controlling thread if it hits zero. + + In the future this could potentialy also apply to non-OpenBLAS libraries + that have a shared process pool. """ count = 0 @@ -480,7 +483,7 @@ def decrement(self): OpenBLASController.clear_controlling_thread() -_OPENBLAS_OWNERSHIP = _OpenBLASOwnership() +CONTROLLING_THREAD_OWNERSHIP = _ControllingThreadOwnership() class BLISController(LibController): @@ -857,12 +860,12 @@ def __init__(self, controller, *, limits=None, user_api=None): self._set_threadpool_limits() def __enter__(self): - _OPENBLAS_OWNERSHIP.increment() + CONTROLLING_THREAD_OWNERSHIP.increment() return self def __exit__(self, type, value, traceback): self.restore_original_limits() - _OPENBLAS_OWNERSHIP.decrement() + CONTROLLING_THREAD_OWNERSHIP.decrement() @classmethod def wrap(cls, controller, *, limits=None, user_api=None): @@ -1008,7 +1011,7 @@ def __enter__(self): # decorator. self._original_info = self._controller.info() self._set_threadpool_limits() - _OPENBLAS_OWNERSHIP.increment() + CONTROLLING_THREAD_OWNERSHIP.increment() return self From df2382dd40b45587afa5b82e8592bd1149e8db0e Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 28 Oct 2026 12:02:16 -0400 Subject: [PATCH 15/16] Mention other new bit --- CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index d8b07a15..20c49c1f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,7 +10,8 @@ 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. + thread controlls OpenBLAS in these cases, and decorator and context manager + limit APIs will automatically call that API when they are done. https://github.com/joblib/threadpoolctl/pull/275 - Added official support for Python 3.15, and declared support for Python 3.14 From e9e0544deb6e7fc558621f8880493fba3927f406 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 28 Oct 2026 12:27:06 -0400 Subject: [PATCH 16/16] Don't leave limits at 1 --- tests/test_threadpoolctl.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index f4bbe197..f82abec0 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -840,7 +840,10 @@ def outer(): assert _openblas_controlling_thread() is None -def test_openblas_controlling_thread_manual_increment_decrement_api(): +def test_openblas_controlling_thread_manual_increment_decrement_api(request): + limiter = ThreadpoolController().limit() + request.addfinalizer(limiter.restore_original_limits) + threadpool_limits(limits=1) CONTROLLING_THREAD_OWNERSHIP.increment() assert _openblas_controlling_thread() is current_thread()