🏠 总目录📚 本教程 01 · 为什么写循环是错的 ← →
📑 本页目录(点开跳转)

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 写成,默认你已经会向量化。这一章是它的前置

✅ 检查点

  1. 纯 Python 循环和 a * b 差多少倍?为什么这一章不给一个确定的数字?
  2. 解释器每转一圈要做八件事,其中哪一件是你真正想做的?其余七件为什么存在?
  3. 一个 Python int 对象占多少字节?100 万个整数,list 和 ndarray 各占多少内存?
  4. ⭐ 那个「同样是 NumPy 却慢 8 倍」的反例是怎么构造的?它推翻了哪个说法?
  5. NumPy 的快是三层叠出来的,按贡献从大到小排一下。为什么 SIMD 排在最后?
  6. 「向量化」的准确含义是什么?(提示:省下的不是算术)
  7. 什么时候写循环反而是对的?举三种情况。
  8. 判断「这个循环该不该改」的判据是什么?
👀 答案
  1. 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 倍」。
  2. 只有 ⑥ 真正的乘法(一条 CPU 指令,纳秒级)是你想做的。其余七步(字节码分派、下标取值、解引用、类型判断、拆箱、装箱、引用计数)是为了让「任意类型都能相乘」成立而付的税,一百万次付的是同一笔税。
  3. 一个 Python int 占 28 字节(真正的数据只有 8 字节,多出的 20 字节是类型指针和引用计数)。100 万个整数:list 连同对象共 36.0 MB(其中指针表 8.0 MB),ndarray int64 8.0 MB,差 4.5 倍。而且这不只是内存账 —— 那 100 万个 PyObject 散落在堆各处,ndarray 那 8 MB 是一整块连着的。
  4. 用 np.arange(N, dtype=object) 对比 dtype=np.int64:同一个库、同一个 *、同一份 C 实现,25.41 ms vs 3.23 ms,差 8 倍。因为 object 数组里存的还是一排指向 PyObject 的指针,NumPy 只能退回去逐元素重做那八步。它推翻的是「NumPy 快是因为它是 C 写的」——正确说法是「快是因为它拿到的是一整块连续的、类型统一的原始内存」。
  5. ① 只判断一次类型(主力,就是那 8 倍的来源)> ② 连续内存(CPU 预取、一条 cache line 64 字节 = 8 个 float64)> ③ SIMD(一条指令算 4/8 个 double)。SIMD 排最后是因为它只是常数因子,而且 ①② 是它的前提 —— 数据不连续、类型不统一,SIMD 根本用不上。
  6. 不是「用了 SIMD」,而是「把逐元素的决策提到整个数组这个层次上做一次」。写 a * b 等于告诉 NumPy「这一百万次乘法类型、形状、步长全一样,按一套方案办」——省下的是决策,不是算术。
  7. ① 迭代次数少、每次的数组很大(训练循环跑 100 个 epoch,外层 100 圈的开销可忽略);② 本质串行(后一步依赖前一步,⚠️ 但先确认不是 cumsum/cumprod/maximum.accumulate 能干的);③ 向量化版本的中间体撑爆内存(03 章那个例子:400 个样本算两两距离,广播版中间体 2.4 MB;样本涨到 4000 就是 244.1 MB)。
  8. 不是「有没有 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

打卡记录保存在你的浏览器里,首页能看到总进度