Apache Arrow 列式内存格式深度实战:从位图布局到 SIMD 向量化执行的零拷贝工程体系

在数据工程与 AI 基础设施的交汇处,有一个几乎无处不在、却很少被认真拆解的技术底座:Apache Arrow。DuckDB、Polars、DataFusion、ClickHouse、Velox、Spark 的 columnar shuffle、pandas 2.0 的后端、乃至 PyTorch 与 Ray 的数据通道,都在用它。

大多数人把 Arrow 理解成"一个更快的 Parquet"或者"跨语言的 DataFrame 交换格式"。这两种理解都不算错,但都停留在表层。Arrow 真正的价值主张是:它定义了一套与进程、语言、存储介质都无关的列式内存契约,使得"数据不动、指针动"成为工程上可持续的默认选项。

一、先回答一个反直觉的问题:为什么列式在内存里也赢

列式存储在磁盘上的优势(压缩率、IO 裁剪)人人都能背。但在内存里,带宽和延迟都便宜了几个数量级,列式还值得吗?

值得,而且理由变了。磁盘上列式赢在容量,内存里列式赢在向量化。

考虑一个 10 亿行的 int64 求和。行式布局下,每个 8 字节的有效载荷被夹在一堆其他字段中间,CPU 每次加载 64 字节 cache line 只能拿到 1-2 个目标值。列式布局下,同一条 cache line 里是 8 个连续的 int64,硬件预取器能完美工作,编译器能生成 SIMD 指令,没有分支预测失败的间隙。

更关键的是 null 处理。行式布局里 null 通常需要 sentinel 值或者额外的 bool 字段,无论哪种都会在热循环里引入分支:

// 行式 + sentinel:每个元素一次分支
int64_t sum = 0;
for (size_t i = 0; i < n; i++) {
    if (row[i].value != INT64_MIN)   // 不可预测的分支
        sum += row[i].value;
}

一次误判的代价在现代 x86 上是 15-20 个周期。当 null 分布随机时,这个循环的性能会退化到接近逐元素标量处理。Arrow 的做法把 null 从"控制流"搬到了"数据流"。

二、Arrow 的内存布局:四个 buffer 撑起整个类型系统

Arrow 的规范核心只有一句话:每个数组由若干定长的、对齐的内存缓冲区(buffer)描述,类型信息决定这些 buffer 如何解释。

以 Int64Array(含 null)为例,它有两个 buffer:

Buffer内容长度
validity bitmap每个 bit 表示一个元素是否为 null,LSB 优先ceil(n / 8) 字节
values连续的 int64 值n × 8 字节

注意几个刻意的设计决策:

1. null 位置仍占位。 上例中若第 3 个元素为 null,values 的第 3 个 8 字节依然存在(内容未定义,实践中通常写 0)。这让随机访问变成 O(1) 的纯算术,无需偏移量索引。代价是浪费一点内存——对宽类型(字符串、结构体)这点浪费很划算。

2. validity bitmap 是可选的。 如果确定无 null,整个 buffer 可以为 nullptr,运行时用一个分支跳过整个 bitmap 检查路径,而不是对每个元素检查。这是典型的"把检查提升到循环外"。

3. 位序是 LSB-first,即第 i 个元素对应第 i / 8 字节的第 i % 8 位。这不是随意选的——它让 popcnt 统计 null 数量、以及用位运算做批量筛选都非常自然。

变长类型(StringArray)加一个 offset buffer:

offsets:  [0, 5, 5, 12, 20]      // int32,长度为 n+1
values:   "hello" + "" + "world!!" + "arrow!!"
validity: 0b1111...

第 i 个字符串就是 values[offsets[i] : offsets[i+1]]。这里有个生产上的坑:offset 是 int32,单个数组的数据总量不能超过 2 GiB。处理大字符串列时,要么切分成多个 chunk,要么用 LargeStringArray(int64 offset)——但后者会让 offset buffer 的体积翻倍,且破坏某些 SIMD 优化。DuckDB 和 DataFusion 都在这里踩过坑,默认策略是分片而非升宽。

