跳到正文
Hacker News· kinabalu76·· 9 天前AI 评分50

Kotlin 在 DHH 的 Campfire 重写中追平 Rust

Kotlin keeps up with Rust in DHH's Campfire rewrite

AI 导读

开发者用 Claude Code(Claude Opus 5.5)约 24 小时将 37signals 的 Rust 版 Campfire 移植为 Kotlin,在 Ryzen AI MAX+ 395 的 x86 机器上吞吐追平甚至超过 Rust。

正文 · AI 翻译

我花了大约一天时间,用 Claude Code 把 37signals 的 Rust Campfire 移植到了 Kotlin。我想看看 JVM 在 DHH 的基准测试表中会排在哪里。在我测试的 x86 机器上,它跟上了 Rust,而且经常领先。

David Heinemeier Hansson 一直在用一门又一门语言重写 Campfire——37signals 的开源聊天应用,大部分代码由 AI 代理编写。记分牌就是一张表:同一个应用、同样的页面,用 Rails、Django、Laravel、Express、Elixir、Go、Rust 和 C 实现,比较每秒请求数。今天的版本,针对房间页面:

Rails  Django  Laravel  Express  Elixir      Go     Rust        C
-----  ------  -------  -------  ------  ------  -------  -------
4,101   1,507    3,872   42,636   5,350  53,060  106,494  137,524

来源:basecamp/once-campfire-rust README。Ryzen AI MAX+ 395 上 16 个客户端,每个应用四个核心。

很容易把这张表读成语言排名:编译型胜过解释型,Rust 和 C 胜过其他所有。我不认为它衡量的是这个。

10 月 7 日,这个对比中的 Rails 房间页面每秒处理 230 个请求。一天后,它每秒处理 4,101 个:在语言、框架和硬件不变的情况下跃升了 18 倍。改变的是架构。最快的移植版本带来的改进被带入了每个实现。用 DHH 的话说:

我让那些 clankers 跑了一天……然后让它把所有架构改进带到每个实现中。

又经过一天的代理工作后,C 也超过了 Rust。DHH 宣布:“老牌的 C 现在领先了!”

这些数字衡量每个移植版本获得了多少关注,至少不亚于衡量语言本身。 Rust 移植版本获得的关注比任何其他版本都多:数百次提交、性能分析文章、Thruster 的替代品、内置的 HTML 解析器,以及一条调优到压缩不再主导性能剖析的 gzip 流水线。JVM 根本不在表里。

所以我问:如果一门垃圾回收语言获得同样的关注,从 Rust 版本而不是 Rails 版本移植,保持相同的输出,并在相同硬件上测量,会发生什么?

once-campfire-kotlin 是 Campfire 的 Rust 移植版的 Kotlin 移植。它运行在 JVM(Java 25)上,或作为 GraalVM 原生镜像运行。它读取相同的 SQLite 数据库,使用相同的签名和加密 cookie,因此可以放在现有安装前面。

它不是玩具端点。每个请求都经过 Rust 移植版运行的相同层次:

  • 一个前端服务器,承担 Thruster(Rust 移植版已替换掉它)的工作,以及 Rack::Deflater;

  • Rails 中间件、public/ 文件和完整的路由表;

  • 带有 cookie jar、session 和 flash 的请求上下文;

  • 控制器的 before-actions,包括每小时会话刷新;

  • 带有 Rails 键的有界片段缓存;

  • 布局;

  • 以及第二轮中来自上游的已完成响应缓存。

它提供一个页面,即房间页面(GET /rooms/:id),因为那是 DHH 表格中的头条行。包含富文本。这意味着要把 Rust 移植版内置的 HTML 解析器 html5ever 移植到 Kotlin。该移植版与 Rust crate 的 658 个用例语料库逐字节匹配,并且针对 Rust 实现额外模糊测试了 180,000 个用例,未发现差异。

