卷 V · 合上书之后CH 21深度 21/23

列式内存:pandas 之后是什么

这本书从「一条列」开始,现在回到它——只是这次的问题变成了:这条列往下还能走多远?过去几年 Python 数据栈发生了一次静悄悄的地基更换:pandas 3 的字符串列换了实现,polars 和 duckdb 从边缘工具变成常规选项,Parquet 取代 CSV 成了默认的落盘格式。这些看起来不相干的变化,其实是同一件事——把「一条列」这个想法贯彻到内存布局和文件格式里。

60 MB 读 3 毫秒省 28.5 倍内存三家答案差 4.3e−14

▷ 先猜一下

一份两百万行、四列的数据。存成 CSV 是 60 MB。你只需要读其中一列(一列小整数)。

问:从 CSV 读一列,和从 Parquet 读一列,各要多久?

A 差不多。反正都要把文件读一遍 B CSV 慢一点,大概差 2 到 3 倍 C CSV 334 毫秒,Parquet 3 毫秒。差一百倍 D Parquet 反而慢,因为要解压

第一件事:pandas 3 换掉了字符串的存法

在 pandas 3 之前,一列字符串的 dtypeobject——也就是第 1 章那个「一列指针 + 一堆散落的对象」。两百万个城市名,就是两百万个 Python 字符串对象。

pandas 3 把默认改成了 Arrow 支持的 str 类型。本机量了一下同一列的三种存法:

存法内存倍数怎么存的
object(pandas 2 的默认)108.7 MB28.5×一列指针 + 两百万个 str 对象
str(pandas 3 的默认)30.5 MB8.0×一大块连续字节 + 一列偏移量
category3.8 MB200 个不重复的值 + 两百万个小整数码

三种存法,同样两百万个城市名,内存差 28.5 倍

中间那一行就是 Arrow 的字符串布局:所有字符串首尾相接放在一块连续内存里,另外一条列记「第 i 个字符串从第几个字节开始」。没有对象、没有指针、没有引用计数——第 1 章那张图的字符串版本。

最后一行更极端:只有 200 个不同的城市,那就存 200 个字符串,其余全部用小整数指代。重复度高的字符串列,转成 category 几乎总是划算的,而且 groupby 也会更快。

第二件事:文件格式

CSV 是行式的:一行的四个字段挨着写。要取「金额」这一列,你必须把整个文件读一遍,一路跳过其他三列。而且每个数字都是文本,读的时候要一个一个解析成 float64

Parquet 是列式的:同一列的值挨着放,每列独立压缩,文件末尾有一张目录记着每列从第几个字节开始。本机实测:

两百万行 × 4 列

  文件大小     CSV 60.0 MB      Parquet 19.4 MB       小 3.1 倍
  写           CSV 1696 ms      Parquet 103 ms
  读全部       CSV  334 ms      Parquet  15 ms        快 21.9 倍
  只读一列     CSV  334 ms      Parquet   3 ms        ★ 快 111 倍

  Parquet 里各列占的字节
    amount  15.78 MB   (81.4%,随机浮点数压不动)
    city     1.92 MB
    cat      0.96 MB
    qty      0.73 MB   (3.7%,只有 8 个取值,压得很扁)

三个数字值得记住:

  • 只读一列快 111 倍——因为 CSV 那 60 MB 必须全读,Parquet 只读那一列的 0.73 MB。
  • 写快 16 倍——CSV 要把每个浮点数格式化成文本,那是很贵的操作。
  • 各列大小差 20 倍——qty 只有 8 个取值,字典编码加位压缩之后几乎不占地方;amount 是随机浮点数,压不动。列式存储的压缩率好,正是因为同一列的值长得像。

还有一件 CSV 做不到的事:Parquet 存了 schema。读回来 int8 还是 int8,日期还是日期,不用猜、不用写 dtype=、不会因为前导零把 ID 读成整数。第 10 章那个「两边键的 dtype 不一样,一行都合不上」,换成 Parquet 就不存在了。

◆ 主线

中间结果落盘,默认用 Parquet,不用 CSV。

一行代码的事:df.to_parquet("x.parquet")(需要 pip install pyarrow)。
CSV 只在一个地方仍然不可替代:要给人看、或者要给不确定的下游用。它的优点是任何工具都能打开。

第三件事:同一份内存,三家一起用

Arrow 的野心不止是「一种存法」,是一种大家都认的内存格式。于是 pandas、polars、duckdb 可以在不复制的前提下互相传数据。本机拿同一份两百万行的数据做同一个 groupby

