Skip to content

threadpool_limits(1, user_api="blas") also limits OpenMP to 1 thread with OpenBLAS's OpenMP threading layer (3.7.0) #272

Closed
scikit-learn/scikit-learn
#35180

Description

@cakedev0

Describe the bug

Since 3.7.0, with OpenBLAS's OpenMP threading layer, limiting BLAS also limits the OpenMP runtime of the current thread: inside threadpool_limits(limits=1, user_api="blas"), omp_get_max_threads() returns 1, so any OpenMP parallel region started from this thread without an explicit num_threads runs on a single thread. With 3.6.0 and the same OpenBLAS (0.3.34), it stays at 8.

It comes from set_num_threads of the OpenBLAS controller, which now calls omp_set_num_threads when the threading layer is OpenMP, to get a limit per thread rather than per process. But OpenBLAS and the caller share the same OpenMP runtime, so the BLAS limit becomes an OpenMP limit too.

In scikit-learn, the usual pattern for nested parallelism is to limit BLAS to 1 thread around Cython code that calls BLAS inside OpenMP prange loops. Any code that reads the number of OpenMP threads inside that limit now gets 1.

For instance, a KMeans fitted under threadpool_limits(1, user_api="blas") now runs its OpenMP code on 1 thread (see below).

I'm not sure what the best fix is, maybe just some documentation?

Steps/Code to Reproduce

import numpy as np  # loads OpenBLAS
from threadpoolctl import threadpool_info, threadpool_limits


def openmp_num_threads():
    return [i["num_threads"] for i in threadpool_info() if i["user_api"] == "openmp"]


print("openmp num_threads:", openmp_num_threads())
with threadpool_limits(limits=1, user_api="blas"):
    print("openmp num_threads under blas limit 1:", openmp_num_threads())

With scikit-learn:

import numpy as np
from threadpoolctl import threadpool_limits
from sklearn.cluster import KMeans

X = np.random.RandomState(0).randn(1000, 5)
with threadpool_limits(limits=1, user_api="blas"):
    print(KMeans(3, n_init=1).fit(X)._n_threads)

Expected Results

With OMP_NUM_THREADS=8:

openmp num_threads: [8]
openmp num_threads under blas limit 1: [8]

and 8 for KMeans._n_threads. That's what 3.6.0 gives.

Actual Results

openmp num_threads: [8]
openmp num_threads under blas limit 1: [1]

and 1 for KMeans._n_threads.

Versions

MacBook Pro M2 (macOS 26.6.2), conda-forge packages:

python         3.13.16
numpy          2.5.4
libopenblas    0.3.34  openmp_h5e6e99c_1  (threading_layer: openmp, VORTEX)
llvm-openmp    23.1.3
threadpoolctl  3.7.0

Activity

  1. cakedev0 commented on Oct 16, 2026

    @cakedev0
    ContributorAuthor
  2. itamarst commented on Oct 16, 2026

    @itamarst
    Contributor

    So the motivation for this was:

    1. In order to work well with Python threads that each want their own limit, you really want to use thread-local limiting APIs when available. And this is what we've got with OpenBLAS.
    2. My impression, which I guess was incorrect, was that usually people set same limits on both.
    3. Bonus: Without this you can get memory corruption - pairwise_distances_argmin_min / ArgKMin64 is not thread-safe scikit-learn/scikit-learn#31884 - although now that you explained the above usage pattern I wonder if this is a real fix, or maybe just a workaround... worth investigating!

    The ideal solution is to have a working openblas_set_num_threads_local() which is a thread-local setting, the way MKL does. Not sure how it'd work with pthreads backend, though...

    For this particular use case... I have vague memory that OpenMP has some APIs for constraining nested parallel loops? So you could set the limit with threadpoolctl for the OpenMP prange(), and then use that OpenMP API before calling into BLAS?

    We could also revert threadpoolctl to old behavior of using openblas_set_num_threads() which is not ideal since it affects all threads, but does work... if we can prove the possible memory corruption issue above is not a systemic issue with using the global-setting API but e.g. maybe a bug in that specific Cython code, say.

  3. itamarst commented on Oct 16, 2026

    @itamarst
    Contributor

    Oh except second solution means different code paths for pthreads vs OpenMP OpenBLAS...

  4. itamarst commented on Oct 19, 2026

    @itamarst
    Contributor

    Isn't this what "sequential_blas_under_openmp" limit is for? And threadpoolctl has special behavior for that for OpenBLAS + OpenMP where it doesn't set a limit of 1 (for reasons I don't quite understand) which... maybe will do the right thing?

  5. itamarst commented on Oct 19, 2026

    @itamarst
    Contributor

    We might be able to improve "sequential_blas_under_openmp" using the hypothetical nested-loops OpenMP limiting API?

  6. cakedev0 commented on Oct 19, 2026

    @cakedev0
    ContributorAuthor

    Sorry, I opened the issue is a bit too fast using an agent, and didn't read it attentively enough.

    The reproducer it wrote is pretty uninformative, the real issue is the following:

    If you're writing a function with BLAS calls inside an OMP prange, you want to disable parallelism in BLAS so you'll use threadpool_limits(limits=1, user_api="blas"). But under this limit, omp_get_max_threads will return 1 for OpenBLAS built with OpenMP.

    Though, it seems "sequential_blas_under_openmp" already does exactly what we need, indeed my agent tells me:

    OpenMP builds of OpenBLAS use 1 thread when omp_in_parallel() is true

    So what threadpool_limits does is right, the bug is on scikit-learn's side not to use "sequential_blas_under_openmp". Easy to fix! 🎉

  7. itamarst commented on Oct 19, 2026

    @itamarst
    Contributor

    So that means this issue can be closed?

  8. cakedev0 commented on Oct 20, 2026

    @cakedev0
    ContributorAuthor

    Let's wait for scikit-learn/scikit-learn#35180 to be merged to close this issue.

  9. ogrisel commented on Oct 20, 2026

    @ogrisel
    Contributor

    Since #273 is merged and the benchmark results in scikit-learn/scikit-learn#35180 show that this solution fixes the problem, let's close the issue on the threadpoolctl side of things.

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