6.2 性能优化:向量化与编译加速 本节摘要:性能优化的第一原则是先测再优化:用计时与剖析工具找到真正的瓶颈,而不是凭感觉改代码。本节依次讲透四条主路——向量化与内存布局(C 连续与避免中间拷贝)、BLAS 线程设置、Numba 的 jit 编译、稀疏矩阵的合理时机,每条都给出适用场景、代价与预期收益,最后落到"每次只改一处、改完复测"的工程节奏上。 阅读收获 阅读完本节,你应当能够: 用 timeit 与剖析工具定位一段代码的真正瓶颈; 解释 C 连续内存为什么快,以及如何避免中间拷贝; 配置 BLAS 线程并说清多线程的收益边界; 判断一个循环该交给 Numba 还是该改写; 说出稀疏矩阵值得使用的量化条件。 一、问题与直觉 同样的计算,别人写的程序跑 0.1 秒,你的要跑 10 秒。
本节摘要:性能优化的第一原则是先测再优化:用计时与剖析工具找到真正的瓶颈,而不是凭感觉改代码。本节依次讲透四条主路——向量化与内存布局(C 连续与避免中间拷贝)、BLAS 线程设置、Numba 的 jit 编译、稀疏矩阵的合理时机,每条都给出适用场景、代价与预期收益,最后落到"每次只改一处、改完复测"的工程节奏上。
阅读完本节,你应当能够:
同样的计算,别人写的程序跑 0.1 秒,你的要跑 10 秒。多数人第一反应是上多进程、上 GPU、换语言——结果往往白忙一场,因为真正的瓶颈根本不在那里。我们在第 6.1 节的管线里已经埋了一个伏笔:站点只有三个时,一切秒回;可当站点变成三百个、序列拉长到六十年,同样的代码可能要跑几个小时。
性能优化的第一原则听起来像废话,但九成的人做不到:先测,再优化。先找到时间到底花在哪一行,再决定用哪招。这一节把四条主路讲透,每条的适用条件、代价和收益都摆出来,你照着决策流程走,基本不会跑偏。
最轻量的工具是 timeit,适合比较"两段写法谁快":
import numpy as np import timeit rng = np.random.default_rng(0) a = rng.normal(size=(3000, 3000)) b = rng.normal(size=(3000, 3000)) t_dot = timeit.timeit(lambda: np.dot(a, b), number=3) t_loop = timeit.timeit(lambda: [a[i] @ b[i] for i in range(3000)], number=1) print(f"向量化矩阵乘: {t_dot:.3f} 秒") print(f"逐行循环: {t_loop:.3f} 秒")
矩阵乘会调用底层 BLAS 库,三千阶的矩阵乘也就零点几秒;而逐行循环每行都触发一次 Python 解释器开销,慢出两个数量级。这就是"向量化"最直观的收益。
当代码变长、函数变多时,用剖析工具看整体分布:
import cProfile def slow_sum(n): total = 0.0 for i in range(n): total += np.sqrt(i) return total cProfile.run("slow_sum(100000)")
输出会按函数列出累计耗时和调用次数,热点一目了然。cProfile 的规则只有一条:只优化排名前几的热点,别碰占比不到 1% 的边角。很多人把时间花在"优化一个只跑一次的函数"上,那是自我感动。
向量化就是用数组运算替代 Python 循环,让计算下沉到 C 层。除了矩阵乘,常见的还有分支用 np.where、条件替换用掩码、聚合用 sum 加 mean 一族。举个典型例子:
x = np.linspace(0, 10, 1_000_000) y = np.where(x > 5, np.sin(x), np.cos(x)) # 向量化分支,替代逐元素 if
同样的活,用循环写要一百多万次解释器跳转,向量化一行结束。
但向量化不是全部。内存布局决定了一个数组运算能不能吃到连续内存的带宽。NumPy 数组默认是 C 连续(行优先),访问时按顺序读内存,缓存命中率高;而转置、按列切片、花式索引会产生不连续视图或副本,访问时步幅跳跃,同样的计算量可能慢几倍:
big = np.random.default_rng(1).normal(size=(4000, 4000)) row = big[100, :] # 连续访问,快 col = big[:, 100] # 不连续访问,慢
如果确实需要按列反复算,先 np.ascontiguousarray 转成连续布局,多数情况下一次拷贝的代价远小于无数次跳跃访问。另一个常见浪费是中间拷贝:c = a + b 会新建数组,循环里反复写 x = x + 1 会反复分配内存。用原地运算和 out 参数可以省掉这些分配:
x += 1.0 # 原地,不新建 np.add(a, b, out=c) # 结果写进已存在的 c
在小数据上这点差别无感,数组上千万元素时,内存分配和拷贝就是实打实的时间。
广播还有一个附带好处:它能替代 tile 和 repeat 这类显式复制。比如要把一维的均值向量加到二维数组的每一行,直接用 a + mean 靠广播完成,零拷贝;先用 tile 复制成同样形状再相加,白白多占一份内存。判断一段代码值不值得改写成广播,就看它是不是在"复制数据只为对齐形状"——如果是,广播通常更省,而且代码更短。
SciPy 的线性代数、NumPy 的矩阵乘,底层都是 BLAS 库(常见的是 OpenBLAS)。它默认会开满机器的线程,大矩阵运算直接吃满多核。这通常是好事,但有两个坑。
坑一:小矩阵上多线程反而慢。启动和同步线程的开销可能超过计算本身,几千乘几千的矩阵乘,单线程往往更快。坑二:嵌套并行。你的程序已经用了多进程,每个进程里的 BLAS 又开满线程,核数会被抢爆,整体反而变慢。
控制线程数的办法是在导入 NumPy 之前设置环境变量:
import os os.environ["OPENBLAS_NUM_THREADS"] = "4" # 必须在导入 numpy 之前 import numpy as np
想确认当前用的是哪个 BLAS、多少线程,可以看 np.show_config() 的输出。我们的一般建议:数据规模大就保持默认多线程,规模小或者已经用了多进程就主动限制线程数,先测两种配置再定。
有些计算天生不适合向量化——比如循环体里有复杂的标量逻辑、依赖前一步结果的递推。这时可以请 Numba 出场,它用 LLVM 把 Python 函数编译成机器码:
import numpy as np from numba import njit @njit def estimate_pi(n): hits = 0 for _ in range(n): x = np.random.random() y = np.random.random() if x * x + y * y <= 1.0: hits += 1 return 4.0 * hits / n print(estimate_pi(2_000_000))
蒙特卡洛算圆周率这种"循环里全是标量判断"的代码,纯 Python 写要几秒,njit 编译后毫秒级,提速常常是两三个数量级。代价有三:第一次调用要编译,会卡一下(约零点几秒到几秒,取决于函数复杂度);函数里只能用 Numba 支持的 NumPy 子集,复杂对象和动态特性用不了;编译失败时错误信息不如纯 Python 友好。
我们的判断标准很简单:循环能向量化,先向量化;向量化不了但循环逻辑简单、会被反复调用,才上 Numba。一次性的小循环不值得折腾。
稀疏矩阵在第 3 章讲过,这里补一个量化的判断。scipy.sparse 的存储有额外索引开销,非零占比(密度)高于某个阈值时反而比稠密数组更慢更费内存。经验阈值大致在 5% 左右:密度低于 5% 的大矩阵值得换稀疏,高于 10% 就别折腾了,中间地带要实测。
另一个容易被忽略的点:稀疏矩阵某些操作会"悄悄变稠密",比如布尔索引、某些逐元素函数,结果类型一变,内存立刻爆炸。遇到这种问题,检查返回类型,必要时转回 csr 或 csc 格式。具体排错细节在第 6.3 节展开。
最后提醒一句:SciPy 不少函数内部已经做了并行化——线性代数走 BLAS 多线程,某些距离计算、滤波也有并行实现。用之前先查文档,别自己再包一层多进程,否则两套并行叠在一起,核数翻倍但速度不升反降。