工具耗时写法
polars8.5 msdf.group_by("city").agg(pl.col("amount").mean())
pandas22.9 msdf.groupby("city")["amount"].mean()
duckdb40.3 msSELECT city, avg(amount) FROM t GROUP BY city

三家算出来的 200 个组均值,最大差异是 4.3 × 10⁻¹⁴——浮点累加顺序不同造成的舍入差别,数学上是同一个答案。

这里要说句公道话:这个规模上 duckdb 反而最慢,因为它有查询解析和计划生成的固定开销。换成「直接查一个 Parquet 文件」,情况就反过来了:

duckdb 直接在 parquet 文件上做同一个 groupby      6.6 ms
(pandas 光是把这个文件读进内存就要 15 ms)

因为它只读需要的那两列,而且边读边算。数据大到装不进内存时,这个差别就不是「快几倍」而是「能不能跑」。

那还要不要学 pandas

要。理由很实际:

工具什么时候用代价
pandas默认。生态最大——sklearn、seaborn、statsmodels、每一份教程单线程、内存吃得多、API 有历史包袱
polars数据几百万行以上;喜欢显式的 API;要多线程生态小;API 要重学(不过更规整)
duckdb你本来就会写 SQL;数据在文件里;数据大于内存要在 SQL 和 Python 之间来回切

更重要的是:这本书前二十章讲的东西,在三家都成立。对齐、缺失值的三个假设、groupby 的两种形状、merge 会放大行数、长表与宽表、泄漏——换个工具一条都不会消失,因为它们是数据本身的性质,不是 API 的性质。

✎ 术语正名

Apache Arrow 常被误认为是一种文件格式。它不是——它是一种内存格式,规定「一列数据在 RAM 里应该长什么样」。落盘的那个是 Parquet(Arrow 项目的姊妹项目)。一个管内存,一个管文件。

Arrow 的价值在于省掉序列化:过去两个进程(或两种语言)之间传数据,要序列化成某种中间格式再反序列化;现在双方都用 Arrow 布局,直接把那块内存指过去就行。「零拷贝」这个词在这里是字面意思。

还有两个词值得知道:

  • 谓词下推(predicate pushdown):把 WHERE amount > 100 推到读文件那一层,跳过整块不满足条件的数据。
  • 投影下推(projection pushdown):只读用到的列。就是这一章那个「只读一列快 111 倍」。

这两个词你会在 Spark、duckdb、polars 的文档里反复看到,它们都是「列式」这个决定的直接推论。

⌨ 自己跑一遍

内存那三种存法(需要 pandas 3):

import numpy as np, pandas as pd
rng = np.random.default_rng(21)
words = np.array([f"city_{i:03d}" for i in range(200)])
col = pd.Series(words[rng.integers(0, 200, 2_000_000)])

def mb(s): return (s.memory_usage(deep=True) - s.index.memory_usage(deep=True)) / 1024**2
print("%s  %.1f MB" % (col.dtype, mb(col)))                          # str  30.5 MB
print("object     %.1f MB" % mb(pd.Series(col.to_numpy(), dtype=object)))   # 108.7
print("category   %.1f MB" % mb(col.astype("category")))             # 3.8

CSV 对 Parquet(要 pip install pyarrow,会写出约 80 MB 的临时文件):

import time, os, tempfile
df = pd.DataFrame({"city": col, "cat": rng.integers(0, 12, 2_000_000).astype(np.int8),
                   "amount": rng.gamma(3., 20., 2_000_000),
                   "qty": rng.integers(1, 9, 2_000_000)})
d = tempfile.mkdtemp(); csv, pq = f"{d}/t.csv", f"{d}/t.parquet"

def T(fn, r=1):
    return min((lambda t: (fn(), time.perf_counter()-t)[1])(time.perf_counter()) for _ in range(r))

print("写 CSV %.0f ms" % (T(lambda: df.to_csv(csv, index=False)) * 1000))
print("写 PQ  %.0f ms" % (T(lambda: df.to_parquet(pq, index=False)) * 1000))
print("大小 %.1f MB vs %.1f MB" % (os.path.getsize(csv)/2**20, os.path.getsize(pq)/2**20))
print("读 CSV %.0f ms" % (T(lambda: pd.read_csv(csv)) * 1000))
print("读 PQ  %.0f ms" % (T(lambda: pd.read_parquet(pq), 2) * 1000))
print("读一列 %.1f ms" % (T(lambda: pd.read_parquet(pq, columns=["qty"]), 3) * 1000))

三家对照(pip install polars duckdb):

