前几天一个同事找我调内存问题:同样一份 Parquet 文件,他用 pandas 读出来的内存占用比我高一倍。我让他看了一眼 dtype,果然,他那边清一色的 int64、float64,我这边全是 int64[pyarrow]、string[pyarrow]。就这个差别,在 Pandas 2.2+ 里被很多人低估了——大家以为 PyArrow 支持只是多了一种存储格式,但真正值钱的,是它带来的内存零拷贝通路。
这篇文章想把这个话题拆透:零拷贝到底是怎么发生的、哪些操作真的零拷贝、哪些操作看似零拷贝实际在偷偷复制,以及 Copy-on-Write 和零拷贝之间到底是什么关系。适合正在做大内存数据处理、想把 pandas 从几十 G 内存里捞出来的朋友,也适合想搞明白 pandas 2.x 内部机制、避免被各种“内存优化技巧”误导的读者。
1. 先说清楚:零拷贝在Pandas里到底指什么
1.1 从Arrow列式格式讲起:为什么同样的数据,Arrow更容易共享
很多人第一反应是:numpy 切片也是 view,不复制啊,凭什么说 Arrow 能带来零拷贝?这句话对了一半。numpy 的 view 确实能在单个 ndarray 内部共享数据,但 pandas 里的数据结构远比单个 ndarray 复杂——一个 DataFrame 是多个 Block、多个索引、多个 ExtensionArray 的组合体,想让数据在整套结构里零成本流转,光靠 numpy 的 slice 语义是不够的。
Arrow 的列式 Layout 是为零拷贝而生的,典型固定宽度类型(比如 int64)的存储逻辑非常简单:一块连续的数据缓冲区 + 一个长度 + 一个 offset。第 n 个元素的地址可以原地算出来:base_ptr + (offset + n) * 8。更关键的是,Arrow 定义了一套标准的 C 数据接口,不同库之间可以直接传指针和元数据,数据本身不需要搬家。
这也解释了为什么 Parquet 文件里的数据能被 DuckDB、Polars、Arrow Flight 这些工具零拷贝共享——因为大家都认同一套内存布局协议。Pandas 2.x 接住 PyArrow 之后,等于在 pandas 和 Arrow 生态之间开了一扇门,数据可以在不复制的前提下流转。
1.2 Pandas老架构的“隐形拷贝”:BlockManager是怎么浪费内存的
老版本 pandas 的内存模型是 BlockManager:按 dtype 把若干列拼进一个二维 numpy 数组。这设计在当时是为了性能,它也确实让同 dtype 列的向量化计算很快。但它有一个隐藏代价——一旦你想取出其中一部分列,或者做某些重排操作,Block 可能被迫复制整块数据。
举个例子,df[['a', 'b']] 这种操作,如果 a、b 两个列在同一个 Block 里,为了得到一个连续的新 Block,pandas 往往得把数据从大 Block 里拷出来;而单列 df['a'] 反而可能拿到的是原生 view,于是 SettingWithCopyWarning 的坑就出现了。更麻烦的是,在 2.0 之前,pandas 根本无法精确追踪“哪些 DataFrame 共享了同一块底层数据”,为了安全,很多操作宁可复制。
这个问题不是某一段代码写得差,而是 BlockManager 的引用模型太粗。到 2.x,pandas 引入 CoW 就是为了从机制上解决这个“看不清楚共享关系”的问题,而 PyArrow dtype 则从另一边把数据内存本身变成了更适合零拷贝的形式,两者是互补的。
1.3 零拷贝不等于“马上能省内存”:它解决的是数据通路问题
我经常看到有人写“一行代码让你的 pandas 内存占用减半”,然后开一堆 PyArrow dtype。实际上 PyArrow dtype 本身并不压缩数据,int64 存进去还是 8 字节一个元素。零拷贝真正解决的,是“数据通路”上的浪费,而不是“数据本身”的缩小。
更直白地说,零拷贝解决的是这种情况:你有一份 10GB 的 Arrow 数据,想从里面切出一个子集,或者只取出其中几列。如果通路顺畅,新 DataFrame 可以继续复用那 10GB 的缓冲区,内存不会显著上涨;如果通路有问题,pandas 会先完整复制一份 10GB 的数组,内存瞬间翻倍。
但有一个边界必须说清楚:只要数据跨越了“Arrow 域”和“numpy/Python 域”之间的边界,零拷贝大概率会失效。比如把 Arrow 字符串列转成 Python object 列、把 Arrow int64 转成 numpy int64——除非布局恰好碰巧兼容,否则新建堆内存是必然的。所以后面所有实操都围绕一个原则:尽量让数据在 Arrow 域内流动,跨域转换要谨慎,最好显式控制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PyArrow dtype在实际项目里走得通的几条零拷贝路径
2.1 从Parquet读出到DataFrame:两个参数保住Arrow缓冲区
先用最常用的场景开场:读 Parquet。
python复制import pandas as pd
df = pd.read_parquet(
"large_dataset.parquet",
engine="pyarrow",
dtype_backend="pyarrow",
)
engine="pyarrow" 决定文件的解码由 pyarrow 完成,dtype_backend="pyarrow" 决定解码出来的数据以 Arrow 类型直接装进 DataFrame。这两个参数缺一个,结果都不同。只写 engine="pyarrow" 不写 dtype_backend,读出来大概率还是 numpy 那套 dtype;只写 dtype_backend="pyarrow" 但文件本身走的是 fastparquet 或其他引擎,Arrow 通路也不成立。
如果数据不是直接用 read_parquet,而是你手里已经有一个 pyarrow.Table,也能零拷贝地转成 DataFrame:
python复制import pyarrow as pa
import pyarrow.parquet as pq
table = pq.read_table("large_dataset.parquet")
df = table.to_pandas(types_mapper=pd.ArrowDtype)
关键在第 7 行的 types_mapper=pd.ArrowDtype。没有这个参数,to_pandas() 会把 Arrow 数据尽力转成 numpy/object 类型,那本质上是一次完整的重新编码,数据缓冲区全部重建。加了之后,pandas 会尽可能保留 Arrow 的 ChunkedArray 作为底层存储。
2.2 列选择、行切片、drop列:三种高频操作的实际表现
数据进来了,下面这些高频操作是否零拷贝,我直接给结论:
| 操作 | 是否零拷贝 | 说明 |
|---|---|---|
df[['col1', 'col2']] |
是 | 只是重构列引用,不复制底层缓冲 |
df['col1'] |
是 | 同理,拿到的是列的引用 |
df.iloc[1000:2000] |
是 | Arrow 的 ChunkedArray 切片是 O(1) 的,只改 offset 和 length |
df.drop(columns=['col1']) |
是 | 删除列只是移除引用,剩余列数据不动 |
df[df['col'] > 0] |
否 | 布尔筛选要按条件收集数据,Arrow 的 filter 是 materialize 的 |
前四行很多人容易混淆,尤其是“行切片”。在老版本 pandas 里,df.iloc[1000:2000] 很难保证零拷贝,因为 BlockManager 要同时维护多个 Block 的视图一致性,稍有不慎就变成完整复制。但在 Arrow 类型下,每个列都是独立的 ChunkedArray,切片时每一列都可以走 ChunkedArray.slice,这个操作不分配新数据,只是把 offset 和 length 包装成新的引用。
需要特别留意的是布尔筛选。很多人以为 df[df['col'] > 0] 也是零拷贝,其实不是。Arrow 的 filter 函数会把符合条件的行“收集”到一个新数组里,它本质上是一次 gather,不是 view。所以筛选操作通常会让内存上涨一部分,这是正常的,不是 bug。
2.3 用缓冲区地址和zero_copy_only做“零拷贝体检”
判断某个操作到底有没有拷贝,别靠猜,用系统的方法验证。
第一步,拿到列的底层 ChunkedArray:
python复制arr = df['col1'].array._pa_array
chunk = arr.chunk(0) # 如果这个列只有一个 chunk
data_buffer = chunk.buffers()[1]
print(data_buffer.address) # 数据缓冲区起始地址
注意,buffers()[0] 通常是 validity bitmap(可能有 None),buffers()[1] 才是固定宽度类型的数据缓冲区。对 string 类型来说,缓冲区结构不同,第 1 个是 offsets 缓冲区,第 2 个才是真正的字符数据缓冲区,地址检查时要对应上。
第二步,用 to_numpy(zero_copy_only=True) 验证是否能零拷贝转 numpy:
python复制np_arr = chunk.to_numpy(zero_copy_only=True)
# 对比地址
print(np_arr.__array_interface__['data'][0])
如果两行代码输出同一个地址,说明底层数据没有复制。如果类型不匹配、有 null 值、或者布局不对,zero_copy_only=True 会直接抛 ValueError,绝不会默默复制一份给你。这就把“我不确定它拷没拷”变成了“没拷就得报错”。
顺便提一个容易踩的点:Pandas 的 Series.to_numpy() 并没有暴露 zero_copy_only 参数,你要在 pyarrow 对象层操作,也就是 df['col'].array._pa_array 这一层。所以“体检”最好是针对 pyarrow 对象,而不是 Series 对象。
3. Copy-on-Write机制和零拷贝经常被混为一谈,需要拆开看
3.1 CoW核心逻辑:引用计数 + 延迟复制
Copy-on-Write 在 pandas 2.x 里是一个独立于 PyArrow 的机制,虽然它和零拷贝经常被放在一起提,但两者解决的问题并不完全相同。
CoW 的核心思路是:当 df2 = df1 或 df2 = df1[['a', 'b']] 这类操作发生时,先不必复制底层数据,只需共享引用;只有当后续真正修改 df2 里的某些元素时,才把被修改的那部分数据复制出来。这样,读多写少的场景里,大量“安全起见”的提前复制就被省掉了。
这和 Python 对象模型的思路很像——Python 对不可变对象也做“引用共享”,你不能原地改一个 int,所以要修改时只能新建一个对象。Pandas 2.x 就是想把 DataFrame 的底层数据也往这个方向推。
在 Pandas 2.2 里,CoW 其实已经很稳定了,官方计划在 3.0 默认开启。如果你想提前用,手动打开即可:
python复制pd.options.mode.copy_on_write = True
3.2 CoW遇上Arrow:修改操作仍然要复制,但粒度更小
很多人在网上看到“CoW 可以零拷贝”,然后误以为“用了 PyArrow dtype 之后,修改单个单元格也不占内存”。这是错的。
Arrow 数组本身是 immutable 的,你没法原地修改某一个元素的 8 个字节。ArrowExtensionArray 要做 df.iloc[0, 0] = 10,通常是调用 pyarrow.compute 下的函数,比如 replace_with_mask,生成一个新的数组。既然要生成新数组,分配内存就逃不掉。
那 CoW 在这里起了什么作用?它让“未修改的 chunk”可以继续共享缓冲区。比如你只改了第 0 个元素,如果数据分成了多个 chunk,pandas 会尽量保留那些没被触及的 chunk 的原有缓冲区,只是新生成被修改 chunk 的数据。相比之下,纯 numpy dtype 的列如果发生 CoW 触发,复制粒度通常是一整列一整列;改为 Arrow dtype 之后,可能细化到 chunk 级别。
这个差异在超大单列场景下非常关键。假设一个列有 20 个 chunk,你只改了一个单元格,Arrow 引擎可能只复制 1 个 chunk;而 numpy 列需要把整列的长度复制一遍。虽然不是完全零拷贝,但复制范围大大收缩了。
3.3 2.2里最容易踩的语义变化:copy()、values、to_numpy
CoW 改变了几个老接口的语义,这些变化在 PyArrow dtype 下更容易让人困惑,值得单独列出来。
df.copy() 在 CoW 下是一个近乎零成本的操作。老教程告诉你 “copy 默认深拷贝”,那是基于旧 pandas 的实现;2.2 开启 CoW 之后,df2 = df1.copy() 返回的新 DataFrame 并不会立即复制底层数据,底层数据的复制被推迟到真正修改的时候。所以你在 2.2 里写 df2 = df1.copy(),再 df2['col'] = ...,前一次复制实际发生在“赋值列”那一刻,而且只复制需要变的列。
df.values 的行为也变了。在老版本里,如果你操作的是同 dtype 的 DataFrame,df.values 很可能返回底层 Block 的视图;而在 2.2 + CoW 下,values 往往不能返回一个安全的底层视图,于是 pandas 会选择复制数据。对 PyArrow dtype 而言,values 更可能转换成 numpy object 数组,拷贝开销更大。
我建议你记住一个判断基准:任何从 Arrow 域跳到 numpy/Python 域的接口,都要默认它会复制,除非你能用缓冲区地址证明它没复制。 在内存敏感的场景里,不要依赖“应该没复制”这种直觉。
4. 零拷贝失效的高发场景与一次完整的内存排查
4.1 我见过最多的失效原因:类型混用和越界转换
先罗列几个最常见的失效场景,都是我实际遇到过的:
- object 列混入:只要 DataFrame 里有一列是 Python object,整个 DataFrame 的“Arrow 域”就不完整了。这一列里的字符串是 Python 对象指针,不是 Arrow 的连续缓冲区,切片、筛选都只能走老路径。
- nullable 全局开关:
convert_dtypes()默认会转成 pandas 自己的 nullable 类型(Int64、boolean这些),而不是 Arrow 类型。如果再用dtype_backend='pyarrow'重新读一遍,中间的转换过程会复制多次。 - 无意识跨域:
df['col'].to_numpy()、np.asarray(df['col'])、df['col'].tolist(),这些操作每做一次,都会把 Arrow 缓冲区里的数据“解释”成另外一套内存结构。字符串列尤其夸张——Arrow string 是 UTF-8 连续存放,转成 numpy object 后变成一长串 Python 字符串对象的指针数组,内存可能直接翻几倍。 - 重排类操作:
sort_values、sample(frac=1)、reindex这类需要改变行顺序的操作,本质上是收集重排后的数据,不管底层是不是 Arrow,都会生成新数组。它不是“崩溃型”问题,但如果你错误期待零拷贝,就会误判内存增长。
4.2 排查步骤:从dtypes到Arrow内存池再到buffer地址
内存出现异常增长时,我会按下面这套流程排查,亲测高效。
第一步,检查每个列的 dtype 到底是不是 [pyarrow]:
python复制print(df.dtypes)
只要出现 object、int64(不带 pyarrow)、float64(不带 pyarrow),就要警惕。这些列要么不是 Arrow 存储,要么是在某次操作里被悄悄转回来了。
第二步,用 pyarrow 的内存池统计 Arrow 相关内存:
python复制import pyarrow as pa
arrow_mem_before = pa.total_allocated_bytes()
# 执行你要排查的操作
arrow_mem_after = pa.total_allocated_bytes()
print(f"Arrow allocated delta: {arrow_mem_after - arrow_mem_before} bytes")
pa.total_allocated_bytes() 统计的是默认内存池里 Arrow 分配的字节数。如果某个操作前这个值很小,操作后暴涨,说明 Arrow 区域发生了大规模复制。
第三步,对可疑列做 buffer 地址检查,确认零拷贝是否成立。如果你怀疑 df2 = df[['a', 'b']] 产生了一次复制,那就分别打印 df 和 df2 里列 a 的第一个 chunk 的数据缓冲区地址,相同就是共享,不同就是复制。
4.3 复盘一个案例:一个筛选操作让内存翻倍
说一个具体案例,帮大家建立数字感。
有个 8GB 的 Parquet 文件,里面是用户行为日志,读进来用 dtype_backend='pyarrow',RSS 大约是 8.5GB。我先是做了一步 df2 = df[df['event_type'] == 'click'],筛选后预计只剩 20% 的行,按理 df2 应该也就 1.7GB 上下。但实际操作后内存飙到了 13GB。
排查过程是这样的:先看 dtypes,没问题,全是 [pyarrow]。接着对比 Arrow 内存池,发现 filter 步骤确实重新分配了约 1.7GB 的新缓冲区,这个符合预期。问题出在另一个方向——筛选后我执行了 df2['event_time'] = pd.to_datetime(df2['event_time']),这行代码把 event_time 列从 Arrow string 类型转成了 pandas 的 datetime64 类型,强制脱离了 Arrow 域,老的 Arrow 缓冲区没法复用,而新的 datetime64 数据又是一份完整的拷贝。
更隐蔽的是,操作里的 pd.to_datetime 内部先做了字符串解析,产生了中间对象,这部分内存也没有及时释放。最后我把转换改成 Arrow 计算域内的做法:
python复制import pyarrow.compute as pc
df2['event_time'] = pc.cast(
df2['event_time'].array._pa_array,
pa.timestamp('ns'),
)
直接把 Arrow string 通过 pc.cast 转成 Arrow timestamp,仍然留在 Arrow 域里。内存问题消失,筛选后的 RSS 恢复到 2GB 左右。
这个案例给我的教训是:很多时候内存暴涨不是零拷贝没生效,而是某一行不起眼的类型转换把数据拉出了 Arrow 域。 排查的时候不要只盯着筛选和切片,要全局扫一遍有没有 astype、to_datetime、to_numpy 这类跨域操作。
5. 结论太长不看版:哪些项目值得切到PyArrow dtypes
5.1 适合的使用场景和判断标准
不是所有项目都需要切 PyArrow dtypes,我先说哪些场景收益最大。
数据链路是“Parquet/Feather → pandas → 分析 → 结果落盘”的,强烈建议全链路使用 Arrow dtype。因为读文件时可以直接保住 Arrow 缓冲区,中间如果只是做筛选、列选、分组聚合、排序(明确接受排序有拷贝成本)、最后再写回 Parquet,整条链路的额外复制很少。
内存吃紧的批处理任务也适合。比如你有一份 40GB 的数据,机器只有 64GB 内存。用老 pandas 读出来、中间随便做两次复制就顶不住了;切到 Arrow dtype 之后,列选择和行切片不再产生大复制,内存余量一下子就宽裕了。
怎么判断自己的场景适不适合?我建议做一个半小时小实验:把同一份数据分别用 dtype_backend='pyarrow' 和旧方式读进来,然后跑一遍你日常的筛选、聚合流程,用 RSS 峰值做对比。
python复制import psutil
pid = psutil.Process().pid
baseline = psutil.Process(pid).memory_info().rss
df = pd.read_parquet("test.parquet", engine="pyarrow", dtype_backend="pyarrow")
# 执行你的核心处理流程
peak = psutil.Process(pid).memory_info().rss
print(f"memory delta: {(peak - baseline) / 1024**3:.2f} GB")
如果差值明显小于旧路径,切换就值得。
5.2 不建议随意切换的场景
有几类场景我反而建议保守。
依赖大量第三方库且这些库对 dtype 有强假设的项目,比如某些绘图库、sklearn 的一些组件,它们遇到 ArrowDtype 可能不认识,会尝试转成 numpy,如果项目里这种转换高频发生,收益会被抵消。
逐行处理为主的项目也不建议。Arrow 数据要逐行访问,必须先把 Arrow buffer 转成 Python 对象,单次虽然只转一个元素,但整个循环下来转换开销很大。这种场景用纯 numpy 反而更直接。
另外,如果团队里其他人不熟悉 Arrow 语义,容易把 df['col'].values、df['col'].tolist() 当老 pandas 一样随手用,那内存收益会被这些“不小心”的跨域操作冲掉。要先在团队里对齐使用规范,再谈全量切换。
5.3 落地时容易踩的环境坑
最后说几个环境层面的坑,都是我实际碰到过的。
第一,pip install pandas 不会自动给你装 pyarrow。pandas 2.x 把 pyarrow 作为可选依赖,如果你直接跑 read_parquet(engine='pyarrow'),可能报 ModuleNotFoundError。稳妥做法是明确安装:
bash复制pip install "pandas>=2.2" "pyarrow>=14"
第二,注意版本匹配。pandas 2.2 官方建议的 pyarrow 版本范围我记得是 11 以上,但我实际用下来 pyarrow 14 以上更稳,尤其是涉及到复杂类型和 pc.cast 时。太老的 pyarrow 在 ArrowDtype 的某些边界 case 上会报一些看不懂的底层错误。
第三,别在 conda 和 pip 之间混装。这个问题主要出在 pyarrow 上,它带了比较重的 C++ 二进制,如果混装导致两个版本的 libarrow 共存,运行时可能直接崩,或者抛出 ArrowNotImplementedError。项目里统一用 conda 就全 conda,统一用 pip 就全 pip。
第四,如果你想在已有项目里做快速验证,不需要重新读文件,直接转换:
python复制df_arrow = df.convert_dtypes(dtype_backend="pyarrow")
这会把当前 DataFrame 的每列尽量转成 Arrow dtype。注意它同样是一次跨域拷贝,所以验证用没问题,但别指望它是“不花内存的白嫖转换”。
我自己现在的项目里,会写一个辅助函数,所有数据入口统一经过它,强制指定 dtype 路径;排查内存问题时第一件事就是列出 dtypes,然后检查有没有脱离 Arrow 域的操作。这套习惯帮我解决了不少“数据量明明不大,内存却爆了”的诡异问题。PyArrow dtype 的零拷贝不是万能钥匙,但它把过去那些看不见摸不着的复制行为,变成了你可以主动观测和控制的东西——这个价值,比单纯省几个 G 内存大得多。