底层使用 Netty 处理 HTTP,使用 Java 的 FFM API 处理 SQLite(用与 Rust 构建相同的 amalgamation 和标志编译,因此两个应用运行相同的数据库引擎),以及大约 22,000 行 Kotlin 代码。在原生镜像中,SQLite 被静态链接,并通过 GraalVM 的 C 接口调用。

如果两个端口做的工作不同,那么它们之间的基准测试就毫无意义。因此 Kotlin 端口有一条硬性规则:每个响应都必须与 Rust 端口的响应逐字节完全相同,包括响应头,且顺序一致。

  • 对于种子中的每个房间,都会针对两个 user-agent 分支和两种编码比较响应体和 ETag。

  • 还会检查另外二十多个请求,包括 304、HEAD、Turbo Frames、重定向、404、静态文件和字节范围。缓存未命中、缓存命中和失效也会被检查。

  • 在负载下,负载生成器会使用共享测试框架的精确响应体契约,将每个响应与 Rust 页面逐字节比对。

在最后四次基准测试运行中,共有 306 次闭环测量和数百万个响应,零个无效响应。

要与 Rust 逐字节匹配,意味着要移植一些你绝不会手写的东西。Kotlin 的 header map 复现了 Rust 的 http::HeaderMap 如何对响应头排序,包括移除一个响应头时另一个如何移到它的位置。零值 Content-Length 会排在所有其他响应头之后,因为 hyper 最后写入它。

以下是两个端口中房间页面的权限检查。Rust:

Kotlin:

Rust 版本接收一个 Scope,因为其他控制器会复用它,而 Kotlin 端口只服务 show,所以这并非严格对等的比较。但大部分差异来自语言本身:没有 async/await 线程,没有 move 闭包,没有针对 None 的 match 分支。可空类型和 ?: 用一行就完成了同样的工作。动作本身在两者中读起来几乎一样:

Kotlin:

我更喜欢读 Kotlin 版本。在这些吞吐量测试中,这似乎没有带来性能损失。

Rust 端口是 DHH 和“代表 DHH 的 GPT”的作品:自 9 月下旬以来超过 400 次提交,约 64,000 行 Rust 代码外加 161 个模板,并通过跨浏览器的 Playwright 测试框架保持与 Rails 的逐像素一致。

Kotlin 端口是用 Claude Code(Claude Opus 5.5)在大约 24 小时的挂钟时间内构建的,包括通宵:

  1. 一次尖峰实验在早上进行:一条路由,约 2,100 行,用来看看 JVM 能否接近。它快得可疑,但它跳过了框架层,所以它的大部分领先来自更少的工作,而不是更快的运行时。我把它扔掉了。

  2. 框架移植在晚上进行:七个代理并行工作,逐模块进行,并与 Rust 端口从 Rails 应用生成的金标准向量进行核对。

  3. 先对齐,再分析:消除与 Rust 的每一字节差异,然后进行两轮性能分析(更便宜的 SQLite 文本读取、无分配的响应头查找、缓存样式表 Link 响应头),之后任何数字才被计入。

  4. 一轮审查。一次独立审查发现了三个真实 bug:流水线 HTTP 请求可能被乱序应答;一个可能阻塞数秒的 SQLite 调用在 FFM API 中被标记为 critical,而 Java 规定它绝不能阻塞;以及 JDBC 回退无法执行写入。这三个问题都已通过回归测试修复,然后重新测量。

  5. 响应缓存:当上游 Rust 在项目中途获得其完成响应缓存时,一个代理移植了它(约 300 行加测试),并在命中、未命中和失效时匹配上游的输出。

Token 数量:约 530 万新 token(生成 60 万,写入 prompt 缓存 470 万),外加约 4.1 亿次缓存读取(每一轮都会重新读取上下文)。在 Hetzner 上每次基准测试运行的云账单,包括我忘记关服务器的那一晚:约 €4.65。

我花了很多时间检查更快的结果到底意味着我们让某些东西变快了,还是不小心少做了一部分工作。

