卷 I · 一条列CH 01深度 1/23

一列数字,为什么比一列对象快几十倍

你多半听过一句话:「numpy 快,因为它底层是 C 写的。」这句话是对的,但它什么也没解释——Python 的 list 底层也是 C 写的,sum() 也是 C 写的。真正的差别不在语言,在于一百万个数在内存里长什么样。这一章把两种长相摆在一起量一遍,顺便把整本书的地基埋下去:这五个库共用的东西,不是「Python」,是一条列

34.3 MB 对 7.6 MB59.4 倍跨领域对照表

▷ 先猜一下

一百万个整数,两种装法:list(range(1_000_000))np.arange(1_000_000)

问:前者占的内存,大约是后者的几倍?

A 差不多一样。都是一百万个八字节的整数,能差到哪去 B 大约 2 倍。list 要多存一份指针 C 大约 4 到 5 倍。除了指针,每个整数自己还是一个对象 D 上百倍。Python 的一切都很贵

顺手再猜第二个:同一个表达式 x * 2 + 1,两种写法的耗时比是多少?答案在本章末尾。

先把两种长相画出来

Python 里没有「整数」这种东西,只有整数对象。一个 int 对象里装着引用计数、类型指针、符号与位数、以及真正的数值——本机上量出来是 28 字节。而 list 里装的不是这些对象,是指向它们的指针,一个指针 8 字节。

于是「一百万个整数」在 list 里是这样的:一条一百万格的指针列,加上散落在堆里各处的一百万个小对象。

numpy 的 ndarray 把这两层压成了一层:一块连续的内存,加上一句「这里面每 8 个字节是一个 int64」。没有指针,没有对象,没有引用计数。数值本身就躺在那儿。

本机量一遍

把上面这段话换成数字(Python 3.12.10,numpy 2.5.2,Apple 芯片的 Mac):

一百万个整数

  list:
    指针数组本身            7.6 MB
    一百万个 int 对象  ×28  26.7 MB
    ────────────────────────────
    合计                    34.3 MB

  ndarray(int64):
    8 字节 × 1000000        7.6 MB
    ────────────────────────────
    合计                    7.6 MB

  比值   4.5 倍

注意那个 7.6 MB 出现了两次:光是 list 里那排指针,就已经和整个 numpy 数组一样大了,而指针指向的一百万个对象还一个字节没算。

内存只是账的一半。真正的差距在时间上:

做的事listndarray倍数
x * 2 + 1(逐元素)23.2 ms0.4 ms59.4×
求和2.3 ms0.12 ms19.8×

为什么?把 [x * 2 + 1 for x in pylist] 这一行拆开,CPU 每处理一个数要走完这一趟:

取指针  →  跳到堆上某处  →  读类型指针  →  确认是 int
        →  拆箱取出数值  →  乘 2  →  加 1
        →  申请一个新 int 对象  →  写进去  →  把指针塞回新 list

九步里只有两步是算术。其余七步是为了「这个格子里可以放任何东西」而付的保管费。而 arr * 2 + 1 那一行里,numpy 只做一次类型判断,然后进入一个 C 循环,从头到尾读的是同一种 8 字节的东西——CPU 的预取器能猜到下一个地址,SIMD 指令能一次算四个或八个。

一个反直觉的实验:把循环写在数组上,会更慢

既然数组快,那把循环写在数组上是不是也会快一点?

[x * 2 + 1 for x in pylist]     23.2 ms
[x * 2 + 1 for x in arr]        67.4 ms      ← 慢了 2.9 倍
arr * 2 + 1                      0.4 ms

在 ndarray 上写 Python 循环,比在 list 上写还慢。原因很干脆:数组里躺的是裸字节,不是对象;每次 for x in arr 取出一个元素,numpy 必须当场造一个 Python 对象把它包起来给你。list 里的对象是现成的,数组里的对象要现造。

这条实验值得记住,因为它推翻了「用了 numpy 就快」这个说法。numpy 提供的不是「快的数据结构」,是「让循环消失的机会」。你不接这个机会,它会比原来更慢。