| 优化手段 | 适用场景 | 主要代价 | 预期收益 |
|---|---|---|---|
| 向量化 | 能用数组运算表达的计算 | 需要改写思维方式 | 数十到数百倍 |
| 内存布局调整 | 大数组反复访问 | 偶尔多一次拷贝 | 数倍 |
| BLAS 线程设置 | 稠密线性代数 | 多核争抢、小矩阵反效果 | 数倍 |
| Numba 编译 | 无法向量化的简单循环 | 编译开销、兼容限制 | 数十到数百倍 |
| 稀疏存储 | 密度低于 5% 的大矩阵 | 元素级访问慢 | 内存与时间双降 |
| 内置并行 | 已有并行实现的函数 | 几乎没有 | 看具体函数 |
第一个习惯:优化前留基准。把当前耗时记下来,改一处、测一次,收益不达预期就回退。没有基准的优化是打空气。第二个习惯:先正确后快。先保证结果正确,再做性能工作;优化前后的结果要能对得上,否则可能是优化出了错答案。第三个习惯:警惕过早优化。第 1 章就说过,小数组上 NumPy 与 SciPy 的差异可以忽略,别在一万次都不调用的函数上较劲。
⚠️ 常见坑:BLAS 默认多线程加你自己开的多进程叠在一起,核数被抢爆,速度不升反降。另外
timeit默认会关掉垃圾回收,测量带大量分配的代码时可能失真,必要时手动开 gc。
💡 关键直觉:性能优化九成是"减少 Python 解释器的参与"——向量化把循环交给 C,Numba 把函数编译成机器码,稀疏化减少内存搬运。理解了这一条,你就知道为什么大部分场景的答案不是多进程而是改写代码本身。
拿一个真实感的问题演示整套流程:对一百万个随机点,统计落在单位圆内的比例。先写最直觉的循环版本,测出基准;再用向量化改写;最后比较两者的时间。
import numpy as np from numba import njit rng = np.random.default_rng(7) n = 1_000_000 x = rng.random(n) y = rng.random(n) # 版本一:Python 循环(慢,作基准) def in_circle_loop(x, y): count = 0 for i in range(x.size): if x[i] * x[i] + y[i] * y[i] <= 1.0: count += 1 return 4.0 * count / x.size # 版本二:向量化(推荐) def in_circle_vec(x, y): return 4.0 * np.sum(x * x + y * y <= 1.0) / x.size # 版本三:Numba 编译循环 @njit def in_circle_jit(x, y): count = 0 for i in range(x.size): if x[i] * x[i] + y[i] * y[i] <= 1.0: count += 1 return 4.0 * count / x.size
在普通笔记本上,版本一要两三秒,版本二只要十几毫秒,版本三首次调用含编译约零点几秒、之后每次调用十几毫秒。向量化版本通常就是终局答案;只有当判断逻辑复杂到 np.where 写不出来、且函数被反复调用时,Numba 才值得出场。这个案例浓缩了本节全部主张:先有基准,再谈优化;能用数组运算就别写循环;编译是兜底手段而不是习惯动作。
性能工作的收益是递减的:把 10 秒压到 1 秒,值得;把 1 秒压到 0.9 秒,多半不值得。我们的原则是"够用就好"——先定一个目标(比如单次分析控制在 10 秒内),达标就收手,把时间花在正确性验证和新功能上。数据规模没变大之前,别为想象出来的瓶颈优化。
优化改的是性能,排错救的是正确性。下一节我们把全书高频报错整理成一张排查地图,让你遇到错误时不再对着英文报错发懵。