.NET性能优化:提升Apache Arrow读写性能

1 阅读8分钟

前言

在很多数据密集型系统里,Apache Arrow 已经是很常见的内存列式数据格式了。它的优势很直接:跨语言、列式、适合做数据交换。

但是当我们在 .NET 里使用 Arrow IPC,并且开启压缩以后,会遇到一个比较现实的问题:压缩和解压本身会变成读写路径上的成本。

Apache Arrow .NET 默认的压缩实现已经能用,但在一些 read-heavy 的 Arrow IPC 场景里,尤其是 LZ4 读取场景,性能并不算理想。

在 Arrow .NET 23 版本里,我其实已经给 arrow-net 提交过不少性能优化相关的 PR。很多路径优化以后,整体性能已经能看到明显提升。

但 LZ4 这条路继续往下走,就绕不开底层库。Arrow .NET 默认用 K4os 做 LZ4 压缩和解压,继续优化意味着要继续啃 K4os,或者换一个实现。

我最后选了一个更保守的办法:不改 Arrow .NET 的默认实现,基于它已有的压缩扩展点单独做一个可选库。

也就是这个:

dotnet add package ArrowNet.Compression.NativeCompressions

项目地址:

https://github.com/InCerryGit/ArrowNet.Compression.NativeCompressions

这个库不是 Apache Arrow 官方包,而是一个可选的高性能压缩后端。它通过 Apache Arrow .NET 暴露出来的 ICompressionCodecFactory 扩展点,把底层压缩实现换成了 Cysharp 的 NativeCompressions。

NativeCompressions 仓库地址:

https://github.com/Cysharp/NativeCompressions

性能对比

先直接看结果。

Benchmark 环境:

  • BenchmarkDotNet 0.15.8
  • Ubuntu 24.04.2 LTS
  • Intel Core i7-14700K
  • .NET SDK 10.0.107
  • Runtime .NET 8.0.26

测试的是 Arrow IPC 读写路径,不是单纯的 codec micro benchmark。也就是说,写入路径里包含 Arrow IPC writer 和 MemoryStream.ToArray() 的成本。

测试命令:

dotnet run --project benchmarks/ArrowNet.Compression.NativeCompressions.Benchmarks/ArrowNet.Compression.NativeCompressions.Benchmarks.csproj -c Release -f net8.0 -- --filter "*ArrowIpcCompressionBenchmarks*"

测试数据是 deterministic 的 int + string Arrow RecordBatch,分别测试:

  • 10w 行
  • 50w 行
  • 100w 行

对比对象:

  • Apache.Arrow.Compression.CompressionCodecFactory
  • NativeCompressionsCodecFactory

结果如下:

RowsPathCodecApache meanApache allocatedNative meanNative allocatedTime differenceAllocated difference
100kWrite compressed IPC streamLZ4 frame3.229 ms6,105.70 KB2.716 ms5,291.66 KB15.9% faster13.3% less
100kRead compressed IPC streamLZ4 frame0.764 ms3.79 KB0.431 ms3.07 KB43.5% faster19.0% less
100kWrite compressed IPC streamZstd4.205 ms2,762.03 KB3.318 ms3,064.87 KB21.1% faster11.0% more
100kRead compressed IPC streamZstd1.555 ms3.12 KB1.313 ms3.16 KB15.6% faster1.3% more
500kWrite compressed IPC streamLZ4 frame15.844 ms28,698.06 KB14.929 ms26,426.71 KB5.8% faster7.9% less
500kRead compressed IPC streamLZ4 frame4.039 ms4.10 KB2.235 ms3.42 KB44.7% faster16.6% less
500kWrite compressed IPC streamZstd21.681 ms13,536.49 KB17.133 ms15,023.90 KB21.0% faster11.0% more
500kRead compressed IPC streamZstd8.181 ms3.45 KB6.800 ms3.48 KB16.9% faster0.9% more
1MWrite compressed IPC streamLZ4 frame36.852 ms57,450.92 KB32.276 ms52,845.62 KB12.4% faster8.0% less
1MRead compressed IPC streamLZ4 frame8.619 ms4.11 KB4.761 ms3.22 KB44.8% faster21.7% less
1MWrite compressed IPC streamZstd41.588 ms27,016.95 KB36.714 ms29,987.13 KB11.7% faster11.0% more
1MRead compressed IPC streamZstd16.717 ms3.74 KB14.523 ms4.14 KB13.1% faster10.7% more