import polars as pl, duckdb
pldf = pl.from_pandas(df)
con = duckdb.connect(); con.register("t", df)
a = df.groupby("city", observed=True)["amount"].mean().sort_index().to_numpy()
b = pldf.group_by("city").agg(pl.col("amount").mean()).sort("city")["amount"].to_numpy()
c = np.array([r[1] for r in sorted(con.execute(
    "SELECT city, avg(amount) FROM t GROUP BY city").fetchall())])
print("三家最大差异 %g" % max(abs(a-b).max(), abs(a-c).max()))     # 4.26e-14

python3 -c "import pandas as pd;print(pd.__version__, pd.Series(['a','b']).dtype)"

如果最后一行打印的是 object,说明你的 pandas 还是 2.x,这一章第一张表的第一行就是你现在的默认。

▸ 在现实里
  • 数据湖就是一堆 Parquet 文件。Iceberg、Delta Lake、Hudi 这些「表格式」,本质是一堆 Parquet 文件 + 一份记录哪些文件属于哪个版本的元数据。它们提供事务和时间旅行,底下的数据布局就是这一章讲的东西。
  • 为什么 Spark 的 select 要写在前面。df.select("a", "b").filter(...) 比读完再选快得多——投影下推。Spark 的查询优化器会自动做这件事,但只有在数据是列式格式时才有效。Parquet 之所以成为大数据的默认格式,就是因为它让这类优化成为可能。
  • 手机上的列式存储。Android 的 Room/SQLite 是行式的(一次读一整条记录),但埋点日志上传前的本地聚合越来越多用列式——同样的理由:只算某几个字段时,不该把整条记录都读出来。
  • 这本书第 1 章那个 4.5 倍,在这里变成了 28.5 倍。同一个道理,换了个层次:把「一列指针 + 一堆对象」换成「一块连续内存 + 一张说明书」。从 numpy 的数值列,到 Arrow 的字符串列,到 Parquet 的磁盘布局——三次同样的取舍,三次同样的收益。
✗ 这个直觉是错的

「pandas 慢是因为 Python 慢,所以想快只能换语言,或者上分布式。」

这一章的每一个提速都不是换语言换来的category 省 28.5 倍内存、Parquet 读一列快 111 倍、polars 快 2.7 倍——全都来自「数据在内存和磁盘上怎么摆」。同一门 Python,同一台机器。

而「上分布式」常常是一个更贵的错误答案:几千万行的数据在一台现代笔记本上用 polars 或 duckdb 处理完全没问题,而一个 Spark 集群带来的是运维成本、调试难度和几十秒的启动开销。先把布局改对,再考虑加机器。

三个几乎零成本的动作:(一)中间结果存 Parquet 不存 CSV;(二)重复度高的字符串列转 category;(三)数据大到 pandas 吃力时,先试 polars 或 duckdb,再想集群。

◇ 揭晓

正确答案是 CCSV 334 毫秒,Parquet 3 毫秒,差 111 倍

A 「反正都要读一遍」——CSV 确实必须读一遍(60 MB 全部),因为它是行式的,你要的那一列被切成了两百万段,散布在整个文件里。Parquet 不用:文件末尾的目录告诉它那一列在哪儿,直接跳过去读 0.73 MB。 B 「差 2 到 3 倍」——读全部列时差 21.9 倍(334 ms 对 15 ms,来自压缩和免解析)。只读一列时差 111 倍,多出来的那部分正是投影下推列越多,这个差距越大——真实的宽表常有上百列。 D 「解压所以更慢」——解压确实要花时间,但它换来的是少读的字节数。现代压缩算法(Snappy、LZ4)的解压速度是每秒几个 GB,而磁盘和内存带宽是瓶颈。「压缩之后反而更快」在数据系统里是常态,不是例外。
⌗ 交接单
内存里的一张表。 一个带 schema 的列式文件(Parquet),下游可以只读需要的列、只读满足条件的块,而且不用猜类型。 Parquet 自己:schema 存在文件里,类型对不上时读的那一方会报错。这是继 sklearn 之后,第二个会替你把关的地方——而 CSV 从来不会。

这一章的一句话

pandas 之后的路,走的仍然是第 1 章那句话:把「一列指针加一堆对象」换成「一块连续内存加一张说明书」——只是这次换的是字符串列(省 28.5 倍)、是磁盘布局(读一列快 111 倍)、是跨工具的内存格式(三家共用一份数据,答案差 4.3 × 10⁻¹⁴)。

下一章把整本书接回一条线:一份三万行的真实数据,从读进来到交叉验证,七步、五次交接、全程报形状。你会看到那五条竖线——所有的返工,都发生在那五条线上。