以下是我进行比较的方式:

  • 硬件。Hillsboro 的 Hetzner Cloud 专用 vCPU 服务器,AMD EPYC Milan 2.0 GHz。应用服务器要么是 ccx13,结果发现它是 一个物理核心及其两个 SMT 线程,要么是 ccx23,即 两个物理核心。负载生成器是私有网络上的另一台 ccx23,实测 12–17 Gbit/s。

  • 一次只跑一个应用,每个都在 Docker 中使用 host 网络,运行在种子数据库的全新副本上。

  • Rust 按原样发布:使用它自己的生产镜像,由其 Dockerfile 构建。

  • Kotlin 运行在 eclipse-temurin:25-jre 上并使用 ParallelGC,以及作为 Oracle GraalVM 25 原生镜像,使用 -O3 和在同一份代码上记录的 PGO profile,目标为 x86-64-v3,因此它可以在任何较新的 x86 机器上运行。

  • 闭环:1 到 4,096 个连接,预热 20 秒,然后每步 15 秒,交替顺序重复三次。表格显示的是中位数。每请求 CPU 来自每个容器的 cgroup。

  • 关闭日志后的核心数据。Docker 的日志驱动会把请求日志的 CPU 开销算在容器之外,在这些机器上让每个应用多花约 30%。开启日志时,比例类似。

  • Rust 得到了从宽处理。Rust 移植版的监听器只向内核请求 128 个待处理连接的队列(详见下文)。这导致它在高连接数时间歇性卡顿。我给它打了补丁,改用内核的 somaxconn,就像 Go 那样,表格使用的是打过补丁的 Rust。在 4,096 个连接以下,该补丁对吞吐量的改变不超过 2%;它只是消除了卡顿。

  • 每次运行前做速度检查。在 Ashburn 的一次下午运行中,每个应用都慢了 45%,连续两台全新服务器都是如此。我把它丢弃并换了区域,而不是把它平均进去。

这是同类比较:Kotlin 对比从其移植来源版本(5eb6d2d)的 Rust 移植版,两者都在每个请求中通过相同的层渲染页面,且片段缓存已预热。

单核(ccx13)。每秒请求数,以及 Rust 的占比和每请求 CPU:

Conns            Rust            Kotlin JVM         Kotlin native
-----  --------------  --------------------  --------------------
    1  2,379 (268 µs)  2,810 (118%; 183 µs)  2,716 (114%; 189 µs)
   16  7,920 (245 µs)  8,850 (112%; 217 µs)  7,647  (97%; 245 µs)
   64  8,530 (228 µs)  8,961 (105%; 212 µs)  7,747  (91%; 242 µs)
  256  8,788 (222 µs)  8,945 (102%; 213 µs)  7,408  (84%; 249 µs)
1,024  7,196 (271 µs)  8,536 (119%; 225 µs)  6,887  (96%; 265 µs)
4,096  5,906 (329 µs)  7,193 (122%; 270 µs)  6,002 (102%; 307 µs)

双核(ccx23)。每秒请求数,以及 Rust 的占比:

Conns    Rust     Kotlin JVM  Kotlin native
-----  ------  -------------  -------------
    1   2,484   3,081 (124%)   2,844 (115%)
   16  14,464  18,340 (127%)  16,095 (111%)
   64  15,122  20,664 (137%)  17,562 (116%)
  256  16,407  20,564 (125%)  17,703 (108%)
1,024  13,448  19,613 (146%)  16,759 (125%)
4,096  11,654  17,300 (148%)  12,605 (108%)

[图表:第一轮,按连接数统计的请求/秒,1 核和 2 核,各三条线]

