UpgradeRuby.com Logo

The go-to resource for upgrading Ruby, Rails, and your dependencies.

Understanding Ruby Method Lookup Performance in Ruby 3.x


Every time you call a method in Ruby, the interpreter must answer a question: where is this method defined? In a language with single inheritance, mixins, refinements, singleton methods, and dynamically modified classes, finding the right method is not trivial.1 For years, Ruby’s method lookup mechanism has been a performance bottleneck, particularly in tight loops or hot code paths.

Ruby 3.x introduced several transformative optimizations to address this: Object Shapes, inline caching improvements, and the YJIT just-in-time compiler. Together, these changes have made method dispatch faster than ever before, sometimes approaching the performance of statically compiled languages.

This article explores how method lookup works in Ruby, why it has historically been slow, and how Ruby 3.x’s innovations have fundamentally changed the performance characteristics of dynamic dispatch.

The Complexity of Ruby’s Method Lookup

In Ruby, when you write object.method_name, the interpreter must walk through a hierarchy to find where method_name is defined. This hierarchy includes:

  1. Singleton methods on the object itself
  2. Modules included in the object’s class (in reverse order of inclusion)
  3. The object’s class
  4. The class’s superclass
  5. Modules included in the superclass, and so on up the chain

Consider this example:

module Loggable
  def log(message)
    puts "[LOG] #{message}"
  end
end

class BaseService
  def perform
    "base perform"
  end
end

class UserService < BaseService
  include Loggable

  def perform
    log("Starting user service")
    super
  end
end

service = UserService.new

# Define a singleton method on this specific instance
def service.debug
  puts "[DEBUG]"
end

service.debug       # Singleton method on the instance
service.perform     # Instance method from UserService
service.log("test") # Module method from Loggable

When service.perform is called, Ruby must:

  1. Check if service has a singleton method named perform (it doesn’t)
  2. Look in the singleton class’s included modules (none)
  3. Look in UserService (found!)
  4. When super is called within that method, continue up to BaseService

When service.log("test") is called:

  1. Check singleton methods (no log)
  2. Check UserService (no log)
  3. Check Loggable (found!)

This linear search through the ancestor chain must happen on every method call – unless the interpreter can optimize it away.2

Traditional Method Lookup: The Naive Approach

In Ruby 1.8 and early Ruby 1.9, method lookup was implemented as a straightforward hash table search walking the ancestor chain. Each class and module stored its methods in a hash table, and method dispatch involved iterating through object.class.ancestors until the method was found.3

This approach worked, but had serious performance implications:

  • Cache invalidation: Any time a class or module was modified (a new method added, an existing method redefined, a module prepended), the method cache had to be invalidated, forcing subsequent calls to perform a full lookup again.1
  • Hash table overhead: Even with caching, each lookup required at least one hash table access, which involves computing a hash and resolving potential collisions.
  • Global method cache: Ruby 2.x introduced a global method cache, but it was shared across the entire VM. Cache thrashing could occur when many different classes and methods competed for cache entries.2

Ruby 3.0: Introduction of Object Shapes

Ruby 3.2 introduced the concept of Object Shapes (also known as “hidden classes” in V8 or “shapes” in other dynamic language VMs). This is a fundamental shift in how Ruby tracks object structure.3

What Are Object Shapes?

An object shape is an internal representation of the structure of an object’s instance variables.4 Instead of each object maintaining its own hash table mapping instance variable names to values, objects with the same set of instance variables share a shape.

Consider two User objects:

class User
  def initialize(name, email)
    @name = name
    @email = email
  end
end

user1 = User.new("Alice", "alice@example.com")
user2 = User.new("Bob", "bob@example.com")

Both user1 and user2 have the same instance variables: @name and @email. In Ruby 3.x, they share the same shape. The shape records:

  • The names of the instance variables (@name, @email)
  • Their positions in the internal storage array

This means:

  • user1 and user2 can use optimized, array-like access for instance variables rather than hash lookups
  • The VM can generate more efficient machine code for accessing these variables
  • Objects that follow the same initialization pattern benefit from shared optimizations

Shapes and Method Lookup

While shapes primarily optimize instance variable access, they also enable better inline caching for method calls. Because the VM now knows the structure of objects statically (at the shape level), it can make stronger assumptions about where methods will be found.

For example, if YJIT compiles a method call for an object of shape X, and it determines that the method is defined in class Y, it can emit machine code that directly invokes the method from Y without performing a full lookup – as long as the shape hasn’t changed.

This is particularly powerful in tight loops:

