Ruby YJIT 到底要不要开?我在生产环境折腾两周后的结论(Ruby 3.4)

🔑 关键词:Ruby YJIT,Ruby 3.4 性能优化,Rails 性能调优,YJIT 开启方法,jemalloc

📖 摘要:YJIT 不是开箱就变快的银弹。从一次容器 OOM 说起,讲清楚 YJIT 的 call threshold 机制、3.3/3.4 的 code GC 与 --yjit-mem-size 参数,以及为什么大多数 Rails 项目应该先修 SQL 再碰 JIT,附一套能直接照抄的 A/B 测试流程和几个反例。

Ruby YJIT 到底要不要开?我在生产环境折腾两周后的结论

图片

先交代背景,不然后面全是空话:一个跑了 7 年的 Rails 项目,Ruby 从 2.6 一路升到 3.4,业务代码 12 万行左右(不算 gem),Puma 开 4 个 worker,每天大概 60 万次请求。我不算什么性能专家,就是个被 p99 曲线反复教育过的普通后端。下面这些数字都是我自己抓的,不严谨,但比二手结论靠谱。

我第一次对 YJIT 有感觉,是因为内存涨了

那是 2022 年底,Ruby 3.2 刚发布(12 月 25 号,我记得很清楚,因为那天在加班修一个线上问题),YJIT 从 C 重写成 Rust,到处都在传「性能提升 40%」。我兴冲冲在 staging 上加了 RUBY_YJIT_ENABLE=1,跑了一天:CPU 确实降了,大概 8%–12%,但 RSS 从 480MB 涨到 700 多 MB,容器 OOM 了一次。当时我的第一反应是「这玩意儿是给 benchmark 用的吧」,关掉,继续干活。

一年之后才想明白问题出在哪。YJIT 是方法级 JIT,它不编译整个文件,而是盯着每个方法的调用次数,超过阈值才把那段字节码编译成机器码,这个阈值默认是 30 次(--yjit-call-threshold=30,可以调,但别乱调)。也就是说:

  • 一个跑 3 秒钟的 rake 任务,方法还没被调用够 30 次就结束了,YJIT 一行机器码都没生成,纯白给;
  • 一个跑 12 小时的 Puma worker,前几分钟都在预热,后面才是净赚。

图片

所以「YJIT 到底快不快」这个问题本身就问错了。该问的是:你的进程活多久。搞不清楚这一点,你在 CI 上、在一次性脚本上开 YJIT,得到的只会是负收益。

参数这块,新手最容易踩的坑

先把怎么开说完,这部分我用到的都是 Ruby 3.4 的写法:

# 方式一:环境变量,Dockerfile / systemd 里最省事
RUBY_YJIT_ENABLE=1 bundle exec puma -C config/puma.rb

# 方式二:命令行开关,二选一,别同时写
ruby --yjit app.rb

确认它真的生效了,别信启动日志里那行字:

图片

RubyVM::YJIT.enabled?        # => true / false
RubyVM::YJIT.runtime_stats   # 想看编译次数、退出原因就靠这个

--yjit-stats 这个东西,官方文档写得很含蓄,说的是「会产生额外开销」。我自己在压测机上量下来大概吃掉 10% 上下的性能,生产环境不要开,要调优就在本机开。

还有个版本差异要注意:3.3 之后 YJIT 有了 code GC,不再是一路只增不减地吃内存;3.4 又加了 --yjit-mem-size,默认 128MiB,超过就触发回收。我 2022 年那次 OOM,现在回头看大概率就是因为老版本没有 code GC,代码段只涨不降。所以如果你的 Ruby 还停在 3.1 / 3.2,开 YJIT 之前先把内存上限调高一点,那个参数当时叫 --yjit-exec-mem-size,跟 3.4 的 --yjit-mem-size 不是一个名字,别抄错。

我最想说的:对绝大多数 Rails 项目来说,YJIT 是最后才该碰的东西

这句话可能有人不爱听。我做过一次很粗糙的统计,在我们那个项目上,一个普通 JSON 接口从进 Rack 到返回,耗时大概是这么分的:

图片

环节 占比(我抓的样本,不严谨)
PostgreSQL 查询 + 等待 55% – 70%
JSON 序列化(ActiveModel::Serializer) 15%
Ruby 业务代码 10% – 15%
中间件 / 框架开销 剩下的

