📑 本页目录(点开跳转)
01 · 为什么写循环是错的
⏱ 42 分钟 | ⭐ 同一件事,纯 Python 68 毫秒、NumPy 3.5 毫秒 —— 差的是一个量级,而差距的来源不是「C 比 Python 快」
🎯 一句话
慢的不是 Python 这门语言,是「每一个元素都要被当成一个独立的 Python 对象处理一遍」这件事。
NumPy 快,是因为它把「一百万次决策」压缩成了「一次决策 + 一百万次执行」。 这一章会给出一个同样是 NumPy、同样走 C 代码、却慢 8 倍的反例来证明这一点。
⚠️ 先消歧:这一套教程里的「向量化」指把 Python 循环改写成整块数组运算。 站内别处出现的「向量化」(
智能体工程教程/11、大模型全景导论/08等 10 处) 说的是把文本变成 embedding 向量,是另一回事。
📏 一、先看差距有多大
同一件事:把两个长度 100 万的数列逐个相乘。三种写法。
import numpy as np
import timeit
N = 1_000_000
setup = """
import numpy as np
N = 1_000_000
rng = np.random.default_rng(0)
a = rng.random(N)
b = rng.random(N)
la = a.tolist()
lb = b.tolist()
"""
cases = [
("列表推导 [x*y for x,y in zip(la,lb)]", "[x*y for x,y in zip(la,lb)]"),
("for + append", "r = []\nfor i in range(N):\n r.append(la[i]*lb[i])"),
("numpy a * b", "a * b"),
]
for name, stmt in cases:
t = timeit.repeat(stmt, setup=setup, repeat=15, number=1)
t = sorted(t)
print(f"{name:38s} min {t[0]*1e3:8.2f} ms median {t[7]*1e3:8.2f} ms max {t[-1]*1e3:8.2f} ms")
实跑输出:
对照
列表推导 [x*y for x,y in zip(la,lb)] min 68.17 ms median 81.21 ms max 104.56 ms
for + append min 81.23 ms median 87.83 ms max 97.64 ms
numpy a * b min 3.51 ms median 3.97 ms max 5.33 ms
min 口径下差 19 倍。但这个「19」不要记。
💀 同一个脚本在同一台机器上重跑 4 次,倍数分别是 19x / 29x / 35x / 37x。 这台笔记本会热降频,NumPy 那一行的 min 从 3.51 ms 掉到 2.17 ms, 而列表推导那一行反而从 68 ms 涨到 79 ms —— 两头一起动,倍数当然乱跳。
⭐ 所以正文只写「差一个量级、大约 20–40 倍」,不钉死一个数。 这一条本身就是这一章最该记住的教学点之一: 在一台会降频的机器上报单点数字,就是在编数字。 你自己跑出来的绝对毫秒数一定和上面不同 —— 看量级,不要看毫秒。
📌 本章不讲怎么测。 「为什么取 min 不取 mean」「怎么预热」 「
perf_counter和time.time差在哪」「cProfile怎么读」 这些是 Python 工程题,不是 NumPy 题,归《Python》板块的性能剖析章。 上面那六行timeit.repeat是本章唯一一处计时代码,之后不再出现。
🧩 二、慢在哪:解释器每转一圈要做的事
for i in range(N): r.append(la[i]*lb[i]) 这一行,Python 每一圈要做的事远不止「乘一下」:
| 步骤 | 做了什么 | 每圈都要重做吗 |
|---|---|---|
| ① 字节码分派 | 解释器主循环取下一条字节码,跳进对应的处理分支 | ✅ 每圈 |
| ② 下标取值 | la[i] 走 list.__getitem__,边界检查、取出指针 |
✅ 每圈 |
| ③ 解引用 | 顺着指针跳到堆上那个 PyObject,读它的类型标签 |
✅ 每圈 |
| ④ 类型判断 | 「左边是 float、右边是 float,那该调哪个乘法」——⭐ 每圈重问一遍 | ✅ 每圈 |
| ⑤ 拆箱 | 从 PyObject 里把真正的 8 字节 double 掏出来 |
✅ 每圈 |
| ⑥ 真正的乘法 | 一条 CPU 指令,纳秒级 | ✅ 每圈 |
| ⑦ 装箱 | 结果要包成一个新的 PyObject,堆上分配、写类型标签、引用计数置 1 |
✅ 每圈 |
| ⑧ 引用计数 | append 进列表要 Py_INCREF,临时变量出作用域要 Py_DECREF |
✅ 每圈 |
⭐ 第 ⑥ 步才是你真正想做的事,它在整圈里占的比例小到可以忽略。 其余七步是为了让「任意类型都能相乘」这件事成立而付的税,而你一百万次付的是同一笔税。
💾 装箱的代价能直接量出来
import sys
import numpy as np
N = 1_000_000
lst = list(range(N))
arr = np.arange(N, dtype=np.int64)
print("一个 Python int 对象自己占", sys.getsizeof(lst[0]), "字节")
print("list 的指针表 ", sys.getsizeof(lst) / 1e6, "MB")
print("list 连同 100 万个 int ", (sys.getsizeof(lst) + sum(sys.getsizeof(x) for x in lst)) / 1e6, "MB")
print("ndarray int64 ", arr.nbytes / 1e6, "MB")
实跑输出:
对照
一个 Python int 对象自己占 28 字节
list 的指针表 8.000056 MB
list 连同 100 万个 int 36.000056 MB
ndarray int64 8.0 MB
一个整数,真正的数据是 8 字节,Python 对象要 28 字节 —— 多出来的 20 字节是类型指针和引用计数。再加上列表自己那张 8 MB 的指针表, 同样 100 万个整数,Python 要 36 MB,NumPy 要 8 MB,差 4.5 倍。
💀 而且这 4.5 倍不只是内存账,是速度账:那 100 万个 PyObject 散落在堆的各处,
CPU 每取一个都要跳一次地址;ndarray 那 8 MB 是一整块连着的。
(这一层往下就是 CPU cache 的事了,AI 基础设施 02 章
在 GPU 的尺度上讲过同一个道理。)
🚦 三、反例:同样是 NumPy,慢 8 倍
如果「快」的原因是「C 比 Python 快」,那么所有 NumPy 数组都该一样快。
下面这段推翻它 —— 两边都是 np.ndarray,都走同一份 C 代码,只有 dtype 不同:
import numpy as np
import timeit
setup = """
import numpy as np
N = 1_000_000
a_int = np.arange(N, dtype=np.int64)
a_obj = np.arange(N, dtype=object)
"""
for name, stmt in [("dtype=int64 ", "a_int * 2"), ("dtype=object", "a_obj * 2")]:
t = min(timeit.repeat(stmt, setup=setup, repeat=7, number=1))
print(f"{name} a * 2 -> {t*1e3:8.2f} ms")
a = np.arange(5, dtype=object)
print("dtype=object 的数组也是 numpy:", type(a), a.dtype)
实跑输出:
算一算
dtype=int64 a * 2 -> 3.23 ms
dtype=object a * 2 -> 25.41 ms
dtype=object 的数组也是 numpy: <class 'numpy.ndarray'> object
⭐ 同一个库、同一个 * 运算符、同一份 C 实现,差 8 倍。
区别只有一个:dtype=object 的数组里存的还是一排指向 PyObject 的指针,
所以 NumPy 只能退回去,对每个元素重做第二节那八步。
结论要改口了: ❌ 「NumPy 快是因为它是 C 写的」 ✅ 「NumPy 快是因为它拿到的是一整块连续的、类型统一的原始内存, 所以那八步里的 ①③④⑤⑦⑧ 只需要在整个数组上做一次,不是每个元素做一次。」
⚠️ 实践推论:往数组里塞字符串、None、Python 对象、或者长度不齐的列表,
dtype 会悄悄变成 object,你的向量化当场退化成带壳的循环、而且不报错。
养成习惯:拿不准就 print(a.dtype)。
⚡ 四、快在哪:三层,一层比一层浅
前两节讲的是「慢在哪」。反过来说,NumPy 的快是三层叠出来的,量级依次递减:
| 层 | 省掉了什么 | 大致贡献 |
|---|---|---|
| ⭐ ① 只判断一次类型 | 八步里的 ①③④⑤⑦⑧,从「每元素一次」变成「整个数组一次」 | 主力,就是上一节那 8 倍的来源 |
| ② 连续内存 | CPU 预取器能猜到下一个地址;一次 cache line 拉进来 64 字节 = 8 个 float64 | 显著 |
| ③ SIMD | 一条指令同时算 4 个 / 8 个 double(AVX2 / AVX-512) | 锦上添花 |
注意第 ③ 层排在最后。很多人以为向量化 = SIMD,其实 SIMD 只是最后那一点常数因子; ①② 两层是它的前提 —— 数据不连续、类型不统一,SIMD 根本用不上。
⭐ 「向量化」不是「用了 SIMD」,是「把逐元素的决策提到整个数组这一层做一次」。 你写
a * b,等于告诉 NumPy:这一百万次乘法,类型、形状、步长全都一样,按一套方案办。 省下的是决策,不是算术。
🧯 那什么时候循环反而是对的?
⚠️ 别把「不许写循环」当教条。这三种情况循环是正确选择:
| 情况 | 为什么 |
|---|---|
| 迭代次数很少、每次的数组很大 | 比如训练循环跑 100 个 epoch,每个 epoch 内部都是大数组运算 —— 外层那 100 圈的解释器开销可以忽略 |
| 本质是串行的 | 后一步依赖前一步的结果(某些递推、某些自适应算法)。⚠️ 但先确认一下:前缀和、累乘、累计最值都有现成的 cumsum / cumprod / maximum.accumulate,见第 07 章 |
| 向量化版本的中间体撑爆内存 | 第 03 章会看到一个具体例子:400 个样本算两两距离,广播版的中间体是 2.4 MB;样本涨到 4000,中间体就是 244.1 MB |
⭐ 判据不是「有没有 for」,是「解释器每秒要转多少圈」。 外层 100 圈、内层 100 万个元素,这是好的;外层 100 万圈,这是要改的。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 07 · 把循环改写成数组运算 | 这一章讲为什么要改,那一章讲具体怎么改 —— 累积 / 分组 / Top-K / 滑窗 / 分桶 / 游程六个套路,各带一次实跑倍数 |
| 03 · 广播的三条规则 | 消掉循环最常用的工具就是广播。⚠️ 也是「中间体撑爆内存」这个反例的出处 |
| 05 · 内存布局、stride 与 C/F order | 本章第四节那个「② 连续内存」只说了结论。⭐ 想知道「连续」到底是什么、以及为什么换个轴求和会慢 2 倍,去那一章 |
| 08 · 整数 dtype 的真相 | 本章说「拿不准就 print(a.dtype)」。dtype 除了影响速度,还会让你的数字静默变成负数 |
| AI 基础设施 02 · GPU 到底是什么 | 「连续内存为什么快」在 GPU 上是同一个道理的另一个尺度(合并访存 vs CPU cache line)。那边讲 GPU 显存,这边讲 CPU 内存,别混着读 |
| ML 基础 附录 C · 手撕代码速查 | 那份附录里十道题全用 NumPy 写成,默认你已经会向量化。这一章是它的前置 |
✅ 检查点
- 纯 Python 循环和
a * b差多少倍?为什么这一章不给一个确定的数字? - 解释器每转一圈要做八件事,其中哪一件是你真正想做的?其余七件为什么存在?
- 一个 Python
int对象占多少字节?100 万个整数,list和ndarray各占多少内存? - ⭐ 那个「同样是 NumPy 却慢 8 倍」的反例是怎么构造的?它推翻了哪个说法?
- NumPy 的快是三层叠出来的,按贡献从大到小排一下。为什么 SIMD 排在最后?
- 「向量化」的准确含义是什么?(提示:省下的不是算术)
- 什么时候写循环反而是对的?举三种情况。
- 判断「这个循环该不该改」的判据是什么?
👀 答案
- min 口径下约 19 倍(68.17 ms vs 3.51 ms)。不给确定数字是因为同一台机器重跑 4 次,倍数在 19x / 29x / 35x / 37x 之间摆动 —— 这台笔记本会热降频,NumPy 那行的 min 从 3.51 ms 掉到 2.17 ms,列表推导那行反而从 68 涨到 79 ms,两头一起动。💀 在会降频的机器上报单点数字就是在编数字,所以正文只写「一个量级、大约 20–40 倍」。
- 只有 ⑥ 真正的乘法(一条 CPU 指令,纳秒级)是你想做的。其余七步(字节码分派、下标取值、解引用、类型判断、拆箱、装箱、引用计数)是为了让「任意类型都能相乘」成立而付的税,一百万次付的是同一笔税。
- 一个 Python
int占 28 字节(真正的数据只有 8 字节,多出的 20 字节是类型指针和引用计数)。100 万个整数:list连同对象共 36.0 MB(其中指针表 8.0 MB),ndarrayint64 8.0 MB,差 4.5 倍。而且这不只是内存账 —— 那 100 万个PyObject散落在堆各处,ndarray 那 8 MB 是一整块连着的。 - 用
np.arange(N, dtype=object)对比dtype=np.int64:同一个库、同一个*、同一份 C 实现,25.41 ms vs 3.23 ms,差 8 倍。因为 object 数组里存的还是一排指向PyObject的指针,NumPy 只能退回去逐元素重做那八步。它推翻的是「NumPy 快是因为它是 C 写的」——正确说法是「快是因为它拿到的是一整块连续的、类型统一的原始内存」。 - ① 只判断一次类型(主力,就是那 8 倍的来源)> ② 连续内存(CPU 预取、一条 cache line 64 字节 = 8 个 float64)> ③ SIMD(一条指令算 4/8 个 double)。SIMD 排最后是因为它只是常数因子,而且 ①② 是它的前提 —— 数据不连续、类型不统一,SIMD 根本用不上。
- 不是「用了 SIMD」,而是「把逐元素的决策提到整个数组这个层次上做一次」。写
a * b等于告诉 NumPy「这一百万次乘法类型、形状、步长全一样,按一套方案办」——省下的是决策,不是算术。 - ① 迭代次数少、每次的数组很大(训练循环跑 100 个 epoch,外层 100 圈的开销可忽略);② 本质串行(后一步依赖前一步,⚠️ 但先确认不是
cumsum/cumprod/maximum.accumulate能干的);③ 向量化版本的中间体撑爆内存(03 章那个例子:400 个样本算两两距离,广播版中间体 2.4 MB;样本涨到 4000 就是 244.1 MB)。 - 不是「有没有 for」,是「解释器每秒要转多少圈」。 外层 100 圈、内层 100 万个元素是好的;外层 100 万圈是要改的。
🛑 可以停在这里
⚡ 走神救援
⭐ 慢的不是 Python 这门语言,是「每个元素都被当成一个独立的 Python 对象处理一遍」这件事。
解释器每转一圈要做八件事:字节码分派、下标取值、解引用、类型判断、拆箱、真正的乘法、装箱、引用计数。只有第 ⑥ 步是你想做的,其余七步是为「任意类型都能相乘」付的税,一百万次付的是同一笔。
本章的关键证据是那个反例:
np.arange(1_000_000, dtype=object) * 2要 25.41 ms,换成dtype=np.int64只要 3.23 ms —— 同一个库、同一个*、同一份 C 实现,差 8 倍。原因是 object 数组里存的还是一排PyObject指针。⭐ 它推翻了「NumPy 快是因为它是 C 写的」:快是因为它拿到了一整块连续、类型统一的原始内存,那八步里的六步只需在整个数组上做一次。快的三层按贡献排:只判断一次类型 > 连续内存 > SIMD。SIMD 只是最后那点常数因子,前两层是它的前提。⚠️ 塞进字符串、
None、长度不齐的列表会让 dtype 悄悄变成object,向量化当场退化成带壳的循环而且不报错 —— 拿不准就print(a.dtype)。判据不是「有没有 for」,是解释器每秒要转多少圈:外层 100 圈、内层 100 万个元素是好的;外层 100 万圈要改。
(速度倍数只看量级、不看毫秒:同一台机器重跑 4 次是 19x/29x/35x/37x,热降频让两头一起动。)
下一节 👉 02-shape与axis到底怎么数.md