资讯动态

Apache Arrow C++核心引擎与Python绑定:零拷贝与本地构建实践

发布时间:2026/10/20 5:11:44 来源:尧图企业网站定制
Apache Arrow这套东西我盯了很久尤其是它“一次定义格式多语言共享内存”的思路。很多做数据和后端的同学第一次听到“Apache Arrow C核心引擎”和“Python绑定”第一反应是这跟用pandas读Parquet、用Protobuf传数据到底有多大区别我的回答是区别大到可以改变你对数据工程的认知。Arrow真正把跨语言数据交换从“序列化、反序列化、再拷贝”这三步痛苦的流程里解放出来直接共享同一块内存布局。这篇文章适合正在做数据平台、计算引擎、或者天天在Python和C之间来回倒数据的开发者我会从底层设计讲到本地构建再把真正踩过的坑一并倒给你。1. 为什么需要Arrow跨语言数据交换的痛点1.1 传统方案到底笨在哪里先说一个特别常见的场景A服务是C写的B服务是Python写的两边要交换一批结构化数据。你用过的方案大概率是JSON、CSV、Protocol Buffers或者Thrift。这些方案本质上都在做同一件事发送方把内存里的对象编码成一串字节流接收方把这串字节流解码回自己语言的对象。听起来没问题但性能账一算就心疼。拿JSON举例。一条包含10万行、每行20个字段的记录序列化时要把每个字段转成字符串再加上逗号、引号、花括号这些语法字符。光是多出来的结构开销就是巨大浪费。接收方拿到字符串后还要逐字符解析为每个字段分配对象、字符串对象再把它们塞进字典或类实例里。这一来一回CPU被字符串处理和内存分配吃掉了而且网络传输的东西根本不是原始数据是数据的“文本照片”。Protocol Buffers比JSON快但依然逃不掉一个核心问题编码和解码。只要两个进程不是共享内存的任何结构化数据在跨语言边界传递时都不得不经历编码-传输-解码这套循环。加上每拷贝一次内存带宽就多一份压力。十万行的数据量还算温柔到了上亿行的分析型场景这套流程就变成整个平台的瓶颈。1.2 Arrow的核心承诺定义一套大家都能直接读的内存格式Arrow不是一个简单的序列化库它的核心是定义了一个跨语言的、标准化的列式内存格式。这个格式不是传输时的中间表示而是数据落地后的“真实存储形态”。C进程写入Arrow数组Python进程拿到的是同一份内存地址里的数据视图不需要编码也不需要解码。打个比方传统方式是A写一封信翻译成摩斯电码发出去B收到后再翻译回正常语言Arrow则是A告诉B“我把文件放在这个共享保险柜里你自己拉开抽屉按固定格式拿就行”。数据从生到死只在一个内存布局里被读取和写入最多通过IPC机制把这块内存的句柄传出去而不是把数据本身拷出去。那Arrow是只适合超大吞吐的场景吗也不是。它的价值覆盖面很广单机内Python和C共享数据、多个进程之间通过IPC交换数据、分布式计算中每个节点用Arrow列式格式做中间结果、甚至是数据库向量化执行引擎的内存表示。越是在性能敏感的数据链路上用Arrow收益越明显。2. C核心引擎的设计地图从内存池到计算框架2.1 列式内存布局是引擎的基石Arrow的C核心引擎里最底层、也最不能搞错的就是列式布局。分析型工作负载和OLTP型负载天然不一样OLTP操作按行来分析型操作按列来。你要计算某一列的SUM或者AVG如果数据是行式存储CPU得把每一行的所有字段都扫一遍再把不需要的字段丢掉这种浪费在千万行级别会变得不能忍。Arrow列式布局把同一列的数据连续放在一起。比如一个包含年龄、城市、收入三列的数据集内存里的排布是“所有年龄挨着、所有城市挨着、所有收入挨着”。这样计算年龄列的均值时缓存命中率和SIMD向量化效果都会好很多。更关键的是列式布局对于压缩算法也友好同一列的数据分布相近压缩率比行式高不少。Arrow的每个数组由两部分组成一部分是真正的数据缓冲另一部分是有效位图validity bitmap。有效位图用每一位标记这个位置的值是不是null。这意味着“缺失值”是格式自带的一等公民不需要用NaN或者哨兵值凑合。你在Python绑定时看到的pyarrow.array([1, None, 3])底层就是数据缓冲里存了1、0、3但位图标记了中间那位无效。2.2 内存池与分配器控制每一字节的归属Arrow C引擎里有个容易被忽视但极其重要的组件内存池。默认情况下Arrow使用系统分配器但你可以切换成jemalloc或mimalloc。为什么对分配器这么挑因为Arrow分析场景里有大量小的临时缓冲反复申请和释放如果依赖glibc默认的malloc碎片化会随着并发和长时间运行迅速恶化。我在某数据平台项目里就遇到过这种情况一个持续跑批的任务内存占用曲线一路向上最后在凌晨被OOM Killer干掉。后来把Arrow的分配器切到jemalloc曲线平稳了很多。Arrow内存池还有一个好处就是可以统一追踪每个数组操作分配的内存用量pyarrow.total_allocate_bytes()能看到当前持有Arrow内存的字节数。这个接口在调试内存泄漏时简直是救命稻草。内存池的另一个职责是配合零拷贝语义。Arrow的Buffer对象本质是一个持有指针和长度的不可变内存块C侧的arrow::Buffer和Python侧的pa.Buffer共享同一个底层指针它不需要在语言边界做数据复制只需要传递地址和元数据。2.3 计算层函数、Kernel与表达式有了内存布局还得有在这上面干活的“手脚”。C核心引擎的计算层围绕arrow::compute展开它的设计思路是声明式的你先构建一个表达式比如(col_a * 2 col_b) 10然后交给执行引擎去跑。执行时表达式会被编译成一颗算子树叶子节点是数据集列中间节点是各种Kernel。Kernel是Arrow里最底层的计算单元按输入类型分门别类有处理整数的Kernel有处理浮点数的Kernel有处理字符串的Kernel。你调pc().call_function(add, args)时引擎会根据参数类型动态选择对应的Kernel执行这也是Arrow支持“同一函数、多类型分发”的关键机制。这套计算层的好处在于计算不需要把数据从Arrow格式转回原语言对象。很多Python用户喜欢把Parquet读进pandas再做计算但如果加了过滤条件其实完全可以在Arrow层面先完成裁切只把最终结果转成pandas或Python原生对象内存占用和计算时间至少省一半。2.4 数据集的IO与Parquet格式的纠缠Arrow引擎里还有一个绕不开的模块arrow::dataset。它负责把文件系统上的数据文件Parquet、Arrow IPC文件、CSV组织成“数据集”的概念再提供扫描功能。这个模块最实用的地方在于谓词下推你给一个SQL-like过滤条件它会在扫描文件元数据时就跳过整块不满足条件的数据而不是把所有行读进内存再过滤。Parquet和Arrow是高度互补的Parquet是面向磁盘的压缩列式存储Arrow是面向内存的列式格式。读取Parquet文件时要先解压、解码然后填进Arrow内存布局这个过程不可避免有CPU开销。但Arrow能做的是一旦数据进入内存就不再为跨语言交互付额外成本。所以你能看到很多大数据引擎“Parquet落盘Arrow处理再IPC分发”的标准套路。3. Python绑定是如何长出来的Cython桥接揭秘3.1 pyarrow的三层结构Python绑定pyarrow不是简单的CtrlC/V把C代码暴露给Python它有三层结构最底下一层是C核心库这一层负责所有计算、IO、内存管理中间一层是Cython封装它把C的namespace、类、函数重新暴露为Python扩展类型的内部结构最上面一层才是用户直接看到的pyarrow公共API。为什么用Cython而不是pybind11一个原因是历史Arrow的Python绑定起步时Cython在科学计算社区已经非常成熟另一个原因是性能。Cython可以精确控制什么时候释放GIL在批量数据处理时把大循环放到真正的并行线程里跑这是纯Python解释器做不到的。还有一点是Cython能直接操作C的指针、模板和STL容器适配Arrow大量使用模板化的内部结构。3.2 生命周期管理谁持有内存Python绑定最隐秘的坑在对象生命周期上。Arrow C对象绝大多数用std::shared_ptr管理而pyarrow的Python对象内部持有的其实是这个shared_ptr的一个实例。什么意思呢只要Python侧的对象还活着C侧的Arrow数组就不会被释放。反过来说如果Python侧建立一个pa.Array然后把它传给C侧的函数C函数短暂地持有它的引用Python侧在这个函数返回后又把变量删了会不会悬空不会因为shared_ptr的引用计数保证内存只有当最后一个持有者放弃时才会释放。理解这条对排查那些“明明删了对象内存却没降下来”的诡异问题特别管用。还有一个细节pyarrow里的RecordBatch和Table关系。RecordBatch是“定长列集合”所有列长度相同Table则可以理解为一组RecordBatch的集合。Python侧的切片、筛选通常先作用于RecordBatch最后再组装。如果你把一个大Table转成pandas再转回来中间会丢失批次结构这在后续处理中会影响性能。3.3 Python缓冲区协议与真正的零拷贝pyarrow实现零拷贝的路径主要靠一个机制Python Buffer Protocol。pyarrow.Buffer对象实现了__buffer__接口你可以通过memoryview(arrow_buffer)拿到一段直接指向Arrow内存的视图而不触发复制。pandas和pyarrow深度集成的秘密之一就在这里。通过pa.Table.to_pandas()pyarrow会尝试把Arrow列转换成numpy数组。如果数据类型兼容且Arrow内存块是连续的numpy可以直接包一层memoryview不需要逐元素拷贝。用pandas_metadata参数还能把Arrow的schema信息同步给pandas保留扩展类型、时区、字段名等细节。反过来pa.Table.from_pandas()也是走同样的零拷贝思路。它先检查pandas的BlockManager内存布局如果是简单单列类型就直接借用numpy缓冲区创建Arrow数组只有遇到object类型列或非连续布局时才会退化为逐行转换。很多人不知道这个细节以为from_pandas速度慢其实它慢在“兼容性兜底路径”。3.4 Python侧无法绕过的GIL问题Python绑定因为到了计算密集环节会释放GIL这是它比纯Python处理速度快得多的原因之一。但GIL释放也有边界凡是涉及Python对象创建的API还是要回到持有GIL的状态。所以如果你在循环里反复调用小的Arrow函数比如逐行构造数组释放/重获GIL的开销反而可能吃掉性能优势。正确姿势是把大批数据一次性包给Arrow的向量化接口让它内部按批次释放GIL并行跑不要小步快跑式地反复跨边界。4. 落地实操本地构建C Arrow库并编译Python绑定4.1 环境准备依赖链条比想象中长很多人第一步就倒在Arrow的依赖上。Arrow C核心库不是那种git clone就能即刻构建的轻量项目它依赖了不少第三方库包括不起眼的Boost、gflags、glog、protobuf、zlib、brotli、lz4、zstd、snappy、OpenSSL如果开Parquet还要额外的Thrift和Snappy。好消息是Arrow提供了官方脚本cpp/thirdparty/download_dependencies.sh在构建前可以先拉齐依赖源码。我建议不要全部禁用依赖来追求简单因为这样你的Arrow会缺胳膊少腿没有压缩库Parquet读写直接死路一条没有内存池分配器只能裸奔。标准做法是启用大多数默认组件这比后续需要某个功能再回头启用要省时省事。构建机器内存最好不少于8G尤其是编译Parquet模块时编译器内存占用会飙到4G以上。在低配机器上构建可以用-j2限制并行度避免编译器被系统杀掉。踩过一次cc1plus: out of memory之后我习惯性地先free -g看一下内存再决定并行数。4.2 C构建步骤和关键CMake参数核心构建命令不复杂但参数选择直接影响你的使用场景。我先给出一份适合开发调试的配置cd cpp mkdir build cd build cmake .. \ -DCMAKE_BUILD_TYPERelease \ -DARROW_COMPUTEON \ -DARROW_CSVON \ -DARROW_DATASETON \ -DARROW_FILESYSTEMON \ -DARROW_IPCON \ -DARROW_JSONON \ -DARROW_PARQUETON \ -DARROW_WITH_ZSTDON \ -DARROW_WITH_LZ4ON \ -DARROW_WITH_SNAPPYON \ -DARROW_WITH_BROTLION \ -DARROW_WITH_JEMALLOCON \ -DARROW_BUILD_TESTSOFF make -j8ARROW_COMPUTE这个开关特别值得说明如果你只做数据的搬家和转换不开计算引擎也能用但只要你想用polars那种表达式下推、过滤、聚合就必须开。ARROW_DATASET决定了你能不能把目录里一堆Parquet文件当成逻辑数据集来查建议直接开。还有个参数ARROW_SIMD_LEVEL它控制SIMD指令集的启用等级比如AVX2。老CPU会把默认等级压到SSE4.2特定场景性能差距很大但强行调高可能导致程序在新机器上运行时吃到非法指令异常。稳妥起见保持默认即可。4.3 编译pyarrow核心是“绑定对齐”从源码编译pyarrow之前你要先决定是否用预编译wheel还是完全本地构建。如果只是为了在Python里用Arrow用pip安装预编译包就够了。但如果你的C侧也用了Arrow并且希望Python侧共享同一套动态库和内存分配器那本地编译绑定就是必须的。编译pyarrow前需要设置好Arrow的安装路径。假设你把C Arrow装到了/opt/arrow-install构建参数大致是这样export ARROW_HOME/opt/arrow-install export PKG_CONFIG_PATH/opt/arrow-install/lib/pkgconfig export LD_LIBRARY_PATH/opt/arrow-install/lib cd python python setup.py build_ext --build-typerelease --with-parquet --with-dataset pip install -e .这里最大的坑是版本对齐pyarrow源码包的版本必须和C Arrow库版本严格一致。用0.17.0的pyarrow去链接0.18.0的libarrow链接阶段大概率报一堆未定义符号。所以编译前务必检查git checkout的tag是否两边一致。此外pyarrow的扩展模块在构建时会直接链接到libarrow.so而不是静态拷贝一份。这块动态库你在运行和部署时都要带上。若用ldd检查得到的路径不是你想要的那个多半是LD_LIBRARY_PATH里混进了另一个版本的Arrow这种“幽灵依赖”很难排查建议用python -c import pyarrow; print(pyarrow.cpp_build_info)打印出来的编译信息先垫底。4.4 最小验证C写数据Python读数据构建完成后的第一步验证我建议做一个真实的最小闭环C侧创建一个Arrow数组并写成一个Arrow IPC文件Python侧读进来并转成pandas。C侧的核心代码#include arrow/api.h #include arrow/ipc/api.h #include arrow/io/file.h #include vector int main() { arrow::Int64Builder builder; builder.AppendValues({1, 2, 3, 4}); std::shared_ptrarrow::Array array; builder.Finish(array); auto schema arrow::schema({arrow::field(value, arrow::int64())}); auto batch arrow::RecordBatch::Make(schema, 4, {array}); auto out_file arrow::io::FileOutputStream::Open(test.arrow).ValueOrDie(); auto writer arrow::ipc::MakeFileWriter(out_file, schema).ValueOrDie(); writer-WriteRecordBatch(*batch); writer-Close(); out_file-Close(); return 0; }Python侧读取import pyarrow.ipc as ipc with ipc.open_file(test.arrow) as reader: batch reader.get_batch(0) print(batch) print(batch.to_pandas())这个闭环验证了什么C写的文件Python直接读中间没有经过任何序列化协议数据从C内存布局到Python内存视图只隔着一层文件映射。如果这一步通了说明Arrow的跨语言内存模型在你的环境里是真正生效的。5. 常见问题快速排查与经验清单5.1 链接时找不到符号、版本不匹配症状很直观编译pyarrow或程序时出现undefined reference to arrow::Something。最常见原因是系统里存在多个Arrow版本链接器选择了旧的头文件和新的动态库组合。排查顺序我建议这样走先确认你的ARROW_HOME和PKG_CONFIG_PATH是否指向同一个前缀再检查LD_LIBRARY_PATH里有没有别的前缀下的libarrow.so最后用ldconfig -p | grep libarrow看看系统装载器认的是哪个路径。整个过程用一句经验概括绝对不要让系统自带或conda装的Arrow和你的源码编译Arrow混在一个进程里。5.2 内存始终不降到底谁持有引用pyarrow用户最常见的疑虑为什么我删掉了一个大Tablepsutil看到的内存还是没释放大部分情况下不是泄漏而是Arrow内存池不会立刻把内存还给操作系统。jemalloc和mimalloc都有缓存机制释放的块暂时留在池里供后续复用。这是性能优化不是bug。真的需要确认有没有泄漏时用pyarrow.total_allocate_bytes()对比前后的值。如果这个值稳定在某个水位不再增长说明内存是被Arrow池缓存了如果这个值持续上升再往业务代码里找哪里长生命周期持有Table。另一种隐藏引用是to_pandas()之后的pandas对象还活着pandas底层某些结构直接引用了Arrow Buffer你删了Table倒是没删Buffer。5.3 IPC共享内存模式的选型Arrow实现跨进程共享数据有两种方式文件写读和IPC共享内存。文件写读适合跨机器或持久化IPC共享内存适合单机多进程高吞吐传递。使用pyarrow.ipc时如果数据量很大建议用open_stream而不是把整个文件读入内存。流式接口可以边反序列化边消费内存占用是恒定的小块而不是整个数据集的大块。还有一个极容易踩的坑IPC消息里的Schema和实际Batch必须严格一致不能一个列有字段元数据另一个没有否则反序列化时会报schema mismatch。处理从外部系统拿来的数据时尽量先用pyarrow.parquet读入再手动转Table不要直接跨协议拼接。5.4 与pandas转换时的“隐性退路”Table.to_pandas()的zero_copy_only参数值得单独拿出来说。默认情况下pyarrow不是绝对零拷贝因为pandas的索引、稀疏列、object类型都需要额外的Python对象支持。如果你设置zero_copy_onlyTruepyarrow会拒绝做任何复制转换直接抛错告诉你能不能零拷贝。这个参数不是“性能增强开关”而是“性能行为契约”。实际项目中我发现把大表从pyarrow转pandas时真正耗时的往往是object列也就是字符串和大字典。Arrow可以用字典编码列压缩存储但pandas里很难保持这种编码。如果最终只需要做数值运算优先只选取数值列转换对象列留在Arrow层处理完再转性能会有质的提升。5.5 实测下来最值得记住的几条调优心得第一过滤条件尽早下推。用pyarrow.dataset扫描Parquet目录时filter参数里的表达式会在扫描时执行谓词下推别把所有数据读进来再筛。一个简单的比较表达式就能让它跳过大量row group性能差距经常是数量级的。第二分批次处理优于一次性巨型拖拉。Arrow虽然能支撑超大数组但没有自动分页机制。内存吃紧时可以用RecordBatchReader流式消费结合批量大小来平滑内存峰值。一次处理10万行比一次拖1000万行再操作要稳得多。第三别在Python层过度迭代。任何形式的“把Arrow列转成Python列表再写循环”都是反模式Arrow的设计目标就是让你用向量化接口代替标量循环。我见过不少代码在to_pandas()后for循环清点某列的合法值改成Table.filter加count聚合后耗时从几十秒变成几百毫秒。第四监控分配器活跃度。如果你的服务是长期运行的建议定期检查total_allocate_bytes在不同阶段的波动。Arrow内存池设计得足够好但它不是银弹当总分配量长期高水位时要考虑是不是代码里持有过大的历史批次该断开引用就断开。我在实际维护数据服务时还有一个体会Arrow这套体系最有价值的地方不是某个API多快而是它让“数据格式”成为多语言之间稳定的契约C侧优化的成果Python侧马上能享受到反之亦然。你可以在Python里快速验证算法再把热点路径下沉到C层中间的胶水被Arrow这种统一格式摊销掉了。刚开始迁移到Arrow时代码里会有一阵子混合了旧序列化代码和Arrow调用的混乱但一旦跑通第一版后面的迭代会顺很多。上面提到的坑都是我这个阶段实实在在踩过的希望能帮你少绕几个弯。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