diff --git a/CHANGES.md b/CHANGES.md index 7317c063..6fd0f074 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,6 +14,7 @@ limit APIs will automatically call that API when they are done. https://github.com/joblib/threadpoolctl/pull/275 https://github.com/joblib/threadpoolctl/pull/289 + https://github.com/joblib/threadpoolctl/pull/294 - Add a new API to get a cached `ThreadpoolController` instance (`get_cached_controller()`), as an optimization for cases where limits are set @@ -50,6 +51,15 @@ - Dropped official support for Python 3.9. https://github.com/joblib/threadpoolctl/pull/255 +- Fixed recursive `@threadpool_limits.wrap()` calls so they restore limits + correctly. + https://github.com/joblib/threadpoolctl/pull/294 + +- Fixed `@threadpool_limits.wrap()` so it can use third-party libraries that + were registered or imported after the function was decorated. + https://github.com/joblib/threadpoolctl/pull/294 + + 3.7.0 (2026-09-15) ================== diff --git a/README.md b/README.md index fcc26221..30cf0735 100644 --- a/README.md +++ b/README.md @@ -1,705 +1,731 @@ -# Thread-pool Controls [](https://github.com/joblib/threadpoolctl/actions?query=branch%3Amaster) [](https://codecov.io/gh/joblib/threadpoolctl) - -Python helpers to limit the number of threads used in the -threadpool-backed of common native libraries used for scientific -computing and data science (e.g. BLAS and OpenMP). - -Fine control of the underlying thread-pool size can be useful in -workloads that involve nested parallelism so as to mitigate -oversubscription issues. - -Note that "limiting the number of threads" in practice involves a variety of -potential semantics depending on the underlying third-party library; see the -section on [semantics](#semantics) below. - -## Installation - -- For users, install the last published version from PyPI: - - ```bash - pip install threadpoolctl - ``` - -- For contributors, install from the source repository in developer - mode: - - ```bash - pip install -r dev-requirements.txt - flit install --symlink - ``` - - then you run the tests with pytest: - - ```bash - pytest - ``` - -## Usage: Introspection and debugging - -### Command Line Interface - -Get a JSON description of thread-pools initialized when importing python -packages such as numpy or scipy for instance: - -``` -python -m threadpoolctl -i numpy scipy.linalg -[ - { - "filepath": "/home/ogrisel/miniconda3/envs/tmp/lib/libmkl_rt.so", - "prefix": "libmkl_rt", - "user_api": "blas", - "internal_api": "mkl", - "version": "2019.0.4", - "num_threads": 2, - "threading_layer": "intel" - }, - { - "filepath": "/home/ogrisel/miniconda3/envs/tmp/lib/libiomp5.so", - "prefix": "libiomp", - "user_api": "openmp", - "internal_api": "openmp", - "version": null, - "num_threads": 4 - } -] -``` - -The JSON information is written on STDOUT. If some of the packages are missing, -a warning message is displayed on STDERR. - -### Python Runtime Programmatic Introspection - -Introspect the current state of the threadpool-enabled runtime libraries -that are loaded when importing Python packages: - -```python ->>> from threadpoolctl import threadpool_info ->>> from pprint import pprint ->>> pprint(threadpool_info()) -[] - ->>> import numpy ->>> pprint(threadpool_info()) -[{'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libmkl_rt.so', - 'internal_api': 'mkl', - 'num_threads': 2, - 'prefix': 'libmkl_rt', - 'threading_layer': 'intel', - 'user_api': 'blas', - 'version': '2019.0.4'}, - {'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libiomp5.so', - 'internal_api': 'openmp', - 'num_threads': 4, - 'prefix': 'libiomp', - 'user_api': 'openmp', - 'version': None}] - ->>> import xgboost ->>> pprint(threadpool_info()) -[{'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libmkl_rt.so', - 'internal_api': 'mkl', - 'num_threads': 2, - 'prefix': 'libmkl_rt', - 'threading_layer': 'intel', - 'user_api': 'blas', - 'version': '2019.0.4'}, - {'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libiomp5.so', - 'internal_api': 'openmp', - 'num_threads': 4, - 'prefix': 'libiomp', - 'user_api': 'openmp', - 'version': None}, - {'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libgomp.so.1.0.0', - 'internal_api': 'openmp', - 'num_threads': 4, - 'prefix': 'libgomp', - 'user_api': 'openmp', - 'version': None}] -``` - -In the above example, `numpy` was installed from the default anaconda channel and comes -with MKL and its Intel OpenMP (`libiomp5`) implementation while `xgboost` was installed -from pypi.org and links against GNU OpenMP (`libgomp`) so both OpenMP runtimes are -loaded in the same Python program. - -The state of these libraries is also accessible through the object oriented API: - -```python ->>> from threadpoolctl import ThreadpoolController, threadpool_info ->>> from pprint import pprint ->>> import numpy ->>> controller = ThreadpoolController() ->>> pprint(controller.info()) -[{'architecture': 'Haswell', - 'filepath': '/home/jeremie/miniconda/envs/dev/lib/libopenblasp-r0.3.17.so', - 'internal_api': 'openblas', - 'num_threads': 4, - 'prefix': 'libopenblas', - 'threading_layer': 'pthreads', - 'user_api': 'blas', - 'version': '0.3.17'}] - ->>> controller.info() == threadpool_info() -True -``` - -## Usage when not using Python threads: Restricting Controlled Library Thread Pool Sizes - +# Thread-pool Controls [](https://github.com/joblib/threadpoolctl/actions?query=branch%3Amaster) [](https://codecov.io/gh/joblib/threadpoolctl) + +Python helpers to limit the number of threads used in the +threadpool-backed of common native libraries used for scientific +computing and data science (e.g. BLAS and OpenMP). + +Fine control of the underlying thread-pool size can be useful in +workloads that involve nested parallelism so as to mitigate +oversubscription issues. + +Note that "limiting the number of threads" in practice involves a variety of +potential semantics depending on the underlying third-party library; see the +section on [semantics](#semantics) below. + +## Installation + +- For users, install the last published version from PyPI: + + ```bash + pip install threadpoolctl + ``` + +- For contributors, install from the source repository in developer + mode: + + ```bash + pip install -r dev-requirements.txt + flit install --symlink + ``` + + then you run the tests with pytest: + + ```bash + pytest + ``` + +## Usage: Introspection and debugging + +### Command Line Interface + +Get a JSON description of thread-pools initialized when importing python +packages such as numpy or scipy for instance: + +``` +python -m threadpoolctl -i numpy scipy.linalg +[ + { + "filepath": "/home/ogrisel/miniconda3/envs/tmp/lib/libmkl_rt.so", + "prefix": "libmkl_rt", + "user_api": "blas", + "internal_api": "mkl", + "version": "2019.0.4", + "num_threads": 2, + "threading_layer": "intel" + }, + { + "filepath": "/home/ogrisel/miniconda3/envs/tmp/lib/libiomp5.so", + "prefix": "libiomp", + "user_api": "openmp", + "internal_api": "openmp", + "version": null, + "num_threads": 4 + } +] +``` + +The JSON information is written on STDOUT. If some of the packages are missing, +a warning message is displayed on STDERR. + +### Python Runtime Programmatic Introspection + +Introspect the current state of the threadpool-enabled runtime libraries +that are loaded when importing Python packages: + +```python +>>> from threadpoolctl import threadpool_info +>>> from pprint import pprint +>>> pprint(threadpool_info()) +[] + +>>> import numpy +>>> pprint(threadpool_info()) +[{'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libmkl_rt.so', + 'internal_api': 'mkl', + 'num_threads': 2, + 'prefix': 'libmkl_rt', + 'threading_layer': 'intel', + 'user_api': 'blas', + 'version': '2019.0.4'}, + {'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libiomp5.so', + 'internal_api': 'openmp', + 'num_threads': 4, + 'prefix': 'libiomp', + 'user_api': 'openmp', + 'version': None}] + +>>> import xgboost +>>> pprint(threadpool_info()) +[{'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libmkl_rt.so', + 'internal_api': 'mkl', + 'num_threads': 2, + 'prefix': 'libmkl_rt', + 'threading_layer': 'intel', + 'user_api': 'blas', + 'version': '2019.0.4'}, + {'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libiomp5.so', + 'internal_api': 'openmp', + 'num_threads': 4, + 'prefix': 'libiomp', + 'user_api': 'openmp', + 'version': None}, + {'filepath': '/home/ogrisel/miniconda3/envs/tmp/lib/libgomp.so.1.0.0', + 'internal_api': 'openmp', + 'num_threads': 4, + 'prefix': 'libgomp', + 'user_api': 'openmp', + 'version': None}] +``` + +In the above example, `numpy` was installed from the default anaconda channel and comes +with MKL and its Intel OpenMP (`libiomp5`) implementation while `xgboost` was installed +from pypi.org and links against GNU OpenMP (`libgomp`) so both OpenMP runtimes are +loaded in the same Python program. + +The state of these libraries is also accessible through the object oriented API: + +```python +>>> from threadpoolctl import ThreadpoolController, threadpool_info +>>> from pprint import pprint +>>> import numpy +>>> controller = ThreadpoolController() +>>> pprint(controller.info()) +[{'architecture': 'Haswell', + 'filepath': '/home/jeremie/miniconda/envs/dev/lib/libopenblasp-r0.3.17.so', + 'internal_api': 'openblas', + 'num_threads': 4, + 'prefix': 'libopenblas', + 'threading_layer': 'pthreads', + 'user_api': 'blas', + 'version': '0.3.17'}] + +>>> controller.info() == threadpool_info() +True +``` + +## Usage when not using Python threads: Restricting Controlled Library Thread Pool Sizes + There are two scenarios in which you might want to use `threadpoolctl`; each -requires you to use different APIs. - -1. You do not expect to use any Python threads, so all the work will be started - directly from the main thread in the process. This is a simple case - where we can globally set thread limits. -2. You will be parallelizing work using a Python thread pool, and your goal is - therefore to limit controlled libraries' thread pool sizes when - concurrently called from Python threads. This case is a bit more - complex to handle properly and requires a bit more verbose code. - -This section will cover the former case, and the latter is covered in the next -usage section. - -### Setting the Maximum Size of Thread-Pools - -Control the number of threads used by the underlying runtime libraries -in specific sections of your Python program: - -```python ->>> from threadpoolctl import threadpool_limits ->>> import numpy as np - ->>> with threadpool_limits(limits=1, user_api='blas'): -... # In this block, calls to blas implementation (like openblas or MKL) -... # will be limited to use only one thread. They can thus be used jointly -... # with thread-parallelism. -... a = np.random.randn(1000, 1000) -... a_squared = a @ a -``` - -The threadpools can also be controlled via the object oriented API, which is -especially useful to avoid searching through all the loaded shared libraries -each time. That is, you can reuse a `ThreadpoolController` to reduce performance -overhead. **However, note that it will not act on libraries loaded after the -instantiation of the `ThreadpoolController`!** - -```python ->>> from threadpoolctl import ThreadpoolController ->>> import numpy as np ->>> controller = ThreadpoolController() - ->>> with controller.limit(limits=1, user_api='blas'): -... a = np.random.randn(1000, 1000) -... a_squared = a @ a -``` - -As a compromise between speed and catching newly loaded controllable libraries -you can use `get_cached_controller()`. This will notice any newly loaded -libraries triggered by importing new libraries, but might miss new libraries -loaded by compiled extensions directly using mechanisms like `dlopen()` on Linux -(as opposed to linked), or via `ctypes.CDLL`. - -```python -import numpy - -with get_cached_controller().limit(limits=1, user_api="blas"): - a = np.random.randn(1000, 1000) - a_squared = a @ a -``` - -To get a sense of how `get_cached_controller()` cache invalidation happens, -consider the following example: - -```python -from threadpoolctl import get_cached_controller - -# This ThreadpoolController will not know about the BLAS library loaded by -# NumPy, since it hasn't yet been imported: -controller = get_cached_controller() - -# Calling the API again doesn't create a new instance, so it's fast: -assert get_cached_controller() is controller - -import numpy as np - -# Now that NumPy has been imported, a new ThreadpoolController is returned that -# will know about NumPy's linked BLAS: -assert get_cached_controller() is not controller -``` - -You can clear the cached controller manually by running -``reset_cached_controller()``. - -### Restricting the Limits to the Scope of a Function - -`threadpool_limits` and `ThreadpoolController` can also be used as decorators to set -the maximum number of threads used by the supported libraries at a function level. The -decorators are accessible through their `wrap` method. - -```python ->>> from threadpoolctl import ThreadpoolController, threadpool_limits ->>> import numpy as np ->>> controller = ThreadpoolController() - ->>> @controller.wrap(limits=1, user_api='blas') -... # or @threadpool_limits.wrap(limits=1, user_api='blas') -... def my_func(): -... # Inside this function, calls to blas implementation (like openblas or MKL) -... # will be limited to use only one thread. -... a = np.random.randn(1000, 1000) -... a_squared = a @ a -... -``` - -## Usage for Python threads: Restricting Controlled Library Thread Pool Sizes - -This section covers APIs to use when you will be using Python thread pools to parallelize work. -The usage is more complicated than one might expect because the underlying APIs have a variety of different semantics, as [explained later in the docs](#semantics). - -### Setting the Maximum Size of Thread-Pools, When Python Thread Pools Are Used - -Limiting thread pool size in controlled libraries requires a two-step process. -**Importantly, each Python worker thread must also call a method to limit -controlled libraries in that thread.** With Python's -`concurrent.futures.ThreadPoolExecutor`, you can do so by passing in an -initializer function that will get called on thread startup. - -Whenever `threadpool_limits` is called, it creates a new `ThreadpoolController`, -which needs to do some work (inspecting and getting access to third-party shared -libraries) that can take some time. To prevent the performance cost of doing -this work in all the threads, you can reuse a `ThreadpoolController` object -across the threads. - -When setting limits, you want to set limits both in the main thread (in case you're using -OpenBLAS+pthreads) and in each worker thread. - -> **Why you need both:** When setting a limit on OpenBLAS with pthreads or the -> Windows threading layer, setting the limit affects the whole process, because -> it uses a process-wide shared thread pool. (This in contrast to some other -> BLAS/OpenMP libraries, or OpenBLAS backed by OpenMP, that allow setting the -> limit per-thread.) Having multiple Python threads set limits in this case -> makes no sense, since they would effectively be arguing over a globally shared -> resource. ``threadpoolctl`` therefore constrains the limiting API in the -> OpenBLAS pthreads/Windows case to only work from the first thread that uses -> it, the so-called controlling thread. -> -> For many other libraries, for example OpenMP, the limit is set per thread. As -> a result, you want to set limits both ways in generic code that doesn't know -> the underlying BLAS library: in main thread for the process-wide limits, and -> per-thread for thread-specific limits like OpenMP. - -```python -from threadpoolctl import ThreadpoolController - -# This won't have any side-effects. Because it caches its list of loaded -# libraries, you need to create a new one if you've imported or loaded any -# relevant libraries in the interim. So storing this on module level may not be -# a good idea if you e.g. only do `import numpy` later on. -controller = ThreadpoolController() - -with ( - # Limit on main thread. This is necessary because some underlying limiting - # APIs operate on a process-wide basis. Because it's a context manager, it - # will restore the limits after the ``with`` is done. - controller.limit(limits=1), - # Make sure each Python worker thread also calls threadpool_limits(). If - # you're using another thread pool class, you will need to do so some other - # way. - ThreadPoolExecutor(4, initializer=lambda: controller.limit(limits=1)) as pool, -): - # ... run some BLAS-using code in the thread pool ... - pool.map(somefunc, someargs) - -# Later... -controller = ThreadpoolController() -with ( - controller.limit(limits=2), - ThreadPoolExecutor(4, initializer=lambda: controller.limit(limits=2)) as pool, -): - # ... run some BLAS-using code in the thread pool ... - pool.map(somefunc, someargs) -``` - -You can also operate without a context manager: - -```python -from threadpoolctl import ThreadpoolController - -controller = ThreadpoolController() -try: - limiter = controller.limit(limits=1) - with ThreadPoolExecutor(4, initializer=lambda: controller.limit(limits=1)) as pool: - # ... run some BLAS-using code in the thread pool ... - pool.map(somefunc, someargs) -finally: - limiter.restore_original_limits() -``` - -### Switching Back And Forth Between Main Thread and Python Threads - -Unfortunately not all controlled libraries providing limiting APIs that are -thread-specific, as detailed in the section on [semantics](#semantics) below. -Limiting some libraries' thread pool sizes can therefore impact the whole -process. This makes switching back and forth between running code that -uses these libraries in Python threads and running it in the main thread a bit -more complex: you need to set the limits each time you switch back and forth. - -Let's say your computer has 4 cores, and you're using some OpenMP API. - -```python -POOL = ThreadPoolExecutor(4) -CONTROLLER = ThreadpoolController() - -# 1. Run some work in a Python thread pool (OpenMP effectively disabled). -with CONTROLLER.limit(limits=1): - - def limit_then_do_work(*args, **kwargs): - # Set a limit on OpenMP in the current thread: - CONTROLLER.limit(limits=1) - # Do the actual work: - return do_real_work_with_openmp(*args, **kwargs) - - results = POOL.map(limit_then_do_work, args) - - -# 2. Run some work with 4-threads OpenMP parallelism: -with CONTROLLER.limit(limits=4): - results2 = do_more_work_with_openmp(results) - - -# 3. Nest some OpenMP parallelism under Python-level parallelism: -with CONTROLLER.limit(limits=2): - - def limit_then_do_work2(*args, **kwargs): - CONTROLLER.limit(limits=2) - return do_even_more_real_work_with_openmp(*args, **kwargs) - - results3 = POOL.map(limit_then_do_work2, results2) -``` - -## Usage: Additional APIs and details - -### OpenBLAS with pthreads/Windows - -As mentioned above, OpenBLAS with pthreads or Windows threading layer uses a -process-wide shared thread pool. As a result, there are only two limits that -make sense in most circumstances: - -* A limit of `1`, which effectively means no OpenBLAS thread pool: all - operations run inline in the calling thread. This is a good setting when you - have a Python thread pool calling BLAS, and the number of Python threads is - close to the number of available cores. -* A limit equal to the number of available cores. This is a good setting when - you are calling BLAS from the main thread and there are no Python threads. - -While `threadpoolctl` does not limit you to these two values, you may wish to do -so yourself in your code. - -### Switching the FlexiBLAS backend - -`FlexiBLAS` is a BLAS wrapper for which the BLAS backend can be switched at runtime. -`threadpoolctl` exposes python bindings for this feature. Here's an example but note -that this part of the API is experimental and subject to change without deprecation: - -```python ->>> from threadpoolctl import ThreadpoolController ->>> import numpy as np ->>> controller = ThreadpoolController() - ->>> controller.info() -[{'user_api': 'blas', - 'internal_api': 'flexiblas', - 'num_threads': 1, - 'prefix': 'libflexiblas', - 'filepath': '/usr/local/lib/libflexiblas.so.3.3', - 'version': '3.3.1', - 'available_backends': ['NETLIB', 'OPENBLASPTHREAD', 'ATLAS'], - 'loaded_backends': ['NETLIB'], - 'current_backend': 'NETLIB'}] - -# Retrieve the flexiblas controller ->>> flexiblas_ct = controller.select(internal_api="flexiblas").lib_controllers[0] - -# Switch the backend with one predefined at build time (listed in "available_backends") ->>> flexiblas_ct.switch_backend("OPENBLASPTHREAD") ->>> controller.info() -[{'user_api': 'blas', - 'internal_api': 'flexiblas', - 'num_threads': 4, - 'prefix': 'libflexiblas', - 'filepath': '/usr/local/lib/libflexiblas.so.3.3', - 'version': '3.3.1', - 'available_backends': ['NETLIB', 'OPENBLASPTHREAD', 'ATLAS'], - 'loaded_backends': ['NETLIB', 'OPENBLASPTHREAD'], - 'current_backend': 'OPENBLASPTHREAD'}, - {'user_api': 'blas', - 'internal_api': 'openblas', - 'num_threads': 4, - 'prefix': 'libopenblas', - 'filepath': '/usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.8.so', - 'version': '0.3.8', - 'threading_layer': 'pthreads', - 'architecture': 'Haswell'}] - -# It's also possible to directly give the path to a shared library ->>> flexiblas_controller.switch_backend("/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libmkl_rt.so") ->>> controller.info() -[{'user_api': 'blas', - 'internal_api': 'flexiblas', - 'num_threads': 2, - 'prefix': 'libflexiblas', - 'filepath': '/usr/local/lib/libflexiblas.so.3.3', - 'version': '3.3.1', - 'available_backends': ['NETLIB', 'OPENBLASPTHREAD', 'ATLAS'], - 'loaded_backends': ['NETLIB', - 'OPENBLASPTHREAD', - '/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libmkl_rt.so'], - 'current_backend': '/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libmkl_rt.so'}, - {'user_api': 'openmp', - 'internal_api': 'openmp', - 'num_threads': 4, - 'prefix': 'libomp', - 'filepath': '/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libomp.so', - 'version': None}, - {'user_api': 'blas', - 'internal_api': 'openblas', - 'num_threads': 4, - 'prefix': 'libopenblas', - 'filepath': '/usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.8.so', - 'version': '0.3.8', - 'threading_layer': 'pthreads', - 'architecture': 'Haswell'}, - {'user_api': 'blas', - 'internal_api': 'mkl', - 'num_threads': 2, - 'prefix': 'libmkl_rt', - 'filepath': '/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libmkl_rt.so.2', - 'version': '2024.0-Product', - 'threading_layer': 'gnu'}] -``` - -You can observe that the previously linked OpenBLAS shared object stays loaded by -the Python program indefinitely, but FlexiBLAS itself no longer delegates BLAS calls -to OpenBLAS as indicated by the `current_backend` attribute. - -### Writing a custom library controller - -Currently, `threadpoolctl` has support for `OpenMP` and the main `BLAS` libraries. -However it can also be used to control the threadpool of other native libraries, -provided that they expose an API to get and set the limit on the number of threads. -For that, one must implement a controller for this library and register it to -`threadpoolctl`. - -A custom controller must be a subclass of the `LibController` class and implement -the attributes and methods described in the docstring of `LibController`. Then this -new controller class must be registered using the `threadpoolctl.register` function. -An complete example can be found [here]( - https://github.com/joblib/threadpoolctl/blob/master/tests/_pyMylib/__init__.py). - -### Sequential BLAS within OpenMP parallel region - -When one wants to have sequential BLAS calls within an OpenMP parallel region, it's -necessary to set `limits="sequential_blas_under_openmp"` since setting `limits=1` and -`user_api="blas"` leads to unexpected behavior for OpenBLAS with the OpenMP threading -layer: OpenBLAS and the calling code share the same OpenMP runtime, so it also sets -the number of OpenMP threads of the current thread to 1. Inside the limit, any OpenMP -parallel region started from this thread without an explicit number of threads runs -on a single thread, and `omp_get_max_threads()` returns 1. - -```python -from threadpoolctl import ThreadpoolController - -controller = ThreadpoolController() - -with controller.limit(limits="sequential_blas_under_openmp"): - # Compiled code with an OpenMP parallel loop that calls BLAS. - my_openmp_kernel(X) -``` - -### Known Limitations - -- `threadpool_limits` can fail to limit the number of inner threads when nesting - parallel loops managed by distinct OpenMP runtime implementations (for instance - libgomp from GCC and libomp from clang/llvm or libiomp from ICC). - - See the `test_openmp_nesting` function in [tests/test_threadpoolctl.py]( - https://github.com/joblib/threadpoolctl/blob/master/tests/test_threadpoolctl.py) - for an example. More information can be found at: - https://github.com/jeremiedbb/Nested_OpenMP - - Note however that this problem does not happen when `threadpool_limits` is - used to limit the number of threads used internally by BLAS calls that are - themselves nested under OpenMP parallel loops. `threadpool_limits` works as - expected, even if the inner BLAS implementation relies on a distinct OpenMP - implementation. - -- Using Intel OpenMP (ICC) and LLVM OpenMP (clang) in the same Python program - under Linux is known to cause problems. See the following guide for more details - and workarounds: - https://github.com/joblib/threadpoolctl/blob/master/multiple_openmp.md - -- Setting the maximum number of threads of the OpenMP and BLAS libraries has - inconsistent scope and semantics (thread-local vs process-wide) depending on - the underlying library. For more details see - https://github.com/joblib/threadpoolctl/issues/208 - - For example, if you're using OpenMP with libgomp (gcc) or libomp (clang), the - setting is thread-local and sets how many OpenMP threads will be started in - the current thread. On the other hand, with OpenBLAS with pthreads backend or - on Windows, the setting is process-wide and impacts the size of a process-wide - thread pool shared across all threads in the process. - +requires you to use different APIs. + +1. You do not expect to use any Python threads, so all the work will be started + directly from the main thread in the process. This is a simple case + where we can globally set thread limits. +2. You will be parallelizing work using a Python thread pool, and your goal is + therefore to limit controlled libraries' thread pool sizes when + concurrently called from Python threads. This case is a bit more + complex to handle properly and requires a bit more verbose code. + +This section will cover the former case, and the latter is covered in the next +usage section. + +### Setting the Maximum Size of Thread-Pools + +Control the number of threads used by the underlying runtime libraries +in specific sections of your Python program: + +```python +>>> from threadpoolctl import threadpool_limits +>>> import numpy as np + +>>> with threadpool_limits(limits=1, user_api='blas'): +... # In this block, calls to blas implementation (like openblas or MKL) +... # will be limited to use only one thread. They can thus be used jointly +... # with thread-parallelism. +... a = np.random.randn(1000, 1000) +... a_squared = a @ a +``` + +The threadpools can also be controlled via the object oriented API, which is +especially useful to avoid searching through all the loaded shared libraries +each time. That is, you can reuse a `ThreadpoolController` to reduce performance +overhead. **However, note that it will not act on libraries loaded after the +instantiation of the `ThreadpoolController`!** + +```python +>>> from threadpoolctl import ThreadpoolController +>>> import numpy as np +>>> controller = ThreadpoolController() + +>>> with controller.limit(limits=1, user_api='blas'): +... a = np.random.randn(1000, 1000) +... a_squared = a @ a +``` + +As a compromise between speed and catching newly loaded controllable libraries +you can use `get_cached_controller()`. This will notice any newly loaded +libraries triggered by importing new libraries, but might miss new libraries +loaded by compiled extensions directly using mechanisms like `dlopen()` on Linux +(as opposed to linked), or via `ctypes.CDLL`. + +```python +import numpy + +with get_cached_controller().limit(limits=1, user_api="blas"): + a = np.random.randn(1000, 1000) + a_squared = a @ a +``` + +To get a sense of how `get_cached_controller()` cache invalidation happens, +consider the following example: + +```python +from threadpoolctl import get_cached_controller + +# This ThreadpoolController will not know about the BLAS library loaded by +# NumPy, since it hasn't yet been imported: +controller = get_cached_controller() + +# Calling the API again doesn't create a new instance, so it's fast: +assert get_cached_controller() is controller + +import numpy as np + +# Now that NumPy has been imported, a new ThreadpoolController is returned that +# will know about NumPy's linked BLAS: +assert get_cached_controller() is not controller +``` + +You can clear the cached controller manually by running +``reset_cached_controller()``. + +### Restricting the Limits to the Scope of a Function + +`threadpool_limits` and `ThreadpoolController` can also be used as decorators to set +the maximum number of threads used by the supported libraries at a function level. The +decorators are accessible through their `wrap` method. + +```python +>>> from threadpoolctl import ThreadpoolController, threadpool_limits +>>> import numpy as np +>>> controller = ThreadpoolController() + +>>> @controller.wrap(limits=1, user_api='blas') +... # or @threadpool_limits.wrap(limits=1, user_api='blas') +... def my_func(): +... # Inside this function, calls to blas implementation (like openblas or MKL) +... # will be limited to use only one thread. +... a = np.random.randn(1000, 1000) +... a_squared = a @ a +... +``` + +## Usage for Python threads: Restricting Controlled Library Thread Pool Sizes + +This section covers APIs to use when you will be using Python thread pools to parallelize work. +The usage is more complicated than one might expect because the underlying APIs have a variety of different semantics, as [explained later in the docs](#semantics). + +### Setting the Maximum Size of Thread-Pools, When Python Thread Pools Are Used + +Limiting thread pool size in controlled libraries requires a two-step process. +**Importantly, each Python worker thread must also call a method to limit +controlled libraries in that thread.** With Python's +`concurrent.futures.ThreadPoolExecutor`, you can do so by passing in an +initializer function that will get called on thread startup. + +Whenever `threadpool_limits` is called, it creates a new `ThreadpoolController`, +which needs to do some work (inspecting and getting access to third-party shared +libraries) that can take some time. To prevent the performance cost of doing +this work in all the threads, you can reuse a `ThreadpoolController` object +across the threads. + +When setting limits, you want to set limits both in the main thread (in case you're using +OpenBLAS+pthreads) and in each worker thread. + +> **Why you need both:** When setting a limit on OpenBLAS with pthreads or the +> Windows threading layer, setting the limit affects the whole process, because +> it uses a process-wide shared thread pool. (This in contrast to some other +> BLAS/OpenMP libraries, or OpenBLAS backed by OpenMP, that allow setting the +> limit per-thread.) +> +> For many other libraries, for example OpenMP, the limit is set per thread. As +> a result, you want to set limits both ways in generic code that doesn't know +> the underlying BLAS library: in main thread for the process-wide limits, and +> per-thread for thread-specific limits like OpenMP. + +```python +from threadpoolctl import ThreadpoolController + +# This won't have any side-effects. Because it caches its list of loaded +# libraries, you need to create a new one if you've imported or loaded any +# relevant libraries in the interim. So storing this on module level may not be +# a good idea if you e.g. only do `import numpy` later on. +controller = ThreadpoolController() + +with ( + # Limit on main thread. This is necessary because some underlying limiting + # APIs operate on a process-wide basis. Because it's a context manager, it + # will restore the limits after the ``with`` is done. + controller.limit(limits=1), + # Make sure each Python worker thread also calls threadpool_limits(). If + # you're using another thread pool class, you will need to do so some other + # way. + ThreadPoolExecutor(4, initializer=lambda: controller.limit(limits=1)) as pool, +): + # ... run some BLAS-using code in the thread pool ... + pool.map(somefunc, someargs) + +# Later... +controller = ThreadpoolController() +with ( + controller.limit(limits=2), + ThreadPoolExecutor(4, initializer=lambda: controller.limit(limits=2)) as pool, +): + # ... run some BLAS-using code in the thread pool ... + pool.map(somefunc, someargs) +``` + +You can also operate without a context manager: + +```python +from threadpoolctl import ThreadpoolController + +controller = ThreadpoolController() +try: + limiter = controller.limit(limits=1) + with ThreadPoolExecutor(4, initializer=lambda: controller.limit(limits=1)) as pool: + # ... run some BLAS-using code in the thread pool ... + pool.map(somefunc, someargs) +finally: + limiter.restore_original_limits() +``` + +### Switching Back And Forth Between Main Thread and Python Threads + +Unfortunately not all controlled libraries providing limiting APIs that are +thread-specific, as detailed in the section on [semantics](#semantics) below. +Limiting some libraries' thread pool sizes can therefore impact the whole +process. This makes switching back and forth between running code that +uses these libraries in Python threads and running it in the main thread a bit +more complex: you need to set the limits each time you switch back and forth. + +Let's say your computer has 4 cores, and you're using some OpenMP API. + +```python +POOL = ThreadPoolExecutor(4) +CONTROLLER = ThreadpoolController() + +# 1. Run some work in a Python thread pool (OpenMP effectively disabled). +with CONTROLLER.limit(limits=1): + + def limit_then_do_work(*args, **kwargs): + # Set a limit on OpenMP in the current thread: + CONTROLLER.limit(limits=1) + # Do the actual work: + return do_real_work_with_openmp(*args, **kwargs) + + results = POOL.map(limit_then_do_work, args) + + +# 2. Run some work with 4-threads OpenMP parallelism: +with CONTROLLER.limit(limits=4): + results2 = do_more_work_with_openmp(results) + + +# 3. Nest some OpenMP parallelism under Python-level parallelism: +with CONTROLLER.limit(limits=2): + + def limit_then_do_work2(*args, **kwargs): + CONTROLLER.limit(limits=2) + return do_even_more_real_work_with_openmp(*args, **kwargs) + + results3 = POOL.map(limit_then_do_work2, results2) +``` + +## Usage: Additional APIs and details + +### OpenBLAS with pthreads/Windows, and the controlling thread + +As mentioned above, OpenBLAS with pthreads or Windows threading layer uses a +process-wide shared thread pool. Having multiple Python threads set limits in this case +makes no sense, since they would effectively be arguing over a globally shared +resource. + +``threadpoolctl`` therefore constrains the limiting API in the OpenBLAS +pthreads/Windows case to only work from the first thread that uses it, the +so-called controlling thread. The controlling thread can change the limits as +normal. For other thread, calls will be silently ignored. + +When using `with threadpool_limits()` as a context manager or +`@threadpool_limits.wrap()` as a decorator, the controlling thread should be +automatically cleared when the code leaves the relevant block. This allows +another thread to take control. You can also manually release the controlling +thread by calling `OpenBLASController.clear_controlling_thread()` from the +controlling thread; passing `force=True` allows clearing it from any thread. + +Note that if you call a standalone `threadpool_limits()` or similar API, and +then later use the `with threadpool_limits()` or similar context +manager/decorator API, clearing the controlling thread will still happen, as +standalone limiting calls are not tracked. + +NumPy and SciPy, when installed from PyPI for example, can each have their own +separate OpenBLAS with its own thread pool. The controlling thread will apply to +both. + +#### Choosing limits for a process-wide shared thread pool + +Given a shared OpenBLAS process pool, logically there are only two limits that +make sense in most circumstances: + +* A limit of `1`, which effectively means no OpenBLAS thread pool: all + operations run inline in the calling thread. This is a good setting when you + have a Python thread pool calling BLAS, and the number of Python threads is + close to the number of available cores. +* A limit equal to the number of available cores. This is a good setting when + you are calling BLAS from the main thread and there are no Python threads. + +While `threadpoolctl` does not limit you to these two values, you may wish to do +so yourself in your code, after benchmarking to ensure that this actually +maximizes performance. Benchmarking is important because empirically, these +limits do not always maximize performance. + +### Switching the FlexiBLAS backend + +`FlexiBLAS` is a BLAS wrapper for which the BLAS backend can be switched at runtime. +`threadpoolctl` exposes python bindings for this feature. Here's an example but note +that this part of the API is experimental and subject to change without deprecation: + +```python +>>> from threadpoolctl import ThreadpoolController +>>> import numpy as np +>>> controller = ThreadpoolController() + +>>> controller.info() +[{'user_api': 'blas', + 'internal_api': 'flexiblas', + 'num_threads': 1, + 'prefix': 'libflexiblas', + 'filepath': '/usr/local/lib/libflexiblas.so.3.3', + 'version': '3.3.1', + 'available_backends': ['NETLIB', 'OPENBLASPTHREAD', 'ATLAS'], + 'loaded_backends': ['NETLIB'], + 'current_backend': 'NETLIB'}] + +# Retrieve the flexiblas controller +>>> flexiblas_ct = controller.select(internal_api="flexiblas").lib_controllers[0] + +# Switch the backend with one predefined at build time (listed in "available_backends") +>>> flexiblas_ct.switch_backend("OPENBLASPTHREAD") +>>> controller.info() +[{'user_api': 'blas', + 'internal_api': 'flexiblas', + 'num_threads': 4, + 'prefix': 'libflexiblas', + 'filepath': '/usr/local/lib/libflexiblas.so.3.3', + 'version': '3.3.1', + 'available_backends': ['NETLIB', 'OPENBLASPTHREAD', 'ATLAS'], + 'loaded_backends': ['NETLIB', 'OPENBLASPTHREAD'], + 'current_backend': 'OPENBLASPTHREAD'}, + {'user_api': 'blas', + 'internal_api': 'openblas', + 'num_threads': 4, + 'prefix': 'libopenblas', + 'filepath': '/usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.8.so', + 'version': '0.3.8', + 'threading_layer': 'pthreads', + 'architecture': 'Haswell'}] + +# It's also possible to directly give the path to a shared library +>>> flexiblas_controller.switch_backend("/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libmkl_rt.so") +>>> controller.info() +[{'user_api': 'blas', + 'internal_api': 'flexiblas', + 'num_threads': 2, + 'prefix': 'libflexiblas', + 'filepath': '/usr/local/lib/libflexiblas.so.3.3', + 'version': '3.3.1', + 'available_backends': ['NETLIB', 'OPENBLASPTHREAD', 'ATLAS'], + 'loaded_backends': ['NETLIB', + 'OPENBLASPTHREAD', + '/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libmkl_rt.so'], + 'current_backend': '/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libmkl_rt.so'}, + {'user_api': 'openmp', + 'internal_api': 'openmp', + 'num_threads': 4, + 'prefix': 'libomp', + 'filepath': '/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libomp.so', + 'version': None}, + {'user_api': 'blas', + 'internal_api': 'openblas', + 'num_threads': 4, + 'prefix': 'libopenblas', + 'filepath': '/usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.8.so', + 'version': '0.3.8', + 'threading_layer': 'pthreads', + 'architecture': 'Haswell'}, + {'user_api': 'blas', + 'internal_api': 'mkl', + 'num_threads': 2, + 'prefix': 'libmkl_rt', + 'filepath': '/home/jeremie/miniforge/envs/flexiblas_threadpoolctl/lib/libmkl_rt.so.2', + 'version': '2024.0-Product', + 'threading_layer': 'gnu'}] +``` + +You can observe that the previously linked OpenBLAS shared object stays loaded by +the Python program indefinitely, but FlexiBLAS itself no longer delegates BLAS calls +to OpenBLAS as indicated by the `current_backend` attribute. + +### Writing a custom library controller + +Currently, `threadpoolctl` has support for `OpenMP` and the main `BLAS` libraries. +However it can also be used to control the threadpool of other native libraries, +provided that they expose an API to get and set the limit on the number of threads. +For that, one must implement a controller for this library and register it to +`threadpoolctl`. + +A custom controller must be a subclass of the `LibController` class and implement +the attributes and methods described in the docstring of `LibController`. Then this +new controller class must be registered using the `threadpoolctl.register` function. +An complete example can be found [here]( + https://github.com/joblib/threadpoolctl/blob/master/tests/_pyMylib/__init__.py). + +### Sequential BLAS within OpenMP parallel region + +When one wants to have sequential BLAS calls within an OpenMP parallel region, it's +necessary to set `limits="sequential_blas_under_openmp"` since setting `limits=1` and +`user_api="blas"` leads to unexpected behavior for OpenBLAS with the OpenMP threading +layer: OpenBLAS and the calling code share the same OpenMP runtime, so it also sets +the number of OpenMP threads of the current thread to 1. Inside the limit, any OpenMP +parallel region started from this thread without an explicit number of threads runs +on a single thread, and `omp_get_max_threads()` returns 1. + +```python +from threadpoolctl import ThreadpoolController + +controller = ThreadpoolController() + +with controller.limit(limits="sequential_blas_under_openmp"): + # Compiled code with an OpenMP parallel loop that calls BLAS. + my_openmp_kernel(X) +``` + +### Known Limitations + +- `threadpool_limits` can fail to limit the number of inner threads when nesting + parallel loops managed by distinct OpenMP runtime implementations (for instance + libgomp from GCC and libomp from clang/llvm or libiomp from ICC). + + See the `test_openmp_nesting` function in [tests/test_threadpoolctl.py]( + https://github.com/joblib/threadpoolctl/blob/master/tests/test_threadpoolctl.py) + for an example. More information can be found at: + https://github.com/jeremiedbb/Nested_OpenMP + + Note however that this problem does not happen when `threadpool_limits` is + used to limit the number of threads used internally by BLAS calls that are + themselves nested under OpenMP parallel loops. `threadpool_limits` works as + expected, even if the inner BLAS implementation relies on a distinct OpenMP + implementation. + +- Using Intel OpenMP (ICC) and LLVM OpenMP (clang) in the same Python program + under Linux is known to cause problems. See the following guide for more details + and workarounds: + https://github.com/joblib/threadpoolctl/blob/master/multiple_openmp.md + +- Setting the maximum number of threads of the OpenMP and BLAS libraries has + inconsistent scope and semantics (thread-local vs process-wide) depending on + the underlying library. For more details see + https://github.com/joblib/threadpoolctl/issues/208 + + For example, if you're using OpenMP with libgomp (gcc) or libomp (clang), the + setting is thread-local and sets how many OpenMP threads will be started in + the current thread. On the other hand, with OpenBLAS with pthreads backend or + on Windows, the setting is process-wide and impacts the size of a process-wide + thread pool shared across all threads in the process. + ##