Repository navigation
Conversation
blas_memory_alloc and blas_memory_free took the global alloc_lock mutex on every call in non-OpenMP threaded builds, so that concurrent callers serialized on it twice per BLAS call. Use the per-slot lock that OpenMP builds already use instead, and keep alloc_lock for the rare overflow path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cking addr is _Atomic and blas_memory_free reads it without alloc_lock, so the lock around its first store no longer protects anything. Builds with neither SMP nor USE_LOCKING took no lock before and still take none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner
Author
|
Closed in favor of OpenMathLib#6105 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
In threaded builds without OpenMP,
blas_memory_allocandblas_memory_freetake the globalalloc_lockon every call, so all concurrent callers of single-threaded BLAS serialize on it twice per call. This is the case when an application calls smalldgemmfrom the threads of its own OpenMP parallel region (e.g. scikit-learn'sKMeans, see scikit-learn/scikit-learn#17334). OpenMP builds don't have this problem: they claim a slot ofmemory[]with its own lock and release it without any lock.This PR makes non-OpenMP builds use the same per-slot path for
memory[].alloc_lockis now only taken for the overflow area (newmemory) and on the first call. This covers pthreads builds, Windows builds (wherealloc_lockis already ablas_lockspinlock) andUSE_THREAD=0 USE_LOCKING=1builds. Single-threaded builds withoutUSE_LOCKINGstill take no lock at all.I think this is the same contention as OpenMathLib#5589 (
symv/trsvfrom many Julia threads, which uses a pthreads build), but I haven't measuredsymvortrsv.The global mutex replaced the per-slot lock in OpenMathLib/OpenBLAS@e882f3d6f (2017, OpenMathLib#1299), for data races found by TSan in OpenMathLib#888. At the time, the fields of
memory[]were plainvolatile,blas_lockwas inline assembly on x86 (xchgl), which TSan can't see, and the diagnosis in OpenMathLib#888 was that mixing pthread mutexes withblas_lockdoesn't work. Since OpenMathLib#5876 (in 0.3.34), the fields are_Atomicand, withHAVE_C11,blas_lockis a C11 compare-and-swap that TSan understands. With this PR, outside ofblas_shutdown, no field ofmemory[]is protected byalloc_lockanymore: a slot is claimed with itsblas_lock, andusedandaddrare only read and written atomically. It's the code OpenMP builds already run on every architecture, and thelinux_thread_sanitizerCI job will check it for pthreads builds. WithoutHAVE_C11, the slot is released withWMBand a volatile store, as in OpenMP builds. The same lock was discussed in OpenMathLib#1782.With 64 threads each calling a small
dgemm(Lloyd layout below, 102400 calls),pthread_mutex_lockis called 204933 times ondevelop(31% contended; 64% with the scipy-openblas wheel) and 132 times with this PR.Benchmarks
Xeon 6787P, one socket (86 cores),
TARGET=SAPPHIRERAPIDS USE_OPENMP=0 NUM_THREADS=64, GCC 16.1 libgomp withGOMP_SPINCOUNT=300000,OPENBLAS_NUM_THREADS=1. Each parallel region does a fixed number ofdgemm("T", "N", k, 256, d, ...)calls (one per chunk of 256 samples, as in k-means). I picked the TN layout on purpose: withM * N > 1200, the Sapphire Rapids small-matrix kernel doesn't take it, so the call goes through the buffer allocator. Min-max of 3 interleaved runs, ms per region:developdevelopOn
develop, these regions get slower than single-threaded beyond 8 threads. With bigger calls the lock matters less: 4.81-4.98 -> 4.00-4.07 ms at 64 threads for k=100, d=50, and 4.08-4.16 -> 3.95-3.96 ms for k=10, d=128 (memory-bound). With 1 thread, this PR is 1-4% slower on the small calls above. I'm not sure why. It could be the seq_cst stores toused.On hybrid CPUs, the lock also causes stalls when the OpenMP runtime spins (scikit-learn/scikit-learn#17334): a contended mutex inside a parallel region stalls the whole team. Core Ultra 5 225H, 14 threads,
TARGET=HASWELL, 20 calls ofdgemm("N", "N", 256, 8, 50, ...)per region,GOMP_SPINCOUNT=300000: 4.2-5.0 ms -> 0.07-0.31 ms per region (laptop on battery, so only indicative).End to end, with the reproducer of scikit-learn/scikit-learn#17334 on a Core Ultra X7 358H (16 threads, GCC 13 libgomp) and a scikit-learn branch where OpenBLAS is the last blocking call in
KMeans' parallel regions, I swapped the OpenBLAS bundled with scipy: 7.4-21.6 s with scipy's wheel, 8.3-14.0 s withdevelop, 1.11-1.16 s with this PR.Benchmark
Tests
On the Xeon with the first commit:
cpp_thread_test(dgemm_testerwith 64 and 96 concurrent threads, the latter going through the overflow path,dgemv_tester,dgemm_mixed_tester),openblas_utestandopenblas_utest_extpass. Measured ondevelopat 3374642 and rebased since (no change tomemory.corcommon.hin between). The benchmarks above are also from the first commit: the second one only changes the first mapping of a slot and builds withoutSMP.With both commits, under TSan (GCC 16.1 with the options of the
linux_thread_sanitizerjob,NUM_THREADS=32 NUM_PARALLEL=2, so 128 buffers):dgemm_thread_safetyanddgemv_thread_safetywith 64 and 200 concurrent callers andOPENBLAS_NUM_THREADS=1, and ondgemm_thread_safety 1024 200 1, which goes through the overflow path.USE_THREAD=0 USE_LOCKING=1: one report, onmemory_initializedinblas_memory_alloc, whose init isn't locked withoutSMP. It's the same ondevelop, so I left it out of this PR.USE_THREAD=0(no TSan): builds andctestpasses.I couldn't test on ARM or POWER, nor with a compiler without C11 atomics, nor on Windows.
Any other comments?
USE_TLS=1doesn't avoid the problem: it takes 3 mutexes per call (alloc_lockto readmemory_initialized,key_locktwice inget_memory_table). I left it out of this PR, but I can look at it in a follow-up if you think it's worth it.AI usage disclosure
I used Claude Code to read
memory.cand its history, write the patch and the benchmark, and run the measurements.🤖 Generated with Claude Code