这个速度不是白拿的:一列只能有一种 dtype

连续内存的前提是每一格一样大、一样解释。所以往里塞一个不同类型的东西,numpy 不会报错,它会把整列都改掉

import numpy as np
np.array([1, 2, "3"])
# array(['1', '2', '3'], dtype='<U21')

你以为放进去的是「两个整数和一个字符串」,拿回来的是三个字符串dtype<U21——最长 21 个字符的 Unicode。整列被最宽松的那个成员拖下了水,而且没有任何提示。

这是全书第一次出现「静默地改变了你的东西」。它不会是最后一次。这套工具的设计哲学是:为了让整列能一次算完,它宁可悄悄统一你的类型,也不愿意为一个格子破例。

把这一层看穿之后,别的地方也跟着变了

「一整队同类型的东西挨着放,然后对整队下一次命令」——这不是 numpy 的发明,是一个反复出现的结构。学会认它,你会在很多地方看到同一张脸:

你以为它是其实它是那里的「一条列」是
numpy 是「快一点的 list」一块内存 + 一张说明书shape / dtype / strides
SQL 是「查数据的语言」一种声明式接口:你说要什么,优化器决定怎么走一个字段就是一列
列式数据库是「换了个存法」把「一行的字段挨着放」改成「一列的值挨着放」ClickHouse / Parquet
GPU 是「很多个小 CPU」一条指令同时作用在一整队数据上一个 warp 32 条线程
Excel 是「电子表格」整列一个公式,而不是一格一格填一列就是一列
Kotlin 的 List<Int>IntArray 差不多前者装的是装箱对象,后者是连续的原始类型IntArray
响应式流是「回调的语法糖」把「一个一个处理」变成「描述整条流水线」一个 Flow 就是一条列

这张表就是这本书想给你的东西。你要学的不是五个库的 API,是一种看数据的姿势:先问「我手上这堆东西是什么形状」,再问「哪一层要求它长成那样」。本书剩下的二十二章,都是这两个问题在不同层上的展开。

⌨ 自己跑一遍

这一章的所有数字,十几行就能复现。装好 numpy 之后:

import sys, time, numpy as np

N = 1_000_000
pylist = list(range(N))
arr = np.arange(N, dtype=np.int64)

