Skip to content

OpenBLAS (pthreads) limits can no longer be changed once the controlling thread exits without releasing control #286

Open

Description

@cakedev0

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() calls controller.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 a with.

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

  1. 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.
  2. 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

  1. ogrisel commented on Oct 29, 2026

    @ogrisel
    Contributor

    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.

  2. itamarst commented on Oct 29, 2026

    @itamarst
    Contributor

    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.

  3. itamarst commented on Oct 29, 2026

    @itamarst
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions