Roc 纯函数式编程距离 C 有多远?roc-deflate 对比 libdeflate 的中位差距为 2.6% 至 8.9%
How close can pure functional programming get to C?
纯 Roc 压缩包 roc-deflate 经作者优化后,在 144 项 Silesia 基准中比 C 版 libdeflate 慢,中位差距依平台为 2.6% 至 8.9%。
文章用同语料基准量化纯 Roc 与 C 的性能差距,并给出边界检查外提、查找表预编译等可迁移优化方法。
2026年10月22日
在为什么 Lean 比 Rust 快一文中,作者分享了一项令人印象深刻的结果:一个用Lean 4编写的DEFLATE 压缩库 outperforms 了一个功能等价的 Rust 包,而crates.io 称该包的下载量已超过十亿次。Lean 4 是一种函数式编程语言和证明助手。文章原文:
lean-zip在每个压缩级别上都击败了miniz_oxide,也就是说,我们能够以更高的吞吐量实现相同的压缩效果。在最快的设置下,我们快 15%,输出小 8.8%。
然而,文章也指出,虽然他们成功超越了 Rust 的 miniz_oxide,但在 DEFLATE 性能的终极对手面前仍无法匹敌:这是用 C 编写的libdeflate。
这里
lean-zip肯定不是最好的:libdeflate毫不意外地将其远远甩在身后(这并不意外,因为这是一个经过非常精心调优、采用特定架构 SIMD 的实现,而我们在 Lean 中无法做到这一点)。
接下来是Roc,一种明确专注于运行时性能的函数式编程语言(但不是定理证明器)。
Roc 的标语是“快速、友好、函数式”,而快速优先,因为快速运行和快速编译一直都是其核心目标。Roc 还不是一门成熟的语言(距离首个带编号的发行版 0.1.0 还有几周),其尚处萌芽阶段的软件包生态系统也几乎没有进行过优化工作。不过,凭借其语言设计、长期以来的优化以及全新的编译器实现,我认为 Roc 已经拥有了足够的基础,足以挑战冠军 libdeflate。
于是我花了不少时间,埋头对roc-deflate进行性能优化。这是Niclas Ahden为 DEFLATE 压缩编写的一个软件包(他也在付费的生产工作中使用它)。我并不期待它能在各方面都击败 libdeflate 的性能,但我想看看它在非流式 API 上能接近到什么程度(因为 roc-deflate 尚未实现流式处理),同时使用经典的 Silesia 语料库进行基准测试,并验证它生成的输出完全相同。
慢不到 10%
以下是目前的基准测试结果:测试在我完成全部优化工作的 2021 款 M1 MacBook Pro 上运行,同时也在一台 AMD Ryzen 9 3900X Ubuntu 机器上进行 x64 对比测试。
在这 144 项基准测试中,roc-deflate 平均比 libdeflate 更慢;从中位数来看,在 Ryzen 3900X 上慢8.9%,在 M1 MacBook Pro 上慢2.6%。
Silesia 语料库上的压缩
MB/s
L1
L2
L3
L4
L5
L6
L7
L8
L9
L10
L11
L12
libdeflate roc-deflate
压缩级别
MB/s
L1
L2
L3
L4
L5
L6
L7
L8
L9
L10
L11
L12
libdeflate roc-deflate
压缩级别
所以,虽然我们的平均表现不及冠军,但与这个经过精心调优、由 SIMD 驱动的 C 库相比,中位数减速百分比仍是个位数,这实在疯狂。 说实话,我一开始并不知道这个结果有可能实现,因为 Roc 是一款带自动内存管理的函数式语言,而它要追赶的代码不必进行边界检查。(边界检查确实有成本,但话说回来,上一次在 C 压缩代码中跳过边界检查导致重大远程代码执行漏洞,还是昨天的事。)
图表显示的细节不止中位数差距。对于最坏情况输入,roc-deflate 比 libdeflate 慢 18%;但在最佳情况输入下,roc-deflate 快了略多于 18%,快多了! 这并不是因为 Roc 掌握了某种 C 无法使用的神奇优化;只是因为两个实现方式不同。虽然 roc-deflate 平均更慢,但对于某些输入,它碰巧确实更快。因此,虽然从 libdeflate 切换到 roc-deflate 有可能提速,但不要指望平均情况下会提速。
需要明确的是,C 的性能上限高于 Roc。对于 roc-deflate 目前胜过 libdeflate 的任何输入,C 代码都可以弥补这一差距。反过来则不成立;Roc 代码是内存安全的,会自动进行引用计数、检查边界,并且不支持 Rust 式的 unsafe 来选择不使用其中任何一项。这些特性使 Roc 代码比用 C、Zig、Rust 以及其他允许完全访问硬件的语言编写的代码具有更强的安全保证——而它为这些保证付出的代价,就是更低的性能上限。
显然,我们无法仅凭这一次结果推断 Roc 在一般情况下能接近 C 到什么程度,但现在已经有人证明了一件此前无人知道可以做到的事。据我所知,这是迄今为止投入优化精力最多的 Roc 软件包,因此其他软件包能接近这一结果多少,目前完全是个未知数。所以,虽然我对这个结果非常兴奋,但我也完全清楚,只有在优化了范围更广的真实世界 Roc 代码库样本之后,我们才会知道它的泛化能力有多好!
性能差距带来的收益
一个显而易见的问题是:“为什么要接受慢哪怕一点?”如果能够达到 C 语言的性能水平,为什么不直接使用 libdeflate 本身,消除剩余的性能差距呢?
纯 Roc 实现相比纯 C 实现有几个优势:
- 内存安全。Roc 代码完全具备内存安全性,也不存在任何允许你选择退出内存安全机制的语言原语。这段代码之所以只能接近 C 语言的性能水平,是因为 Roc 的优化流水线判定,许多边界检查和引用计数操作可证明是安全的,因而可以将其优化掉。以纯 Roc 编写的压缩包无法使用那种未经检查的内存操作,而昨日的 Brotli 远程代码执行漏洞正是由这种操作导致的。
- 无副作用。Roc 的类型系统会推断哪些函数是纯函数,哪些函数可以产生副作用。纯函数的类型使用细箭头,如
Bool -> Bool,而有副作用的函数使用粗箭头,如Bool => Bool。(与内存安全机制一样,Roc 会强制执行这一点,并且不支持任何选择退出的方式。)因此,当你看到 roc-deflate 的函数标注中只有细箭头时,就知道它们确实是纯函数。 - 供应链安全。 Roc 不具备不受限制的 FFI 能力,这意味着你完全可以确信,roc-deflate 的纯函数甚至无法尝试执行 I/O 或访问任意内存。鉴于 供应链攻击 激增,了解这一点很有价值:恶意行为者借助被攻破的 Roc 软件包中的纯函数所能造成的损害,仅限于返回错误的值、导致崩溃或浪费系统资源。Roc 软件包也不支持构建脚本,而构建脚本正是两个月前用于成功攻破广受欢迎的 Rust
arrayref软件包的手段。 - 可移植性。 Roc 的纯函数在设计上具有极强的确定性:只要参数相同,它们总是返回相同的值——不仅在同一台机器上运行时如此,在将代码编译到的任何目标平台上也都如此。(这些函数也可能崩溃或无法终止,但这种行为在不同目标平台之间也应保持确定性。)如果某个 Roc 纯函数在两套不同系统上运行时的预期表现有所差异,唯一可能的情况是其中一套系统在执行过程中耗尽了内存。
如果这些保证在实际使用中未能成立,那只能归因于 Roc 的编译器、应用程序运行所在的 平台 或底层硬件存在缺陷。因此,当你看到 roc-deflate 的文档 声称整个软件包由 100% 的纯函数构成时,你可以确信,该软件包所提供的一切都具备上述全部特性。类型系统会强制保证这一点!
我们如此努力地推进这一切的原因,如果反过来看命题,或许会更清楚:为了换取平均只有个位数百分比的提速,你是否愿意放弃有保证的内存安全、有保证的无副作用、跨所有目标平台的有保证的确定性行为,以及软件包中没有可被利用的构建脚本所带来的安全优势……?
这个答案当然可以因具体用例而异——例如,Roc 自己的编译器希望发挥硬件所能提供的最高性能——但我敢肯定,许多读到这里的读者都能明白,我们为什么会为取得这一成果而如此兴奋。
Roc 专属优化
大幅提升 roc-deflate 性能指标的优化,大多来自适用于各种语言且能带来普遍收益的通用优化技术。(我总是推荐 Casey Muratori 的性能感知编程系列,这是我付费购买并从中学到很多东西的系列。)不过,其中一些优化技术是 Roc 特有的,我想在这里提及它们,供其他人参考。即使你不是在编写 Roc 代码,这也能让你对 Roc 如何实现其性能有所了解。
避免不必要的共享
Roc 最有效的优化之一是 机会式变更(Koka 将其称为 “函数式但原地执行”)。你可以在 Roc 的机会式变更文档中了解其工作原理,但从性能优化的角度看,避免不必要的共享至关重要,这样值才能保持唯一,从而可进行原地变更。
外提边界检查
你无法像在 C、Zig 和 Rust 中那样,在 Roc 里跳过边界检查(例如从动态大小的集合中获取值),但如果 Roc 编译器知道这些检查必定通过,就可以将它们优化掉。边界检查在循环内部代价最高,因此,在循环开始前添加类似 if collection.len() <= highest_index_used { return 0 } 的代码往往能带来很大收益。这会添加一个看似冗余的额外检查,但一旦编译器能够判断提前返回后失败路径不再可达,它就可以安全地删除循环内代价更高的检查。
在编译时构建查找表
Roc 的编译时求值可以在编译时组装查找表,只要没有任何内容依赖运行时输入。更进一步,由于这些表的长度在编译时已知,你可以执行类似 Ok(entry) = lookup_table.get(5) 的操作来完成一次穷尽式模式匹配;得益于编译时求值,Roc 编译器会知道该匹配是有效的。这样可以避免非常量表在运行时可能需要承担的边界检查。
考虑使用回绕算术
在 Roc 中,a + b 会被脱糖为 a.plus(b)。对于像 U64 这样的整数,U64.plus 包含溢出检查,而 plus_wrap 不包含。尽管 plus 是更安全的默认选项,但溢出检查会产生运行时开销,因为 CPU 可以用一条指令完成回绕整数运算。当 Roc 编译器能够证明不会发生溢出时,plus 和 plus_wrap 都会优化为相同的指令;但如果无法证明,选择 plus_wrap 可能会提升速度。
仍有改进空间
我相信,roc-deflate 的性能——尤其是在 x64 CPU 上——以及 Roc 编译器的优化流程,仍有改进空间。
到这里,我相当有把握认为所有唾手可得的机会都已经被利用完了。不过,如果有人想亲自试用,这是我用来在 Linux 上设置并运行基准测试套件的脚本。(请注意,此脚本由 Claude Opus 5.5 生成,我尚未对其进行安全审查;请自行承担风险,在沙箱外运行!)它会固定 Roc 编译器的两个修订版和 roc-deflate 的版本,因此如果你想试用潜在的改进,就需要调整相应的提交。
就我而言,从现在起直到 Roc 0.1.0 发布,我都会专注于这个即将推出的版本。它已经酝酿了八年,因此我们终于能有一个带编号的发布版本,我会非常高兴!如果你想进一步了解 Roc,请访问 roc-lang.org,或者来 Roc Zulip 跟我们打个招呼!
来源:Hacker News · rtfeldman.com