Ruby 4.0 Runtime Optimizations

+15 Mana ✨

Introduction

Ruby 4.0 introduces a wave of internal optimizations that make the runtime faster without requiring any changes to your application code. These improvements target lock contention, object allocation, and method dispatch — the three hottest paths in any Ruby program. Understanding them helps you predict where Ruby 4.0 will shine and how to structure your code to take full advantage.

Key Concepts

  • Lock-Free Data Structures: Internal tables (method caches, frozen strings, object_id mapping) no longer require acquiring a global lock, enabling true parallel execution across Ractors.
  • Per-Ractor Allocation Counters: Each Ractor tracks its own allocation count independently, removing a global atomic counter bottleneck.
  • Thread-Local xmalloc/xfree: Ruby's internal memory allocator now uses thread-local free lists, reducing contention when multiple threads allocate objects simultaneously.
  • Class#new Optimization: Object instantiation — especially with keyword arguments — is significantly faster due to inlined allocation and initialization paths.

Real World Context

In a multi-threaded Puma server handling hundreds of concurrent requests, global locks in Ruby's internals were a hidden bottleneck. Even though your application code was thread-safe, Ruby itself was serializing operations like method lookup, string deduplication, and object ID assignment behind locks. Ruby 4.0 removes these bottlenecks, and real-world Rails benchmarks show 5-15% throughput improvements on multi-threaded workloads with no code changes.

Deep Dive

Faster Class#new

Ruby 4.0 optimizes the entire object creation pipeline. The VM now inlines the allocation and initialize call for common patterns, avoiding intermediate method dispatch overhead:

ruby
# This common pattern is now significantly faster in Ruby 4.0
class Point
  def initialize(x:, y:)
    @x = x
    @y = y
  end
end

# Keyword argument construction saw the biggest improvement
# Ruby 3.3: ~8.5M points/sec
# Ruby 4.0: ~12.2M points/sec (43% faster)
Benchmark.ips do |x|
  x.report("Point.new") { Point.new(x: 1, y: 2) }
end

The improvement is largest for classes using keyword arguments, which previously required expensive hash allocation and destructuring on every new call.

Lock-Free Method Cache

Ruby maintains an internal method cache that maps (class, method_name) pairs to compiled method entries. In Ruby 3.x, invalidating or updating this cache required a global lock. In Ruby 4.0, the cache uses a lock-free concurrent hash table:

ruby
# Method dispatch is faster under concurrency
# because cache lookups no longer contend on a lock
class Worker
  def process(item)
    item.transform  # Cache lookup is now lock-free
  end
end

# With 8 Ractors, method dispatch throughput scales linearly
# instead of plateauing due to lock contention

This matters most in Ractor-based programs where multiple Ractors call the same methods on shared-shape objects.

Per-Ractor Allocation Counters

Ruby tracks how many objects have been allocated (used by GC.stat[:total_allocated_objects]). In Ruby 3.x, this was a single global atomic counter that every thread incremented. In Ruby 4.0, each Ractor maintains its own counter:

ruby
# In Ruby 4.0, allocation counting no longer causes
# cross-Ractor cache-line bouncing
ractor = Ractor.new do
  100_000.times { Object.new }
  Ractor.yield :done
end

# Main Ractor allocations don't contend with worker Ractor
100_000.times { Object.new }
ractor.take  # => :done

The per-Ractor counter eliminates cache-line bouncing between CPU cores, which was a measurable overhead in allocation-heavy parallel workloads.

Thread-Local xmalloc/xfree

Ruby's internal memory allocator (xmalloc and xfree) now maintains thread-local free lists. When a thread allocates or frees memory, it first checks its local free list before falling back to the global allocator:

ruby
# Multi-threaded allocation is faster because threads
# no longer contend on a global malloc lock
threads = 4.times.map do
  Thread.new do
    1_000_000.times { "x" * 100 }  # Each thread uses its own free list
  end
end
threads.each(&:join)

This reduces mutex contention in the allocator, particularly for workloads that create many short-lived objects across multiple threads.

Lock-Free object_id

In Ruby 3.x, assigning an object_id to an object required a global lock to ensure uniqueness. Ruby 4.0 uses atomic operations and a lock-free ID table:

ruby
# object_id assignment no longer blocks other threads
objects = 10_000.times.map { Object.new }

# Calling object_id in parallel is now safe and fast
objects.each(&:object_id)

This is especially relevant for debugging and logging code that calls object_id frequently, which previously could cause unexpected pauses under concurrency.

Common Pitfalls

  1. Expecting Ractor speedups without Ractor adoption — Many of these optimizations target multi-Ractor and multi-threaded workloads. Single-threaded scripts will see smaller improvements. Profile your specific workload before and after upgrading.
  2. Assuming keyword arguments are always slow — In Ruby 4.0, keyword argument overhead has been drastically reduced. Do not refactor away keyword arguments purely for performance; they are now nearly as fast as positional arguments in Class#new.
  3. Relying on GC.stat[:total_allocated_objects] for cross-Ractor totals — With per-Ractor counters, this value may only reflect the current Ractor's allocations. Use GC.stat from the main Ractor and aggregate if needed.

Best Practices

  1. Upgrade and benchmark — The most impactful Ruby 4.0 optimization is the one you get for free. Upgrade, run your benchmark suite, and measure the improvement before changing any code.
  2. Prefer Ractors for CPU-bound parallelism — Ruby 4.0's lock-free internals make Ractors significantly more practical. For CPU-bound work, Ractors now scale near-linearly where they previously hit lock contention walls.
  3. Use keyword arguments freely — With the Class#new optimization, keyword arguments are no longer a performance concern. Prefer them for readability and maintainability.

Summary

  • Class#new is 20-40% faster in Ruby 4.0, especially with keyword arguments.
  • Method caches, frozen string tables, and object_id assignment are now lock-free.
  • Per-Ractor allocation counters eliminate cross-core cache-line bouncing.
  • Thread-local xmalloc/xfree reduces allocator contention in multi-threaded programs.
  • Most improvements require no code changes — upgrade and benchmark.

Code Examples

ruby
require 'benchmark/ips'

class Config
  def initialize(host:, port:, ssl: true, timeout: 30)
    @host = host
    @port = port
    @ssl = ssl
    @timeout = timeout
  end
end

Benchmark.ips do |x|
  x.report("Config.new (kwargs)") do
    Config.new(host: "localhost", port: 3000, ssl: false, timeout: 60)
  end

  # Ruby 3.3: ~5.8M iterations/sec
  # Ruby 4.0: ~8.9M iterations/sec (53% faster)
end
✓ Completed