You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
KMeans segfaults on 344-logical-core box: concurrent single-threaded callers exhaust NUM_BUFFERS(256) even with threads pinned to 1; exhaustion path returns NULL instead of terminating #5958
Hi, I'm working on scikit-learn and testing/benchmarking it on big machines.
I used Claude to help investigate this and draft the report, I tried to make sure it was relevant and to make it digest.
Summary
sklearn.cluster.KMeans.fit() reliably crashes (segfault or corrupted size vs. prev_size) on a 344-logical-core machine when linked against OpenBLAS's pthreads threading layer. scikit-learn is well aware of BLAS-oversubscription hazards and explicitly guards its Lloyd loop with threadpoolctl, forcing openblas_get_num_threads() == 1 for the whole call. I verified that guard is genuinely active at the moment of the crash. It doesn't help, because the actual trigger doesn't involve OpenBLAS recruiting extra threads per call at all — it's simpler than that:
sklearn's own outer parallelism (one OpenMP worker per logical core — 344 here — in its prange distance-computation loop) means 344 threads hit blas_memory_alloc() at roughly the same time (OpenMP's fork/join barrier), each wanting one buffer for its own single-threaded BLAS call. NUM_BUFFERS (the size of the memory[] allocator table) is 256 on this build. 344 concurrent callers against 256 slots exhausts the table regardless of how many threads any individual call is allowed to use.
Minimal reproducer
/* ob_repro.c — build: gcc -fopenmp -O2 ob_repro.c -o ob_repro -l:libopenblas.so.0 * run: ./ob_repro <n_threads> <n_iters> <matrix_size> e.g. ./ob_repro 344 50 128 * * openblas_set_num_threads(1) mirrors scikit-learn's own * threadpool_limits(limits=1, user_api="blas") guard around its Lloyd loop. * Crashes anyway: the hazard is *how many threads call in*, not how many * threads OpenBLAS recruits per call. */#include<stdio.h>#include<stdlib.h>externvoiddgemm_(constchar*transa, constchar*transb,
constint*m, constint*n, constint*k,
constdouble*alpha, constdouble*a, constint*lda,
constdouble*b, constint*ldb,
constdouble*beta, double*c, constint*ldc);
externvoidopenblas_set_num_threads(int);
intmain(intargc, char**argv) {
intn_threads=argc>1 ? atoi(argv[1]) : 344;
intn_iters=argc>2 ? atoi(argv[2]) : 50;
intm=argc>3 ? atoi(argv[3]) : 128;
openblas_set_num_threads(1); /* <- pinned, like scikit-learn does */charta='N', tb='N';
doublealpha=1.0, beta=0.0;
intit;
for (it=0; it<n_iters; it++) {
#pragma omp parallel num_threads(n_threads)
{
double*A=malloc(sizeof(double) *m*m);
double*B=malloc(sizeof(double) *m*m);
double*C=malloc(sizeof(double) *m*m);
inti;
for (i=0; i<m*m; i++) { A[i] = (double)(i % 97) / 97.0; B[i] = (double)(i % 89) / 89.0; }
dgemm_(&ta, &tb, &m, &m, &m, &alpha, A, &m, B, &m, &beta, C, &m);
free(A); free(B); free(C);
}
}
printf("done, no crash (n_threads=%d n_iters=%d m=%d, num_threads pinned to 1)\n", n_threads, n_iters, m);
return0;
}
6/6 runs of ./ob_repro 344 50 128 crash (mix of SIGSEGV and SIGABRT with glibc's corrupted size vs. prev_size — real heap corruption, not a clean stop). ./ob_repro 344 50 64 (smaller matrix) never crashes — small enough calls apparently skip the buffer table entirely, so the effect only shows up once individual calls are large enough to need a real workspace buffer.
Every crash is preceded by:
OpenBLAS warning: precompiled NUM_THREADS exceeded, adding auxiliary array for thread metadata.
Note that your application may still crash, if it is calling OpenBLAS from multiple threads in parallel
To avoid this warning, please rebuild your copy of OpenBLAS with a larger NUM_THREADS setting
or set the environment variable OPENBLAS_NUM_THREADS to 128 or lower
Note this is with num_threads already pinned to 1 — the warning's own suggested fixes don't apply to this trigger at all, since the caller-side thread count isn't something either of those settings controls.
Why this crashes instead of erroring cleanly
Table exhaustion should, per the code's own message, terminate cleanly — but it doesn't:
// driver/others/memory.cterminate:
...
printf("OpenBLAS : Program is Terminated. Because you tried to allocate too many memory regions.\n");
printf("This library was built to support a maximum of %d threads - either rebuild OpenBLAS\n", NUM_BUFFERS);
...
returnNULL; // <- not exit(). Caller gets NULL back instead.
fprintf(stderr, "OpenBLAS error: Memory allocation still failed after 10 retries, giving up.\n");
exit(1);
}
If the internal caller doesn't check for NULL (or can't safely bail out mid-computation), that's a null-pointer write standing in for the clean stop the message promises — consistent with the segfaults/heap-corruption we actually see.
NUM_BUFFERS is MAX_CPU_NUMBER * 2 * MAX_PARALLEL_NUMBER:
Race condition in memory.c while running dgemm many times from omp parallel region #2444 — a confirmed, partially-fixed race on memory[position].used visibility, but in the fine-grained-locking (USE_OPENMP) path. The plain pthreads path (!USE_OPENMP, used here) takes a single coarse alloc_lock around the whole scan+claim+free cycle, which reads as correctly serialized to me — so I don't think this is literally the same bug, just the same neighborhood of code.
A secondary, distinct hazard also exists on this build and is worth keeping in mind separately: without pinning num_threads, a single large-enough GEMM called from inside an already-active OpenMP region triggers OpenBLAS's own "Detect OpenMP Loop and this application may hang" nested-dispatch path, which can also corrupt memory. That one is about per-call thread recruitment, and it's what scikit-learn's guard correctly prevents — it just isn't what's causing the scikit-learn crash specifically, since I confirmed num_threads=1 there.
Hi, I'm working on scikit-learn and testing/benchmarking it on big machines.
I used Claude to help investigate this and draft the report, I tried to make sure it was relevant and to make it digest.
Summary
sklearn.cluster.KMeans.fit()reliably crashes (segfault orcorrupted size vs. prev_size) on a 344-logical-core machine when linked against OpenBLAS's pthreads threading layer. scikit-learn is well aware of BLAS-oversubscription hazards and explicitly guards its Lloyd loop withthreadpoolctl, forcingopenblas_get_num_threads() == 1for the whole call. I verified that guard is genuinely active at the moment of the crash. It doesn't help, because the actual trigger doesn't involve OpenBLAS recruiting extra threads per call at all — it's simpler than that:sklearn's own outer parallelism (one OpenMP worker per logical core — 344 here — in its
prangedistance-computation loop) means 344 threads hitblas_memory_alloc()at roughly the same time (OpenMP's fork/join barrier), each wanting one buffer for its own single-threaded BLAS call.NUM_BUFFERS(the size of thememory[]allocator table) is 256 on this build. 344 concurrent callers against 256 slots exhausts the table regardless of how many threads any individual call is allowed to use.Minimal reproducer
6/6 runs of
./ob_repro 344 50 128crash (mix ofSIGSEGVandSIGABRTwith glibc'scorrupted size vs. prev_size— real heap corruption, not a clean stop)../ob_repro 344 50 64(smaller matrix) never crashes — small enough calls apparently skip the buffer table entirely, so the effect only shows up once individual calls are large enough to need a real workspace buffer.Every crash is preceded by:
Note this is with
num_threadsalready pinned to 1 — the warning's own suggested fixes don't apply to this trigger at all, since the caller-side thread count isn't something either of those settings controls.Why this crashes instead of erroring cleanly
Table exhaustion should, per the code's own message, terminate cleanly — but it doesn't:
OpenBLAS/driver/others/memory.c
Lines 3148 to 3162 in 62bcfb0
The only real
exit(1)in that function is a different, unrelated branch (mmap retries exhausted):OpenBLAS/driver/others/memory.c
Lines 2981 to 2987 in 62bcfb0
If the internal caller doesn't check for
NULL(or can't safely bail out mid-computation), that's a null-pointer write standing in for the clean stop the message promises — consistent with the segfaults/heap-corruption we actually see.NUM_BUFFERSisMAX_CPU_NUMBER * 2 * MAX_PARALLEL_NUMBER:OpenBLAS/common.h
Line 192 in 62bcfb0
128 * 2 * 1 = 256for this build, matching what's printed.Related, possibly same root cause
KMeans/kNN/DBSCANon a many-core box).OPENBLAS_NUM_THREADS=64didn't help there either, consistent with this not being about per-call thread count.memory[position].usedvisibility, but in the fine-grained-locking (USE_OPENMP) path. The plain pthreads path (!USE_OPENMP, used here) takes a single coarsealloc_lockaround the whole scan+claim+free cycle, which reads as correctly serialized to me — so I don't think this is literally the same bug, just the same neighborhood of code.num_threads, a single large-enough GEMM called from inside an already-active OpenMP region triggers OpenBLAS's own"Detect OpenMP Loop and this application may hang"nested-dispatch path, which can also corrupt memory. That one is about per-call thread recruitment, and it's what scikit-learn's guard correctly prevents — it just isn't what's causing the scikit-learn crash specifically, since I confirmednum_threads=1there.Environment
libopenblas-0.3.33-pthreads_h94d23a6_0(conda-forge)