系列导航

导读 · 上一篇:35:Prism、YARV 与字节码 · 下一篇:37:Ruby 惯用法与必要优化 · 完整源码包

先定义值得测量的收益

启用 YJIT 之后,某段 Ruby 代码可能更快,也可能因为启动、预热或额外内存成本而不适合当前任务。对短命命令行程序,用户等待的是从进程启动到结果出现的总时间;对常驻服务,稳定负载下的吞吐与尾延迟更重要。两种场景不能用同一段已经预热的循环统一回答。

Taskbook 目前处理小型本地任务集合,没有因为 Ruby 支持 JIT 就自动需要开启它。本章用确定输入建立可复现性能实验,结果只用于解释测量方法。只有业务瓶颈确认落在受益路径上,才有理由调整运行参数。

YJIT 3.4 文档说明构建要求、平台限制与内存代价。安装了 Ruby 不一定意味着二进制包含可用 YJIT;命令行传入选项也不应作为启用成功的唯一证据。实验在每个子进程内检查 RubyVM::YJIT.enabled?。

相同输入与相同结果先于计时

从仓库根目录运行:

1
ruby examples/ruby/labs/36/run.rb

实验使用一到两万的整数数组,每个元素执行乘三加一,再累加。结果由等差数列公式独立计算,并在预热和正式阶段重复验证。优化后结果若发生变化,无论耗时多短,都不属于同一个业务契约。

1
2
3
4
5
def calculate(values)
sum = 0
values.each { |number| sum += number * 3 + 1 }
sum
end

这个负载有稳定的整数类型与重复调用,适合观察热点执行的变化,但并不代表 JSON 解析、数据库等待或复杂对象访问。公开性能数字常来自特定基准组合,不能把它们直接填进自己的容量规划。

两种模式使用同一个解释器路径、同一输入生成方式和同一代码文件。区别仅在 --disable-yjit 与 --yjit。若拿不同版本 Ruby 或不同编译优化等级对比,结果会混入版本和构建差异,无法把全部变化归因于 JIT。

每个样本从独立进程开始

主脚本为解释执行和 YJIT 各启动五个新进程,并交替先后顺序。总是先运行解释器、再运行 JIT,容易把系统升温、后台活动或缓存变化与模式绑定。轮换顺序不能消除全部噪声,但至少避免固定顺序带来的明显偏向。

每个子进程先调用一百次预热,再对两百次调用计时。这样正式样本主要观察预热后的工作区间。输入创建、进程启动和预热成本不包含在该秒数中,因此结果不能直接宣称命令行启动速度改善。若目标是 CLI,必须另加包含这些成本的端到端计时。

计时使用单调时钟。记录所有五个样本后取中位数,保留分布供复查。只展示最佳一次,会偏向偶然少受干扰的样本;只展示平均数,也可能被一次后台任务影响拉偏。五个样本只是小型教学测量,不能替代长期生产压测。

解释器中位数除以 JIT 中位数得到本次倍率,大于一表示该预热区间更快,小于一表示更慢。倍率必须和原始秒数同时读:两个极小数相除可以出现显眼比例,却未必带来有意义的用户等待减少。

启用证据与执行证据

每个样本输出 Ruby 完整描述、enabled 值、耗时、分配量、RSS 和可用运行统计。父进程要求解释模式 enabled 为 false,JIT 模式为 true;构建不支持时直接失败,不把“能力不可用”静默算成通过。

JIT 模式中还保存 runtime_stats,帮助观察代码区域和编译活动。统计字段属于当前实现,脚本不假定每个构建都开放完整计数器。需要更详细的诊断时,应明确说明是否打开统计选项,因为观测本身可能改变成本。

启用 true 说明运行时开启了该功能,不能单独证明任意指定方法的每条路径都已经编译。热点阈值、代码形状与回退会影响覆盖率。要对单个方法作更强断言,需要编译日志或对应统计证据,不能把全局开关状态当作逐方法事实。

本实验把同结果检查放在性能循环中。这会增加一点固定开销,却防止对错误或空工作负载测速。两种模式接受相同检查,解释结果时仍应记住测量的是“计算加结果校验”,没有声称纯机器指令理论上限。

时间之外还要付内存成本

JIT 需要保存生成代码和元数据。Ruby 对象分配数无法完整反映这些成本,因此实验同时采集进程 RSS。若执行更快但进程常驻内存更多,同一机器可容纳的工作者数量可能下降;吞吐收益应结合内存预算评估。

分配量使用正式测量前后的 GC.stat(:total_allocated_objects) 差值。它描述 Ruby 对象分配,不是字节数量,更不是 JIT 生成代码占用。RSS 则在测量后通过外部工具读取,可能包含解释器、共享库、堆与 JIT,不能把两个模式 RSS 差额逐字节归因于机器码。

