列式内存:pandas 之后是什么
这本书从「一条列」开始,现在回到它——只是这次的问题变成了:这条列往下还能走多远?过去几年 Python 数据栈发生了一次静悄悄的地基更换:pandas 3 的字符串列换了实现,polars 和 duckdb 从边缘工具变成常规选项,Parquet 取代 CSV 成了默认的落盘格式。这些看起来不相干的变化,其实是同一件事——把「一条列」这个想法贯彻到内存布局和文件格式里。
一份两百万行、四列的数据。存成 CSV 是 60 MB。你只需要读其中一列(一列小整数)。
问:从 CSV 读一列,和从 Parquet 读一列,各要多久?
第一件事:pandas 3 换掉了字符串的存法
在 pandas 3 之前,一列字符串的 dtype 是 object——也就是第 1 章那个「一列指针 + 一堆散落的对象」。两百万个城市名,就是两百万个 Python 字符串对象。
pandas 3 把默认改成了 Arrow 支持的 str 类型。本机量了一下同一列的三种存法:
| 存法 | 内存 | 倍数 | 怎么存的 |
|---|---|---|---|
object(pandas 2 的默认) | 108.7 MB | 28.5× | 一列指针 + 两百万个 str 对象 |
str(pandas 3 的默认) | 30.5 MB | 8.0× | 一大块连续字节 + 一列偏移量 |
category | 3.8 MB | 1× | 200 个不重复的值 + 两百万个小整数码 |
三种存法,同样两百万个城市名,内存差 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:
| 工具 | 耗时 | 写法 |
|---|---|---|
| polars | 8.5 ms | df.group_by("city").agg(pl.col("amount").mean()) |
| pandas | 22.9 ms | df.groupby("city")["amount"].mean() |
| duckdb | 40.3 ms | SELECT 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,再想集群。
正确答案是 C:CSV 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,而磁盘和内存带宽是瓶颈。「压缩之后反而更快」在数据系统里是常态,不是例外。这一章的一句话
pandas 之后的路,走的仍然是第 1 章那句话:把「一列指针加一堆对象」换成「一块连续内存加一张说明书」——只是这次换的是字符串列(省 28.5 倍)、是磁盘布局(读一列快 111 倍)、是跨工具的内存格式(三家共用一份数据,答案差 4.3 × 10⁻¹⁴)。
下一章把整本书接回一条线:一份三万行的真实数据,从读进来到交叉验证,七步、五次交接、全程报形状。你会看到那五条竖线——所有的返工,都发生在那五条线上。