可以看到,最明显的是 LZ4 read 场景。

在 10w、50w、100w 三组数据下,NativeCompressions 后端快了大约 44%,managed allocation 也更低。

Zstd 这边也有时间收益,不过内存分配上并不是所有场景都更好。尤其是 Zstd write,速度更快,但 managed allocation 会多一些。

所以这个优化不能简单理解成“所有场景都更好”。更准确地说:

  • LZ4 read:收益非常明显,时间和 managed allocation 都更好;
  • LZ4 write:时间更快,allocation 更少;
  • Zstd read/write:时间更快,但 allocation 可能略高。

性能优化不能只看一个指标。只看耗时,容易忽略 allocation;只看 allocation,又可能错过真实吞吐收益。

这里的 allocated 是 BenchmarkDotNet MemoryDiagnoser 统计出来的 managed allocation per operation,不是进程峰值内存,也不是 native memory。

关于 NativeCompressions

NativeCompressions 是 Cysharp 做的 native compression binding / high-level API。

它支持:

  • LZ4
  • Zstandard
  • OpenZL

对于 Arrow .NET 来说,最相关的就是:

  • CompressionCodecType.Lz4Frame
  • CompressionCodecType.Zstd

正好对应 Arrow IPC 当前公开的两个压缩 codec。

不过要注意,NativeCompressions 当前仍然是 preview 状态。它的 README 里也明确写了 API 可能变化,不建议直接无脑用于所有生产环境。

在这个库里,它只负责替换 Arrow IPC 的 LZ4/Zstd codec 实现。Arrow 的数据结构、IPC 格式、reader/writer API 还是 Apache Arrow .NET 的。

Arrow .NET 是怎么接入压缩的?

Apache Arrow .NET 这里设计得比较好,它没有把压缩实现完全写死。

它提供了一个扩展点:

ICompressionCodecFactory

也就是说,只要实现这个 factory,就可以让 Arrow reader / writer 使用自己的 codec。

使用方式大概是这样:

using Apache.Arrow.Ipc;
using ArrowNet.Compression.NativeCompressions;

var options = new IpcOptions
{
    CompressionCodecFactory = new NativeCompressionsCodecFactory(),
    CompressionCodec = CompressionCodecType.Lz4Frame
};

如果使用 Zstd,把 CompressionCodec 改成 CompressionCodecType.Zstd 即可。

所以这个库可以做得很小。

不需要 fork Apache Arrow,也不需要改 Arrow 的源码,只需要实现它已经暴露出来的 codec factory 即可。

NativeCompressionsCodecFactory 做了什么?

核心入口就是:

NativeCompressionsCodecFactory

它负责根据 Arrow 的 CompressionCodecType 创建对应 codec。

目前只支持两个:

CompressionCodecType.Lz4Frame
CompressionCodecType.Zstd

不支持的 codec 会直接抛 NotSupportedException

这样做有一个好处:失败是显式的。

压缩格式这种东西最怕静默 fallback。你以为用了某个高性能 backend,实际却 fallback 到别的实现,这种问题很难排查。所以这里宁可直接失败,也不要偷偷降级。

LZ4 和 Zstd 的实现思路

实现上分别有两个 internal codec:

  • NativeCompressionsLz4CompressionCodec
  • NativeCompressionsZstdCompressionCodec

LZ4 路径使用 NativeCompressions 的 LZ4 API。

Zstd 路径使用 NativeCompressions 的 Zstandard API,默认压缩级别是 3。

更值得注意的是,压缩路径没有使用 one-shot Compress(...) 返回新 byte[] 的方式。

一开始我也看过这个方向,但这会引入额外的临时压缩数组。对于 Arrow IPC 写入来说,本来就有 writer、buffer、stream、ToArray() 等成本,再多一个临时大数组,会让 allocation 更难看。

所以现在的实现使用了:

  • ArrayPool<byte>.Shared
  • span-based output API
  • 最大压缩长度预估
  • 压缩完成后只写实际压缩长度

这样做不是严格意义上的“零拷贝”,但已经是比较接近当前接口约束下的 minimal-copy 路径。

对于解压路径,Arrow 会给出目标输出大小。codec 只需要把压缩 payload 解到 Arrow 期望的目标 buffer 里即可。

