From 7af9bd01df3cc598d1b5fc715dbec81149d60141 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Tue, 15 Sep 2026 11:33:59 -0400 Subject: [PATCH 01/12] Sketch of caching. --- threadpoolctl.py | 204 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 142 insertions(+), 62 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 1caa1906..f5986ec7 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -11,6 +11,8 @@ # adapted from code by Intel developer @anton-malakhov available at # https://github.com/IntelPython/smp (Copyright (c) 2017, Intel Corporation) # and also published under the BSD 3-Clause license +from __future__ import annotations + import os import re import sys @@ -21,6 +23,7 @@ from typing import Callable, Literal, final import warnings from abc import ABC, abstractmethod +from dataclasses import dataclass, field from functools import lru_cache from contextlib import ContextDecorator @@ -198,7 +201,7 @@ def __init__(self, *, filepath=None, prefix=None, parent=None): self.parent = parent self.prefix = prefix self.filepath = filepath - self.dynlib = ctypes.CDLL(filepath, mode=_RTLD_NOLOAD) + self.dynlib = _CDLL_CACHE.get_cdll(filepath) self._symbol_prefix, self._symbol_suffix = self._find_affixes() self.version = self.get_version() self.set_additional_attributes() @@ -264,6 +267,137 @@ def _get_symbol(self, name): ) +# Internal marker for missing cache lookups: +_MISSING = object() + + +@dataclass +class _CachingCDLL: + """Wrap a ``CDLL``, caching attributes.""" + + _cdll: ctypes.CDLL + _cache: dict[str, ctypes._CFuncPtr | None] = field(default_factory=dict) + + def __getattr__(self, symbol: str) -> ctypes._CFuncPtr | None: + result = self._cache.get(symbol, _MISSING) + if result is _MISSING: + result = getattr(self._cdll, symbol, None) + self._cache[symbol] = result + if result is None: + raise AttributeError(f"{self._cdll._name}: undefined attribute {symbol}") + return result + + +@dataclass +class _CDLLCache: + """Cache CDLL instances and their associated functions.""" + + _controller_cache: dict[str, type[LibController] | None] = field( + default_factory=dict + ) + + # Map filepath to tuple (CDLL if any, normalized path, prefix): + _cdll_cache: dict[str, tuple[ctypes.CDLL | None, str, str]] = field( + default_factory=dict + ) + + def _check_prefix( + self, library_basename: str, filename_prefixes: list[str] + ) -> str | None: + """Return the prefix library_basename starts with + + Return None if none matches. + """ + for prefix in filename_prefixes: + if library_basename.startswith(prefix): + return prefix + return None + + def create_controller( + self, filepath: str, parent: ThreadpoolController + ) -> LibController | None: + """Create the associated controller, if there is one.""" + result = self._controller_cache.get(filepath, _MISSING) + if result is not _MISSING: + controller_class, filepath, prefix = result + if controller_class is None: + return None + return controller_class(filepath=filepath, prefix=prefix, parent=parent) + + # It's not in the cache, so proceed to actually search for it. + + # Required to resolve symlinks + original_filepath = filepath + filepath = _realpath(filepath) + # `lower` required to take account of OpenMP dll case on Windows + # (vcomp, VCOMP, Vcomp, ...) + filename = os.path.basename(filepath).lower() + + # Loop through supported libraries to find if this filename corresponds + # to a supported one. + for controller_class in _ALL_CONTROLLERS: + # check if filename matches a supported prefix + prefix = self._check_prefix(filename, controller_class.filename_prefixes) + + # filename does not match any of the prefixes of the candidate + # library. move to next library. + if prefix is None: + continue + + # Legacy workaround for BLAS libraries that conda-forge used to expose + # on Windows as libblas.dll, disambiguated via implementation-specific + # symbols. Current conda-forge stacks no longer load libblas.dll (e.g. + # MKL is exposed as mkl_rt..dll instead), so this path is + # kept for older installs but cannot be exercised in today's CI. + if prefix == "libblas": + if filename.endswith(".dll"): + libblas = ctypes.CDLL(filepath, _RTLD_NOLOAD) + if not any( + hasattr(libblas, func) + for func in controller_class.check_symbols + ): + continue + else: + # Non-Windows libblas DSOs (e.g. from openblas) lack the symbols + # needed to instantiate a controller and would duplicate entries. + continue + + # filename matches a prefix. Now we check if the library has the symbols we + # are looking for. If none of the symbols exists, it's very likely not the + # expected library (e.g. a library having a common prefix with one of the + # our supported libraries). Otherwise, create and store the library + # controller. + lib_controller = controller_class( + filepath=filepath, prefix=prefix, parent=parent + ) + + if not hasattr(controller_class, "check_symbols") or any( + hasattr(lib_controller.dynlib, func) + for func in controller_class.check_symbols + ): + self._controller_cache[original_filepath] = ( + controller_class, + filepath, + prefix, + ) + return lib_controller + + # Didn't find any matching libraries. + self._controller_cache[original_filepath] = (None, "", "") + return None + + def get_cdll(self, filepath: str) -> _CachingCDLL: + """Get the ``CDLL`` for a path, loading if necessary.""" + result = self._cdll_cache.get(filepath, _MISSING) + if result is _MISSING: + result = ctypes.CDLL(filepath, mode=_RTLD_NOLOAD) + self._cdll_cache[filepath] = result + return result + + +_CDLL_CACHE = _CDLLCache() + + class OpenBLASController(LibController): """Controller class for OpenBLAS""" @@ -1559,69 +1693,15 @@ def _find_libraries_pyodide(self): def _make_controller_from_path(self, filepath): """Store a library controller if it is supported and selected""" - # Required to resolve symlinks - filepath = _realpath(filepath) - # `lower` required to take account of OpenMP dll case on Windows - # (vcomp, VCOMP, Vcomp, ...) - filename = os.path.basename(filepath).lower() - - # Loop through supported libraries to find if this filename corresponds - # to a supported one. - for controller_class in _ALL_CONTROLLERS: - # check if filename matches a supported prefix - prefix = self._check_prefix(filename, controller_class.filename_prefixes) - - # filename does not match any of the prefixes of the candidate - # library. move to next library. - if prefix is None: - continue - - # Legacy workaround for BLAS libraries that conda-forge used to expose - # on Windows as libblas.dll, disambiguated via implementation-specific - # symbols. Current conda-forge stacks no longer load libblas.dll (e.g. - # MKL is exposed as mkl_rt..dll instead), so this path is - # kept for older installs but cannot be exercised in today's CI. - if prefix == "libblas": - if filename.endswith(".dll"): - libblas = ctypes.CDLL(filepath, _RTLD_NOLOAD) - if not any( - hasattr(libblas, func) - for func in controller_class.check_symbols - ): - continue - else: - # Non-Windows libblas DSOs (e.g. from openblas) lack the symbols - # needed to instantiate a controller and would duplicate entries. - continue - - # filename matches a prefix. Now we check if the library has the symbols we - # are looking for. If none of the symbols exists, it's very likely not the - # expected library (e.g. a library having a common prefix with one of the - # our supported libraries). Otherwise, create and store the library - # controller. - lib_controller = controller_class( - filepath=filepath, prefix=prefix, parent=self - ) - - if filepath in (lib.filepath for lib in self.lib_controllers): - # We already have a controller for this library. - continue - - if not hasattr(controller_class, "check_symbols") or any( - hasattr(lib_controller.dynlib, func) - for func in controller_class.check_symbols + lib_controller = _CDLL_CACHE.create_controller(filepath, self) + if lib_controller is not None: + if lib_controller.filepath in ( + lib.filepath for lib in self.lib_controllers ): - self.lib_controllers.append(lib_controller) - - def _check_prefix(self, library_basename, filename_prefixes): - """Return the prefix library_basename starts with + # We already have a controller for this library. + return - Return None if none matches. - """ - for prefix in filename_prefixes: - if library_basename.startswith(prefix): - return prefix - return None + self.lib_controllers.append(lib_controller) def _warn_if_incompatible_openmp(self): """Raise a warning if llvm-OpenMP and intel-OpenMP are both loaded""" From 5a20a87f87b77b41f321dc96c92b5a88633f68b7 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 18 Sep 2026 09:35:28 -0400 Subject: [PATCH 02/12] Actually use the _CachingCDLL cache. --- threadpoolctl.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 583f2cd3..c249beeb 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -34,8 +34,7 @@ dllist = None if sys.platform != "emscripten" and ( # Python 3.15 doesn't have the CFUNCTYPE anymore: - sys.platform == "linux" - and sys.version_info[:2] >= (3, 15) + sys.platform == "linux" and sys.version_info[:2] >= (3, 15) ): try: from ctypes.util import dllist @@ -321,10 +320,7 @@ class _CDLLCache: default_factory=dict ) - # Map filepath to tuple (CDLL if any, normalized path, prefix): - _cdll_cache: dict[str, tuple[ctypes.CDLL | None, str, str]] = field( - default_factory=dict - ) + _cdll_cache: dict[str, _CachingCDLL] = field(default_factory=dict) def _check_prefix( self, library_basename: str, filename_prefixes: list[str] @@ -419,7 +415,7 @@ def get_cdll(self, filepath: str) -> _CachingCDLL: """Get the ``CDLL`` for a path, loading if necessary.""" result = self._cdll_cache.get(filepath, _MISSING) if result is _MISSING: - result = ctypes.CDLL(filepath, mode=_RTLD_NOLOAD) + result = _CachingCDLL(ctypes.CDLL(filepath, mode=_RTLD_NOLOAD)) self._cdll_cache[filepath] = result return result From ecc982a706b121eef9f83465179e90291bcb76ac Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 18 Sep 2026 10:30:35 -0400 Subject: [PATCH 03/12] Cache idempotent methods. --- threadpoolctl.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index c249beeb..e250d4a4 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -20,11 +20,11 @@ import itertools import textwrap from threading import Thread -from typing import Callable, Literal, final +from typing import Callable, Literal, TypeVar, final import warnings from abc import ABC, abstractmethod from dataclasses import dataclass, field -from functools import lru_cache +from functools import lru_cache, wraps from contextlib import ContextDecorator # ctypes.util is not imported on Linux: on CPython 3.14 it allocates a @@ -312,6 +312,9 @@ def __getattr__(self, symbol: str) -> ctypes._CFuncPtr | None: return result +_T = TypeVar("_T") + + @dataclass class _CDLLCache: """Cache CDLL instances and their associated functions.""" @@ -322,6 +325,10 @@ class _CDLLCache: _cdll_cache: dict[str, _CachingCDLL] = field(default_factory=dict) + _method_result_cache: dict[ + tuple[type[LibController], str, CDLL], object + ] = field(default_factory=dict) + def _check_prefix( self, library_basename: str, filename_prefixes: list[str] ) -> str | None: @@ -419,6 +426,27 @@ def get_cdll(self, filepath: str) -> _CachingCDLL: self._cdll_cache[filepath] = result return result + def cache_method_on_dynlib( + self, method: Callable[[LibController], _T] + ) -> Callable[[LibController], _T]: + """ + Caching decorator for idempotent read-only methods of + ``LibController``. + """ + cache = self._method_result_cache + name = method.__name__ + + @wraps(method) + def wrapper(self): + key = (self.__class__, name, self.dynlib._cdll) + result = cache.get(key, _MISSING) + if result is _MISSING: + result = method(self) + cache[key] = result + return result + + return wrapper + _CDLL_CACHE = _CDLLCache() @@ -444,6 +472,7 @@ class OpenBLASController(LibController): for prefix, suffix in itertools.product(_symbol_prefixes, _symbol_suffixes) ) + @_CDLL_CACHE.cache_method_on_dynlib def _find_affixes(self): for prefix, suffix in itertools.product( self._symbol_prefixes, self._symbol_suffixes @@ -489,6 +518,7 @@ def set_num_threads(self, num_threads): return set_num_threads_func(num_threads) return None + @_CDLL_CACHE.cache_method_on_dynlib def get_version(self): # None means OpenBLAS is not loaded or version < 0.3.4, since OpenBLAS # did not expose its version before that. @@ -501,6 +531,7 @@ def get_version(self): return None return None + @_CDLL_CACHE.cache_method_on_dynlib def _get_threading_layer(self): """Return the threading layer of OpenBLAS""" get_threading_layer_func = self._get_symbol("openblas_get_parallel") @@ -513,6 +544,7 @@ def _get_threading_layer(self): return "disabled" return "unknown" + @_CDLL_CACHE.cache_method_on_dynlib def _get_architecture(self): """Return the architecture detected by OpenBLAS""" get_architecture_func = self._get_symbol("openblas_get_corename") @@ -558,6 +590,7 @@ def set_num_threads(self, num_threads): ) return set_func(num_threads) + @_CDLL_CACHE.cache_method_on_dynlib def get_version(self): get_version_ = getattr(self.dynlib, "bli_info_get_version_str", None) if get_version_ is None: @@ -566,6 +599,7 @@ def get_version(self): get_version_.restype = ctypes.c_char_p return get_version_().decode("utf-8") + @_CDLL_CACHE.cache_method_on_dynlib def _get_threading_layer(self): """Return the threading layer of BLIS""" if getattr(self.dynlib, "bli_info_get_enable_openmp", lambda: False)(): @@ -574,6 +608,7 @@ def _get_threading_layer(self): return "pthreads" return "disabled" + @_CDLL_CACHE.cache_method_on_dynlib def _get_architecture(self): """Return the architecture detected by BLIS""" bli_arch_query_id = getattr(self.dynlib, "bli_arch_query_id", None) @@ -637,6 +672,7 @@ def set_num_threads(self, num_threads): ) return set_func(num_threads) + @_CDLL_CACHE.cache_method_on_dynlib def get_version(self): get_version_ = getattr(self.dynlib, "flexiblas_get_version", None) if get_version_ is None: @@ -746,6 +782,7 @@ def set_num_threads(self, num_threads): ) return set_func(num_threads) + @_CDLL_CACHE.cache_method_on_dynlib def get_version(self): if not hasattr(self.dynlib, "MKL_Get_Version_String"): return None @@ -759,6 +796,7 @@ def get_version(self): version = group.groups()[0] return version.strip() + @_CDLL_CACHE.cache_method_on_dynlib def _get_threading_layer(self): """Return the threading layer of MKL""" # The function mkl_set_threading_layer returns the current threading From b54187f6f0772ea608ec4e66324be6a35991bb82 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 18 Sep 2026 10:40:17 -0400 Subject: [PATCH 04/12] Get rid of expensive dedent --- threadpoolctl.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index e250d4a4..86fb51d2 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -325,9 +325,9 @@ class _CDLLCache: _cdll_cache: dict[str, _CachingCDLL] = field(default_factory=dict) - _method_result_cache: dict[ - tuple[type[LibController], str, CDLL], object - ] = field(default_factory=dict) + _method_result_cache: dict[tuple[type[LibController], str, CDLL], object] = field( + default_factory=dict + ) def _check_prefix( self, library_basename: str, filename_prefixes: list[str] @@ -1144,6 +1144,17 @@ def wrap(cls, limits=None, user_api=None): return super().wrap(ThreadpoolController(), limits=limits, user_api=user_api) +_INCOMPATIBLE_OPENMP_MESSAGE = """ +Found Intel OpenMP ('libiomp') and LLVM OpenMP ('libomp') loaded at +the same time. Both libraries are known to be incompatible and this +can cause random crashes or deadlocks on Linux when loaded in the +same Python program. +Using threadpoolctl may cause crashes or deadlocks. For more +information and possible workarounds, please see + https://github.com/joblib/threadpoolctl/blob/master/multiple_openmp.md +""" + + class ThreadpoolController: """Collection of LibController objects for all loaded supported libraries @@ -1778,19 +1789,8 @@ def _warn_if_incompatible_openmp(self): return prefixes = [lib_controller.prefix for lib_controller in self.lib_controllers] - msg = textwrap.dedent( - """ - Found Intel OpenMP ('libiomp') and LLVM OpenMP ('libomp') loaded at - the same time. Both libraries are known to be incompatible and this - can cause random crashes or deadlocks on Linux when loaded in the - same Python program. - Using threadpoolctl may cause crashes or deadlocks. For more - information and possible workarounds, please see - https://github.com/joblib/threadpoolctl/blob/master/multiple_openmp.md - """ - ) if "libomp" in prefixes and "libiomp" in prefixes: - warnings.warn(msg, RuntimeWarning) + warnings.warn(_INCOMPATIBLE_OPENMP_MESSAGE, RuntimeWarning) @classmethod def _get_libc(cls): From d1613a6c4109afe1821d0b3f4ac725b06b97fd2a Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 21 Sep 2026 08:47:19 -0400 Subject: [PATCH 05/12] Changelog entries --- CHANGES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index d7a4bb27..1a5ec1af 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,16 @@ in package metadata. https://github.com/joblib/threadpoolctl/issues/251 +- Faster performance when creating a `ThreadpoolController`. + https://github.com/joblib/threadpoolctl/pull/249 + +- Shared libraries are now cached across runs, whereas previously they would be + unloaded after the `ThreadpoolController` was garbage collected. + https://github.com/joblib/threadpoolctl/pull/249 + +- A single shared library can no longer have multiple `LibController` instances. + https://github.com/joblib/threadpoolctl/pull/249 + 3.7.0 (2026-09-15) ================== From 31d61fd9f945b5c5094bf5c548399fba9bd5d88f Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 21 Sep 2026 09:11:20 -0400 Subject: [PATCH 06/12] Some tests for caching. --- tests/test_cache.py | 67 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/test_cache.py diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 00000000..545dbf94 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,67 @@ +"""Tests for ``threadpoolctl``'s internal caching layer.""" + +from ctypes import CDLL + +import pytest + +from threadpoolctl import _CachingCDLL, _CDLLCache, ThreadpoolController + + +class FakeCDLL: + """A stand-in for a real ``CDLL``.""" + + @property + def real_attr(self): + return (object(), 123) + + +def test_caching_cdll(): + """ + Attributes of a ``_CachingCDLL`` are retrieved from the wrapped ``CDLL`` + only once. + """ + fake_dll = FakeCDLL() + # The attribute is created from scratch each time: + assert fake_dll.real_attr is not fake_dll.real_attr + + # But not when cached! + python_cache = _CachingCDLL(fake_dll) + assert python_cache.real_attr is python_cache.real_attr + assert python_cache.real_attr[1] == 123 + + with pytest.raises(AttributeError): + python_cache.none_such + + assert not hasattr(python_cache, "none_such") + + +def test_cdlls_are_cached(): + """ + When a ``LibController`` is created, it reuses the same ``CDLL``. + """ + pytest.importorskip("numpy") + + controller = ThreadpoolController() + cached_cdll = controller.lib_controllers[0].dynlib + assert isinstance(cached_cdll, _CachingCDLL) + assert isinstance(cached_cdll._cdll, CDLL) + + controller2 = ThreadpoolController() + assert cached_cdll._cdll is controller2.lib_controllers[0].dynlib._cdll + + +def test_cache_methods_on_dynlib(): + """ + ``_CDLLCache.cache_method_on_dynlib()`` caches the result, tied to the + underlying ``CDLL`` as an invalidation key. + """ + pytest.importorskip("numpy") + + # We assume all BLAS libs have ``get_version()`` wrapped with + # ``cache_method_on_dynlib()``, which is currently the case. + controller = ThreadpoolController() + libs = controller.select(user_api="blas").lib_controllers + assert libs[0].get_version() is libs[0].get_version() + + # Access underlying, uncached get_version(): + assert libs[0].get_version.__wrapped__(libs[0]) is not libs[0].get_version() From 0e420a0359c6004e8dcdc628fa05ae13f24c4782 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 21 Sep 2026 09:35:35 -0400 Subject: [PATCH 07/12] Reformat --- threadpoolctl.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 86fb51d2..1ec8e499 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -34,7 +34,8 @@ dllist = None if sys.platform != "emscripten" and ( # Python 3.15 doesn't have the CFUNCTYPE anymore: - sys.platform == "linux" and sys.version_info[:2] >= (3, 15) + sys.platform == "linux" + and sys.version_info[:2] >= (3, 15) ): try: from ctypes.util import dllist From e33417998f3faba646d35496015e25d6e3f2a80b Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 21 Sep 2026 09:43:36 -0400 Subject: [PATCH 08/12] Higher resolution benchmark, now that it's faster. --- benchmarks/bench_context_manager_overhead.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/bench_context_manager_overhead.py b/benchmarks/bench_context_manager_overhead.py index d3b69c15..dc27a2a9 100644 --- a/benchmarks/bench_context_manager_overhead.py +++ b/benchmarks/bench_context_manager_overhead.py @@ -27,4 +27,4 @@ pass timings.append(time.time() - t) -print(f"Overhead per call: {mean(timings) * 1e3:.3f} +/-{stdev(timings) * 1e3:.3f} ms") +print(f"Overhead per call: {mean(timings) * 1e3:.4f} +/-{stdev(timings) * 1e3:.4f} ms") From caa39ca1bc3e39f4eb363d4386b66e618be87f6e Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 21 Sep 2026 09:43:52 -0400 Subject: [PATCH 09/12] Remove attribute cache, not worth it. --- tests/test_cache.py | 35 +++-------------------------------- threadpoolctl.py | 25 ++++--------------------- 2 files changed, 7 insertions(+), 53 deletions(-) diff --git a/tests/test_cache.py b/tests/test_cache.py index 545dbf94..8355be56 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -4,35 +4,7 @@ import pytest -from threadpoolctl import _CachingCDLL, _CDLLCache, ThreadpoolController - - -class FakeCDLL: - """A stand-in for a real ``CDLL``.""" - - @property - def real_attr(self): - return (object(), 123) - - -def test_caching_cdll(): - """ - Attributes of a ``_CachingCDLL`` are retrieved from the wrapped ``CDLL`` - only once. - """ - fake_dll = FakeCDLL() - # The attribute is created from scratch each time: - assert fake_dll.real_attr is not fake_dll.real_attr - - # But not when cached! - python_cache = _CachingCDLL(fake_dll) - assert python_cache.real_attr is python_cache.real_attr - assert python_cache.real_attr[1] == 123 - - with pytest.raises(AttributeError): - python_cache.none_such - - assert not hasattr(python_cache, "none_such") +from threadpoolctl import ThreadpoolController def test_cdlls_are_cached(): @@ -43,11 +15,10 @@ def test_cdlls_are_cached(): controller = ThreadpoolController() cached_cdll = controller.lib_controllers[0].dynlib - assert isinstance(cached_cdll, _CachingCDLL) - assert isinstance(cached_cdll._cdll, CDLL) + assert isinstance(cached_cdll, CDLL) controller2 = ThreadpoolController() - assert cached_cdll._cdll is controller2.lib_controllers[0].dynlib._cdll + assert cached_cdll is controller2.lib_controllers[0].dynlib def test_cache_methods_on_dynlib(): diff --git a/threadpoolctl.py b/threadpoolctl.py index 1ec8e499..6b9f6631 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -296,23 +296,6 @@ def _get_symbol(self, name): _MISSING = object() -@dataclass -class _CachingCDLL: - """Wrap a ``CDLL``, caching attributes.""" - - _cdll: ctypes.CDLL - _cache: dict[str, ctypes._CFuncPtr | None] = field(default_factory=dict) - - def __getattr__(self, symbol: str) -> ctypes._CFuncPtr | None: - result = self._cache.get(symbol, _MISSING) - if result is _MISSING: - result = getattr(self._cdll, symbol, None) - self._cache[symbol] = result - if result is None: - raise AttributeError(f"{self._cdll._name}: undefined attribute {symbol}") - return result - - _T = TypeVar("_T") @@ -324,7 +307,7 @@ class _CDLLCache: default_factory=dict ) - _cdll_cache: dict[str, _CachingCDLL] = field(default_factory=dict) + _cdll_cache: dict[str, CDLL] = field(default_factory=dict) _method_result_cache: dict[tuple[type[LibController], str, CDLL], object] = field( default_factory=dict @@ -419,11 +402,11 @@ def create_controller( self._controller_cache[original_filepath] = (None, "", "") return None - def get_cdll(self, filepath: str) -> _CachingCDLL: + def get_cdll(self, filepath: str) -> CDLL: """Get the ``CDLL`` for a path, loading if necessary.""" result = self._cdll_cache.get(filepath, _MISSING) if result is _MISSING: - result = _CachingCDLL(ctypes.CDLL(filepath, mode=_RTLD_NOLOAD)) + result = ctypes.CDLL(filepath, mode=_RTLD_NOLOAD) self._cdll_cache[filepath] = result return result @@ -439,7 +422,7 @@ def cache_method_on_dynlib( @wraps(method) def wrapper(self): - key = (self.__class__, name, self.dynlib._cdll) + key = (self.__class__, name, self.dynlib) result = cache.get(key, _MISSING) if result is _MISSING: result = method(self) From fe031590541a799b6733a27fa175d8db4567e29c Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 21 Sep 2026 09:50:41 -0400 Subject: [PATCH 10/12] Skip if no libraries --- tests/test_cache.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_cache.py b/tests/test_cache.py index 8355be56..b787b365 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -14,6 +14,9 @@ def test_cdlls_are_cached(): pytest.importorskip("numpy") controller = ThreadpoolController() + if not controller.lib_controllers: + pytest.skip("No libraries loaded") + cached_cdll = controller.lib_controllers[0].dynlib assert isinstance(cached_cdll, CDLL) @@ -32,6 +35,9 @@ def test_cache_methods_on_dynlib(): # ``cache_method_on_dynlib()``, which is currently the case. controller = ThreadpoolController() libs = controller.select(user_api="blas").lib_controllers + if not libs: + pytest.skip("No libraries loaded") + assert libs[0].get_version() is libs[0].get_version() # Access underlying, uncached get_version(): From a21885a2b741823c88164fc1c856f75412fea0ab Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 21 Sep 2026 09:55:46 -0400 Subject: [PATCH 11/12] Better docs --- threadpoolctl.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/threadpoolctl.py b/threadpoolctl.py index 6b9f6631..f34ce41a 100644 --- a/threadpoolctl.py +++ b/threadpoolctl.py @@ -301,16 +301,19 @@ def _get_symbol(self, name): @dataclass class _CDLLCache: - """Cache CDLL instances and their associated functions.""" + """ + Cache CDLL instances and their associated method results, as well as which + ``LibController`` subclass to use for a given shared library file path. + """ _controller_cache: dict[str, type[LibController] | None] = field( default_factory=dict ) - _cdll_cache: dict[str, CDLL] = field(default_factory=dict) + _cdll_cache: dict[str, ctypes.CDLL] = field(default_factory=dict) - _method_result_cache: dict[tuple[type[LibController], str, CDLL], object] = field( - default_factory=dict + _method_result_cache: dict[tuple[type[LibController], str, ctypes.CDLL], object] = ( + field(default_factory=dict) ) def _check_prefix( @@ -328,7 +331,10 @@ def _check_prefix( def create_controller( self, filepath: str, parent: ThreadpoolController ) -> LibController | None: - """Create the associated controller, if there is one.""" + """ + Create the associated controller, if there is one, relying on cached + info for the given ``filepath``. + """ result = self._controller_cache.get(filepath, _MISSING) if result is not _MISSING: controller_class, filepath, prefix = result @@ -403,7 +409,10 @@ def create_controller( return None def get_cdll(self, filepath: str) -> CDLL: - """Get the ``CDLL`` for a path, loading if necessary.""" + """ + Get the ``CDLL`` for a path, loading if necessary, using a cached + version if it was already loaded. + """ result = self._cdll_cache.get(filepath, _MISSING) if result is _MISSING: result = ctypes.CDLL(filepath, mode=_RTLD_NOLOAD) @@ -415,7 +424,8 @@ def cache_method_on_dynlib( ) -> Callable[[LibController], _T]: """ Caching decorator for idempotent read-only methods of - ``LibController``. + ``LibController``, with the cache shared across instances that have the + same ``CDLL`` instance. """ cache = self._method_result_cache name = method.__name__ From 3f2556502b5a9e6f1f549cef9766ce383ea79f5a Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 21 Sep 2026 12:54:03 -0400 Subject: [PATCH 12/12] More robust test --- tests/test_cache.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/test_cache.py b/tests/test_cache.py index b787b365..39d8fd96 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -4,7 +4,7 @@ import pytest -from threadpoolctl import ThreadpoolController +from threadpoolctl import ThreadpoolController, _CDLL_CACHE def test_cdlls_are_cached(): @@ -24,21 +24,36 @@ def test_cdlls_are_cached(): assert cached_cdll is controller2.lib_controllers[0].dynlib -def test_cache_methods_on_dynlib(): +def test_cache_methods_on_dynlib(request): """ ``_CDLLCache.cache_method_on_dynlib()`` caches the result, tied to the underlying ``CDLL`` as an invalidation key. """ pytest.importorskip("numpy") - # We assume all BLAS libs have ``get_version()`` wrapped with - # ``cache_method_on_dynlib()``, which is currently the case. controller = ThreadpoolController() libs = controller.select(user_api="blas").lib_controllers if not libs: pytest.skip("No libraries loaded") - assert libs[0].get_version() is libs[0].get_version() + @_CDLL_CACHE.cache_method_on_dynlib + def my_extra_method(self): + return object() - # Access underlying, uncached get_version(): - assert libs[0].get_version.__wrapped__(libs[0]) is not libs[0].get_version() + # Can't use pytest's monkeypatch since this method doesn't already exist, + # so add it manually: + libs[0].__class__.my_extra_method = my_extra_method + + def cleanup(): + del libs[0].__class__.my_extra_method + + request.addfinalizer(cleanup) + + # Accessing underlying, uncached method returns different objects each time: + initial = libs[0].my_extra_method() + assert libs[0].my_extra_method.__wrapped__(libs[0]) is not initial + assert libs[0].my_extra_method.__wrapped__(libs[0]) is not initial + + # Only one call should ever happen when using the cached method, however: + assert libs[0].my_extra_method() is initial + assert libs[0].my_extra_method() is initial