我的实验性 Ruby 到原生编译器 Ruby Tumbler,一直在慢慢但稳稳地推进。
目标是拿 Ruby 代码,不改语法也不改行为,让它跑得更快、吃内存更少。
终极想法,是把整个 Rails 应用编译成自包含的原生二进制。
我一直在用 Graphlient——一个 Ruby GraphQL 客户端库——编译它原封未动的部分代码,再对完整的查询构建和序列化做基准测试。
代码更快,应用未必更快
新问题:原生代码更快,不代表应用更快。
你可以把一个 Ruby 方法编译成机器码,省掉一些分配,然后眼看着性能优势在执行穿越 Ruby 与原生代码边界时蒸发掉。
有方法守卫要检查。Ruby 的动态分派规则要保住。回退路径要维护。
这些全都要花 CPU。
一个实验里,我靠接收者专属的绑定复用干掉了分配。结果反倒把小查询拖慢了,内存占用也没有稳定的改善,所以我把它关了。
最新的实验换了方向:预计算方法标识符,把多个守卫检查合并成一次可调用查找,再顺手干掉一个多余的依赖。
我们还逮到一个微妙的正确性问题,出在 Ruby 的方法查找规则上。
移除 Array#reject 可能暴露出 Enumerable#reject,而两个实现可以带着同一个内部 builtin 标志。
这不足以证明你调用的还是同一个实现。
所以我们把守卫收紧,改查实现同一性。
实测结果
结果呢?
在 560 万次计时查询里,比起我们之前 YJIT 下的原生实现,最新版本把 CPU 用量的中位数降了大约 8%。
小查询上已经追到距普通 Ruby 2.3% 以内,嵌套查询上 0.4% 以内。
误差区间是重叠的,现在宣布胜利未免太早。
不过之前省下的分配都保住了,而且这版实现通过了 4,590 项聚焦检查、698 项插装检查、原始上游测试套件,和 Rails 回归测试。
我觉得我们正在学到的是:
机会可能不在把单个 Ruby 方法编译成更快的机器码,而在把更大片的执行区域整段编译,把方法_之间_发生的活儿消掉。
不优化单个方法,而是把一整串应用行为编译掉,会怎样?
穿过 Ruby VM 的次数更少。消掉中间步骤的机会更多。
但 Ruby 的语义还是得保住,包括那些有意思的案例:关键字参数、块、运行时被替换的方法,还有绝不许重复副作用的各种回退。
接下来,我就往这儿去。
立项时,我想知道的是:不逼开发者重写应用,能从 Ruby 里榨出多少性能。
现在我怀疑,有意思的答案在另一头:有多少运行时机器,我们本来就能安全地压根不跑。