I've been making nice slow progress on Ruby Tumbler, my experimental Ruby-to-native compiler.
The goal is to take Ruby code, without changing its syntax or behavior, and make it run faster while using less memory.
Eventually, I'd like to compile entire Rails apps into self-contained native binaries.
I've been working with Graphlient, a Ruby GraphQL client library. Compiling parts of its original, unchanged code and benchmarking complete query construction and serialization.
Faster code is not always a faster app
New problem: Faster native code doesn't necessarily mean a faster app.
You can compile a Ruby method into machine code, eliminate some allocations, and still lose the performance advantage when execution crosses between Ruby and native code.
There are method guards to check. Ruby's dynamic dispatch rules to preserve. Fallback paths to maintain.
All of that costs CPU.
In one experiment, I eliminated allocations through receiver-specific binding reuse. Except it made small queries slower, with no consistent improvement in memory consumption, so I disabled it.
The latest experiment went in a different direction: precomputing method identifiers, consolidating multiple guard checks into a callable lookup, and eliminating an unnecessary dependency.
We also caught a subtle correctness problem involving Ruby's method lookup rules.
Removing Array#reject can expose Enumerable#reject, and both implementations can carry the same internal builtin flag.
That's not enough to establish that you're calling the same implementation.
So we tightened our guards to check implementation identity.
The measured result
The result?
Across 5.6 million timed queries, the latest version reduced median CPU consumption by approximately 8% compared with our previous native implementation under YJIT.
It's now within 2.3% of ordinary Ruby on small queries, and 0.4% on nested queries.
The measurement ranges overlap, so claims of victory would be premature.
But we have retained earlier allocation savings, and the implementation passed 4,590 focused checks, 698 instrumented checks, original upstream test suites, and Rails regression tests.
Here's what I think we're learning:
The opportunity may not be compiling individual Ruby methods into faster machine code, but rather compiling larger regions of execution, removing the work that happens _between_ those methods.
Instead of optimizing methods, what happens when we compile an entire sequence of application behavior?
Fewer trips through the Ruby VM. More opportunities to eliminate intermediate work.
We still have to preserve Ruby's semantics, including the interesting cases involving keyword arguments, blocks, dynamically replaced methods and fallbacks that must never repeat side effects.
That's where I'm going next.
I started this project wondering how much performance we could squeeze out of Ruby without asking devs to rewrite their apps.
Now, I suspect the interesting answer lies in how much runtime machinery we can safely avoid executing in the first place.