在单核上,JVM 在每个连接数下都领先 Rust,领先幅度为 2% 到 22%,而原生镜像处于 84–114% 之间。在双核上差距进一步拉大:JVM 比 Rust 多服务 25–48%,甚至原生镜像也领先 8–25%。每个应用都让机器保持繁忙(4 个线程中约 3.8 个),而负载生成器保持在自身容量的一半以下,所以这不是测量假象。在这套硬件上,Rust 每个请求消耗更多 CPU:在 64 个连接时,Rust 为 250 µs,而 JVM 为 184 µs。

尾延迟也遵循同样的模式。在单核 1,024 个连接时,Rust 的 p99 为 284 ms,JVM 为 147 ms,原生镜像为 169 ms。

然后上游在我脚下发生了变化。Rust 移植版增加了一个已完成、已认证响应的缓存,在数据库发生变化(SQLite 的 data_version)或 15 秒过去之前一直有效。跳到 106,494 的大部分原因就在于此。与昨天的 Rust 做基准测试很容易被忽视,所以响应缓存被逐行移植到 Kotlin,并针对 upstream HEAD(ac6d655)进行了基准测试,也就是 DHH 最新表格背后的同一份代码。在未命中、命中和失效时都保持了逐字节一致。

单核(ccx13)。每秒请求数,以及相对 Rust 的占比和每请求 CPU:

Conns             Rust            Kotlin JVM          Kotlin native
-----  ---------------  --------------------  ---------------------
    1   3,787 (123 µs)   4,411 (116%; 86 µs)   4,264 (113%;  90 µs)
   16  18,614 (101 µs)  19,175 (103%; 97 µs)  16,201  (87%; 115 µs)
   64  18,634 (102 µs)  21,643 (116%; 88 µs)  16,993  (91%; 110 µs)
  256  19,155  (99 µs)  24,049 (126%; 81 µs)  18,431  (96%; 102 µs)
1,024  15,686 (122 µs)  21,377 (136%; 92 µs)  16,419 (105%; 113 µs)

双核(ccx23)。每秒请求数,以及相对 Rust 的占比:

Conns    Rust     Kotlin JVM  Kotlin native
-----  ------  -------------  -------------
    1   4,337   4,318 (100%)   3,876  (89%)
   16  25,505  28,701 (113%)  24,557  (96%)
   64  30,045  38,057 (127%)  36,106 (120%)
  256  30,154  35,581 (118%)  34,746 (115%)

[图表:第二轮,按连接数统计的每秒请求数,单核和双核]

缓存大致让每个实现的吞吐量翻倍(1.8 倍到 2.4 倍),而排序没有变化。渲染被缓存掉之后,剩下的是 HTTP 栈、cookie 验证和字节复制。JVM 用比 Rust 更少的 CPU 完成这些:在双核上每请求 77 µs,而 Rust 为 109 µs。

双核表格止于 256 个连接,因为超过之后测试就不再测量应用了。在 1,024 个已缓存连接时,应用服务器推送 4–5 Gbit/s 的页面,Hetzner 的虚拟网络开始丢包。在 15 秒内,Rust 重传了 252,000 个 TCP 段,Kotlin 重传了 452,000 个。两个应用都有一半时间处于空闲,等待重传超时。我用线程转储、飞行记录、更多工作线程和第二个负载生成器确认了这一点,所有证据都指向网络。Kotlin 受丢包影响更大(每秒请求数更少),可能是因为 Netty 会刷新更大的突发流量,但我还没有证明这一点。

以下是 Rust 更好的地方:

  • 内存。 Rust 空闲时为 15–17 MB,并且在 4,096 个连接之前一直保持在约 80 MB 以下。JVM 空闲时为 117–170 MB,负载下运行在 170–500 MB。如果内存是你的约束,JVM 是错误的选择,但请继续往下读。

  • 启动。 Rust 从 docker run 开始,包括 Docker 自身的容器启动在内,约 175 ms 内响应第一个请求。JVM 大约需要 1.5 s。

  • 预热。 JVM 需要其 JIT 在确切的请求组合上预热。在固定的 4,000 请求/秒下,使用基准测试通常的预热时,其 p99 为 315–389 ms,而 Rust 为 5 ms。用测量所发送的相同请求进行预热后,它降到了 31–80 ms,但这仍然高于 Rust 的 2–3 ms。其余部分最可能是垃圾回收,不过我还没有测量。

  • Apple 芯片。 在 M4 Max 上的 Docker 中(每个应用两个 CPU,在评审修复之前),JVM 与 Rust 持平(93–105%),原生镜像落后至 75–87%。Kotlin 在 x86 上的领先并不适用于每一种 CPU。