同一输入重复计算,缓存和对象分配行为可能比真实请求更稳定。服务中的不同类型、异常路径和动态方法变化会产生更复杂的执行轨迹。因此微基准发现收益后,下一步应该是代表性业务样本,而非直接把倍率写入生产容量结论。

JIT 内存上限也是取舍。限制过低可能影响可编译路径,限制较高则占用更多进程空间。不能以“开启后更快”为理由任意放大配置;先看负载需要与统计,再作单变量实验,保留可回退的启动参数。

无收益也是有效结果

如果 JIT 的样本与解释模式接近,先确认被测代码是否真的重复运行、测量区间是否足够长、系统噪声是否占主导,以及工作是否主要发生在 C 扩展或 I/O 中。不要为了得到预设结论不断换输入,最后只展示最有利的一例。

若短命 CLI 整体变慢,而预热循环更快,两者并不矛盾。启动与编译需要成本,执行次数不够时可能无法摊薄。报告应该同时给冷启动与稳态结果,使读者能够按自身调用频率决策。

如果一个变体通过缓存改变了计算次数,或者跳过了输入验证,它已经改变实验条件。比较 JIT 时,输入、错误检查和结果必须一致;比较算法时,则要清楚记录算法差异。把多项优化一次混在一起,容易产生无法复现的归因。

本次构建与测量结果

实测解释器为 Ruby 3.4.11,完整修订号为 592f1ffdb36153e8be83603ade3c2e9ab6138a77,平台为 arm64-darwin27。这个发行源使用 Rust 1.85.1 构建 YJIT。解释模式的五个进程全部报告 enabled=false,JIT 模式全部为 true,所有计算都返回 600050000。

解释模式的五次耗时依次为 0.111950、0.104379、0.120409、0.100721、0.099213 秒;YJIT 对应为 0.018045、0.018502、0.023967、0.026531、0.018799 秒。中位数分别为 0.104379 与 0.018799 秒,解释模式除以 JIT 得到约 5.55。这个结果支持本机该稳定整数负载的预热区间加速,不包含启动与编译预热成本。

每个样本在正式区间都记录到六个 Ruby 对象分配。解释模式 RSS 范围为 16112 到 18128 KiB,JIT 为 17232 到 19088 KiB。样本范围有重叠,不能据此推导固定的额外常驻内存大小;这些读数没有构成峰值监控,也没有覆盖长时间运行后的碎片变化。

五个 JIT 样本都报告 inline_code_size=3164、outlined_code_size=5520、code_region_size=16384 和 compiled_iseq_count=6。这些统计表明进程发生了编译活动,但统计范围包含进程内其他执行,不把六个指令序列全部归属于 calculate。分配数相等也不能说明 JIT 没有额外内存,生成代码与上下文缓存由不同计数描述。

实验保留完整 JSON 原始样本,使后续比较可以检查分布。若换一台机器得到较低倍率,应先核对构建选项、平台、负载与计时区间;只要正确性和启用断言成立,性能数字不同本身不意味着复现失败。验收要求复现测量过程与结论边界,并不要求复制这一倍率。

实验脚本不会因倍率低于一而失败。失败条件限定为结果错误、子进程非正常结束或启用状态不符,把性能观察与正确性门槛分开。这样机器忙碌导致样本变慢时,可以保留数据并重新测量,不会把一次较慢运行误报为语言功能损坏。需要性能回归门槛时,应另外建立固定设备、稳定基线和允许波动区间,不能沿用本章的一次测量作为永久标准。

练习与验收

增加一个只运行一次 calculate 的冷启动模式,从父进程计时整个调用。与当前预热模式并列,检查短任务是否仍受益。保留五个样本,不以其中某一次结果替代分布。

再把一部分输入改为不同数值类型,独立定义正确结果,并观察编译统计和耗时变化。不能沿用整数结果公式而仅删除失败断言。练习的完成条件是两种模式输出相同、启用状态正确、样本与资源数据完整。

结论 最少证据 不足证据
本进程启用了YJIT enabled为true 命令行含有选项
当前负载更快 同输入同结果的重复样本 单次最佳耗时
适合当前部署 时间与内存预算共同满足 某个微基准倍率
某路径被编译 对应日志或统计 全局启用状态

参考资料

实验附件

下载完整实验。原始运行输出保存在仓库 `writing-plans/ruby/evidence/36/run.log`。使用 Ruby 3.4.11 从仓库根目录执行正文命令;附件与仓库实验内容一致。