【一人 bugs.ruby Advent Calendar 2021】[Misc #18285] NoMethodError#message uses a lot of CPU/is really expensive to call【12日目】

一人 bugs.ruby Advent Calendar 2021 12日目の記事になります。
今回は特定の条件下で NoMethodError#message がパフォーマンス的にコストがかかる話です。

[Misc #18285] NoMethodError#message uses a lot of CPU/is really expensive to call

NoMethodError#message がパフォーマンス的にコストがかかるというチケットです。
これは dd-trace-rb の内部テストで問題になったらしい。
参照記事::the unexpected cost of ruby's NoMethodError exception
実際にチケットで提示されているベンチマークは以下の通り。

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

  # NoMethodError#message でこのメソッドが呼び出される
  # Rails の Controller だと複雑な #inspect が実装されておりそれがボトルネックになっているらしい
  def inspect
    Gem::Specification._all.inspect
  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

__END__

output:
ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux]
Warming up --------------------------------------
no method error message cost
                        20.000  i/100ms
runtime error message cost
                         1.620M i/100ms
Calculating -------------------------------------
no method error message cost
                        249.716  (± 6.0%) i/s -      1.260k in   5.068273s
runtime error message cost
                         16.046M (± 1.1%) i/s -     81.020M in   5.049800s

Comparison:
runtime error message cost: 16045983.5 i/s
no method error message cost:      249.7 i/s - 64256.99x  (± 0.00) slower

このだとめっちゃ遅いですね…これは NoMethodError#message#inspect を呼び出しているのが原因らしいです。
以下、サンプルコード

class NoInspect
end

begin
  NoInspect.new.hoge
rescue => e
  pp e.message
  # => "undefined method `hoge' for #<NoInspect:0x00005644abd35978>"
end

class WithInspect
  def inspect
    "My WithInspect"
  end
end

begin
  WithInspect.new.hoge
rescue => e
  # メッセージに WithInspect#inspect も含められる
  # error: undefined method `hoge' for My WithInspect:WithInspect (NoMethodError)
  pp e.message
  # => "undefined method `hoge' for My WithInspect:WithInspect"
end

Rails の Controller だと #inspect が複雑になっているので結果的に NoMethodError#messageボトルネックになっているとのこと。
ちなみに Ruby 3.0 以前は #inspect の文字数が66文字以上だと #message に含まれていなかったらしい。

class WithInspect
  def inspect
    "A" * 66
  end
end

begin
  WithInspect.new.hoge
rescue => e
  pp e.message
  # Ruby 2.7 => "undefined method `hoge' for #<WithInspect:0x000055a784c8a560>"
  # Ruby 3.0 => "undefined method `hoge' for AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:WithInspect"
end

これは知らんかった。