这是我觉得最有趣的部分。同样的 Kotlin 代码用 GraalVM 提前编译后:

  • 启动比 Rust 更快: 首屏 165 毫秒对 178 毫秒,且包含 Docker 的启动时间;

  • 空闲时占用 28–29 MB,是 Rust 15–17 MB 的两倍,但只有 JVM 的五分之一;

  • 负载下保持在 30–50 MB 左右,在高连接数下低于 Rust 的 70–225 MB;

  • 未缓存时吞吐量为 Rust 的 84–125%,缓存时为 87–120%。

有一个内存问题:在双核、1,024 个未缓存连接下,它的堆增长到了 555 MB。和任何垃圾回收运行时一样,它在生产环境中需要设置堆上限(-Xmx)。

对于这个端点,GraalVM 给了我另一个有用的部署选项:内存占用远低于 JVM,而吞吐量相当接近 Rust。同一份代码库既可以部署在 JVM 上追求峰值吞吐量,也可以作为原生镜像实现极小的占用。

Rust 的 128 连接监听队列。 在 4,096 个连接的 6 次运行中有 4 次,原版 Rust 卡住了。一半 CPU 闲置,吞吐量降至 2,100–3,100 请求/秒,最差请求耗时 27.5 秒。内核在一次运行中统计到 13,465 次被丢弃的连接尝试,客户端按 Linux 的 1、3、7 和 15 秒计划重试。原因就是一个数字:Rust 端口通过 Tokio 和 mio 监听,而 mio 硬编码了 128 的积压值,即标准库的值。它所取代的 Go 服务器 Thruster 使用内核的 somaxconn(在这些机器上是 65,535)。这是一行代码的修复,基准测试仓库以补丁形式提供了它。

云环境噪声比你测量的差异更大。 某天下午某个区域所有应用都慢了 45%,这比本文中的大多数差距都大。如果没有对照已知良好运行进行速度检查,我可能就把它发布出去了。

在 Docker 中日志记录代价高昂。 请求日志在这些机器上消耗约 30% 的吞吐量,超过这里任何语言差异,因为 Docker 的日志驱动在容器外完成工作。

我开始做这件事是因为表格中缺少 JVM。这些结果让我觉得它值得占有一席之地。

它们也让我更谨慎地看待这些表格作为语言排名的意义。Rails 在没有更换语言的情况下提升了 18 倍,而在我的测试中,添加响应缓存大约使吞吐量翻倍。架构显然非常重要。

  • 一个页面。 Kotlin 端口只提供房间页面,别无其他。发布消息(数据库写入占主导)、WebSockets、搜索和附件均未测量。

  • 两台小型机器。 AMD EPYC Milan 上的一个和两个物理核心。DHH 的表格使用 Ryzen AI MAX+ 395 的四个核心,因此绝对数字不可比,只能比较每个表格内的比率。

  • 一种负载形态。 闭环客户端猛击一个缓存页面,外加第一轮中固定速率的开环运行。在那次开环运行中,JVM 在单核上维持了约 7,500 请求/秒,而 Rust 为 6,400,原生镜像为 6,050。

  • 是实验室,不是生产环境。 无 TLS,且标题数字中关闭了日志记录。

一切都是公开的:

在 Hetzner 上完整运行一次大约需要一小时,花费约一欧元。如果你在方法中发现错误,请提交 issue。

没有帖子

来源:Hacker News · wecodefire.com