NumPy默认单核运行因底层BLAS库线程数未启用;需在导入前设置OMP_NUM_THREADS、MKL_NUM_THREADS等环境变量或用threadpoolctl控制,否则多进程反而降低性能。

NumPy 在多核 CPU 上无法跑满所有核心,不是因为写得不好,而是因为它默认不自动开满线程——背后是底层 BLAS 库的线程策略 + Python 层未显式启用并行。
np.dot 等运算为什么只用 1 个核心?
-
NumPy的矩阵乘、SVD、Cholesky 等重计算操作,实际调用的是链接的 BLAS 库(如 Intel MKL、OpenBLAS、Apple Accelerate); - 这些库本身支持多线程,但默认线程数常为 1,尤其在未设置环境变量或未调用初始化函数时;
- 例如:Linux 下 conda 安装的 PyTorch/NumPy 默认链接 MKL,但若没设
MKL_NUM_THREADS,就只用单线程; - macOS 上部分 NumPy 构建使用 Accelerate 框架,它不响应
MKL_NUM_THREADS,也不暴露 OpenMP 控制接口。
常见现象:
-
np.dot(A, B)跑起来只占一个核心 100%,其余核心 idle; -
np.linalg.inv或np.eig同样慢且单核; - 用
psutil.cpu_percent(percpu=True)观察,只有索引 0 的核心跳变,其他恒为 0。
如何让 NumPy 真正用上全部 CPU 核心?
必须显式配置底层 BLAS 的线程数,且要在 NumPy 导入前或首次调用前生效:
-
设置环境变量(推荐,在脚本开头或 shell 中):
立即学习“Python免费学习笔记(深入)”;
macOS UI 自动化技能包 —— 用 Python 控制你的 Mac下载让 macOS 自动化像说话一样简单 —— 用代码控制你的 Mac,无需学习 AppleScript 系统要求 macOS 14+ (Sonoma) Python 3.9+ 需要开启:辅助功能 + 屏幕录制权限
-
export OMP_NUM_THREADS=8(通用,影响 OpenMP 后端) -
export OPENBLAS_NUM_THREADS=8(仅 OpenBLAS) -
export MKL_NUM_THREADS=8(仅 Intel MKL,conda 默认)
-
-
或在 Python 中动态设置(注意:必须在
import numpy之前):import os os.environ["MKL_NUM_THREADS"] = "8" os.environ["OMP_NUM_THREADS"] = "8" os.environ["OPENBLAS_NUM_THREADS"] = "8" import numpy as np
-
验证是否生效:
import numpy as np a = np.random.randn(4000, 4000) b = np.random.randn(4000, 4000) %timeit np.dot(a, b) # 观察耗时下降 + 多核 CPU 利用率跃升
为什么 np.set_num_threads() 不起作用?
-
numpy没有np.set_num_threads()这个函数(那是 PyTorch 的); - 常见误操作:把
torch.set_num_threads(8)当成 NumPy 接口,结果完全无效; - 更隐蔽的坑:Jupyter 中先 import numpy,再设环境变量 → 已加载的 BLAS 实例不会重新读取变量,必须重启 kernel。
和 multiprocessing 一起用会冲突吗?
会,而且很典型:
- 主进程开了
MKL_NUM_THREADS=8,又用ProcessPoolExecutor(max_workers=4)启 4 个子进程; - 每个子进程又继承了环境变量,各自再开 8 线程 → 总共 32 线程争抢缓存和内存带宽;
- 结果:CPU 利用率可能飙到 300%(Linux top 显示),但实际耗时反而比单进程更长。
解决办法:
- 子进程中强制限制 BLAS 线程为 1:
def worker_task(data): import os os.environ["MKL_NUM_THREADS"] = "1" os.environ["OMP_NUM_THREADS"] = "1" import numpy as np return np.dot(data, data.T)
- 或用
threadpoolctl(更干净):from threadpoolctl import threadpool_limits with threadpool_limits(limits=1, user_api="blas"): result = np.linalg.svd(x)
真正跑满 CPU,靠的不是“加进程”,而是在单次 BLAS 调用里填满物理核心;否则就是 8 个进程各占 1 核,不如 1 个进程打满 8 核来得高效。

















