跳到正文
Hacker News· stmonty·· 5 小时前AI 评分34

纯函数式编程能多接近 C?Roc 的 DEFLATE 实现比 libdeflate 中位慢 8.9%

How close can pure functional programming get to C?

AI 导读

Roc 语言编写的 roc-deflate 在 Silesia 语料库基准测试中,中位性能比 C 语言 libdeflate 慢 8.9%(Ryzen 9 3900X)和 2.6%(M1 MacBook Pro),最差输入慢 18%,最佳输入反快 18%。

正文 · AI 翻译

2026年10月22日

在为什么 Lean 比 Rust 更快一文中,作者分享了一个令人印象深刻的结果:一个用Lean 4(一种函数式编程语言兼证明助手)编写的DEFLATE 压缩库,性能超过了等效的 Rust 包,而crates.io 显示该 Rust 包已被下载超过十亿次。文章节选如下:

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 对比。

roc-deflate 在这 144 项基准测试中平均比 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 的机会性变异文档中了解其工作原理,但从性能优化的角度来看,避免不必要的共享至关重要,这样值才能保持唯一,从而有资格进行原地变异。

提升边界检查

在 Roc 中,你不能像在 C、Zig 和 Rust 中那样选择关闭边界检查(例如从动态大小的集合中取值),但如果 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