Repository navigation
OpenBLAS (pthreads) limits can no longer be changed once the controlling thread exits without releasing control #286
Description
Since #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 #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. #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
ogrisel commentedon Oct 29, 2026
joblib/joblib#1871 needs to be updated to explicitly use the new OpenBLAS ownership singleton.
EDIT: I commented before fully understanding the details of the report. I should not do that... We can indeed improve the ownership handling in case of misuse.
But I don't think this is a bug for older (released) joblib versions that do not attempt to set any limit.
itamarst commentedon Oct 29, 2026
I'll fix this.
I like the
is_alive()variant, it's a pretty cheap operation and I'd rather users not have to think about this at all.itamarst commentedon Oct 29, 2026
To expand: I don't think this is a bug as far as joblib is concerned cause yes I'm working on updating that PR. But it does make things work that much more nicely for users, without weird edge cases. So I think it's worth doing.