是当我们在 .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.8Ubuntu 24.04.2 LTSIntel Core i7-14700K.NET SDK 10.0.107Runtime .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 stringArrow RecordBatch分别测试10w 行50w 行100w 行对比对象Apache.Arrow.Compression.CompressionCodecFactoryNativeCompressionsCodecFactory结果如下RowsPathCodecApache meanApache allocatedNative meanNative allocatedTime differenceAllocated difference100kWrite compressed IPC streamLZ4 frame3.229 ms6,105.70 KB2.716 ms5,291.66 KB15.9% faster13.3% less100kRead compressed IPC streamLZ4 frame0.764 ms3.79 KB0.431 ms3.07 KB43.5% faster19.0% less100kWrite compressed IPC streamZstd4.205 ms2,762.03 KB3.318 ms3,064.87 KB21.1% faster11.0% more100kRead compressed IPC streamZstd1.555 ms3.12 KB1.313 ms3.16 KB15.6% faster1.3% more500kWrite compressed IPC streamLZ4 frame15.844 ms28,698.06 KB14.929 ms26,426.71 KB5.8% faster7.9% less500kRead compressed IPC streamLZ4 frame4.039 ms4.10 KB2.235 ms3.42 KB44.7% faster16.6% less500kWrite compressed IPC streamZstd21.681 ms13,536.49 KB17.133 ms15,023.90 KB21.0% faster11.0% more500kRead compressed IPC streamZstd8.181 ms3.45 KB6.800 ms3.48 KB16.9% faster0.9% more1MWrite compressed IPC streamLZ4 frame36.852 ms57,450.92 KB32.276 ms52,845.62 KB12.4% faster8.0% less1MRead compressed IPC streamLZ4 frame8.619 ms4.11 KB4.761 ms3.22 KB44.8% faster21.7% less1MWrite compressed IPC streamZstd41.588 ms27,016.95 KB36.714 ms29,987.13 KB11.7% faster11.0% more1MRead 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 是 BenchmarkDotNetMemoryDiagnoser统计出来的 managed allocation per operation不是进程峰值内存也不是 native memory。关于 NativeCompressions#NativeCompressions 是 Cysharp 做的 native compression binding / high-level API。它支持LZ4ZstandardOpenZL对于 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 codecNativeCompressionsLz4CompressionCodecNativeCompressionsZstdCompressionCodecLZ4 路径使用 NativeCompressions 的 LZ4 API。Zstd 路径使用 NativeCompressions 的 Zstandard API默认压缩级别是 3。更值得注意的是压缩路径没有使用 one-shotCompress(...)返回新byte[]的方式。一开始我也看过这个方向但这会引入额外的临时压缩数组。对于 Arrow IPC 写入来说本来就有 writer、buffer、stream、ToArray()等成本再多一个临时大数组会让 allocation 更难看。所以现在的实现使用了ArrayPoolbyte.Sharedspan-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 读写路径。主要有两个 benchmarkWriteCompressedIpcStream() 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 个 codec2 个 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 frameZstd其他 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% fasterLZ4 write 也有稳定收益并且 allocation 更少Zstd 时间上也更快但 managed allocation 不一定更低这个库可以当作 Arrow .NET 的可选高性能压缩后端由于 NativeCompressions 仍是 preview生产使用前建议结合自己的 workload 重新 benchmark。