the unexpected cost of ruby's NoMethodError exception
💡 | A fix for this performance gotcha has been merged for Ruby 3.3! See https://github.com/ruby/ruby/pull/6950 and https://bugs.ruby-lang.org/issues/18285 for details! |
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