Repository navigation
OpenBLAS (pthreads) limits can no longer be changed once the controlling thread exits without releasing control #4
Description
Since joblib#275, when OpenBLAS doesn't use the OpenMP threading layer (pthreads, as in the numpy and scipy wheels on PyPI, or Windows threading), only the first thread that sets a limit can change it. Calls from other threads are ignored silently (OpenBLASController.set_num_threads). That owner thread only releases control in __exit__ of a with block or decorator (_ControllingThreadOwnership). A limit set with a plain call never releases it, even after the owner thread has exited.
So if a worker thread is the first to set a limit with a plain call, OpenBLAS keeps that limit for the rest of the process once the worker exits. Nothing can change it afterwards, and there is no warning.
Reproducer: joblib's threading backend (joblib/joblib#1871)
joblib/joblib#1871 adds BLAS/OpenMP limits to the threading backend with exactly this pattern:
ThreadingBackend.configure()callscontroller.limit()with no limits in the main thread, to restore the limits later.- Each worker's initializer calls
controller.limit(limits=cores_per_thread)without awith.
The main thread never sets a limit, so the first worker becomes the owner and terminate()'s restore_original_limits() is ignored.
With that PR (at 108d14b) and numpy from PyPI (OpenBLAS with pthreads) on a 14-thread machine:
import numpy as np
import threadpoolctl
from joblib import Parallel, delayed
from threadpoolctl import threadpool_info, threadpool_limits
def blas_threads():
return [lib["num_threads"] for lib in threadpool_info() if lib["user_api"] == "blas"]
a = np.random.rand(200, 200)
print("threadpoolctl", threadpoolctl.__version__, "| initial:", blas_threads())
Parallel(n_jobs=4, backend="threading")(delayed(np.dot)(a, a) for _ in range(8))
print("after Parallel(n_jobs=4, backend='threading'):", blas_threads())
with threadpool_limits(limits=8, user_api="blas"):
print("main thread inside `with threadpool_limits(8)`:", blas_threads())threadpoolctl 3.7.0 | initial: [14]
after Parallel(n_jobs=4, backend='threading'): [14]
main thread inside `with threadpool_limits(8)`: [8]
threadpoolctl 3.8.0.dev0 | initial: [14]
after Parallel(n_jobs=4, backend='threading'): [3]
main thread inside `with threadpool_limits(8)`: [3]
After a single threaded Parallel call, OpenBLAS stays at 3 threads for the rest of the process. The ownership counting sketched for joblib in joblib#275 wouldn't help: the main thread never becomes the owner, and clear_controlling_thread() without force=True only has an effect when called from the owner.
The thread pool recipe from the 3.7.0 README has the same shape (an outer with threadpool_limits():, then plain threadpool_limits(limits=1) calls in the worker initializers) and breaks the same way. joblib#275 updated the README to set a limit in the main thread first, which avoids the problem.
Suggested fixes
- Treat a dead owner thread as no owner (
not controlling_thread.is_alive()). This fixes the reproducer above and any other case where the owner thread exits while still holding control. - Warn, at least once, when a call from a thread that isn't the owner is ignored. The silent no-op is what makes this hard to diagnose. A warning is also the only signal left when the owner is still alive and never releases control, e.g. while a long-lived pool's workers are running.
This should probably be fixed before 3.8.0 is released, or at least before joblib/joblib#1871 is merged.
Activity
cakedev0 commentedon Oct 29, 2026
Posted upstream as joblib#286.