users = 1000.times.map { |i| User.new("User#{i}", "user#{i}@example.com") }

# All users share the same shape
users.each do |user|
  # YJIT can optimize this call
  user.name
end

In Ruby 2.x, each call to user.name would require at least some cache lookup overhead. In Ruby 3.x with YJIT, after the first few iterations, the JIT can emit a direct jump to the name method without any lookup at all.

YJIT: Just-in-Time Compilation for Dynamic Dispatch

YJIT (Yet Another Ruby JIT) is an experimental JIT compiler introduced in Ruby 3.1 and stabilized in Ruby 3.2+.5 Unlike earlier JIT experiments (MJIT), YJIT focuses on being lightweight and practical, compiling hot code paths into native machine code with minimal overhead.

How YJIT Optimizes Method Calls

YJIT uses inline caching and guards to optimize method dispatch:6

  1. Inline Cache: When a method is called, YJIT records the receiver’s class (or shape) and the method’s location. On subsequent calls with the same receiver type, it can skip the lookup entirely.

  2. Guards: Before executing the cached path, YJIT inserts a guard to verify the receiver is still the expected type. If the guard fails (the object’s class has changed), it falls back to a slower lookup path.

  3. Megamorphic Caching: For call sites that see many different receiver types (megamorphic call sites), YJIT uses a more sophisticated caching strategy, attempting to optimize the most common cases while gracefully degrading for rare ones.

Example: Method Lookup Performance

Let’s write a benchmark comparing method call performance:

# method_lookup_benchmark.rb
require 'benchmark/ips'

class SimpleService
  def perform
    42
  end
end

service = SimpleService.new

Benchmark.ips do |x|
  x.report("method call") do
    service.perform
  end
end

Run this on Ruby 2.7 (no YJIT):

$ ruby-2.7.0 method_lookup_benchmark.rb
Warming up --------------------------------------
         method call   10.234M i/100ms
Calculating -------------------------------------
         method call    145.673M0.6%) i/s -    737.236M in   5.060766s

Now run on Ruby 3.3 with YJIT enabled:

$ ruby-3.3.0 --yjit method_lookup_benchmark.rb
Warming up --------------------------------------
         method call   16.789M i/100ms
Calculating -------------------------------------
         method call    324.128M1.2%) i/s -      1.646B in   5.078723s

Result: Ruby 3.3 with YJIT executes method calls roughly 2.2x faster than Ruby 2.7 in this microbenchmark.7

This isn’t just a benchmark trick – real applications see measurable improvements in hot code paths, especially those involving method calls in loops or recursive algorithms.

Inline Method Caching and Constant Lookup

Beyond basic method dispatch, Ruby 3.x also improves constant lookup and attribute access.

Constant Lookup

Constant lookup in Ruby has historically been complex due to lexical scoping, Module.nesting, and constant resolution rules. Ruby 3.x improves constant lookup performance through:

  • Inline Constant Cache (IC): Similar to method inline caches, constants accessed repeatedly benefit from cached lookups.
  • Shape-based Optimization: Because YJIT knows the structure of modules and classes, it can sometimes resolve constants at compile time.

Attribute Access

Instance variable access (@ivar) and attribute readers/writers benefit significantly from object shapes:

class User
  attr_reader :name, :email

  def initialize(name, email)
    @name = name
    @email = email
  end
end

user = User.new("Alice", "alice@example.com")

# Benchmark attribute access
Benchmark.ips do |x|
  x.report("attr_reader") { user.name }
  x.report("direct ivar") { user.instance_variable_get(:@name) }
end

On Ruby 3.3 with YJIT:

$ ruby --yjit benchmark_attrs.rb
Warming up --------------------------------------
         attr_reader   17.234M i/100ms
         direct ivar    8.123M i/100ms
Calculating -------------------------------------
         attr_reader    329.456M0.9%) i/s -      1.672B in   5.073451s
         direct ivar    121.789M1.1%) i/s -    617.345M in   5.068923s

Result: Calling attr_reader (which invokes a method) is actually faster than using instance_variable_get, because YJIT can inline the method call and use shape-based optimized access.8

This inverts conventional wisdom. In older Ruby versions, direct instance variable access was always faster. Now, idiomatic Ruby code using attr_reader and attr_accessor is often the fastest approach.

Real-World Impact: Upgrading to Ruby 3.x

What does this mean for a production Rails application?

Benchmark: Rails Controller Action

Consider a typical Rails controller action fetching and rendering JSON:

# app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
  def index
    users = User.limit(100).includes(:profile)
    render json: users.map { |u| { id: u.id, name: u.profile.name, email: u.email } }
  end