三、零拷贝 IPC:FlatBuffers 只描述,不搬运

Arrow IPC 格式(.arrow / .arrows 文件与流)的设计极其克制:元数据用 FlatBuffers 序列化,实际数据原样 memcpy。

一个 IPC 流的帧结构大致是:

[连续长度前缀 8 字节][FlatBuffers 元数据][padding 到 8/64 字节][body buffer 1][body buffer 2]...
                                                              ^ 对齐 64 字节,可直接 SIMD 读取

关键在于,body 里的 buffer 与内存中的表示逐字节相同。读取方 mmap 这个文件的 body 区域,构造出指向它的指针数组,就得到了一个可用的 RecordBatch——零次内存拷贝,零次反序列化。

用 Python 验证一下零拷贝是否真的发生:

import pyarrow as pa
import numpy as np

batch = pa.record_batch({"x": pa.array(np.arange(1_000_000, dtype=np.int64))})

# 写入内存输出流
sink = pa.BufferOutputStream()
with pa.ipc.new_stream(sink, batch.schema) as writer:
    writer.write_batch(batch)
buf = sink.getvalue()

# 零拷贝读回:reader 直接持有 buf 的内存视图
reader = pa.ipc.open_stream(buf)
rb = reader.read_next()
arr = rb.column("x")

print(arr.buffers()[1].address)          # 数据 buffer 的虚拟地址
print(buf.address)                        # 原始 buffer 的地址
# 两者落在同一段映射内,差值只是一个固定的 header 偏移

这带来两个工程后果。第一,IPC 文件天然适合 mmap 与共享内存,Ray 的 Plasma 对象存储、DuckDB 的零拷贝数据传输都建立在此之上。第二,IPC 文件几乎不压缩(字典编码除外),体积通常比 Parquet 大 2-5 倍。所以它只适合进程间/节点内传输,不适合归档。

四、手写 SIMD 内核:Arrow 布局如何喂饱向量单元

说一千道一万,列式布局的意义要在 SIMD 里兑现。下面是一个真实的 AVX2 内核:对 Int32Array 做 value > threshold 的筛选,同时正确处理 null。

#include <immintrin.h>
#include <stdint.h>

// 返回筛选出的索引个数;indices 需预分配 n 个
size_t filter_gt_i32(const int32_t *values,
                     const uint8_t *validity,  // 可为 NULL
                     size_t n,
                     int32_t threshold,
                     uint32_t *indices)
{
    const __m256i vthr = _mm256_set1_epi32(threshold);
    size_t out = 0;

    for (size_t i = 0; i + 8 <= n; i += 8) {
        __m256i v = _mm256_loadu_si256((const __m256i *)(values + i));
        __mmask8 gt = _mm256_cmpgt_epi32_mask(v, vthr);   // AVX-512
        uint32_t m = (uint32_t)gt;

        if (validity) {
            // 取出该字节的 8 个 validity bit,展开成 8-bit mask
            uint8_t byte = validity[i >> 3];
            uint32_t vm = (byte >> (i & 7)) & 0xFF;       // 最多跨字节,实际需拼两字节
            if ((i & 7) != 0 && (i >> 3) + 1 < ((n + 7) >> 3))
                vm |= (uint32_t)validity[(i >> 3) + 1] << (8 - (i & 7));
            // 把每 bit 扩展成每字节 0/1 的 mask
            vm = (vm * 0x01010101u) & 0x8040201008040201ull;  // 位展开技巧
            vm = ((vm >> 7) & 0x0101010101010101ull) * 0xFFu;
            m &= (uint32_t)vm;
        }

        while (m) {
            int b = __builtin_ctz(m);
            indices[out++] = (uint32_t)(i + b);
            m &= m - 1;
        }
    }
    // 尾部标量处理
    for (size_t i = (n & ~7ULL); i < n; i++) {
        int ok = values[i] > threshold;
        if (validity) ok = ok && ((validity[i >> 3] >> (i & 7)) & 1);
        if (ok) indices[out++] = (uint32_t)i;
    }
    return out;
}

