I found an old ten-line link note about jemalloc and Ruby bytecode. The links came from a period when large Ruby applications had two very visible costs: resident memory that grew and fragmented over time, and boot paths that parsed and compiled a small mountain of Ruby files.
Putting jemalloc and bytecode caching in the same note makes them look like alternative answers to one problem. They are not. One changes how the process obtains and returns heap memory. The other tries to avoid repeating parser and compiler work during require.
| heap | jemalloc targets allocation and fragmentation behaviour |
|---|---|
| boot | ISeq binaries target repeated parse and compile work |
| steady state | measure RSS, PSS, latency and copy-on-write |
| cold start | measure require time, boot time and cache validity |
The more useful story is therefore not “which optimisation won?” It is how the Ruby community learned to separate layers of cost.
Two bottlenecks inside one Rails process
A Rails server can pay both costs, but at different times and for different reasons.
| Question | jemalloc | ISeq bytecode cache |
|---|---|---|
| Layer | Native heap allocator | CRuby parser/compiler and loader |
| Intended problem | Fragmentation, allocator overhead, copy-on-write loss | Repeated source parsing and instruction-sequence compilation |
| Most visible in | Long-lived, allocation-heavy or preforked servers | Cold boots, commands and repeated require work |
| Primary measurements | RSS/PSS over time, request latency, fork behaviour | Boot/require time, cache hit rate, binary size |
| Main uncertainty | Results depend heavily on workload and allocator version | Binaries are VM-, version-, architecture- and machine-dependent |
| Failure mode | A different allocator can regress another workload | Stale or untrusted binary data can be unsafe |
This distinction sounds obvious in retrospect. It was less obvious when the common complaint was simply that “Rails uses too much memory and starts too slowly”.
Jemalloc changes the behaviour of the heap
Ruby delegates many allocations to the system allocator. The garbage collector decides when Ruby objects are dead, but the allocator still decides how native pages, bins and arenas are organised underneath them. A process can free many Ruby objects and still keep a large resident footprint because the remaining allocations are scattered across pages that cannot be returned cleanly to the operating system.
That is why allocator discussions quickly become fragmentation discussions.
The 2013 Ruby feature request proposed shipping Linux Ruby with jemalloc out of the box. Its motivating Discourse measurements reported reductions of up to 10% in median request time and about 10% in proportional set size with jemalloc. Those numbers were useful evidence for that application, not a universal law.
The thread itself became more interesting than the headline. A later comparison found mostly close results between jemalloc and eglibc on Ruby's standard benchmark suite, with some regressions, and argued that the suite lacked realistic fork- and concurrency-heavy large-application cases. Sam Saffron's allocation stress case and Discourse run showed the opposite side: the difference became clearer as the heap and fragmentation grew.
An allocator benchmark that never reaches the application's failure mode is a benchmark of the wrong application.
For a prefork server, proportional set size matters as much as raw RSS. Pages shared after fork are cheap until either the runtime or allocator dirties them. The same Ruby issue also noted that instruction-sequence data occupied a large read-mostly region in a Discourse profile, which made allocator work, VM layout and bytecode sharing part of the same copy-on-write conversation even though they remained separate mechanisms.
Ruby core did not get a clean, universal answer from the issue. That was reasonable. Allocator behaviour depends on allocation sizes, object lifetimes, thread count, fork model, release policy, libc and jemalloc versions, and the surrounding native extensions. “Use jemalloc” can be a good deployment result; it is not a substitute for measuring the actual service.
Bytecode caching changes what require has to do
The bytecode-cache links attack an earlier phase.
Without a cache, loading a Ruby file broadly follows this path:
Ruby source
→ read file
→ parse source
→ compile RubyVM::InstructionSequence
→ execute top-level initialisation
→ retain methods, constants and runtime stateA persistent ISeq cache tries to replace the parse-and-compile middle with a validated binary load:
Ruby source + cache key
→ find matching ISeq binary
→ load instruction sequence
→ execute top-level initialisation
→ retain methods, constants and runtime stateThe last two stages still matter. Rails boot is not merely compilation. Gems register hooks, define constants, inspect the environment, build routes, establish framework state and run application initialisers. A bytecode cache can remove compiler work and still leave most of a real boot intact.
The 2015 spike was deliberately rough
A 2015 GitHub pull request described the idea in familiar terms: Rubinius had .rbc, Python had .pyc, so CRuby could keep a compiled representation beside the source. The first spike used marshalled instruction sequences and proposed eventually adding a raw format, source checksum and version header.
It was also explicitly unstable. Simple cases worked, broader round trips exposed serialisation bugs, and early versions could segfault. That is exactly what an honest VM experiment looks like before it becomes an interface.
Koichi Sasada's work then moved the experiment into a more deliberate shape. The RubyKaigi 2015 presentation framed persistent bytecode around two goals: faster boot and, eventually, lower memory through unloading or sharing. The storage policy was intentionally left open rather than forcing one .pyc-style convention on every Ruby installation.
Ruby 2.3 exposed a primitive, not a policy
Feature #11788 introduced a machine-dependent ISeq binary serializer and loader to Ruby 2.3 as an experimental feature. The important boundary was architectural:
- CRuby supplied low-level
to_binaryandload_from_binaryprimitives; - a
RubyVM::InstructionSequence.load_iseqhook could return compiled code when Ruby loaded a file; - user code decided where binaries lived and how they were invalidated;
- the format was not intended as a portable package or source-code obfuscation mechanism.
The sample loader made those choices concrete. It could put a .yarb file next to the source, use a separate directory, or store entries in DBM. It embedded a SHA-1 value as extra data, checked whether the compiled file was at least as new as the source, and returned the cached ISeq through the hook.
class RubyVM::InstructionSequence
def self.load_iseq(filename)
STORAGE.load_iseq(filename)
end
end
The later yomikomu experiment packaged the same basic model: kakidasu wrote compiled instruction sequences and requiring yomikomu loaded them. Its README tied the project specifically to Ruby 2.3, which is the right way to read it now: as performance archaeology, not as current drop-in advice.
The historical benchmarks explain the limit
The feature request included three useful shapes of result.
| Historical workload | Normal compile/load | Eager binary load | Lazy binary load | What it suggests |
|---|---|---|---|---|
resolv.rb, repeated 1,000 times |
about 13.4 s | about 3.9 s | about 2.6 s | Compiler work dominated the artificial loop |
fileutils.rb, repeated similarly |
about 8.7 s | about 4.7 s | about 3.7 s | Top-level initialisation reduced the relative win |
Simple rails r '' |
about 2.05 s | about 1.54 s | about 1.54 s | Real framework initialisation remained substantial |
These are old measurements from one implementation and should stay historical. Their pattern is more durable than their absolute numbers: the closer a workload is to “compile this source again”, the larger the benefit; the more work happens after compilation, the smaller the visible boot win.
The memory goal was even less complete. The proposal openly said that binary sizes were still large and that memory reduction had not yet been achieved because sharing and unloading techniques were future work. Serialising an ISeq is not the same thing as mapping one immutable copy across many workers.
Portability and trust are part of the design
The low-level ISeq binary API still exists in current Ruby documentation, and the documentation still warns that the data is not portable across Ruby versions, architectures or machines. That constraint is not cosmetic. Instruction sequences encode VM implementation details, so a transparent cache needs a precise invalidation key.
The original Ruby 2.3 feature also shipped with a stronger warning: the loader had no verifier, and modified or broken binary data could cause critical failures. A cache therefore belongs inside a trusted build/runtime boundary. It should never become a channel for accepting arbitrary “precompiled Ruby” from elsewhere.
This is one reason the primitive survived more naturally than a universal cache policy. CRuby can expose the mechanism while applications, build systems and experiments decide whether the operational complexity is worth it.
What survived from both ideas
The two experiments left different lessons.
For jemalloc, the lesson is to benchmark allocator behaviour where the production heap actually hurts: after warm-up, under real concurrency, with the real fork model and over enough time for fragmentation to emerge. A one-minute microbenchmark can miss the entire reason to change allocators.
For ISeq caching, the lesson is to separate compiler time from application initialisation. A faster binary load cannot remove gem side effects, Rails initialisers or database setup. It also needs versioning, source validation, storage policy and a trusted boundary before the first cached byte is executed.
The shared lesson is simpler:
Measure the layer you intend to optimise. RSS after six hours and
rails runnercold-start time may occur in the same process, but they are not the same performance problem.
Practical reading of the old note
I would not turn this link file into a recipe that says “install jemalloc and enable a bytecode cache”. I would turn it into two diagnostic questions.
First: does a long-lived Ruby service retain too much physical memory because of live objects, fragmentation, native extensions, copy-on-write loss or allocator release behaviour? Profile the heap and compare allocators under that exact workload.
Second: is process startup materially limited by parsing and compiling Ruby source, or by framework and application initialisation after the code is loaded? Trace boot, then decide whether precompilation or another deployment strategy targets enough of the total.
That is less exciting than one magic switch. It is also the conclusion the sources support.
Sources
- Ruby feature #9113: ship Ruby for Linux with jemalloc out of the box
- Sam Saffron's allocator stress test
- RubyKaigi 2015: ISeq loader and persistent bytecode
- 2015 WIP bytecode-cache pull request
- Ruby feature #11788: new ISeq serialise binary format
- Ruby 2.3.1 sample ISeq loader
- Yomikomu repository
- Current RubyVM::InstructionSequence documentation
- Archival CRuby bytecode-cache announcement