From dfcda95901d6ffa7720411e488f17ca77bc71bff Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 1 Jul 2026 15:31:17 -0400 Subject: [PATCH 01/26] Expose mkl thread-local setting API. --- threadpoolctl.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index ceed5b88..e30c89c0 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -17,7 +17,7 @@ import ctypes import itertools import textwrap -from typing import final +from typing import final, Any import warnings from ctypes.util import find_library from abc import ABC, abstractmethod @@ -160,6 +160,24 @@ def _get_symbol(self, name): ) +class ThreadScopedController(ABC): + """ + Provides a thread-number-setting API that only affects the current thread. + + Useful in cases where there are two different APIs provided by the underlying library. + """ + + @abstractmethod + def set_num_threads_current_thread(self, num_threads: int) -> Any: + """ + Set the maximum number of threads to use in library operations started + from the current thread. + + Unlike ``set_num_threads``, which may have different scopes, this must + have thread-local scope only. + """ + + class OpenBLASController(LibController): """Controller class for OpenBLAS""" @@ -423,7 +441,7 @@ def switch_backend(self, backend): raise RuntimeError(f"Failed to switch to backend {backend!r}.") -class MKLController(LibController): +class MKLController(LibController, ThreadScopedController): """Controller class for MKL""" user_api = "blas" @@ -447,6 +465,12 @@ def set_num_threads(self, num_threads): set_func = getattr(self.dynlib, "MKL_Set_Num_Threads", lambda num_threads: None) return set_func(num_threads) + def set_num_threads_current_thread(self, num_threads: int) -> Any: + set_func = getattr( + self.dynlib, "MKL_Set_Num_Threads_Local", lambda num_threads: None + ) + return set_func(num_threads) + def get_version(self): if not hasattr(self.dynlib, "MKL_Get_Version_String"): return None From d4c0060c0dcd9c40ca61bb6659b136640d1656ec Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 10 Jul 2026 09:19:07 -0400 Subject: [PATCH 02/26] Always use the current-thread-only limiting API, when possible. --- threadpoolctl.py | 37 +++++++++---------------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index e30c89c0..b80f9e4d 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -143,7 +143,12 @@ def get_num_threads(self): @abstractmethod def set_num_threads(self, num_threads): - """Set the maximum number of threads to use""" + """Set the maximum number of threads to use + + If the underlying library supports multiple APIs where the choice is + between setting process-wide or current thread limits, the limiting API + should be the one that only applies to the current thread. + """ @abstractmethod def get_version(self): @@ -160,24 +165,6 @@ def _get_symbol(self, name): ) -class ThreadScopedController(ABC): - """ - Provides a thread-number-setting API that only affects the current thread. - - Useful in cases where there are two different APIs provided by the underlying library. - """ - - @abstractmethod - def set_num_threads_current_thread(self, num_threads: int) -> Any: - """ - Set the maximum number of threads to use in library operations started - from the current thread. - - Unlike ``set_num_threads``, which may have different scopes, this must - have thread-local scope only. - """ - - class OpenBLASController(LibController): """Controller class for OpenBLAS""" @@ -441,7 +428,7 @@ def switch_backend(self, backend): raise RuntimeError(f"Failed to switch to backend {backend!r}.") -class MKLController(LibController, ThreadScopedController): +class MKLController(LibController): """Controller class for MKL""" user_api = "blas" @@ -449,7 +436,7 @@ class MKLController(LibController, ThreadScopedController): filename_prefixes = ("libmkl_rt", "mkl_rt", "libblas") check_symbols = ( "MKL_Get_Max_Threads", - "MKL_Set_Num_Threads", + "MKL_Set_Num_Threads_Local", "MKL_Get_Version_String", "MKL_Set_Threading_Layer", ) @@ -462,13 +449,7 @@ def get_num_threads(self): return get_func() def set_num_threads(self, num_threads): - set_func = getattr(self.dynlib, "MKL_Set_Num_Threads", lambda num_threads: None) - return set_func(num_threads) - - def set_num_threads_current_thread(self, num_threads: int) -> Any: - set_func = getattr( - self.dynlib, "MKL_Set_Num_Threads_Local", lambda num_threads: None - ) + set_func = getattr(self.dynlib, "MKL_Set_Num_Threads_Local", lambda: None) return set_func(num_threads) def get_version(self): From 9f51d12f6b13c288e73463332141e5131ef856c5 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 10 Jul 2026 09:19:25 -0400 Subject: [PATCH 03/26] Use a better limiting API for OpenBLAS when backed by OpenMP. --- threadpoolctl.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index b80f9e4d..0d1592f0 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -193,13 +193,30 @@ def set_additional_attributes(self): self.architecture = self._get_architecture() def get_num_threads(self): - get_num_threads_func = self._get_symbol("openblas_get_num_threads") + # See discussion in set_num_threads for details: + if self.threading_layer == "openmp" and sys.platform in ("linux", "darwin"): + symbol = "omp_get_max_threads" + else: + symbol = "openblas_get_num_threads" + get_num_threads_func = self._get_symbol(symbol) if get_num_threads_func is not None: return get_num_threads_func() return None def set_num_threads(self, num_threads): - set_num_threads_func = self._get_symbol("openblas_set_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 + # behavior. That is the case for libgomp, libomp, and libiomp, what you + # would find on Linux or macOS. On Windows the Visual C++ OpenMP API is + # process-wide, unfortunately. Also worth knowing that in some + # versions, the OpenBLAS limiting API is broken when using OpenMP + # threading: https://github.com/OpenMathLib/OpenBLAS/issues/5806 + if self.threading_layer == "openmp" and sys.platform in ("linux", "darwin"): + symbol = "omp_set_num_threads" + else: + symbol = "openblas_set_num_threads" + set_num_threads_func = self._get_symbol(symbol) if set_num_threads_func is not None: return set_num_threads_func(num_threads) return None From 2b713e02572f92eccb9473d332ee6c6c715dc92b Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 31 Aug 2026 09:45:48 -0400 Subject: [PATCH 04/26] Clarify. --- threadpoolctl.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 5ccb6e2a..fa095be2 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -242,9 +242,8 @@ def get_num_threads(self): def set_num_threads(self, num_threads): """Set the maximum number of threads to use - If the underlying library supports multiple APIs where the choice is - between setting process-wide or current thread limits, the limiting API - should be the one that only applies to the current thread. + When possible, implementations of this method should choose a thread + limiting API that only applies to the current thread. """ @abstractmethod From 7f8ff370e217731c820b48b15571609fc7be65ca Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 31 Aug 2026 09:46:40 -0400 Subject: [PATCH 05/26] Add the specific version. --- threadpoolctl.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index fa095be2..cee78bed 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -309,9 +309,9 @@ def set_num_threads(self, num_threads): # OpenMP API allows for current thread limiting when OpenMP has that # behavior. That is the case for libgomp, libomp, and libiomp, what you # would find on Linux or macOS. On Windows the Visual C++ OpenMP API is - # process-wide, unfortunately. Also worth knowing that in some - # versions, the OpenBLAS limiting API is broken when using OpenMP - # threading: https://github.com/OpenMathLib/OpenBLAS/issues/5806 + # process-wide, unfortunately. Also worth knowing that before v0.3.34, + # the OpenBLAS limiting API is broken when using OpenMP threading: + # https://github.com/OpenMathLib/OpenBLAS/issues/5806 if self.threading_layer == "openmp" and sys.platform in ("linux", "darwin"): symbol = "omp_set_num_threads" else: From 46825f7a3bf0f573cf8549ea8563d8b7d22ccb9b Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 31 Aug 2026 10:18:07 -0400 Subject: [PATCH 06/26] Improve testing mechanism so it skips less. --- tests/test_api_introspection.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index 6c09d503..d534b409 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -60,9 +60,6 @@ def test_determine_thread_limit_scope_processwide(default: int) -> None: assert _determine_thread_limit_scope(api.get, api.set) == "process" -@pytest.mark.skipif( - sys.platform != "linux", reason="Non-Linux OpenMP might be different" -) @pytest.mark.parametrize( ["select_filter", "expected_thread_limit_scope", "extra_check"], [ @@ -77,7 +74,12 @@ def test_determine_thread_limit_scope_processwide(default: int) -> None: # pthreads here. lambda lib: lib.threading_layer == "pthreads", ), - ({"user_api": "openmp"}, "current_thread", lambda _lib: True), + ( + {"user_api": "openmp"}, + "current_thread", + # Windows OpenMP is process-wide: + lambda _lib: sys.platform in ("linux", "darwin"), + ), ], ) def test_api_scope( @@ -94,9 +96,11 @@ def test_api_scope( if not controller.lib_controllers: pytest.skip(f"{select_filter} controller not found") - for lib in controller.lib_controllers: - if not extra_check(lib): - pytest.skip("extra check returned false") + libs = [lib for lib in controller.lib_controllers if extra_check(lib)] + if not libs: + pytest.skip("No libraries matched the requirements") + + for lib in libs: assert ( _determine_thread_limit_scope(lib.get_num_threads, lib.set_num_threads) == expected_thread_limit_scope From cb219e45892ad2cb63185a812f9348402e22fe15 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 31 Aug 2026 10:18:18 -0400 Subject: [PATCH 07/26] Ensure thread-local APIs are used in known cases. --- tests/test_threadpoolctl.py | 40 ++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 19012c5f..3bf8e711 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -6,10 +6,12 @@ import re import subprocess import sys +from typing import Callable from threadpoolctl import threadpool_limits, threadpool_info -from threadpoolctl import ThreadpoolController +from threadpoolctl import LibController, ThreadpoolController from threadpoolctl import _ALL_PREFIXES, _ALL_USER_APIS +from threadpoolctl import _determine_thread_limit_scope from .utils import cython_extensions_compiled from .utils import check_nested_prange_blas @@ -795,3 +797,39 @@ def test_custom_controller(): assert mylib_controller.num_threads == 1 assert ThreadpoolController().info() == original_info + + +@pytest.mark.parametrize( + ["select_filter", "extra_check"], + [ + ( + {"internal_api": "openblas"}, + lambda lib: ( + lib.threading_layer == "openmp" and sys.platform in ("linux", "darwin") + ), + ), + ( + {"internal_api": "mkl"}, + lambda _lib: True, + ), + ], +) +def test_blas_setting_is_thread_local( + select_filter: dict[str, str], + extra_check: Callable[[LibController], bool], +): + """ + Setting the number of threads for mkl and OpenMP-based OpenBLAS uses a + thread-local setting API. + """ + controller = ThreadpoolController().select(**select_filter) + if not controller.lib_controllers: + pytest.skip(f"{select_filter} controller not found") + + libs = [lib for lib in controller.lib_controllers if extra_check(lib)] + if not libs: + pytest.skip("No libraries matched the requirements") + + for lib in libs: + scope = _determine_thread_limit_scope(lib.get_num_threads, lib.set_num_threads) + assert scope == "current_thread" From 83e44740f4e0df454ec67232726d92b36bad9766 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 31 Aug 2026 11:40:31 -0400 Subject: [PATCH 08/26] Refactor out some useful functionality. --- tests/test_threadpoolctl.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 3bf8e711..ab861b2f 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -799,9 +799,8 @@ def test_custom_controller(): assert ThreadpoolController().info() == original_info -@pytest.mark.parametrize( - ["select_filter", "extra_check"], - [ +@pytest.fixture( + params=[ ( {"internal_api": "openblas"}, lambda lib: ( @@ -812,16 +811,11 @@ def test_custom_controller(): {"internal_api": "mkl"}, lambda _lib: True, ), - ], + ] ) -def test_blas_setting_is_thread_local( - select_filter: dict[str, str], - extra_check: Callable[[LibController], bool], -): - """ - Setting the number of threads for mkl and OpenMP-based OpenBLAS uses a - thread-local setting API. - """ +def thread_local_blas_libs(request) -> list[LibController]: + """Create all LibControllers that use a thread-local setting.""" + select_filter, extra_check = request.param controller = ThreadpoolController().select(**select_filter) if not controller.lib_controllers: pytest.skip(f"{select_filter} controller not found") @@ -830,6 +824,18 @@ def test_blas_setting_is_thread_local( if not libs: pytest.skip("No libraries matched the requirements") - for lib in libs: + return libs + + +def test_setting_limit_on_thread_local_blas_api_is_reported_as_thread_local( + thread_local_blas_libs: list[LibController], +) -> None: + """ + Setting the number of threads for libraries that support thread-local + setting API actually does so, according to the thread-number reporting API. + + This doesn't check actual behavior, only reported behavior. + """ + for lib in thread_local_blas_libs: scope = _determine_thread_limit_scope(lib.get_num_threads, lib.set_num_threads) assert scope == "current_thread" From c78474f5233b4c8e3c045bdccaf2e970a5c14417 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 31 Aug 2026 13:38:17 -0400 Subject: [PATCH 09/26] Test thread-local limits actual outcomes. --- tests/_limit_blas.py | 21 ++++++++++++++++ tests/test_threadpoolctl.py | 50 ++++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/_limit_blas.py diff --git a/tests/_limit_blas.py b/tests/_limit_blas.py new file mode 100644 index 00000000..5ff2e0af --- /dev/null +++ b/tests/_limit_blas.py @@ -0,0 +1,21 @@ +# Used by test_setting_limit_on_thread_local_blas_api_is_actually_thread_local() + +from concurrent.futures import ThreadPoolExecutor +from time import sleep +import sys + +import numpy as np +import threadpoolctl + +ARR = np.ones((1500, 1500)) + + +def in_thread(_): + with threadpoolctl.threadpool_limits(limits=int(sys.argv[1]), user_api="blas"): + ARR.dot(ARR) + # Make sure jobs are evenly distributed and don't end up in one thread. + sleep(0.01) + + +with ThreadPoolExecutor(2) as pool: + list(pool.map(in_thread, range(2))) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index ab861b2f..393931e3 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -6,7 +6,7 @@ import re import subprocess import sys -from typing import Callable +from shutil import which from threadpoolctl import threadpool_limits, threadpool_info from threadpoolctl import LibController, ThreadpoolController @@ -799,12 +799,18 @@ def test_custom_controller(): assert ThreadpoolController().info() == original_info +def parse_version(version: str) -> list[int]: + return list(map(int, version.split("."))) + + @pytest.fixture( params=[ ( {"internal_api": "openblas"}, lambda lib: ( - lib.threading_layer == "openmp" and sys.platform in ("linux", "darwin") + lib.threading_layer == "openmp" + and sys.platform in ("linux", "darwin") + and parse_version(lib.version) >= parse_version("0.3.34") ), ), ( @@ -832,10 +838,48 @@ def test_setting_limit_on_thread_local_blas_api_is_reported_as_thread_local( ) -> None: """ Setting the number of threads for libraries that support thread-local - setting API actually does so, according to the thread-number reporting API. + setting API is reported as doing so. This doesn't check actual behavior, only reported behavior. """ for lib in thread_local_blas_libs: scope = _determine_thread_limit_scope(lib.get_num_threads, lib.set_num_threads) assert scope == "current_thread" + + +@pytest.mark.skipif( + sys.platform != "linux" or which("strace") is None, + reason="requires strace on Linux", +) +def test_setting_limit_on_thread_local_blas_api_is_actually_thread_local( + thread_local_blas_libs: list[LibController], +) -> None: + """ + Setting the number of threads for libraries that support thread-local + setting API actually does so. + """ + + def num_threads_created(limit: int) -> int: + result = 0 + for line in subprocess.check_output( + [ + "strace", + "-f", + "-e", + "clone3", + "python", + "-m", + "tests._limit_blas", + str(limit), + ], + stderr=subprocess.STDOUT, + ).splitlines(): + if b" clone3(" in line and b"CLONE_THREAD" in line: + result += 1 + print(limit, result) + return result + + # _limit_blas runs BLAS operations in 2 Python threads, so by changing the + # BLAS limit from 2 to 9 we expect an extra 2 * (9 - 2) == 14 threads. + extra_threads = num_threads_created(9) - num_threads_created(2) + assert extra_threads == 14 From c670e4d59b198b24c9f092cfc9cc409eeb96794f Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 31 Aug 2026 14:08:00 -0400 Subject: [PATCH 10/26] Be more lenient --- tests/test_threadpoolctl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 393931e3..50108fad 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -874,7 +874,7 @@ def num_threads_created(limit: int) -> int: ], stderr=subprocess.STDOUT, ).splitlines(): - if b" clone3(" in line and b"CLONE_THREAD" in line: + if b"clone3(" in line and b"CLONE_THREAD" in line: result += 1 print(limit, result) return result From d8b2d0e622c1c4aa0937878550999aece12afcdf Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 31 Aug 2026 14:18:57 -0400 Subject: [PATCH 11/26] An AMD architecture that started showing up in GitHub Actions --- tests/test_threadpoolctl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 50108fad..0de6a001 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -617,6 +617,7 @@ def test_architecture(): expected_openblas_architectures = ( # XXX: add more as needed by CI or developer laptops "armv8", + "barcelona", "cooperlake", "haswell", "neoversen1", From bbfb797625934ffba7527139af150a8dfd21e2f5 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 31 Aug 2026 14:30:23 -0400 Subject: [PATCH 12/26] Try a smaller number. --- tests/test_threadpoolctl.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 0de6a001..4983165f 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -881,6 +881,6 @@ def num_threads_created(limit: int) -> int: return result # _limit_blas runs BLAS operations in 2 Python threads, so by changing the - # BLAS limit from 2 to 9 we expect an extra 2 * (9 - 2) == 14 threads. - extra_threads = num_threads_created(9) - num_threads_created(2) - assert extra_threads == 14 + # BLAS limit from 2 to 5 we expect an extra 2 * (5 - 2) == 6 threads. + extra_threads = num_threads_created(5) - num_threads_created(2) + assert extra_threads == 6 From 751cffad8a76fff0b6e67235c2575b7761474aed Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 1 Sep 2026 10:42:51 -0400 Subject: [PATCH 13/26] Another file to ignore --- conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index bf303839..fe7b8fcf 100644 --- a/conftest.py +++ b/conftest.py @@ -1 +1 @@ -collect_ignore = ["tests/_openmp_test_helper"] +collect_ignore = ["tests/_openmp_test_helper", "tests/_limit_blas"] From deec01c00c521382581dd6d6e31fd19f832f4e0b Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 1 Sep 2026 10:43:31 -0400 Subject: [PATCH 14/26] Don't run on import --- tests/_limit_blas.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/_limit_blas.py b/tests/_limit_blas.py index 5ff2e0af..b2c37889 100644 --- a/tests/_limit_blas.py +++ b/tests/_limit_blas.py @@ -17,5 +17,6 @@ def in_thread(_): sleep(0.01) -with ThreadPoolExecutor(2) as pool: - list(pool.map(in_thread, range(2))) +if __name__ == '__main__': + with ThreadPoolExecutor(2) as pool: + list(pool.map(in_thread, range(2))) From eebbcdda83c7bda6c135c136eb87b29042a37ac6 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 1 Sep 2026 11:05:27 -0400 Subject: [PATCH 15/26] Fix a deadlock --- threadpoolctl.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index cee78bed..56e2cbf9 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -1141,16 +1141,18 @@ def _find_libraries_with_dl_iterate_phdr(self): ) return [] + filepaths = [] + # Callback function for `dl_iterate_phdr` which is called for every - # library loaded in the current process until it returns 1. + # library loaded in the current process until it returns 1. To minimize + # the potential for deadlocks (see #228), this code should not do + # anything that might result in reentrancy into the library, the dl + # system, or anything else. def match_library_callback(info, size, data): # Get the path of the current library filepath = info.contents.dlpi_name if filepath: - filepath = filepath.decode("utf-8") - - # Store the library controller if it is supported and selected - self._make_controller_from_path(filepath) + filepaths.append(filepath) return 0 c_func_signature = ctypes.CFUNCTYPE( @@ -1164,6 +1166,12 @@ def match_library_callback(info, size, data): data = ctypes.c_char_p(b"") libc.dl_iterate_phdr(c_match_library_callback, data) + # Now that a list of filepaths is available, load the respective + # libraries: + for filepath in filepaths: + # Store the library controller if it is supported and selected + self._make_controller_from_path(filepath.decode("utf-8")) + def _find_libraries_with_dyld(self): """Loop through loaded libraries and return binders on supported ones From 3028da72ae1a5afdc7d952aa71b39ec2b1d50b45 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 1 Sep 2026 11:14:03 -0400 Subject: [PATCH 16/26] Reformat --- tests/_limit_blas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/_limit_blas.py b/tests/_limit_blas.py index b2c37889..0e38c9a1 100644 --- a/tests/_limit_blas.py +++ b/tests/_limit_blas.py @@ -17,6 +17,6 @@ def in_thread(_): sleep(0.01) -if __name__ == '__main__': +if __name__ == "__main__": with ThreadPoolExecutor(2) as pool: list(pool.map(in_thread, range(2))) From 60a7be60b4a55d95e6176215d853d5185cce01d5 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 1 Sep 2026 11:33:35 -0400 Subject: [PATCH 17/26] See if this number is more robust --- tests/test_threadpoolctl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 4983165f..ef8b5732 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -881,6 +881,6 @@ def num_threads_created(limit: int) -> int: return result # _limit_blas runs BLAS operations in 2 Python threads, so by changing the - # BLAS limit from 2 to 5 we expect an extra 2 * (5 - 2) == 6 threads. - extra_threads = num_threads_created(5) - num_threads_created(2) + # BLAS limit from 1 to 4 we expect an extra 2 * (4 - 1) == 6 threads. + extra_threads = num_threads_created(4) - num_threads_created(1) assert extra_threads == 6 From 1c4d49ebe2c6fa3f19426e71ea7976173c8c5e44 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 1 Sep 2026 11:44:55 -0400 Subject: [PATCH 18/26] Pretend to work, don't blow up --- threadpoolctl.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 56e2cbf9..2806a381 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -573,7 +573,9 @@ def get_num_threads(self): return get_func() def set_num_threads(self, num_threads): - set_func = getattr(self.dynlib, "MKL_Set_Num_Threads_Local", lambda: None) + set_func = getattr( + self.dynlib, "MKL_Set_Num_Threads_Local", lambda num_threads: None + ) return set_func(num_threads) def get_version(self): From 0c79887c24689880c8bdc75dfdf2c9de49cbda82 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 1 Sep 2026 11:45:22 -0400 Subject: [PATCH 19/26] No need for print() --- tests/test_threadpoolctl.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index ef8b5732..b46eac04 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -877,7 +877,6 @@ def num_threads_created(limit: int) -> int: ).splitlines(): if b"clone3(" in line and b"CLONE_THREAD" in line: result += 1 - print(limit, result) return result # _limit_blas runs BLAS operations in 2 Python threads, so by changing the From 9522d11547ca8797b8e63e5a48855650e5358496 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 2 Sep 2026 13:19:30 -0400 Subject: [PATCH 20/26] Turns out Windows can, in theory maybe sometimes, do the right thing with omp_set_num_threads() --- tests/test_threadpoolctl.py | 2 ++ threadpoolctl.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index b46eac04..9043130b 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -810,6 +810,8 @@ def parse_version(version: str) -> list[int]: {"internal_api": "openblas"}, lambda lib: ( lib.threading_layer == "openmp" + # For Windows support, see + # https://github.com/joblib/threadpoolctl/issues/230 and sys.platform in ("linux", "darwin") and parse_version(lib.version) >= parse_version("0.3.34") ), diff --git a/threadpoolctl.py b/threadpoolctl.py index 2806a381..72fda34f 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -294,7 +294,7 @@ def set_additional_attributes(self): def get_num_threads(self): # See discussion in set_num_threads for details: - if self.threading_layer == "openmp" and sys.platform in ("linux", "darwin"): + if self.threading_layer == "openmp": symbol = "omp_get_max_threads" else: symbol = "openblas_get_num_threads" @@ -308,11 +308,16 @@ def set_num_threads(self, num_threads): # limit if possible. When OpenBLAS is backed by OpenMP, using the # OpenMP API allows for current thread limiting when OpenMP has that # behavior. That is the case for libgomp, libomp, and libiomp, what you - # would find on Linux or macOS. On Windows the Visual C++ OpenMP API is - # process-wide, unfortunately. Also worth knowing that before v0.3.34, - # the OpenBLAS limiting API is broken when using OpenMP threading: + # would find on Linux or macOS. + # + # On Windows the Visual C++ OpenMP API is process-wide, unfortunately, + # though this may be fixed if the /openmp:llvm flag is used: + # https://github.com/joblib/threadpoolctl/issues/230 + # + # Also worth knowing that before v0.3.34, the OpenBLAS limiting API is + # broken when using OpenMP threading: # https://github.com/OpenMathLib/OpenBLAS/issues/5806 - if self.threading_layer == "openmp" and sys.platform in ("linux", "darwin"): + if self.threading_layer == "openmp": symbol = "omp_set_num_threads" else: symbol = "openblas_set_num_threads" From 8eb782d225f9d348b6c39a9e897d3f02070136ad Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 2 Sep 2026 13:24:23 -0400 Subject: [PATCH 21/26] Changelog entries --- CHANGES.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index a7b50dec..e07ea574 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,23 @@ - Only warn about simultaneous `libomp` and `libiomp` usage on Linux, where the incompatibility is known to cause crashes. +- Fixed a deadlock triggered by getting or setting MKL's number of threads from + parallel threads when using MKL with libiomp (Intel threading) on Linux. + +- Going forward, setting the number of threads will only have a thread-local + impact if feasible (for example, at minimum the underlying library must + support this option, and many don't.) + +- For MKL, setting the number of threads is now thread-local, i.e. limiting the + number of threads won't impact MKL's thread pool size when using MKL in other + Python threads. + +- For OpenBLAS compiled with OpenMP on Linux and macOS, setting the number of + threads is now thread-local, i.e. won't impact OpenBLAS thread pool size in + other Python threads. On Windows behavior is likely process-wide, but this may + depend on how OpenBLAS was compiled with OpenMP. + + 3.6.0 (2025-03-13) ================== From d683a51af759d7adc2065ba2acf17bd4efd37a34 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 2 Sep 2026 13:38:01 -0400 Subject: [PATCH 22/26] Try with older Cython --- dev-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 04901b66..b0248b24 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,5 +2,5 @@ flit coverage pytest pytest-cov -cython +cython<3.3 setuptools From fdcb0847e14bc4c1a436a65d387efc3392ad35dd Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 2 Sep 2026 13:42:57 -0400 Subject: [PATCH 23/26] Not relevant --- dev-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index b0248b24..04901b66 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,5 +2,5 @@ flit coverage pytest pytest-cov -cython<3.3 +cython setuptools From 39067b46869c85ef2d8e34e3c2f4d3a5392d8619 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 3 Sep 2026 10:59:52 -0400 Subject: [PATCH 24/26] Better test names --- tests/test_threadpoolctl.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 9043130b..834eb050 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -820,7 +820,9 @@ def parse_version(version: str) -> list[int]: {"internal_api": "mkl"}, lambda _lib: True, ), - ] + ], + # ids correspond to the params above: + ids=["openblas-openmp", "mkl"] ) def thread_local_blas_libs(request) -> list[LibController]: """Create all LibControllers that use a thread-local setting.""" From c46a1d1dc1807d1d60186a2173a1b4bccd5f233a Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 3 Sep 2026 11:00:34 -0400 Subject: [PATCH 25/26] Link to PR. --- CHANGES.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index e07ea574..fe6e1dc0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,20 +12,23 @@ - Fixed a deadlock triggered by getting or setting MKL's number of threads from parallel threads when using MKL with libiomp (Intel threading) on Linux. + https://github.com/joblib/threadpoolctl/pull/228 - Going forward, setting the number of threads will only have a thread-local impact if feasible (for example, at minimum the underlying library must support this option, and many don't.) + https://github.com/joblib/threadpoolctl/pull/228 - For MKL, setting the number of threads is now thread-local, i.e. limiting the number of threads won't impact MKL's thread pool size when using MKL in other Python threads. + https://github.com/joblib/threadpoolctl/pull/228 - For OpenBLAS compiled with OpenMP on Linux and macOS, setting the number of threads is now thread-local, i.e. won't impact OpenBLAS thread pool size in other Python threads. On Windows behavior is likely process-wide, but this may depend on how OpenBLAS was compiled with OpenMP. - + https://github.com/joblib/threadpoolctl/pull/228 3.6.0 (2025-03-13) ================== From 76b2ee0aa742203885445acdf5891a206c85751d Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 3 Sep 2026 11:58:58 -0400 Subject: [PATCH 26/26] Make sure NumPy actually uses the BLAS we want to test, for cases where there are multiple BLAS installed on the system. --- tests/test_threadpoolctl.py | 48 ++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 834eb050..541662a6 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -822,24 +822,29 @@ def parse_version(version: str) -> list[int]: ), ], # ids correspond to the params above: - ids=["openblas-openmp", "mkl"] + ids=["openblas-openmp", "mkl"], ) -def thread_local_blas_libs(request) -> list[LibController]: +def thread_local_blas_lib(request) -> LibController: """Create all LibControllers that use a thread-local setting.""" select_filter, extra_check = request.param controller = ThreadpoolController().select(**select_filter) if not controller.lib_controllers: pytest.skip(f"{select_filter} controller not found") - libs = [lib for lib in controller.lib_controllers if extra_check(lib)] + libs = [ + lib + for lib in controller.lib_controllers + if extra_check(lib) and lib.internal_api == select_filter["internal_api"] + ] if not libs: pytest.skip("No libraries matched the requirements") - return libs + assert len(libs) == 1 + return libs[0] def test_setting_limit_on_thread_local_blas_api_is_reported_as_thread_local( - thread_local_blas_libs: list[LibController], + thread_local_blas_lib: LibController, ) -> None: """ Setting the number of threads for libraries that support thread-local @@ -847,9 +852,9 @@ def test_setting_limit_on_thread_local_blas_api_is_reported_as_thread_local( This doesn't check actual behavior, only reported behavior. """ - for lib in thread_local_blas_libs: - scope = _determine_thread_limit_scope(lib.get_num_threads, lib.set_num_threads) - assert scope == "current_thread" + lib = thread_local_blas_lib + scope = _determine_thread_limit_scope(lib.get_num_threads, lib.set_num_threads) + assert scope == "current_thread" @pytest.mark.skipif( @@ -857,13 +862,33 @@ def test_setting_limit_on_thread_local_blas_api_is_reported_as_thread_local( reason="requires strace on Linux", ) def test_setting_limit_on_thread_local_blas_api_is_actually_thread_local( - thread_local_blas_libs: list[LibController], + thread_local_blas_lib: LibController, ) -> None: """ Setting the number of threads for libraries that support thread-local setting API actually does so. """ + # The test script uses NumPy, there might be multiple BLAS in this test + # process, and we want to only run if _NumPy_ uses that library. + # So check that before proceeding. + output = json.loads( + subprocess.check_output( + [ + "python", + "-c", + "import numpy, json, threadpoolctl; print(json.dumps(threadpoolctl.threadpool_info()))", + ] + ) + ) + found_correct_blas = False + for library in output: + if library["internal_api"] == thread_local_blas_lib.internal_api: + found_correct_blas = True + break + if not found_correct_blas: + pytest.skip("NumPy doesn't use the BLAS we want to test") + def num_threads_created(limit: int) -> int: result = 0 for line in subprocess.check_output( @@ -885,5 +910,6 @@ def num_threads_created(limit: int) -> int: # _limit_blas runs BLAS operations in 2 Python threads, so by changing the # BLAS limit from 1 to 4 we expect an extra 2 * (4 - 1) == 6 threads. - extra_threads = num_threads_created(4) - num_threads_created(1) - assert extra_threads == 6 + nmc_1 = num_threads_created(1) + nmc_4 = num_threads_created(4) + assert nmc_4 - nmc_1 == 6