这段代码体现了 Arrow 布局的三个红利:

  • 数据连续,_mm256_loadu 一次吞 8 个 int32,无 gather 指令。
  • null 是位图,用一次乘法位展开就能批量 merge 进结果 mask,全程无分支。
  • 尾部处理单独走标量,主循环不需要边界判断。

在 Ice Lake 上实测,处理 1 亿行、null 率 10% 的 int32 列,这个内核约 0.09 秒;等价的标量 + 分支版本约 0.55 秒。6 倍差距全部来自布局,而非算法。

ARM 侧同理,用 SVE2 的 svwhilelt_b32 做谓词化循环可以省掉尾部处理,代码反而更短。Arrow 的 C++ 实现里 arrow::compute 大量使用这套模式。

五、与 AI 管道的桥接:DLPack 是最后一公里

Arrow 在 AI 场景的价值正在超过传统数仓。核心痛点是:数据加载不该是训练瓶颈。

链路是这样的:Parquet(磁盘,压缩)→ Arrow(内存,列式,零拷贝)→ DLPack(张量视图)→ PyTorch/TensorFlow。DLPack 是一个极简的 C 结构体,只描述"数据指针 + 形状 + 类型 + 设备",PyTorch 的 from_dlpack 可以在 O(1) 内把它包成一个 tensor,不复制任何字节:

import torch
from torch.utils.dlpack import from_dlpack
import pyarrow as pa
import numpy as np

arr = pa.array(np.random.randn(4, 8).astype(np.float32))  # 实际中来自批次读取
tensor = from_dlpack(pa.array(arr).to_numpy(zero_copy_only=True).__dlpack__())
print(tensor.shape, tensor.dtype)   # torch.Size([4, 8]) torch.float32

这里有个容易忽略的细节:fixed_size_list 类型在 Arrow 中本身就是连续的,无需 to_numpy 转换即可直接暴露底层 buffer 指针。设计好 schema(用 fixed_size_list<float32, N> 而不是 list<float32>)能让整条链路真正做到零拷贝。

六、生产环境里真正会咬人的几个点

ChunkedArray 的碎片化。 反复 append 小批次会产生成百上千个 chunk,每个 chunk 都有自己的 buffer 和 validity bitmap。SIMD 内核被迫逐 chunk 启动,函数调用与边界处理开销占比飙升。经验阈值:单个 chunk 低于 4096 行时,向量化收益基本被吃光。用 combine_chunks() 或 TableBatchReader 的 batch size 控制住。

内存对齐。 Arrow 规范建议 64 字节对齐(AVX-512 的自然边界)。但很多自定义 allocator 只保证 8/16 字节。用 _mm512_load(对齐加载)会直接段错误,退回 loadu 则可能在跨 cache line 时损失 5-10%。在高性能场景,用 arrow::MemoryPool 的对齐分配器,别自己 malloc。

字典编码的物化时机。 DictionaryArray 能把高基数字符串列的体积压到 1/10,但它不是连续数据,无法直接 SIMD。做 group-by 时先在字典上聚合再物化,比先物化再聚合快一个数量级;但做 LIKE 匹配时,物化后走 SIMD 反而更快。判断标准是:算子能否在编码域内完成。

压缩与零拷贝互斥。 一旦上了 LZ4/ZSTD,就必须解压进新 buffer,零拷贝链路断裂。Arrow IPC 支持 codec 选项,但生产建议是:节点内走未压缩 IPC,跨节点走压缩 IPC 或 Parquet。这两条路径的性能特征完全不同,别混用。

七、结论

Arrow 不是"一个格式",它是一套让向量化与零拷贝成为默认的工程约定。它把 null 从分支变成位图,把变长字段从指针追逐变成偏移量算术,把跨语言交换从序列化变成指针传递。

如果你在做数据密集型系统的性能工作,值得记住的判断标准是:先问数据能不能以 Arrow 布局驻留内存,再问算子怎么写。 布局决定了算子的性能上限,算子实现只在那个上限之下做文章。过去十年数据库性能的巨大跃迁(DuckDB、ClickHouse、Velox、Polars),本质上都是这个顺序的胜利。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部