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