From 470765da7ffea35f5576affe5844511f48617756 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 23 Oct 2026 08:22:19 -0400 Subject: [PATCH 01/12] Use the filepath as the unique key to identify controllers. Also, remove an unnecessary attribute and correct a docstring. --- threadpoolctl.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 2eab17c3..28dbe194 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -754,7 +754,7 @@ class _ThreadpoolLimiter: def __init__(self, controller, *, limits=None, user_api=None): self._controller = controller - self._limits, self._user_api, self._prefixes = self._check_params( + self._limits, self._user_api = self._check_params( limits, user_api ) self._original_info = self._controller.info() @@ -820,7 +820,7 @@ def get_original_num_threads(self): return num_threads def _check_params(self, limits, user_api): - """Suitable values for the _limits, _user_api and _prefixes attributes""" + """Suitable values for the _limits and _user_api attributes""" if isinstance(limits, str) and limits == "sequential_blas_under_openmp": ( @@ -841,19 +841,23 @@ def _check_params(self, limits, user_api): if limits is not None: limits = {api: limits for api in user_api} - prefixes = [] else: if isinstance(limits, list): # This should be a list of dicts of library info, for # compatibility with the result from threadpool_info. limits = { - lib_info["prefix"]: lib_info["num_threads"] for lib_info in limits + # Use prefix key for backwards compatibility with older + # versions that didn't use the filepath as the key. + lib_info.get("filepath", lib_info["prefix"]): lib_info[ + "num_threads" + ] + for lib_info in limits } elif isinstance(limits, ThreadpoolController): # To set the limits from the library controllers of a # ThreadpoolController object. limits = { - lib_controller.prefix: lib_controller.num_threads + lib_controller.filepath: lib_controller.num_threads for lib_controller in limits.lib_controllers } @@ -865,25 +869,22 @@ def _check_params(self, limits, user_api): # With a dictionary, can set both specific limit for given # libraries and global limit for user_api. Fetch each separately. - prefixes = [prefix for prefix in limits if prefix in _ALL_PREFIXES] user_api = [api for api in limits if api in _ALL_USER_APIS] - return limits, user_api, prefixes + return limits, user_api def _set_threadpool_limits(self): - """Change the maximal number of threads in selected thread pools. - - Return a list with all the supported libraries that have been found - matching `self._prefixes` and `self._user_api`. - """ + """Change the maximal number of threads in selected thread pools.""" if self._limits is None: return for lib_controller in self._controller.lib_controllers: - # self._limits is a dict {key: num_threads} where key is either - # a prefix or a user_api. If a library matches both, the limit - # corresponding to the prefix is chosen. - if lib_controller.prefix in self._limits: + # self._limits is a dict {key: num_threads} where key is either a + # filepath, a prefix, or a user_api. Libraries are matched in that + # order. + if lib_controller.filepath in self._limits: + num_threads = self._limits[lib_controller.filepath] + elif lib_controller.prefix in self._limits: num_threads = self._limits[lib_controller.prefix] elif lib_controller.user_api in self._limits: num_threads = self._limits[lib_controller.user_api] @@ -898,7 +899,7 @@ class _ThreadpoolLimiterDecorator(_ThreadpoolLimiter, ContextDecorator): """Same as _ThreadpoolLimiter but to be used as a decorator""" def __init__(self, controller, *, limits=None, user_api=None): - self._limits, self._user_api, self._prefixes = self._check_params( + self._limits, self._user_api = self._check_params( limits, user_api ) self._controller = controller From 0c601b8a59ce476cc0fa7117c3a733c7dea7b6e1 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 23 Oct 2026 08:27:06 -0400 Subject: [PATCH 02/12] Changelog entry --- CHANGES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 0e643c66..ecdf873c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,12 @@ in package metadata. https://github.com/joblib/threadpoolctl/issues/251 +- When passing a list of dicts or a dict to `threadpool_limits(limit=...)` and + related APIs, the `"filepath"` is used as the unique key identifying + ``LibController``s, if available, falling back to ``"prefix"``. Previously the + ``"prefix"`` was checked first. + https://github.com/joblib/threadpoolctl/issues/257 + 3.7.0 (2026-09-15) ================== From 1b770bdb92cd1c3f5dd6fde8366f7e9685549151 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 26 Oct 2026 10:37:42 -0400 Subject: [PATCH 03/12] Add tests and corresponding fixes for filepath prioritization in limit setting via dicts and lists --- tests/test_threadpoolctl.py | 40 +++++++++++++++++++++++++++++++++++++ threadpoolctl.py | 17 ++++++++++------ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 2b7d6065..7f044d7a 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -140,6 +140,46 @@ def test_threadpool_controller_select(kwargs): ) +@pytest.mark.parametrize( + "to_structure", + [ + lambda filepath, limit: [{"filepath": filepath, "num_threads": limit}], + lambda filepath, limit: {filepath: limit}, + ], +) +@pytest.mark.parametrize("limit", [1, 3]) +def test_threadpool_limits_by_filepath(to_structure, limit): + controller = ThreadpoolController() + original_info = controller.info() + original_limits = {d["filepath"]: d["num_threads"] for d in original_info} + + for lib_controller in controller.lib_controllers: + with threadpool_limits(limits=to_structure(lib_controller.filepath, limit)): + # threadpool_limits only sets an upper bound on the number of + # threads. + assert 0 < lib_controller.num_threads <= limit + for other_lib_controller in controller.lib_controllers: + if other_lib_controller is lib_controller: + continue + assert ( + other_lib_controller.get_num_threads() + == original_limits[other_lib_controller.filepath] + ) + assert ThreadpoolController().info() == original_info + + +@pytest.mark.skipif(scipy is None, reason="need numpy and scipy") +def test_threadpool_limits_prioritizes_filepath_over_prefix(): + controller = ThreadpoolController() + openblases = controller.select(internal_api="openblas").lib_controllers + if len(openblases) < 2 or len({ob.prefix for ob in openblases}) == len(openblases): + pytest.skip("Need two OpenBLASes with same prefix") + + with controller.limit(limits={openblases[0].filepath: 1, openblases[1].prefix: 3}): + assert 0 <= openblases[0].get_num_threads() <= 1 + assert 0 <= openblases[1].get_num_threads() <= 3 + + @pytest.mark.parametrize("prefix", _ALL_PREFIXES) @pytest.mark.parametrize("limit", [1, 3]) def test_threadpool_limits_by_prefix(prefix, limit): diff --git a/threadpoolctl.py b/threadpoolctl.py index 28dbe194..3477b5ec 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -741,6 +741,9 @@ def threadpool_info(debugging_info: bool = False): return ThreadpoolController().info(debugging_info) +_missing = object() + + class _ThreadpoolLimiter: """The guts of ThreadpoolController.limit @@ -845,14 +848,16 @@ def _check_params(self, limits, user_api): if isinstance(limits, list): # This should be a list of dicts of library info, for # compatibility with the result from threadpool_info. - limits = { + result = {} + for lib_info in limits: # Use prefix key for backwards compatibility with older # versions that didn't use the filepath as the key. - lib_info.get("filepath", lib_info["prefix"]): lib_info[ - "num_threads" - ] - for lib_info in limits - } + if (filepath := lib_info.get("filepath", _missing)) is not _missing: + key = filepath + else: + key = lib_info["prefix"] + result[key] = lib_info["num_threads"] + limits = result elif isinstance(limits, ThreadpoolController): # To set the limits from the library controllers of a # ThreadpoolController object. From ece219bb793ab208793f9245fa338818bd71209c Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 26 Oct 2026 10:40:58 -0400 Subject: [PATCH 04/12] Link to PR --- CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index ecdf873c..1194fef1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,7 +13,8 @@ related APIs, the `"filepath"` is used as the unique key identifying ``LibController``s, if available, falling back to ``"prefix"``. Previously the ``"prefix"`` was checked first. - https://github.com/joblib/threadpoolctl/issues/257 + https://github.com/joblib/threadpoolctl/pull/278 + 3.7.0 (2026-09-15) ================== From 434181bced394b5a4ad932bece3e33da5f516a3b Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 26 Oct 2026 10:42:07 -0400 Subject: [PATCH 05/12] Reformat --- threadpoolctl.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 3477b5ec..e37355ae 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -757,9 +757,7 @@ class _ThreadpoolLimiter: def __init__(self, controller, *, limits=None, user_api=None): self._controller = controller - self._limits, self._user_api = self._check_params( - limits, user_api - ) + self._limits, self._user_api = self._check_params(limits, user_api) self._original_info = self._controller.info() self._set_threadpool_limits() @@ -904,9 +902,7 @@ class _ThreadpoolLimiterDecorator(_ThreadpoolLimiter, ContextDecorator): """Same as _ThreadpoolLimiter but to be used as a decorator""" def __init__(self, controller, *, limits=None, user_api=None): - self._limits, self._user_api = self._check_params( - limits, user_api - ) + self._limits, self._user_api = self._check_params(limits, user_api) self._controller = controller def __enter__(self): From 3ab89603ea5a6cec975d9cbd2f6194d8ee353377 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 26 Oct 2026 11:04:58 -0400 Subject: [PATCH 06/12] Don't rely on identity --- 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 7f044d7a..8a59ab97 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -159,7 +159,7 @@ def test_threadpool_limits_by_filepath(to_structure, limit): # threads. assert 0 < lib_controller.num_threads <= limit for other_lib_controller in controller.lib_controllers: - if other_lib_controller is lib_controller: + if other_lib_controller.filepath == lib_controller.filepath: continue assert ( other_lib_controller.get_num_threads() From f56b5e3c33e82f9dbce33167744e657ffc82c871 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 26 Oct 2026 11:13:18 -0400 Subject: [PATCH 07/12] Another edge case --- tests/test_threadpoolctl.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 8a59ab97..79dcfe1c 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -15,6 +15,7 @@ _ALL_PREFIXES, _ALL_USER_APIS, LibController, + OpenBLASController, ThreadpoolController, _determine_thread_limit_scope, threadpool_info, @@ -158,6 +159,13 @@ def test_threadpool_limits_by_filepath(to_structure, limit): # threadpool_limits only sets an upper bound on the number of # threads. assert 0 < lib_controller.num_threads <= limit + if ( + isinstance(lib_controller, OpenBLASController) + and lib_controller.threading_layer == "openmp" + ): + # Uses omp_set_num_threads() internally so does actually affect + # other libraries + continue for other_lib_controller in controller.lib_controllers: if other_lib_controller.filepath == lib_controller.filepath: continue From 4f64cc5d212e5348ff0da3fc1c7be4670fa6e8f1 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 26 Oct 2026 11:15:11 -0400 Subject: [PATCH 08/12] And another edge case --- 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 79dcfe1c..2ccaeee7 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -16,6 +16,7 @@ _ALL_USER_APIS, LibController, OpenBLASController, + OpenMPController, ThreadpoolController, _determine_thread_limit_scope, threadpool_info, @@ -162,7 +163,7 @@ def test_threadpool_limits_by_filepath(to_structure, limit): if ( isinstance(lib_controller, OpenBLASController) and lib_controller.threading_layer == "openmp" - ): + ) or isinstance(lib_controller, OpenMPController): # Uses omp_set_num_threads() internally so does actually affect # other libraries continue From 5b80786c21fceeac540426ffad4c252ed67ca861 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 26 Oct 2026 11:31:21 -0400 Subject: [PATCH 09/12] Give up on checking other libraries, too many edge cases --- tests/test_threadpoolctl.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 2ccaeee7..36458a14 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -153,27 +153,13 @@ def test_threadpool_controller_select(kwargs): def test_threadpool_limits_by_filepath(to_structure, limit): controller = ThreadpoolController() original_info = controller.info() - original_limits = {d["filepath"]: d["num_threads"] for d in original_info} for lib_controller in controller.lib_controllers: with threadpool_limits(limits=to_structure(lib_controller.filepath, limit)): # threadpool_limits only sets an upper bound on the number of # threads. assert 0 < lib_controller.num_threads <= limit - if ( - isinstance(lib_controller, OpenBLASController) - and lib_controller.threading_layer == "openmp" - ) or isinstance(lib_controller, OpenMPController): - # Uses omp_set_num_threads() internally so does actually affect - # other libraries - continue - for other_lib_controller in controller.lib_controllers: - if other_lib_controller.filepath == lib_controller.filepath: - continue - assert ( - other_lib_controller.get_num_threads() - == original_limits[other_lib_controller.filepath] - ) + assert ThreadpoolController().info() == original_info From 886226d522ab3a0ba2a617f3e6dedaaf77b60628 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 26 Oct 2026 11:38:04 -0400 Subject: [PATCH 10/12] Fix lint --- tests/test_threadpoolctl.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_threadpoolctl.py b/tests/test_threadpoolctl.py index 36458a14..332c46a2 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -15,8 +15,6 @@ _ALL_PREFIXES, _ALL_USER_APIS, LibController, - OpenBLASController, - OpenMPController, ThreadpoolController, _determine_thread_limit_scope, threadpool_info, From 3519107c256af896e4d7488b406f5c5b39076f7c Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 27 Oct 2026 07:54:50 -0400 Subject: [PATCH 11/12] More robust testing Co-authored-by: Olivier Grisel --- 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 332c46a2..fc2c491f 100644 --- a/tests/test_threadpoolctl.py +++ b/tests/test_threadpoolctl.py @@ -158,7 +158,7 @@ def test_threadpool_limits_by_filepath(to_structure, limit): # threads. assert 0 < lib_controller.num_threads <= limit - assert ThreadpoolController().info() == original_info + assert ThreadpoolController().info() == original_info @pytest.mark.skipif(scipy is None, reason="need numpy and scipy") From 5f6db5a88a9e95ff34aae21957ca075806942759 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 27 Oct 2026 08:09:45 -0400 Subject: [PATCH 12/12] Update docs to cover missing info --- threadpoolctl.py | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index e37355ae..b883cae0 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -937,7 +937,7 @@ class threadpool_limits(_ThreadpoolLimiter): Parameters ---------- - limits : int, dict, 'sequential_blas_under_openmp' or None (default=None) + limits : int, dict, list, 'sequential_blas_under_openmp' or None (default=None) The maximal number of threads that can be used in thread pools - If int, sets the maximum number of threads to `limits` for each @@ -945,7 +945,14 @@ class threadpool_limits(_ThreadpoolLimiter): - If it is a dictionary `{{key: max_threads}}`, this function sets a custom maximum number of threads for each `key` which can be either a - `user_api` or a `prefix` for a specific library. + `filepath`, `prefix`, or `user_api` for a specific library, + checked in that order. + + - If it is a list, it should be a list of dictionaries of the form + `{{"filepath": "/.../", "num_threads": 12}}`. Older versions of + threadpoolctl accepted a `"prefix"` instead of `"filepath"`, + so for maximal compatibility set both: + `[{{"filepath": "...", "prefix": "...", "num_threads": 7}}]` - If 'sequential_blas_under_openmp', it will chose the appropriate `limits` and `user_api` parameters for the specific use case of sequential BLAS @@ -1079,7 +1086,7 @@ def limit(self, *, limits=None, user_api=None): Parameters ---------- - limits : int, dict, 'sequential_blas_under_openmp' or None (default=None) + limits : int, dict, list, 'sequential_blas_under_openmp' or None (default=None) The maximal number of threads that can be used in thread pools - If int, sets the maximum number of threads to `limits` for each @@ -1087,7 +1094,14 @@ def limit(self, *, limits=None, user_api=None): - If it is a dictionary `{{key: max_threads}}`, this function sets a custom maximum number of threads for each `key` which can be either a - `user_api` or a `prefix` for a specific library. + `filepath`, `prefix`, or `user_api` for a specific library, + checked in that order. + + - If it is a list, it should be a list of dictionaries of the form + `{{"filepath": "/.../", "num_threads": 12}}`. Older versions of + threadpoolctl accepted a `"prefix"` instead of `"filepath"`, + so for maximal compatibility set both: + `[{{"filepath": "...", "prefix": "...", "num_threads": 7}}]` - If 'sequential_blas_under_openmp', it will chose the appropriate `limits` and `user_api` parameters for the specific use case of sequential BLAS @@ -1125,7 +1139,7 @@ def wrap(self, *, limits=None, user_api=None): Parameters ---------- - limits : int, dict or None (default=None) + limits : int, dict, list, 'sequential_blas_under_openmp', or None (default=None) The maximal number of threads that can be used in thread pools - If int, sets the maximum number of threads to `limits` for each @@ -1133,7 +1147,19 @@ def wrap(self, *, limits=None, user_api=None): - If it is a dictionary `{{key: max_threads}}`, this function sets a custom maximum number of threads for each `key` which can be either a - `user_api` or a `prefix` for a specific library. + `filepath`, `prefix`, or `user_api` for a specific library, + checked in that order. + + - If it is a list, it should be a list of dictionaries of the form + `{{"filepath": "/.../", "num_threads": 12}}`. Older versions of + threadpoolctl accepted a `"prefix"` instead of `"filepath"`, + so for maximal compatibility set both: + `[{{"filepath": "...", "prefix": "...", "num_threads": 7}}]` + + - If 'sequential_blas_under_openmp', it will chose the appropriate `limits` + and `user_api` parameters for the specific use case of sequential BLAS + calls within an OpenMP parallel region. The `user_api` parameter is + ignored. - If None, this function does not do anything.