前言
在很多数据密集型系统里,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.CompressionCodecFactoryNativeCompressionsCodecFactory
结果如下:
| Rows | Path | Codec | Apache mean | Apache allocated | Native mean | Native allocated | Time difference | Allocated difference |
|---|---|---|---|---|---|---|---|---|
| 100k | Write compressed IPC stream | LZ4 frame | 3.229 ms | 6,105.70 KB | 2.716 ms | 5,291.66 KB | 15.9% faster | 13.3% less |
| 100k | Read compressed IPC stream | LZ4 frame | 0.764 ms | 3.79 KB | 0.431 ms | 3.07 KB | 43.5% faster | 19.0% less |
| 100k | Write compressed IPC stream | Zstd | 4.205 ms | 2,762.03 KB | 3.318 ms | 3,064.87 KB | 21.1% faster | 11.0% more |
| 100k | Read compressed IPC stream | Zstd | 1.555 ms | 3.12 KB | 1.313 ms | 3.16 KB | 15.6% faster | 1.3% more |
| 500k | Write compressed IPC stream | LZ4 frame | 15.844 ms | 28,698.06 KB | 14.929 ms | 26,426.71 KB | 5.8% faster | 7.9% less |
| 500k | Read compressed IPC stream | LZ4 frame | 4.039 ms | 4.10 KB | 2.235 ms | 3.42 KB | 44.7% faster | 16.6% less |
| 500k | Write compressed IPC stream | Zstd | 21.681 ms | 13,536.49 KB | 17.133 ms | 15,023.90 KB | 21.0% faster | 11.0% more |
| 500k | Read compressed IPC stream | Zstd | 8.181 ms | 3.45 KB | 6.800 ms | 3.48 KB | 16.9% faster | 0.9% more |
| 1M | Write compressed IPC stream | LZ4 frame | 36.852 ms | 57,450.92 KB | 32.276 ms | 52,845.62 KB | 12.4% faster | 8.0% less |
| 1M | Read compressed IPC stream | LZ4 frame | 8.619 ms | 4.11 KB | 4.761 ms | 3.22 KB | 44.8% faster | 21.7% less |
| 1M | Write compressed IPC stream | Zstd | 41.588 ms | 27,016.95 KB | 36.714 ms | 29,987.13 KB | 11.7% faster | 11.0% more |
| 1M | Read compressed IPC stream | Zstd | 16.717 ms | 3.74 KB | 14.523 ms | 4.14 KB | 13.1% faster | 10.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.Lz4FrameCompressionCodecType.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:
NativeCompressionsLz4CompressionCodecNativeCompressionsZstdCompressionCodec
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,再选择 Lz4Frame 或 Zstd。
如果主要是读 Arrow IPC stream,也是在构造 reader 时传入相应 options / factory。
具体接入点取决于使用的是 ArrowStreamReader、ArrowFileReader,还是 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 才更接近真正会感受到的性能。
参考资料
-
ArrowNet.Compression.NativeCompressions
https://github.com/InCerryGit/ArrowNet.Compression.NativeCompressions -
Cysharp NativeCompressions
https://github.com/Cysharp/NativeCompressions -
Apache Arrow .NET
ICompressionCodecFactory
https://arrow.apache.org/dotnet/current/api/Apache.Arrow.Ipc.ICompressionCodecFactory.html -
Apache Arrow .NET
IpcOptions
https://arrow.apache.org/dotnet/current/api/Apache.Arrow.Ipc.IpcOptions.html -
Apache Arrow .NET
CompressionCodecType
https://arrow.apache.org/dotnet/current/api/Apache.Arrow.Ipc.CompressionCodecType.htmlC# .NET 交流群
相信大家在开发中经常会遇到一些性能问题,苦于没有有效的工具去发现性能瓶颈,或者是发现瓶颈以后不知道该如何优化。之前一直有读者朋友询问有没有技术交流群,但是由于各种原因一直都没创建,现在很高兴的在这里宣布,我创建了一个专门交流.NET 性能优化经验的群组,主题包括但不限于:
- 如何找到.NET 性能瓶颈,如使用 APM、dotnet tools 等工具
- .NET 框架底层原理的实现,如垃圾回收器、JIT 等等
- 如何编写高性能的.NET 代码,哪些地方存在性能陷阱
希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET 问题和宝贵的分析优化经验。目前一群已满,现在开放二群。可以加我 vx,我拉你进群: ls1075 另外也创建了 QQ Group: 687779078,欢迎大家加入。
