💡

A fix for this performance gotcha has been merged for Ruby 3.3!

The NoMethodError exception is how Ruby signals that the code tried to call a method that does not exist.

class Example
end

Example.new.foo
# => NoMethodError: undefined method `foo' for #<Example:0x00005636879ce398>

Now, raising and rescuing exceptions is known to be slow (due to, among other things, expensive stack trace generation) and thus it’s pretty common knowledge that you should avoid having triggering too many of them in your production code.

But…​ have you ever considered that after you rescue an exception, getting or printing the message from it may be expensive as well?

In particular, the message from a NoMethodError can be much costlier than for other Ruby exceptions. Consider the following example:

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'

  gem 'benchmark-ips'
end

puts RUBY_DESCRIPTION

class GemInformation
  # ...

  def get_no_method_error
    method_does_not_exist
  rescue => e
    e
  end

  def get_runtime_error
    raise 'Another Error'
  rescue => e
    e
  end
end

NO_METHOD_ERROR_INSTANCE = GemInformation.new.get_no_method_error
RUNTIME_ERROR_INSTANCE = GemInformation.new.get_runtime_error

Benchmark.ips do |x|
  x.config(:time => 5, :warmup => 2)

  x.report("no method error message cost") { NO_METHOD_ERROR_INSTANCE.message }
  x.report("runtime error message cost") { RUNTIME_ERROR_INSTANCE.message }

  x.compare!
end

…​now let’s run it:

ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux]

no method error message cost
                        180.215k (±14.4%) i/s -    885.250k in   5.015251s
runtime error message cost
                          6.806M (± 1.8%) i/s -     34.324M in   5.045347s

Comparison:
runtime error message cost:  6805678.3 i/s
no method error message cost:   180214.6 i/s - 37.76x  (± 0.00) slower

On my machine, getting the message from a NoMethodError is 37 times slower than from a regular RuntimeException in this example.

But wait, it gets worse! A common pattern in Ruby is to implement an inspect method that prints extra information for debugging. Let’s see what happens to the benchmark when we augment our Example class with one such method:

class GemInformation
  # ...

  def get_no_method_error
    method_does_not_exist
  rescue => e
    e
  end

  def get_runtime_error
    raise 'Another Error'
  rescue => e
    e
  end

  # <-- We added this!
  def inspect
    Gem::Specification._all.inspect
  end
end

…​and the benchmark result:

ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux]

no method error message cost
                        115.390  (± 1.7%) i/s -    580.000  in   5.027822s
runtime error message cost
                          6.938M (± 0.5%) i/s -     35.334M in   5.092617s

Comparison:
runtime error message cost:  6938381.6 i/s
no method error message cost:      115.4 i/s - 60130.02x  (± 0.00) slower

Yes, you read that well. Printing the message of a NoMethodError just got 60130 times slower.

TL;DR today I learned that while getting the message from a NoMethodError exception, Ruby will call inspect on the object that was missing the method.

This means that for instance, if your method has a very costly inspect (because you really only thought it was going to be called during development), you may have a bad time. For instance, if your object is a Rails controller, or a similar object that holds and prints many references when being debugged.

We can confirm this with another small example:

class Example
  def inspect
    puts '#inspect got called'
  end
end

begin
  Example.new.foo
rescue => e
  puts 'Error message is:'
  puts "#{e.message}"
end

…​and its output:

Error message is:
#inspect got called
undefined method `foo' for :Example