From 28bd246d447aa38ab1d643ae17cc7cc2ad883c3c Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 23 Jun 2026 11:28:11 -0400 Subject: [PATCH 01/18] Sketch of determine_api_scope. --- tests/test_api_introspection.py | 50 ++++++++++++++++++ threadpoolctl.py | 89 ++++++++++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/test_api_introspection.py diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py new file mode 100644 index 00000000..b850e1c2 --- /dev/null +++ b/tests/test_api_introspection.py @@ -0,0 +1,50 @@ +""" +Tests for get/set number of threads API introspection. +""" + +from threading import local as threadlocal +from threadpoolctl import APIScope, determine_api_scope + +import pytest + + +class FakeThreadLocalAPI(threadlocal): + """Thread-local num threads setting API.""" + + def get(self) -> int: + return getattr(self, "num_threads", 17) + + def set(self, n: int) -> None: + self.num_threads = n + + +class FakeProcesswideAPI: + """Process-wide num threads setting API.""" + + def __init__(self, num_threads: int): + self.num_threads = num_threads + + def get(self) -> int: + return self.num_threads + + def set(self, n: int) -> None: + self.num_threads = n + + +def test_determine_api_scope_thread_local(): + """ + Check ``determine_api_scope()`` can correctly diagnose a trivial + thread-local implementation. + """ + api = FakeThreadLocalAPI() + assert determine_api_scope(api.get, api.set) == APIScope.THREAD_LOCAL + + +@pytest.mark.parametrize("default", [1, 17]) +def test_determine_api_scope_processiwde(default: int): + """ + Check ``determine_api_scope()`` can correctly diagnose a trivial + process-wide implementation. + """ + api = FakeProcesswideAPI(default) + assert determine_api_scope(api.get, api.set) == APIScope.PROCESSWIDE diff --git a/threadpoolctl.py b/threadpoolctl.py index ceed5b88..a1368844 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -17,15 +17,19 @@ import ctypes import itertools import textwrap -from typing import final +from threading import Thread +from typing import Callable, final import warnings from ctypes.util import find_library from abc import ABC, abstractmethod from functools import lru_cache from contextlib import ContextDecorator +from enum import Enum, auto __version__ = "3.7.0.dev0" __all__ = [ + "APIScope", + "determine_api_scope", "threadpool_limits", "threadpool_info", "ThreadpoolController", @@ -69,6 +73,89 @@ class _dl_phdr_info(ctypes.Structure): _RTLD_NOLOAD = ctypes.DEFAULT_MODE +class APIScope(Enum): + """ + What scope does the API affect. + """ + + # Using the API sets a limit only on the current thread: + THREAD_LOCAL = auto() + # Using the API sets a limit for every thread in the process; whether or + # not it's a shared process-wide pool or per-thread limit needs to be + # determined some other way. + PROCESSWIDE = auto() + # Something else, unexpected; perhaps an unknown API, perhaps information + # can't be determined under current configuration: + UNKNOWN = auto() + + +def determine_api_scope( + get_n_threads: Callable[[], int], set_n_threads: Callable[[int], None] +) -> APIScope: + """ + Run some experiments to determine the scope of the given get/set API. + + An attempt will be made to restore all settings to their previous state. + + This won't work reliably if you only have one core available. + """ + if os.cpu_count() == 1 or ( + hasattr(os, "process_cpu_count") and os.process_cpu_count() == 1 + ): + raise RuntimeError("Cannot determine API meaning if only one core is available") + + previous = get_n_threads() + + # Some plausible constraints we need to keep in mind: + # + # 1. The API might not allow setting more than the number of (available, or + # physical) cores. + # 2. ... + try: + # Choose a desired number of threads that is different than the current + # number, and hopefully achievable under the current configuration: + if previous < 2: + expected = 2 + else: + # It's 2 or more, so shrink it slightly: + expected = previous - 1 + + thread_result = [] + + def get_and_set() -> None: + set_n_threads(expected) + thread_result.append(get_n_threads()) + + thread = Thread(target=get_and_set) + thread.start() + thread.join() + + # 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 + # is if the number of thread is limited by available CPU, and only one + # CPU is available. In that case we can't empirically determine how the + # API works. We try to not reach that point here, but you can imagine a + # thread pool implementation that is aware of cgroups, in which case a + # Docker container limited to one core will pass the safety check at + # the start of the function. Perhaps cpu_count() from loky should be + # moved into this package... + if thread_result != [expected]: + return APIScope.UNKNOWN + + # Now, check this thread: + if get_n_threads() == expected: + # Setting is process-wide. + return APIScope.PROCESSWIDE + elif get_n_threads() == previous: + # Setting modified the other thread, but not this one. + return APIScope.THREAD_LOCAL + else: + # No idea what's going on: + return APIScope.UNKNOWN + finally: + set_n_threads(previous) + + class LibController(ABC): """Abstract base class for the individual library controllers From 128c4eff0372604ce25c648733bc4cec53a5054c Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 23 Jun 2026 16:20:55 -0400 Subject: [PATCH 02/18] Add API scope to info. --- tests/test_api_introspection.py | 10 ++++++---- threadpoolctl.py | 21 +++++++++++---------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index b850e1c2..b0098387 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -3,10 +3,12 @@ """ from threading import local as threadlocal -from threadpoolctl import APIScope, determine_api_scope import pytest +from threadpoolctl import _APIScope, _determine_api_scope + + class FakeThreadLocalAPI(threadlocal): """Thread-local num threads setting API.""" @@ -33,11 +35,11 @@ def set(self, n: int) -> None: def test_determine_api_scope_thread_local(): """ - Check ``determine_api_scope()`` can correctly diagnose a trivial + Check ``_determine_api_scope()`` can correctly diagnose a trivial thread-local implementation. """ api = FakeThreadLocalAPI() - assert determine_api_scope(api.get, api.set) == APIScope.THREAD_LOCAL + assert _determine_api_scope(api.get, api.set) == _APIScope.THREAD_LOCAL @pytest.mark.parametrize("default", [1, 17]) @@ -47,4 +49,4 @@ def test_determine_api_scope_processiwde(default: int): process-wide implementation. """ api = FakeProcesswideAPI(default) - assert determine_api_scope(api.get, api.set) == APIScope.PROCESSWIDE + assert _determine_api_scope(api.get, api.set) == _APIScope.PROCESSWIDE diff --git a/threadpoolctl.py b/threadpoolctl.py index a1368844..09702edf 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -24,12 +24,10 @@ from abc import ABC, abstractmethod from functools import lru_cache from contextlib import ContextDecorator -from enum import Enum, auto +from enum import StrEnum, auto __version__ = "3.7.0.dev0" __all__ = [ - "APIScope", - "determine_api_scope", "threadpool_limits", "threadpool_info", "ThreadpoolController", @@ -73,7 +71,7 @@ class _dl_phdr_info(ctypes.Structure): _RTLD_NOLOAD = ctypes.DEFAULT_MODE -class APIScope(Enum): +class _APIScope(StrEnum): """ What scope does the API affect. """ @@ -89,9 +87,9 @@ class APIScope(Enum): UNKNOWN = auto() -def determine_api_scope( +def _determine_api_scope( get_n_threads: Callable[[], int], set_n_threads: Callable[[int], None] -) -> APIScope: +) -> _APIScope: """ Run some experiments to determine the scope of the given get/set API. @@ -140,18 +138,18 @@ def get_and_set() -> None: # the start of the function. Perhaps cpu_count() from loky should be # moved into this package... if thread_result != [expected]: - return APIScope.UNKNOWN + return _APIScope.UNKNOWN # Now, check this thread: if get_n_threads() == expected: # Setting is process-wide. - return APIScope.PROCESSWIDE + return _APIScope.PROCESSWIDE elif get_n_threads() == previous: # Setting modified the other thread, but not this one. - return APIScope.THREAD_LOCAL + return _APIScope.THREAD_LOCAL else: # No idea what's going on: - return APIScope.UNKNOWN + return _APIScope.UNKNOWN finally: set_n_threads(previous) @@ -210,6 +208,9 @@ def info(self): "user_api": self.user_api, "internal_api": self.internal_api, "num_threads": self.num_threads, + "api_scope": _determine_api_scope( + self.get_num_threads, self.set_num_threads + ).value.lower(), **{k: v for k, v in vars(self).items() if k not in hidden_attrs}, } From 86372487cfd208faaee242ba07626d65d186d8d1 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 23 Jun 2026 16:29:34 -0400 Subject: [PATCH 03/18] Reformat --- tests/test_api_introspection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index b0098387..24842467 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -9,7 +9,6 @@ from threadpoolctl import _APIScope, _determine_api_scope - class FakeThreadLocalAPI(threadlocal): """Thread-local num threads setting API.""" From 6946867975426104d7f6965450f55c14abfbd59a Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 23 Jun 2026 16:38:25 -0400 Subject: [PATCH 04/18] Reformat. --- threadpoolctl.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 09702edf..8761c183 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -208,9 +208,11 @@ def info(self): "user_api": self.user_api, "internal_api": self.internal_api, "num_threads": self.num_threads, - "api_scope": _determine_api_scope( - self.get_num_threads, self.set_num_threads - ).value.lower(), + "api_scope": ( + _determine_api_scope( + self.get_num_threads, self.set_num_threads + ).value.lower() + ), **{k: v for k, v in vars(self).items() if k not in hidden_attrs}, } From 31bbcf5683d1f93682792a6bb099712f051f6935 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 23 Jun 2026 16:41:07 -0400 Subject: [PATCH 05/18] Try to work with older Python. --- threadpoolctl.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 8761c183..7f809130 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -24,7 +24,7 @@ from abc import ABC, abstractmethod from functools import lru_cache from contextlib import ContextDecorator -from enum import StrEnum, auto +from enum import Enum, auto __version__ = "3.7.0.dev0" __all__ = [ @@ -71,7 +71,7 @@ class _dl_phdr_info(ctypes.Structure): _RTLD_NOLOAD = ctypes.DEFAULT_MODE -class _APIScope(StrEnum): +class _APIScope(Enum): """ What scope does the API affect. """ @@ -211,7 +211,7 @@ def info(self): "api_scope": ( _determine_api_scope( self.get_num_threads, self.set_num_threads - ).value.lower() + ).name.lower() ), **{k: v for k, v in vars(self).items() if k not in hidden_attrs}, } From f27e6f22fa98ea0839d002997b4d966b60509390 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 23 Jun 2026 16:49:36 -0400 Subject: [PATCH 06/18] Better names. --- tests/test_api_introspection.py | 4 ++-- threadpoolctl.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index 24842467..071dfed6 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -38,7 +38,7 @@ def test_determine_api_scope_thread_local(): thread-local implementation. """ api = FakeThreadLocalAPI() - assert _determine_api_scope(api.get, api.set) == _APIScope.THREAD_LOCAL + assert _determine_api_scope(api.get, api.set) == _APIScope.CURRENT_THREAD @pytest.mark.parametrize("default", [1, 17]) @@ -48,4 +48,4 @@ def test_determine_api_scope_processiwde(default: int): process-wide implementation. """ api = FakeProcesswideAPI(default) - assert _determine_api_scope(api.get, api.set) == _APIScope.PROCESSWIDE + assert _determine_api_scope(api.get, api.set) == _APIScope.PROCESS diff --git a/threadpoolctl.py b/threadpoolctl.py index 7f809130..0d12de87 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -76,14 +76,14 @@ class _APIScope(Enum): What scope does the API affect. """ - # Using the API sets a limit only on the current thread: - THREAD_LOCAL = auto() + # Using the API sets a limit only on the current thread. + CURRENT_THREAD = auto() # Using the API sets a limit for every thread in the process; whether or # not it's a shared process-wide pool or per-thread limit needs to be # determined some other way. - PROCESSWIDE = auto() - # Something else, unexpected; perhaps an unknown API, perhaps information - # can't be determined under current configuration: + PROCESS = auto() + # Something else, unexpected; perhaps another variant, perhaps information + # can't be determined under the current configuration. UNKNOWN = auto() @@ -95,7 +95,7 @@ def _determine_api_scope( An attempt will be made to restore all settings to their previous state. - This won't work reliably if you only have one core available. + This won't work if you only have one core available. """ if os.cpu_count() == 1 or ( hasattr(os, "process_cpu_count") and os.process_cpu_count() == 1 @@ -142,11 +142,11 @@ def get_and_set() -> None: # Now, check this thread: if get_n_threads() == expected: - # Setting is process-wide. - return _APIScope.PROCESSWIDE + # Setting modified this thread's results too: + return _APIScope.PROCESS elif get_n_threads() == previous: - # Setting modified the other thread, but not this one. - return _APIScope.THREAD_LOCAL + # Setting modified the other thread, but not this one: + return _APIScope.CURRENT_THREAD else: # No idea what's going on: return _APIScope.UNKNOWN From 0d4dde52ebff196de053017acd022426c0c83e4c Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 23 Jun 2026 16:58:43 -0400 Subject: [PATCH 07/18] Test for expected API scopes for different libraries. --- tests/test_threadpoolctl.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index ea27cc2b..615fd48d 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import os import pytest @@ -792,3 +794,26 @@ def test_custom_controller(): assert mylib_controller.num_threads == 1 assert ThreadpoolController().info() == original_info + + +@pytest.mark.parametrize( + ["select_filter", "expected_api_scope"], + [ + ({"internal_api": "openblas"}, "process"), + ({"internal_api": "mkl"}, "process"), + ({"internal_api": "blis"}, "process"), + ({"user_api": "openmp"}, "current_thread"), + ], +) +def test_api_scope(select_filter: dict[str, str], expected_api_scope: str) -> None: + """ + For the given controller, expect the given API scope. + """ + controller = ThreadpoolController().select(**select_filter) + if not controller.lib_controllers: + pytest.skip(f"{select_filter} controller not found") + + # Filters should be limited to one result: + [lib] = controller.lib_controllers + + assert lib.info()["api_scope"] == expected_api_scope From 60dd11c12e6fd264566c9ea6d3a492eb68135c80 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 23 Jun 2026 17:03:50 -0400 Subject: [PATCH 08/18] Be more lenient. --- tests/test_threadpoolctl.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 615fd48d..5f471deb 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -813,7 +813,5 @@ def test_api_scope(select_filter: dict[str, str], expected_api_scope: str) -> No if not controller.lib_controllers: pytest.skip(f"{select_filter} controller not found") - # Filters should be limited to one result: - [lib] = controller.lib_controllers - - assert lib.info()["api_scope"] == expected_api_scope + for lib in controller.lib_controllers: + assert lib.info()["api_scope"] == expected_api_scope From 2c985b6031be3a6749b57c039cc3788512ccfccf Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 30 Jun 2026 15:33:21 -0400 Subject: [PATCH 09/18] Convert into test to check _determine_api_scope(), rather than of expected outcomes across a variety of libraries. --- tests/test_api_introspection.py | 31 +++++++++++++++++++++++++++++-- tests/test_threadpoolctl.py | 21 --------------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index 071dfed6..db0344bf 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -6,7 +6,7 @@ import pytest -from threadpoolctl import _APIScope, _determine_api_scope +from threadpoolctl import _APIScope, _determine_api_scope, ThreadpoolController class FakeThreadLocalAPI(threadlocal): @@ -44,8 +44,35 @@ def test_determine_api_scope_thread_local(): @pytest.mark.parametrize("default", [1, 17]) def test_determine_api_scope_processiwde(default: int): """ - Check ``determine_api_scope()`` can correctly diagnose a trivial + Check ``_determine_api_scope()`` can correctly diagnose a trivial process-wide implementation. """ api = FakeProcesswideAPI(default) assert _determine_api_scope(api.get, api.set) == _APIScope.PROCESS + + +@pytest.mark.parametrize( + ["select_filter", "expected_api_scope"], + [ + ( + {"internal_api": "openblas", "threading_layer": "pthreads"}, + _APIScope.PROCESS, + ), + ({"user_api": "openmp", "prefix": "libgomp"}, _APIScope.CURRENT_THREAD), + ({"user_api": "openmp", "prefix": "libomp"}, _APIScope.CURRENT_THREAD), + ], +) +def test_api_scope(select_filter: dict[str, str], expected_api_scope: str) -> None: + """ + Check ``determine_api_scope()`` against known values, to make sure it + detects them correctly. + """ + controller = ThreadpoolController().select(**select_filter) + if not controller.lib_controllers: + pytest.skip(f"{select_filter} controller not found") + + for lib in controller.lib_controllers: + assert ( + _determine_api_scope(lib.get_num_threads, lib.set_num_threads) + == expected_api_scope + ) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 5f471deb..807d351e 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -794,24 +794,3 @@ def test_custom_controller(): assert mylib_controller.num_threads == 1 assert ThreadpoolController().info() == original_info - - -@pytest.mark.parametrize( - ["select_filter", "expected_api_scope"], - [ - ({"internal_api": "openblas"}, "process"), - ({"internal_api": "mkl"}, "process"), - ({"internal_api": "blis"}, "process"), - ({"user_api": "openmp"}, "current_thread"), - ], -) -def test_api_scope(select_filter: dict[str, str], expected_api_scope: str) -> None: - """ - For the given controller, expect the given API scope. - """ - controller = ThreadpoolController().select(**select_filter) - if not controller.lib_controllers: - pytest.skip(f"{select_filter} controller not found") - - for lib in controller.lib_controllers: - assert lib.info()["api_scope"] == expected_api_scope From bb7b5283fd20154eae96bc3a4c85cdfb3af1c553 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 30 Jun 2026 15:41:01 -0400 Subject: [PATCH 10/18] Don't include API scope in info by default, since it might have side-effects. --- threadpoolctl.py | 57 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 0d12de87..65c577d4 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -201,20 +201,28 @@ def __init__(self, *, filepath=None, prefix=None, parent=None): self.version = self.get_version() self.set_additional_attributes() - def info(self): - """Return relevant info wrapped in a dict""" + def info(self, extra_info: bool = False): + """Return relevant info wrapped in a dict. + + Parameters + ---------- + extra_info : bool + + Include extra fields which requires more intrusive actions to + obtain. + """ hidden_attrs = ("dynlib", "parent", "_symbol_prefix", "_symbol_suffix") - return { + result = { "user_api": self.user_api, "internal_api": self.internal_api, "num_threads": self.num_threads, - "api_scope": ( - _determine_api_scope( - self.get_num_threads, self.set_num_threads - ).name.lower() - ), **{k: v for k, v in vars(self).items() if k not in hidden_attrs}, } + if extra_info: + result["api_scope"] = _determine_api_scope( + self.get_num_threads, self.set_num_threads + ).name.lower() + return result def set_additional_attributes(self): """Set additional attributes meant to be exposed in the info dict""" @@ -639,7 +647,7 @@ def _realpath(filepath): @_format_docstring(USER_APIS=list(_ALL_USER_APIS), INTERNAL_APIS=_ALL_INTERNAL_APIS) -def threadpool_info(): +def threadpool_info(extra_info: bool = False): """Return the maximal number of threads for each detected library. Return a list with all the supported libraries that have been found. Each @@ -653,8 +661,18 @@ def threadpool_info(): - "num_threads": the current thread limit. In addition, each library may contain internal_api specific entries. + + Parameters + ---------- + extra_info : bool + + Include extra fields which requires more intrusive actions to + obtain. + + - "api_scope": When setting the number of threads, what is affected. + Possible values are "process", "current_thread". """ - return ThreadpoolController().info() + return ThreadpoolController().info(extra_info) class _ThreadpoolLimiter: @@ -914,9 +932,20 @@ def _from_controllers(cls, lib_controllers): new_controller.lib_controllers = lib_controllers return new_controller - def info(self): - """Return lib_controllers info as a list of dicts""" - return [lib_controller.info() for lib_controller in self.lib_controllers] + def info(self, extra_info: bool = False): + """Return lib_controllers info as a list of dicts. + + Parameters + ---------- + extra_info : bool + + Include extra fields which requires more intrusive actions to + obtain. + """ + return [ + lib_controller.info(extra_info=extra_info) + for lib_controller in self.lib_controllers + ] def select(self, **kwargs): """Return a ThreadpoolController containing a subset of its current @@ -1380,7 +1409,7 @@ def _main(): if options.command: exec(options.command) - print(json.dumps(threadpool_info(), indent=2)) + print(json.dumps(threadpool_info(extra_info=True), indent=2)) if __name__ == "__main__": From 7fee2dc55ce025193e2cbb19bc6fdb567c0d3c0e Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 30 Jun 2026 15:44:42 -0400 Subject: [PATCH 11/18] Minor cleanups. --- tests/test_api_introspection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index db0344bf..5ddcb434 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -32,7 +32,7 @@ def set(self, n: int) -> None: self.num_threads = n -def test_determine_api_scope_thread_local(): +def test_determine_api_scope_thread_local() -> None: """ Check ``_determine_api_scope()`` can correctly diagnose a trivial thread-local implementation. @@ -42,7 +42,7 @@ def test_determine_api_scope_thread_local(): @pytest.mark.parametrize("default", [1, 17]) -def test_determine_api_scope_processiwde(default: int): +def test_determine_api_scope_processwide(default: int) -> None: """ Check ``_determine_api_scope()`` can correctly diagnose a trivial process-wide implementation. @@ -64,8 +64,8 @@ def test_determine_api_scope_processiwde(default: int): ) def test_api_scope(select_filter: dict[str, str], expected_api_scope: str) -> None: """ - Check ``determine_api_scope()`` against known values, to make sure it - detects them correctly. + Check ``_determine_api_scope()`` against libraries with known properties, + to make sure it detects them correctly. """ controller = ThreadpoolController().select(**select_filter) if not controller.lib_controllers: From 1c6ae7966a0cf22e8cc1ce9eaea14aa3a60546f3 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 30 Jun 2026 15:48:11 -0400 Subject: [PATCH 12/18] Improve docs. --- threadpoolctl.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 65c577d4..d063cbe6 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -93,9 +93,16 @@ def _determine_api_scope( """ Run some experiments to determine the scope of the given get/set API. - An attempt will be made to restore all settings to their previous state. + This function might not work if you only have one core available. - This won't work if you only have one core available. + This function might not work if you set a limit on a library with an + environment variable. + + The function works by changing the number of threads in loaded controllers, + which can be a process-wide change. As such, it is not always thread-safe. + An attempt will be made to restore all settings to their previous state, + but the result may be subtly different, e.g. if "unset" has different + semantics than "set to the default returned value". """ if os.cpu_count() == 1 or ( hasattr(os, "process_cpu_count") and os.process_cpu_count() == 1 @@ -108,7 +115,7 @@ def _determine_api_scope( # # 1. The API might not allow setting more than the number of (available, or # physical) cores. - # 2. ... + # 2. Some hard limit on number of threads. try: # Choose a desired number of threads that is different than the current # number, and hopefully achievable under the current configuration: @@ -130,13 +137,13 @@ def get_and_set() -> None: # 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 - # is if the number of thread is limited by available CPU, and only one - # CPU is available. In that case we can't empirically determine how the - # API works. We try to not reach that point here, but you can imagine a - # thread pool implementation that is aware of cgroups, in which case a - # Docker container limited to one core will pass the safety check at - # the start of the function. Perhaps cpu_count() from loky should be - # moved into this package... + # fo failing this is if the number of thread is limited by available + # CPU, and only one CPU is available. In that case we can't empirically + # determine how the API works. We try to not reach that point here, but + # you can imagine a thread pool implementation that is aware of + # cgroups, in which case a Docker container limited to one core will + # pass the safety check at the start of the function. Perhaps + # cpu_count() from loky should be moved into this package... if thread_result != [expected]: return _APIScope.UNKNOWN From 9ad838289610cdc33a455f26972b90c4b4318bea Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 30 Jun 2026 15:54:58 -0400 Subject: [PATCH 13/18] Match command-line behavior --- 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 807d351e..46a2c28b 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -572,7 +572,7 @@ def test_command_line_command_flag(): ) cli_info = json.loads(output.decode("utf-8")) - this_process_info = threadpool_info() + this_process_info = threadpool_info(extra_info=True) for lib_info in cli_info: assert lib_info in this_process_info @@ -598,7 +598,7 @@ def test_command_line_import_flag(): ) cli_info = json.loads(result.stdout) - this_process_info = threadpool_info() + this_process_info = threadpool_info(extra_info=True) for lib_info in cli_info: assert lib_info in this_process_info From d306c5570651d4184771d76f82da8ca1a566aad8 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 30 Jun 2026 16:14:14 -0400 Subject: [PATCH 14/18] Give up on live testing, it's too inconsistent. --- tests/test_api_introspection.py | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index 5ddcb434..31ad56cb 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -6,7 +6,7 @@ import pytest -from threadpoolctl import _APIScope, _determine_api_scope, ThreadpoolController +from threadpoolctl import _APIScope, _determine_api_scope class FakeThreadLocalAPI(threadlocal): @@ -49,30 +49,3 @@ def test_determine_api_scope_processwide(default: int) -> None: """ api = FakeProcesswideAPI(default) assert _determine_api_scope(api.get, api.set) == _APIScope.PROCESS - - -@pytest.mark.parametrize( - ["select_filter", "expected_api_scope"], - [ - ( - {"internal_api": "openblas", "threading_layer": "pthreads"}, - _APIScope.PROCESS, - ), - ({"user_api": "openmp", "prefix": "libgomp"}, _APIScope.CURRENT_THREAD), - ({"user_api": "openmp", "prefix": "libomp"}, _APIScope.CURRENT_THREAD), - ], -) -def test_api_scope(select_filter: dict[str, str], expected_api_scope: str) -> None: - """ - Check ``_determine_api_scope()`` against libraries with known properties, - to make sure it detects them correctly. - """ - controller = ThreadpoolController().select(**select_filter) - if not controller.lib_controllers: - pytest.skip(f"{select_filter} controller not found") - - for lib in controller.lib_controllers: - assert ( - _determine_api_scope(lib.get_num_threads, lib.set_num_threads) - == expected_api_scope - ) From 8740d13fc4f00948ad1f6e698edfdbe37c5e1d71 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 1 Jul 2026 10:33:14 -0400 Subject: [PATCH 15/18] Cleanups from code review Co-authored-by: Olivier Grisel --- threadpoolctl.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index d063cbe6..f442f551 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -670,14 +670,12 @@ def threadpool_info(extra_info: bool = False): In addition, each library may contain internal_api specific entries. Parameters - ---------- - extra_info : bool - - Include extra fields which requires more intrusive actions to - obtain. - - - "api_scope": When setting the number of threads, what is affected. - Possible values are "process", "current_thread". + ---------- + extra_info : bool + Include extra fields which requires more intrusive actions to obtain. + + - "api_scope": When setting the number of threads, what is affected. + Possible values are "process", "current_thread". """ return ThreadpoolController().info(extra_info) @@ -945,7 +943,6 @@ def info(self, extra_info: bool = False): Parameters ---------- extra_info : bool - Include extra fields which requires more intrusive actions to obtain. """ From 12a9d963555817e831fb00f43fe78894256354bf Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 1 Jul 2026 10:56:33 -0400 Subject: [PATCH 16/18] Review comments: better names, don't raise exception. --- tests/test_api_introspection.py | 17 ++++++++++------- threadpoolctl.py | 27 +++++++++++---------------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index 31ad56cb..00767cc2 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -6,7 +6,7 @@ import pytest -from threadpoolctl import _APIScope, _determine_api_scope +from threadpoolctl import _ThreadLimitScope, _determine_thread_limit_scope class FakeThreadLocalAPI(threadlocal): @@ -32,20 +32,23 @@ def set(self, n: int) -> None: self.num_threads = n -def test_determine_api_scope_thread_local() -> None: +def test_determine_thread_limit_scope_thread_local() -> None: """ - Check ``_determine_api_scope()`` can correctly diagnose a trivial + Check ``_determine_thread_limit_scope()`` can correctly diagnose a trivial thread-local implementation. """ api = FakeThreadLocalAPI() - assert _determine_api_scope(api.get, api.set) == _APIScope.CURRENT_THREAD + assert ( + _determine_thread_limit_scope(api.get, api.set) + == _ThreadLimitScope.CURRENT_THREAD + ) @pytest.mark.parametrize("default", [1, 17]) -def test_determine_api_scope_processwide(default: int) -> None: +def test_determine_thread_limit_scope_processwide(default: int) -> None: """ - Check ``_determine_api_scope()`` can correctly diagnose a trivial + Check ``_determine_thread_limit_scope()`` can correctly diagnose a trivial process-wide implementation. """ api = FakeProcesswideAPI(default) - assert _determine_api_scope(api.get, api.set) == _APIScope.PROCESS + assert _determine_thread_limit_scope(api.get, api.set) == _ThreadLimitScope.PROCESS diff --git a/threadpoolctl.py b/threadpoolctl.py index f442f551..6632d8c7 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -71,7 +71,7 @@ class _dl_phdr_info(ctypes.Structure): _RTLD_NOLOAD = ctypes.DEFAULT_MODE -class _APIScope(Enum): +class _ThreadLimitScope(Enum): """ What scope does the API affect. """ @@ -87,9 +87,9 @@ class _APIScope(Enum): UNKNOWN = auto() -def _determine_api_scope( +def _determine_thread_limit_scope( get_n_threads: Callable[[], int], set_n_threads: Callable[[int], None] -) -> _APIScope: +) -> _ThreadLimitScope: """ Run some experiments to determine the scope of the given get/set API. @@ -104,11 +104,6 @@ def _determine_api_scope( but the result may be subtly different, e.g. if "unset" has different semantics than "set to the default returned value". """ - if os.cpu_count() == 1 or ( - hasattr(os, "process_cpu_count") and os.process_cpu_count() == 1 - ): - raise RuntimeError("Cannot determine API meaning if only one core is available") - previous = get_n_threads() # Some plausible constraints we need to keep in mind: @@ -145,18 +140,18 @@ def get_and_set() -> None: # pass the safety check at the start of the function. Perhaps # cpu_count() from loky should be moved into this package... if thread_result != [expected]: - return _APIScope.UNKNOWN + return _ThreadLimitScope.UNKNOWN # Now, check this thread: if get_n_threads() == expected: # Setting modified this thread's results too: - return _APIScope.PROCESS + return _ThreadLimitScope.PROCESS elif get_n_threads() == previous: # Setting modified the other thread, but not this one: - return _APIScope.CURRENT_THREAD + return _ThreadLimitScope.CURRENT_THREAD else: # No idea what's going on: - return _APIScope.UNKNOWN + return _ThreadLimitScope.UNKNOWN finally: set_n_threads(previous) @@ -226,7 +221,7 @@ def info(self, extra_info: bool = False): **{k: v for k, v in vars(self).items() if k not in hidden_attrs}, } if extra_info: - result["api_scope"] = _determine_api_scope( + result["thread_limit_scope"] = _determine_thread_limit_scope( self.get_num_threads, self.set_num_threads ).name.lower() return result @@ -673,9 +668,9 @@ def threadpool_info(extra_info: bool = False): ---------- extra_info : bool Include extra fields which requires more intrusive actions to obtain. - - - "api_scope": When setting the number of threads, what is affected. - Possible values are "process", "current_thread". + + - "thread_limit_scope": When setting the number of threads, what is + affected. Possible values are "process", "current_thread". """ return ThreadpoolController().info(extra_info) From 95d77a050c21aa301bd3b6f3626464eb40d0bb4e Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Wed, 1 Jul 2026 11:10:48 -0400 Subject: [PATCH 17/18] Try to reintroduce a test that checks real libraries. --- tests/test_api_introspection.py | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index 00767cc2..08637275 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -2,11 +2,19 @@ Tests for get/set number of threads API introspection. """ +import sys from threading import local as threadlocal import pytest -from threadpoolctl import _ThreadLimitScope, _determine_thread_limit_scope +from threadpoolctl import ( + _ThreadLimitScope, + _determine_thread_limit_scope, + ThreadpoolController, +) + +# Make sure we have some BLAS libraries loaded: +from . import utils as _ class FakeThreadLocalAPI(threadlocal): @@ -52,3 +60,38 @@ def test_determine_thread_limit_scope_processwide(default: int) -> None: """ api = FakeProcesswideAPI(default) assert _determine_thread_limit_scope(api.get, api.set) == _ThreadLimitScope.PROCESS + + +@pytest.mark.skipif( + sys.platform != "linux", reason="We only hardcoded Linux-specific behavior" +) +@pytest.mark.parametrize( + ["select_filter", "expected_thread_limit_scope"], + [ + ( + {"internal_api": "openblas", "threading_layer": "pthreads"}, + _ThreadLimitScope.PROCESS, + ), + ( + {"user_api": "openmp"}, + _ThreadLimitScope.CURRENT_THREAD, + ), + ], +) +def test_api_scope( + select_filter: dict[str, str], expected_thread_limit_scope: str +) -> None: + """ + Check ``_determine_thread_limit_scope()`` against libraries with known + properties, to make sure it detects them correctly. The test is intended + to be of the function's behavior, not of the libraries. + """ + controller = ThreadpoolController().select(**select_filter) + if not controller.lib_controllers: + pytest.skip(f"{select_filter} controller not found") + + for lib in controller.lib_controllers: + assert ( + _determine_thread_limit_scope(lib.get_num_threads, lib.set_num_threads) + == expected_thread_limit_scope + ) From 3c831adfa38adbc3e4af004e3206bd2400f8013e Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 2 Jul 2026 10:47:35 -0400 Subject: [PATCH 18/18] Better explanation. --- tests/test_api_introspection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_introspection.py b/tests/test_api_introspection.py index 08637275..55a713be 100644 --- a/tests/test_api_introspection.py +++ b/tests/test_api_introspection.py @@ -63,7 +63,7 @@ def test_determine_thread_limit_scope_processwide(default: int) -> None: @pytest.mark.skipif( - sys.platform != "linux", reason="We only hardcoded Linux-specific behavior" + sys.platform != "linux", reason="Non-Linux OpenMP might be different" ) @pytest.mark.parametrize( ["select_filter", "expected_thread_limit_scope"],