-
Notifications
You must be signed in to change notification settings - Fork 37
Thread limit introspection API, part 1: API scope #213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
28bd246
128c4ef
8637248
6946867
31bbcf5
f27e6f2
0d4dde5
60dd11c
2c985b6
bb7b528
7fee2dc
1c6ae79
9ad8382
d306c55
8740d13
12a9d96
95d77a0
3c831ad
db35024
b076937
a686a9b
fa3be78
0e44183
586809d
5899f47
8d6bee5
3bc6b79
a9f684a
f409521
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| """ | ||
| Tests for get/set number of threads API introspection. | ||
| """ | ||
|
|
||
| import sys | ||
| from threading import local as threadlocal | ||
| from typing import Callable | ||
|
|
||
| import pytest | ||
|
|
||
| from threadpoolctl import ( | ||
| _ThreadLimitScope, | ||
| LibController, | ||
| _determine_thread_limit_scope, | ||
| ThreadpoolController, | ||
| ) | ||
|
|
||
| # Make sure we have some BLAS libraries loaded: | ||
| from . import utils as _ | ||
|
|
||
|
|
||
| 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_thread_limit_scope_thread_local() -> None: | ||
| """ | ||
| Check ``_determine_thread_limit_scope()`` can correctly diagnose a trivial | ||
| thread-local implementation. | ||
| """ | ||
| api = FakeThreadLocalAPI() | ||
| assert ( | ||
| _determine_thread_limit_scope(api.get, api.set) | ||
| == _ThreadLimitScope.CURRENT_THREAD | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("default", [1, 17]) | ||
| def test_determine_thread_limit_scope_processwide(default: int) -> None: | ||
| """ | ||
| Check ``_determine_thread_limit_scope()`` can correctly diagnose a trivial | ||
| process-wide implementation. | ||
| """ | ||
| api = FakeProcesswideAPI(default) | ||
| assert _determine_thread_limit_scope(api.get, api.set) == _ThreadLimitScope.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"], | ||
| [ | ||
| ( | ||
| {"internal_api": "openblas"}, | ||
| _ThreadLimitScope.PROCESS, | ||
| # Different backends have different behavior; on Linux OpenMP is | ||
| # thread-local, for example, for libgomp/libomp at least. Since it | ||
| # depends on the specific OpenMP behavior, and since the goal here | ||
| # iin any case is to test _determine_thread_limit_scope()'s API | ||
| # specific ability to detect process-scoped APIs, we only check | ||
| # pthreads here. | ||
| lambda lib: lib.threading_layer == "pthreads", | ||
| ), | ||
| ({"user_api": "openmp"}, _ThreadLimitScope.CURRENT_THREAD, lambda _lib: True), | ||
| ], | ||
| ) | ||
| def test_api_scope( | ||
| select_filter: dict[str, str], | ||
| expected_thread_limit_scope: str, | ||
| extra_check: Callable[[LibController], bool], | ||
| ) -> 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: | ||
| if not extra_check(lib): | ||
| pytest.skip("extra check returned false") | ||
| assert ( | ||
| _determine_thread_limit_scope(lib.get_num_threads, lib.set_num_threads) | ||
| == expected_thread_limit_scope | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,12 +17,14 @@ | |
| 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__ = [ | ||
|
|
@@ -69,6 +71,91 @@ class _dl_phdr_info(ctypes.Structure): | |
| _RTLD_NOLOAD = ctypes.DEFAULT_MODE | ||
|
|
||
|
|
||
| class _ThreadLimitScope(Enum): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feels quite complex for a three string returns that are setup only once in the code. |
||
| """ | ||
| What scope does the API affect. | ||
| """ | ||
|
|
||
| # 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. | ||
| PROCESS = auto() | ||
| # Something else, unexpected; perhaps another variant, perhaps information | ||
| # can't be determined under the current configuration. | ||
| UNKNOWN = auto() | ||
|
|
||
|
|
||
| def _determine_thread_limit_scope( | ||
| get_n_threads: Callable[[], int], set_n_threads: Callable[[int], None] | ||
| ) -> _ThreadLimitScope: | ||
| """ | ||
| Run some experiments to determine the scope of the given get/set API. | ||
|
|
||
| This function might not 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". | ||
|
ogrisel marked this conversation as resolved.
|
||
| """ | ||
| 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. 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: | ||
| if previous < 2: | ||
| expected = 2 | ||
|
itamarst marked this conversation as resolved.
|
||
| 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 | ||
| # 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 _ThreadLimitScope.UNKNOWN | ||
|
|
||
| # Now, check this thread: | ||
| if get_n_threads() == expected: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would be interesting on the thread local setup to check that the return of get_n_theads is also the previous one, as there could be border effects of initiating a new thread? But maybe that's plan in follow ups |
||
| # Setting modified this thread's results too: | ||
| return _ThreadLimitScope.PROCESS | ||
| elif get_n_threads() == previous: | ||
| # Setting modified the other thread, but not this one: | ||
| return _ThreadLimitScope.CURRENT_THREAD | ||
| else: | ||
| # No idea what's going on: | ||
| return _ThreadLimitScope.UNKNOWN | ||
| finally: | ||
| set_n_threads(previous) | ||
|
|
||
|
|
||
| class LibController(ABC): | ||
| """Abstract base class for the individual library controllers | ||
|
|
||
|
|
@@ -116,15 +203,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, debugging_info: bool = False): | ||
| """Return relevant info wrapped in a dict. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| debugging_info : bool | ||
|
|
||
| Include extra fields which require more intrusive actions to | ||
| obtain, and which can't always reliably be determined. | ||
| """ | ||
| 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, | ||
| **{k: v for k, v in vars(self).items() if k not in hidden_attrs}, | ||
| } | ||
| if debugging_info: | ||
| result["thread_limit_scope"] = _determine_thread_limit_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""" | ||
|
|
@@ -325,11 +425,11 @@ def loaded_backends(self): | |
| def current_backend(self): | ||
| return self._get_current_backend() | ||
|
|
||
| def info(self): | ||
| def info(self, debugging_info: bool = False): | ||
| """Return relevant info wrapped in a dict""" | ||
| # We override the info method because the loaded and current backends | ||
| # are dynamic properties | ||
| exposed_attrs = super().info() | ||
| exposed_attrs = super().info(debugging_info=debugging_info) | ||
| exposed_attrs["loaded_backends"] = self.loaded_backends | ||
| exposed_attrs["current_backend"] = self.current_backend | ||
|
|
||
|
|
@@ -560,7 +660,7 @@ def _realpath(filepath): | |
|
|
||
|
|
||
| @_format_docstring(USER_APIS=list(_ALL_USER_APIS), INTERNAL_APIS=_ALL_INTERNAL_APIS) | ||
| def threadpool_info(): | ||
| def threadpool_info(debugging_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 | ||
|
|
@@ -574,8 +674,17 @@ def threadpool_info(): | |
| - "num_threads": the current thread limit. | ||
|
|
||
| In addition, each library may contain internal_api specific entries. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| debugging_info : bool | ||
| Include extra fields which require more intrusive actions to obtain, | ||
| and which can't always reliably be determined. | ||
|
|
||
| - "thread_limit_scope": When setting the number of threads, what is | ||
| affected. Possible values are "process", "current_thread", "unknown". | ||
| """ | ||
|
itamarst marked this conversation as resolved.
|
||
| return ThreadpoolController().info() | ||
| return ThreadpoolController().info(debugging_info) | ||
|
|
||
|
|
||
| class _ThreadpoolLimiter: | ||
|
|
@@ -835,9 +944,19 @@ 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, debugging_info: bool = False): | ||
| """Return lib_controllers info as a list of dicts. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| debugging_info : bool | ||
| Include extra fields which require more intrusive actions to | ||
| obtain, and which can't always reliably be determined. | ||
| """ | ||
| return [ | ||
| lib_controller.info(debugging_info=debugging_info) | ||
| for lib_controller in self.lib_controllers | ||
| ] | ||
|
|
||
| def select(self, **kwargs): | ||
| """Return a ThreadpoolController containing a subset of its current | ||
|
|
@@ -1306,7 +1425,7 @@ def _main(): | |
| if options.command: | ||
| exec(options.command) | ||
|
|
||
| print(json.dumps(threadpool_info(), indent=2)) | ||
| print(json.dumps(threadpool_info(debugging_info=True), indent=2)) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.