From e44c88ddcf6a567423d5029092ab4961aab45ba0 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 13 Jul 2026 13:56:24 -0400 Subject: [PATCH 1/9] Script to check number of threads --- tests/lots_of_threads.py | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/lots_of_threads.py diff --git a/tests/lots_of_threads.py b/tests/lots_of_threads.py new file mode 100644 index 00000000..eb8874e2 --- /dev/null +++ b/tests/lots_of_threads.py @@ -0,0 +1,73 @@ +import sys +from concurrent.futures import ThreadPoolExecutor +from json import dumps +from threading import Thread +from typing import Callable + +import numpy as np +import threadpoolctl +import psutil + +from tests._openmp_test_helper.openmp_helpers_inner import check_openmp_num_threads + +def start_counting_threads() -> Callable[[], int]: + num_threads = 0 + stop = False + + def in_thread(): + nonlocal num_threads, stop + process = psutil.Process() + while not stop: + num_threads = max(num_threads, process.num_threads()) + + thread = Thread(target=in_thread) + thread.start() + + def finish(): + nonlocal stop, num_threads + stop = True + thread.join() + return num_threads + + return finish + + +def blas(): + A = np.ones((10_000_000,)) + + def blas_math(_): + for _ in range(5): + A.dot(A) + + with ThreadPoolExecutor(10) as pool: + for _ in pool.map(blas_math, range(100)): + pass + + +def openmp(): + def run_openmp(_): + check_openmp_num_threads(1000) + + with ThreadPoolExecutor(10) as pool: + for _ in pool.map(run_openmp, range(100)): + pass + + + +def run(which: str) -> None: + print(threadpoolctl.threadpool_info()) + threadpoolctl.threadpool_limits(12) + + if which == "blas": + func = blas + else: + func = openmp + + count_threads = start_counting_threads() + func() + max_num_threads = count_threads() + print(dumps({"max_threads": max_num_threads})) + + +if __name__ == '__main__': + run(sys.argv[1]) From ba8d196b1a56343285f85758a4354e13a3979876 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 13 Jul 2026 14:09:11 -0400 Subject: [PATCH 2/9] Make it more useful for CI --- tests/lots_of_threads.py | 55 ++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/tests/lots_of_threads.py b/tests/lots_of_threads.py index eb8874e2..d71a4de5 100644 --- a/tests/lots_of_threads.py +++ b/tests/lots_of_threads.py @@ -1,6 +1,5 @@ -import sys from concurrent.futures import ThreadPoolExecutor -from json import dumps +from pprint import pprint from threading import Thread from typing import Callable @@ -8,7 +7,11 @@ import threadpoolctl import psutil -from tests._openmp_test_helper.openmp_helpers_inner import check_openmp_num_threads +try: + from tests._openmp_test_helper.openmp_helpers_inner import check_openmp_num_threads +except ImportError: + check_openmp_num_threads = None + def start_counting_threads() -> Callable[[], int]: num_threads = 0 @@ -36,28 +39,27 @@ def blas(): A = np.ones((10_000_000,)) def blas_math(_): - for _ in range(5): - A.dot(A) + A.dot(A) - with ThreadPoolExecutor(10) as pool: - for _ in pool.map(blas_math, range(100)): + with ThreadPoolExecutor(100) as pool: + for _ in pool.map(blas_math, range(400)): pass def openmp(): + if check_openmp_num_threads is None: + print("OpenMP not available") + return + def run_openmp(_): check_openmp_num_threads(1000) - with ThreadPoolExecutor(10) as pool: - for _ in pool.map(run_openmp, range(100)): + with ThreadPoolExecutor(100) as pool: + for _ in pool.map(run_openmp, range(400)): pass - def run(which: str) -> None: - print(threadpoolctl.threadpool_info()) - threadpoolctl.threadpool_limits(12) - if which == "blas": func = blas else: @@ -66,8 +68,29 @@ def run(which: str) -> None: count_threads = start_counting_threads() func() max_num_threads = count_threads() - print(dumps({"max_threads": max_num_threads})) + # We're creating 100 Python threads, and asking for 2 native threads. If + # number is close to 100, we'll assume process-wide shared thread pool. If + # the number is close to 200, we'll assume a thread pool per thread. + if max_num_threads < 130: + scope = "process-wide shared thread pool" + elif max_num_threads > 170: + scope = "thread pool per thread" + else: + scope = "not sure" + + print(f"== Observed behavior: {which} ==") + print("Maximum number of observed threads:", max_num_threads) + print("Presumed API scope:", scope) + print() + + +if __name__ == "__main__": + threadpoolctl.threadpool_limits(2) + print("== Library info ==") + pprint(threadpoolctl.threadpool_info()) + print() -if __name__ == '__main__': - run(sys.argv[1]) + run("openmp") + print() + run("blas") From 4d7b7cc5a044d2cc73062bd204acea72c8167050 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 13 Jul 2026 14:10:51 -0400 Subject: [PATCH 3/9] Run in CI --- continuous_integration/run_tests.sh | 3 +++ tests/{lots_of_threads.py => empirical_scope_observation.py} | 0 2 files changed, 3 insertions(+) rename tests/{lots_of_threads.py => empirical_scope_observation.py} (100%) diff --git a/continuous_integration/run_tests.sh b/continuous_integration/run_tests.sh index f26f0fdc..6ce28619 100755 --- a/continuous_integration/run_tests.sh +++ b/continuous_integration/run_tests.sh @@ -18,4 +18,7 @@ fi # launching the tests: python -m threadpoolctl -i numpy scipy.linalg tests._openmp_test_helper.openmp_helpers_inner +# Introspect API scope: +PYTHONPATH=. python tests/empirical_scope_observation.py + pytest -vlrxXs -W error -k "$TESTS" --junitxml=test_result.xml --cov=threadpoolctl --cov-report xml diff --git a/tests/lots_of_threads.py b/tests/empirical_scope_observation.py similarity index 100% rename from tests/lots_of_threads.py rename to tests/empirical_scope_observation.py From f90b5d55340c14da857bf3cb8ab52880526dc0d2 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 13 Jul 2026 14:18:49 -0400 Subject: [PATCH 4/9] Need psutil too --- dev-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-requirements.txt b/dev-requirements.txt index 04901b66..d98aa93a 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,6 @@ flit coverage +psutil pytest pytest-cov cython From 11c9cc072eebab7bc69f54944f6fce393ccb6bf0 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 13 Jul 2026 14:24:55 -0400 Subject: [PATCH 5/9] Handle missing NumPy case --- tests/empirical_scope_observation.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/empirical_scope_observation.py b/tests/empirical_scope_observation.py index d71a4de5..1fbc90c9 100644 --- a/tests/empirical_scope_observation.py +++ b/tests/empirical_scope_observation.py @@ -1,9 +1,16 @@ +""" +Run this script to empirically determine if OpenMP and BLAS limiting API set +the size of a shared process-wide thread pool or a per-thread limit. +""" from concurrent.futures import ThreadPoolExecutor from pprint import pprint from threading import Thread from typing import Callable -import numpy as np +try: + import numpy as np +except ImportError: + np = None import threadpoolctl import psutil @@ -36,6 +43,10 @@ def finish(): def blas(): + if np is None: + print("BLAS not available") + return + A = np.ones((10_000_000,)) def blas_math(_): From 8eddb9e9cdba2267fbfc4aefaeec49ff4723c72d Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 13 Jul 2026 14:27:28 -0400 Subject: [PATCH 6/9] Try adjusting numbers so oversaturation on limited CPU CI doesn't give misleading results --- tests/empirical_scope_observation.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/empirical_scope_observation.py b/tests/empirical_scope_observation.py index 1fbc90c9..03bc26e0 100644 --- a/tests/empirical_scope_observation.py +++ b/tests/empirical_scope_observation.py @@ -52,7 +52,7 @@ def blas(): def blas_math(_): A.dot(A) - with ThreadPoolExecutor(100) as pool: + with ThreadPoolExecutor(20) as pool: for _ in pool.map(blas_math, range(400)): pass @@ -65,7 +65,7 @@ def openmp(): def run_openmp(_): check_openmp_num_threads(1000) - with ThreadPoolExecutor(100) as pool: + with ThreadPoolExecutor(20) as pool: for _ in pool.map(run_openmp, range(400)): pass @@ -80,12 +80,12 @@ def run(which: str) -> None: func() max_num_threads = count_threads() - # We're creating 100 Python threads, and asking for 2 native threads. If - # number is close to 100, we'll assume process-wide shared thread pool. If - # the number is close to 200, we'll assume a thread pool per thread. - if max_num_threads < 130: + # We're creating 20 Python threads, and asking for 2 native threads. If + # number is close to 20, we'll assume process-wide shared thread pool. If + # the number is close to 40, we'll assume a thread pool per thread. + if max_num_threads < 26: scope = "process-wide shared thread pool" - elif max_num_threads > 170: + elif max_num_threads > 35: scope = "thread pool per thread" else: scope = "not sure" From aeb1488016a09d9fa615dd26d36a403214440c12 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 13 Jul 2026 14:31:56 -0400 Subject: [PATCH 7/9] Reformat --- tests/empirical_scope_observation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/empirical_scope_observation.py b/tests/empirical_scope_observation.py index 03bc26e0..0801e1e3 100644 --- a/tests/empirical_scope_observation.py +++ b/tests/empirical_scope_observation.py @@ -2,6 +2,7 @@ Run this script to empirically determine if OpenMP and BLAS limiting API set the size of a shared process-wide thread pool or a per-thread limit. """ + from concurrent.futures import ThreadPoolExecutor from pprint import pprint from threading import Thread From 7b5e1202420fa3b0bb2fb1b383db0bf99274701e Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 13 Jul 2026 14:43:43 -0400 Subject: [PATCH 8/9] Can't run both checks in same process --- continuous_integration/run_tests.sh | 3 +- tests/empirical_scope_observation.py | 75 ++++++++++++++++------------ 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/continuous_integration/run_tests.sh b/continuous_integration/run_tests.sh index 6ce28619..078bc89a 100755 --- a/continuous_integration/run_tests.sh +++ b/continuous_integration/run_tests.sh @@ -19,6 +19,7 @@ fi python -m threadpoolctl -i numpy scipy.linalg tests._openmp_test_helper.openmp_helpers_inner # Introspect API scope: -PYTHONPATH=. python tests/empirical_scope_observation.py +PYTHONPATH=. python tests/empirical_scope_observation.py blas +PYTHONPATH=. python tests/empirical_scope_observation.py openmp pytest -vlrxXs -W error -k "$TESTS" --junitxml=test_result.xml --cov=threadpoolctl --cov-report xml diff --git a/tests/empirical_scope_observation.py b/tests/empirical_scope_observation.py index 0801e1e3..f7b2c299 100644 --- a/tests/empirical_scope_observation.py +++ b/tests/empirical_scope_observation.py @@ -3,23 +3,16 @@ the size of a shared process-wide thread pool or a per-thread limit. """ +import sys from concurrent.futures import ThreadPoolExecutor +from os import cpu_count from pprint import pprint from threading import Thread from typing import Callable -try: - import numpy as np -except ImportError: - np = None import threadpoolctl import psutil -try: - from tests._openmp_test_helper.openmp_helpers_inner import check_openmp_num_threads -except ImportError: - check_openmp_num_threads = None - def start_counting_threads() -> Callable[[], int]: num_threads = 0 @@ -43,33 +36,51 @@ def finish(): return finish -def blas(): - if np is None: +def set_limits(): + threadpoolctl.threadpool_limits(2) + print("== Library info ==") + pprint(threadpoolctl.threadpool_info()) + print() + + +def blas(num_python_threads): + try: + import numpy as np + except ImportError: print("BLAS not available") - return + return False + set_limits() A = np.ones((10_000_000,)) def blas_math(_): A.dot(A) - with ThreadPoolExecutor(20) as pool: + with ThreadPoolExecutor(num_python_threads) as pool: for _ in pool.map(blas_math, range(400)): pass + return True -def openmp(): - if check_openmp_num_threads is None: +def openmp(num_python_threads): + try: + from tests._openmp_test_helper.openmp_helpers_inner import ( + check_openmp_num_threads, + ) + except ImportError: print("OpenMP not available") - return + return False + + set_limits() def run_openmp(_): check_openmp_num_threads(1000) - with ThreadPoolExecutor(20) as pool: + with ThreadPoolExecutor(num_python_threads) as pool: for _ in pool.map(run_openmp, range(400)): pass + return True def run(which: str) -> None: if which == "blas": @@ -77,16 +88,20 @@ def run(which: str) -> None: else: func = openmp + num_python_threads = 10 * cpu_count() count_threads = start_counting_threads() - func() + if not func(num_python_threads): + count_threads() + return max_num_threads = count_threads() - # We're creating 20 Python threads, and asking for 2 native threads. If - # number is close to 20, we'll assume process-wide shared thread pool. If - # the number is close to 40, we'll assume a thread pool per thread. - if max_num_threads < 26: + # We're creating os.cpu_count() * 10 Python threads, and asking for 2 + # native threads. If number is close to number of Python threads, we'll + # assume process-wide shared thread pool. If the number is close to double + # the number of Python threads, we'll assume a thread pool per thread. + if max_num_threads < num_python_threads * 1.25: scope = "process-wide shared thread pool" - elif max_num_threads > 35: + elif max_num_threads > num_python_threads * 1.75: scope = "thread pool per thread" else: scope = "not sure" @@ -98,11 +113,9 @@ def run(which: str) -> None: if __name__ == "__main__": - threadpoolctl.threadpool_limits(2) - print("== Library info ==") - pprint(threadpoolctl.threadpool_info()) - print() - - run("openmp") - print() - run("blas") + if sys.argv[1] == "openmp": + run("openmp") + elif sys.argv[1] == "blas": + run("blas") + else: + raise SystemExit("Bad argument") From f027303933e39baa8ab46b05046949436efc486a Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 13 Jul 2026 14:46:31 -0400 Subject: [PATCH 9/9] Reformat --- tests/empirical_scope_observation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/empirical_scope_observation.py b/tests/empirical_scope_observation.py index f7b2c299..8624820a 100644 --- a/tests/empirical_scope_observation.py +++ b/tests/empirical_scope_observation.py @@ -62,6 +62,7 @@ def blas_math(_): return True + def openmp(num_python_threads): try: from tests._openmp_test_helper.openmp_helpers_inner import ( @@ -82,6 +83,7 @@ def run_openmp(_): return True + def run(which: str) -> None: if which == "blas": func = blas