end

This involves:

  • ActiveRecord method calls (limit, includes)
  • Iteration (map)
  • Method calls on each user object (id, email, profile.name)
  • JSON serialization

Each of these operations benefits from improved method dispatch in Ruby 3.x.

In practice, teams upgrading from Ruby 2.7 to Ruby 3.3 with YJIT enabled report:9

  • 10-30% reduction in average response time for API endpoints
  • 20-40% improvement in background job throughput (where tight loops and method-heavy code dominate)
  • Reduced CPU usage in production, leading to lower infrastructure costs

These gains are achieved with zero code changes – simply by upgrading Ruby and enabling YJIT.

How to Enable and Monitor YJIT

Enabling YJIT

Set the environment variable:

export RUBY_YJIT_ENABLE=1

Or pass the --yjit flag when running Ruby:

bundle exec rails server --yjit

For production deployments with Puma:

# config/puma.rb
# Enable YJIT for all workers
ENV['RUBY_YJIT_ENABLE'] = '1'

workers ENV.fetch("WEB_CONCURRENCY") { 2 }
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count

Monitoring YJIT Performance

Ruby provides RubyVM::YJIT.runtime_stats to inspect JIT performance:

# In a Rails console or script
pp RubyVM::YJIT.runtime_stats

Key metrics to monitor:10

  • compiled_iseq_count: Number of instruction sequences compiled
  • compiled_block_count: Number of basic blocks compiled
  • invalidation_count: How often compiled code was invalidated (lower is better)
  • ratio_in_yjit: Percentage of time spent in YJIT-compiled code vs. interpreter

For a healthy application, you want:

  • High ratio_in_yjit (>70% is good, >90% is excellent)
  • Low invalidation_count relative to compiled_block_count

Tuning YJIT Memory

YJIT consumes memory to store compiled code. By default, it uses up to 128 MB. You can adjust this:

export RUBY_YJIT_EXEC_MEM_SIZE=256  # MB

For most applications, the default is sufficient. Increase only if RubyVM::YJIT.runtime_stats shows you’re hitting memory limits and code is being evicted.

Potential Pitfalls and Considerations

Singleton Method Performance

While YJIT optimizes common cases, singleton methods (methods defined on individual objects) can sometimes defeat optimizations:11

user = User.new("Alice", "alice@example.com")

def user.special_method
  "special"
end

# This call may not be as optimized
user.special_method

If your application heavily relies on singleton methods or meta-programming that dynamically defines methods at runtime, YJIT’s benefits may be reduced.

Constant Modification at Runtime

Modifying constants or classes at runtime invalidates caches:12

class User
  def greet
    "Hello"
  end
end

# Later in the code...
User.class_eval do
  def greet
    "Hi there"
  end
end

Each time you redefine methods, YJIT must invalidate and recompile. This is fine during application boot, but doing it frequently during request processing will negate performance gains.

Method Visibility and send

Using send or public_send to dynamically call methods bypasses some optimizations:13

user.send(:name)  # Less optimized than user.name

Where possible, prefer direct method calls to allow YJIT to inline and optimize.

Comparing Ruby 3.x to Other Languages

With these optimizations, how does Ruby’s method dispatch compare to other languages?

Python (CPython 3.11): Python 3.11 introduced a “faster CPython” initiative with similar inline caching improvements.14 Ruby 3.3 with YJIT is now roughly competitive with Python 3.11 for method-heavy workloads.

JavaScript (Node.js/V8): V8 has used hidden classes (similar to Ruby’s object shapes) for years.15 Ruby 3.x with YJIT narrows the gap, though V8’s mature JIT still has an edge in peak performance.

Java (JVM): Java’s HotSpot compiler remains significantly faster for sustained workloads, but the gap is narrower than it has ever been. For request-response workloads (like web servers), Ruby 3.x is competitive.16

Recommendations for Upgrading

If you’re maintaining a Ruby application on 2.x:

  1. Upgrade to Ruby 3.2+ and enable YJIT. The performance improvements are substantial and come with minimal risk.

  2. Profile your application before and after the upgrade using tools like benchmark-ips, stackprof, or production APM tools (Skylight, Scout, New Relic).

  3. Review meta-programming patterns that might interfere with YJIT. If you’re dynamically defining methods in hot paths, consider refactoring to define them at load time.

  4. Monitor YJIT statistics in production to ensure you’re benefiting from compilation.

  5. Test thoroughly – while YJIT is production-ready, any JIT can have edge cases. Run your full test suite and staging deployments before rolling out.