这里还有一个细节:Arrow IPC buffer 里可能存在 padding,所以 decoder 不能简单假设输入长度就等于压缩帧的精确长度。实现需要遵守 Arrow 的 exact-output-size contract。

Benchmark 是怎么设计的?

Benchmark 不是只测 codec 本身,而是测端到端 Arrow IPC 读写路径。

主要有两个 benchmark:

WriteCompressedIpcStream()
ReadOfficialCompressedIpcStream()

参数有三组:

[Params(100_000, 500_000, 1_000_000)]
public int RowCount { get; set; }

[Params(CompressionCodecType.Lz4Frame, CompressionCodecType.Zstd)]
public CompressionCodecType Codec { get; set; }

[Params(CompressionBackend.ApacheArrowCompression, CompressionBackend.NativeCompressions)]
public CompressionBackend Backend { get; set; }

也就是:

  • 3 个数据量
  • 2 个 codec
  • 2 个 backend
  • 读写两个路径

总共 24 组结果。

另外 benchmark 加了:

[MemoryDiagnoser]

这个属性也是 README 表格里 allocated 数据的来源。

读路径还有一个特意设计:两边 backend 解压的是同一份由 Apache Arrow 官方 compression factory 写出来的 payload。

这样可以避免“不同 writer 生成不同 payload”影响读路径对比。

为什么要写成一个独立包?

一开始我并不是奔着“新建一个库”去的。

前面在 Arrow .NET 23 上做性能优化时,很多问题都还能在 arrow-net 自己的代码里解决。但 LZ4 不太一样。越往下看,越像是底层库本身的事情。

Arrow .NET 默认使用 K4os 做 LZ4 后端。如果继续沿着这条路优化,就需要深入 K4os 的实现细节;如果直接替换 Arrow .NET 的默认压缩库,又会带来更大的兼容性和维护成本。

所以最终的选择是:不动默认实现,基于 Arrow .NET 已有的 ICompressionCodecFactory 扩展点,做一个可选后端。

这样既不用 fork Apache Arrow,也不用改变默认行为。需要这部分性能收益的用户,可以主动安装并切换到 NativeCompressions 后端;不需要的人,则完全不受影响。

这个边界对我来说很重要。

所以这个库刻意保持得很小:

  • 不做自动检测
  • 不做 DI 封装
  • 不做 fallback chain
  • 不 patch Apache Arrow
  • 不支持 Arrow 当前没有公开的 codec

只做一件事:提供一个 NativeCompressions-backed codec factory。

使用方式

安装:

dotnet add package ArrowNet.Compression.NativeCompressions

然后像前面一样,在 IpcOptions 里把 CompressionCodecFactory 设置成 NativeCompressionsCodecFactory,再选择 Lz4FrameZstd

如果主要是读 Arrow IPC stream,也是在构造 reader 时传入相应 options / factory。

具体接入点取决于使用的是 ArrowStreamReaderArrowFileReader,还是 IPC writer。

目前的限制

这个库目前有几个明确限制。

第一,只支持:

  • LZ4 frame
  • Zstd

其他 codec 会直接失败。

第二,NativeCompressions 当前还是 preview。它的 API 和 runtime package 后续可能会变化。

第三,当前没有 strong-name signing。原因是 NativeCompressions 相关依赖目前不是 strong-named。

第四,benchmark 结果只代表当前仓库里的测试环境和 workload。真实 workload 如果字段类型、字符串分布、压缩比例、IO 方式不同,结果也可能不同。

第五,表格里的 allocation 口径要看清楚。它不是 native memory,也不是进程峰值工作集。

总结

这次优化的核心其实不是“换个库”这么简单,而是利用 Arrow .NET 已经设计好的扩展点,把压缩后端替换成 NativeCompressions,并且用真实 Arrow IPC 路径做 benchmark 验证。

当前结果看下来:

  • LZ4 read 是最值得关注的场景,大约 44% faster;
  • LZ4 write 也有稳定收益,并且 allocation 更少;
  • Zstd 时间上也更快,但 managed allocation 不一定更低;
  • 这个库可以当作 Arrow .NET 的可选高性能压缩后端;
  • 由于 NativeCompressions 仍是 preview,生产使用前建议结合自己的 workload 重新 benchmark。

最后,性能优化一定要回到真实路径里验证。

只测 codec throughput 当然有意义,但如果真实业务走的是 Arrow IPC reader/writer,那么 end-to-end IPC benchmark 才更接近真正会感受到的性能。

参考资料