# 内存
obj = sys.getsizeof(pylist[N // 2])          # 一个 int 对象多少字节
print("list  %.1f MB" % ((sys.getsizeof(pylist) + obj * N) / 1024**2))
print("array %.1f MB" % (arr.nbytes / 1024**2))

# 时间:各跑三遍取最好成绩
def best(fn, r=3):
    return min((lambda t: (fn(), time.perf_counter() - t)[1])(time.perf_counter())
               for _ in range(r))

print("list   %.1f ms" % (best(lambda: [x * 2 + 1 for x in pylist]) * 1000))
print("array  %.1f ms" % (best(lambda: arr * 2 + 1) * 1000))
print("loop on array %.1f ms" % (best(lambda: [x * 2 + 1 for x in arr], 1) * 1000))

本机得到 34.3 MB / 7.6 MB,23.2 ms / 0.4 ms / 67.4 ms。你的机器上倍数会不一样(内存带宽和 CPU 都不同),但三者的相对次序不会变:数组表达式最快,list 循环其次,数组上写循环最慢。

python3 -c "import numpy as np,sys;a=np.arange(10**6);print(a.nbytes/2**20,'MB vs',(sys.getsizeof(list(range(10**6)))+28*10**6)/2**20,'MB')"

要装环境:pip install numpy,或者不装也行——在线跑 jupyter.org/try-jupyter(浏览器里的 JupyterLab,numpy/pandas 都是现成的)。

▸ 在现实里
  • 你手机相册里的每一张照片。一张 4000 × 3000 的照片是 1200 万个像素,每个像素三个通道。如果每个通道是一个对象,光是对象头就要 1 GB。它当然不是——它是一块连续的字节,加上一句「宽 4000、高 3000、每像素 3 个 uint8」。这就是 shapedtype,只不过换了个名字叫「图像格式」。
  • 数据库为什么在二十年前分了岔。OLTP 数据库(MySQL、PostgreSQL)按行存,因为它一次要读一整条订单;OLAP 数据库(ClickHouse、BigQuery)按列存,因为它一次要读一亿行的某一个字段。同一份数据,两种摆法,性能差一到两个数量级。第 21 章会把这笔账算清楚。
  • 深度学习框架的第一个概念就是 tensor。PyTorch 的 torch.Tensor 和 numpy 的 ndarray 是同一个东西的两个实现——连内存布局协议都是通的(torch.from_numpy 零拷贝)。你在这一章学到的 shape 直觉,在深度学习里一天要用二十次。
  • Kotlin 里的 IntArrayList<Int>JVM 上同一个取舍:List<Int> 里装的是装箱的 Integer 对象,IntArray 是连续的原始类型。Android 上处理大数组时选后者,理由和这一章一字不差。
✗ 这个直觉是错的

「numpy 是用 C 写的,所以只要我用 numpy,代码就快了。」

快的不是 numpy,是「循环发生在 C 那一侧」这件事。刚才量过:在 numpy 数组上写 Python 循环,比在 list 上写还慢 2.9 倍——因为每取一个元素都要现造一个 Python 对象。

所以判据不是「我 import 了 numpy 吗」,而是「我这一行代码,Python 解释器要执行多少次?」arr * 2 + 1 是一次;[x * 2 + 1 for x in arr] 是一百万次。这条判据一路管到第 9 章(groupby.applygroupby.sum 慢 8.8 倍,原因一模一样)。

同一个错误还有一个常见变体:df.apply(f, axis=1)。它读起来像「向量化」,实际上是一个逐行的 Python 循环,只是循环写在 pandas 里面而已。

◇ 揭晓

第一问正确答案是 C4.5 倍(34.3 MB 对 7.6 MB)。
第二问:59.4 倍——内存只差 4.5 倍,时间却差 59.4 倍。

A 「都是八字节的整数」——如果 Python 的整数真是八个字节,这个答案就对了。但 Python 的整数是任意精度的:它必须能表示 2 ** 1000,所以每个 int 都带着位数信息和一个变长的数值区,本机上最小 28 字节。这个设计换来了「永不溢出」,代价就是这一章。 B 「多存一份指针」——指针确实多存了,但只占 7.6 MB 里的一半。真正的大头是那一百万个对象各自的对象头。 D 「上百倍」——高估了。对象头虽然贵,但只是常数倍的贵。真正会「上百倍」的是时间,不是内存:如果把上面那个表达式换成一个更重的运算(比如逐元素开方再取指数),倍数会继续往上走,因为 numpy 那一侧还能吃到 SIMD。 顺带说一个容易忽略的点:内存差 4.5 倍、时间差 59.4 倍,这两个数不成比例,说明省下来的主要不是「搬字节的时间」,而是「解释器的时间」。第 21 章会遇到一个刚好相反的例子——那里省下的确实是搬字节的时间。
⌗ 交接单
一堆 Python 对象(list、生成器、CSV 里的字符串)。 一条列:一块连续内存 + 一个统一的 dtype numpy 自己。它不报错,它统一你的类型——[1, 2, "3"] 变成三个字符串,dtype<U21。所以每次 np.array(...) 之后,先看一眼 .dtype

这一章的一句话

numpy 的全部价值,是把「一百万个对象」换成「一块连续的字节 + 一张说明书」;换来的是几十倍的速度,付出的是「一列只能有一种类型」——而这套五个库共用的地基,就是这一条列。

下一章去看那张说明书。它只有三个字段:shapestridesdtype。有意思的是,转置一个 4000 × 4000 的矩阵只要 63 纳秒,而把它真的复制一遍要 35.6 毫秒——差 56 万倍。这不是优化,是因为转置根本没有动那块字节。