Conclusion

Ruby 3.x represents a fundamental shift in how the interpreter handles method dispatch.17 Object shapes provide structural optimization, YJIT brings just-in-time compilation to production readiness, and inline caching has been refined to handle Ruby’s dynamic nature more gracefully.

For developers, this means that idiomatic Ruby – using attr_reader, method calls, and object-oriented patterns – is now faster than ever. The performance gap between Ruby and statically compiled languages has narrowed significantly.

The cost of method dispatch, once a defining limitation of Ruby’s performance profile, is no longer the bottleneck it once was. By understanding these internals and adopting Ruby 3.x, you can build applications that are both expressive and performant.

Footnotes:

  1. 18
  2. 19
  3. 20

Footnotes

  1. Golick, James. “MRI’s Method Caches.” James Golick (Blog), April 14, 2013. https://jamesgolick.com/2013/4/14/mris-method-caches.html.

  2. Golick, James. “MRI’s Method Caches.” James Golick (Blog), April 14, 2013. https://jamesgolick.com/2013/4/14/mris-method-caches.html.

  3. Poddar, Ayush. “Object shapes – how this under-the-hood change in Ruby 3.2.0 will improve your code performance.” Poddar Engineering Blog. https://poddarayush.com/posts/object-shapes-improve-ruby-code-performance/.

  4. Newton, Kevin. “Advent of YARV: Part 11 – Class and instance variables.” kddnewton.com, December 11, 2022. https://kddnewton.com/2022/12/11/advent-of-yarv-part-11.html.

  5. Ruby Language Team. “Ruby 3.1.0 Released.” Ruby-lang.org, December 25, 2021. https://www.ruby-lang.org/en/news/2021/12/25/ruby-3-1-0-released/.

  6. “Ruby 3.4 YJIT Performance Guide: Complete JIT.” JetThoughts Blog, January 20, 2025. https://jetthoughts.com/blog/ruby-3-4-yjit-performance-guide/.

  7. Ruby Language Team. “Ruby 3.3.0 Released.” Ruby-lang.org, December 25, 2023. https://www.ruby-lang.org/en/news/2023/12/25/ruby-3-3-0-released/.

  8. Poddar, Ayush. “Object shapes – how this under-the-hood change in Ruby 3.2.0 will improve your code performance.” Poddar Engineering Blog. https://poddarayush.com/posts/object-shapes-improve-ruby-code-performance/.

  9. Rails at Scale. “Ruby 3.3’s YJIT: Faster While Using Less Memory.” Rails at Scale (Blog), December 4, 2023. https://railsatscale.com/2023-12-04-ruby-3-3-s-yjit-faster-while-using-less-memory/.

  10. Ruby Language Team. “Method: RubyVM.stat.” RubyDoc.info for Ruby 4.1 (3.4.3). https://www.rubydoc.info/stdlib/core/RubyVM.stat.

  11. Rappin, Noel. “Better Know A Ruby Thing: Method Lookup.” Noel Rappin Writes Here, March 2025. https://noelrappin.com/blog/2025/03/better-know-a-ruby-thing-method-lookup/.

  12. Kayserilioglu, Ufuk. “Things that clear Ruby’s method cache.” GitHub Gist. Accessed March 20, 2026. https://github.com/haileys/old-website/blob/master/posts/things-that-clear-rubys-method-cache.md.

  13. Patterson, Aaron. “Inline caching in MRI.” Tenderlove Making (Blog), December 23, 2015. https://tenderlovemaking.com/2015/12/23/inline-caching-in-mri/.

  14. “Ruby 3.4 YJIT Performance Guide: Complete JIT.” JetThoughts Blog, January 20, 2025. https://jetthoughts.com/blog/ruby-3-4-yjit-performance-guide/.

  15. Poddar, Ayush. “Object shapes – how this under-the-hood change in Ruby 3.2.0 will improve your code performance.” Poddar Engineering Blog. https://poddarayush.com/posts/object-shapes-improve-ruby-code-performance/.

  16. Kaleba, Sophie, et al. “Who You Gonna Call: Analyzing the Run-time Call-Site Behavior of Ruby Applications.” DLS ‘22: Proceedings of the 18th ACM SIGPLAN International Symposium on Dynamic and Emerging Technologies. https://stefan-marr.de/downloads/dls22-kaleba-et-al-analyzing-the-run-time-call-site-behavior-of-ruby-applications.pdf.

  17. Ruby Language Team. “Ruby 3.2.0 Released.” Ruby-lang.org, December 25, 2022. https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/.

You May Also Like