YJIT 能加速的是第三行。就算它把 Ruby 代码本身提速 35%(这已经是很乐观的数字了),端到端也就快 4%–5%。而同期我只做了一件事——把一个列表接口的 N+1 干掉,原来是 1 + 87 次查询,改成 3 次——那个接口的 p95 从 380ms 掉到 62ms。这两个数字摆在一起,你自己判断该先干哪个。

所以我的排序是:先查 SQL(bullet gem、pg_stat_statements),再优化序列化(别在循环里反复 new 序列化器实例),然后才是内存分配,JIT 排最后。内存这块有个几乎零成本的招:

# glibc 环境下,Rails 应用基本都要这么配
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
MALLOC_ARENA_MAX=2

这两个环境变量我见过把常驻内存砍掉 25%–40% 的,比 YJIT 那点 CPU 收益实在多了。当然,坑也有:MALLOC_ARENA_MAX 设太小在某些高并发场景下会掉吞吐,得自己压一遍。

图片

顺便说清楚几个老被混在一起的概念

YJIT 不是 Ruby 唯一的 JIT。TruffleRuby 跑在 GraalVM 上,峰值性能理论上比 CRuby + YJIT 高不少,代价是预热慢、内存吃得多、C 扩展兼容性时不时翻车;JRuby 在 JVM 上,JIT 成熟得多,但你在 Rails 生态里天天用的 gem 里一堆原生扩展,一装就卡住。我 2023 年试着把一个中型项目往 TruffleRuby 上搬,卡在一个图像处理的 C 扩展上,两天没搞定,放弃了。这不是人家的错,是我这种项目本来就不适合。

再说并发路线,这块的对比挺有意思。Python 3.13 在折腾 free-threading,琢磨着把 GIL 拆了;Ruby 反着来,GVL 一直留着,并发交给 Ractor 和 Fiber。Ractor 从 3.0 到现在还是实验特性,我反正不敢上生产;Fiber 倒是稳了,Rails 7.1 之后可以设 config.active_support.isolation_level = :fiber,配合 Falcon 这类基于 async 的服务器能吃到一点并发红利。但说句实话,这也不是大多数人的瓶颈——瓶颈还是那些慢查询。

想自己测一遍的话,照这个流程走

如果你打算在自己项目上做一次 A/B,我给你一个我反复用过的流程,不复杂:

  1. 先固定变量。同一台机器、同一个数据集,把所有后台任务停掉。bundle exec ruby -v 和 bundle exec rails -v 记下来,别测完才发现中途 gem 版本变了。
  2. 用 wrk 或 ab 打,别用浏览器点。wrk -t4 -c50 -d60s --latency http://localhost:3000/api/xxx,重点看 Latency Distribution 里 99% 那一行,平均值会骗人。
  3. 每组至少跑 3 次,丢掉第一次。YJIT 的数据尤其要看第 2、3 次,因为它需要预热,第一次跑出来的数字基本没参考价值。
  4. 换参数再跑一轮。--yjit-call-threshold=10 和默认的 30 各来一次,看你这种调用模式下哪个更划算。阈值调低编译得更早,短请求可能受益,但编译开销和内存也跟着上去。
  5. 3.4 的话,把 --yjit-mem-size=256 也试一下,有的项目在 192–256MiB 之间能找到拐点。

图片

(这套流程没有统计学意义,我承认,但足够让你判断「值不值得开」,而不是听别人说快 40% 就跟着抄。)

两个反例:什么情况下别折腾

第一,短命进程。AWS Lambda、Cloud Run 的按需实例、CI 里跑的单测、一次性的数据迁移脚本——这些进程本来就活不长,YJIT 编译出来的机器码还没被复用几次,进程就没了,你付出的只有编译开销和内存。我们 CI 上开过一次,跑测试的总时长反而多了 6% 左右,当天就回滚了。短命进程该优化的是启动时间:bootsnap、砍掉用不上的 gem、把 require 挪到真正需要的地方,这些比 JIT 有用得多。

第二,代码里还有明显 N+1 的时候。这时候开 YJIT 甚至可能让你更迷惑,因为 CPU 降了,但 RT 没降,你会以为是 JIT 没用,其实是数据库在等你。

写了这么多,我的结论其实就一句:YJIT 是个好东西,但它是你已经没有更明显的问题可修之后的那一步。我见过太多人(包括两年前的我)一上来就加 RUBY_YJIT_ENABLE=1,然后把一个 400ms 的接口优化成 390ms,还挺高兴,顺手发个朋友圈说 Ruby 变快了。

🏷️ 标签: