【一人 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

これは知らんかった。

【一人 bugs.ruby Advent Calendar 2021】[Feature #18276] `Proc#bind_call(obj)` same as `obj.instance_exec(..., &proc_obj)`【11日目】

一人 bugs.ruby Advent Calendar 2021 11日目の記事になります。
今回は obj.instance_exec(..., &proc_obj) と同じ意味の Proc#bind_call(obj) を追加する話です。

[Feature #18276] Proc#bind_call(obj) same as obj.instance_exec(..., &proc_obj)

obj.instance_exec(..., &proc_obj) と同じ意味の Proc#bind_call(obj) を追加する提案です。
obj.instance_exec(..., &proc_obj)proc_obj.bind_call(...) のように書くことができるようになります。
もともとは [Bug #18243] への対応として Ractor の共有可能オブジェクトの Proc#call を禁止して obj.instance_exec(..., &proc_obj) で呼び出すようにすることも考慮しているらしいです。
そのショートカットとして Proc#bind_call を使用する想定みたい。
Proc#bind_call だと以下のように block オブジェクトも渡せるようようになるので個人的には普通にほしい。

pr = proc { |*args, &block|
  # ...
  block.call(something)
}

# pr に対してブロック引数を渡すことができない
obj.instance_exec(arg, &pr)

# bind_call だと pr に対してブロック引数を渡すことができる
pr.bind_call(obj, arg) do |something|
  # ...
end

現在はこの提案は Reject されているようですね。残念。
関係ないけどこれを読んで昔同じようなことをする gem つくったのを思い出した。

require "proc/unbind"

using Proc::Unbind

class X
  attr_accessor :value
end

x = X.new

set = proc { |x|
  @value = x
}
# Proc#rebind で特定のオブジェクトに bind する
set.rebind(x).call(42)

p x.value
# => 42

便利。

【一人 bugs.ruby Advent Calendar 2021】[Feature #6210] load should provide a way to specify the top-level module【10日目】

一人 bugs.ruby Advent Calendar 2021 10日目の記事になります。 load メソッドに Module を渡せるようにする事でそのモジュール内で load された定義が追加できるように提案する話です。

[Feature #6210] load should provide a way to specify the top-level module

load メソッドに Module を指定できるようにする提案です。
以下のように Module を渡すとその Module に対して load した Ruby のコードが展開されるようになります。

# test.rb
def hoge
  "hoge"
end

class Foo
  def foo
    "foo"
  end
end
module M
end

# M に対して test.rb の中身が定義される
load "./test.rb", M

p M::Foo
# => M::Foo

p M::Foo.new.foo
# => "foo"

class X
  include M
  public :hoge
end

p X.new.hoge
# => "hoge"

これは Ruby 3.1 から使えるようになる機能です。
これ、任意のモジュール/クラスに対して特定の Rubyスクリプトが展開できるようになるので面白いですよね。
なんかいろんな使い方ができそう。

2021/12/09 今回の気になった bugs.ruby のチケット

今週は import_methods に C拡張で定義されたモジュールを渡せないバグ報告などがありました。

[Bug #18396] An unexpected "hash value omission" syntax error when without parentheses call expr follows

  • Ruby 3.1 では Hash の省略記法が新しく入ったが次のようなコードの時に『次の行の式』が値になってしまうというバグ報告
# error: syntax error, unexpected local variable or method, expecting `do' or '{' or '('
foo key:
foo arg
  • もしくは以下のような感じ
def hoge(**kwd)
  pp kwd
end

key = 42

# 意図としては hoge(key: key) となってほしい
# しかし実際には hoge(key: 1 + 2) と解釈される
hoge key:
1 + 2
# => {:key=>3}
  • Ruby 3.0 でも以下のように動作するのでこれは仕様とのこと
foo key:
bar
  • 今回の場合は () を記述する事で回避する事ができる
def hoge(**kwd)
  pp kwd
end

key = 42

hoge(key:)  # => {:key=>42}
1 + 2

[Feature #18395] Introduce Array#subtract! for performance

  • レシーバから特定の要素を取り除く Array#subtract! を追加する提案
ary = [0, 1, 1, 2, 1, 1, 3, 1, 1]
ary.subtract!([1]) #=> [0, 2, 3]
ary                #=> [0, 2, 3]

ary = [0, 1, 2, 3]
ary.subtract!([3, 0], [1, 3]) #=> [2]
ary                           #=> [2]
  • これは Array#- と似たようなメソッドになる
ary = [0, 1, 1, 2, 1, 1, 3, 1, 1]
ary - [1] #=> [0, 2, 3]

# これが ary.subtract! と同じ挙動になる
ary -= [1] #=> [0, 2, 3]
ary        #=> [0, 2, 3]
  • ary -= othetr を高速化するためのメソッドとして ary.subtract!(other) を追加したらしい
    • #subtract! の方が 1.22倍早いらしい
$ cat test.rb
require 'benchmark'

b_array = [2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 99]

n = 5_000_000
Benchmark.bm do |x|
  x.report("Array#subtract!") { n.times do; a = [1, 2, 3]; a.subtract!(b_array); end }
  x.report("Array#-=   ") { n.times do; a = [1, 2, 3]; a -= b_array; end }
end
$ make runruby
generating vm_call_iseq_optimized.inc
vm_call_iseq_optimized.inc unchanged
RUBY_ON_BUG='gdb -x ./.gdbinit -p' ./miniruby -I./lib -I. -I.ext/common  ./tool/runruby.rb --extout=.ext  -- --disable-gems  ./test.rb
       user     system      total        real
able-gems  ./test.rb
       user     system      total        real
Array#subtract!  1.168282   0.002174   1.170456 (  1.172227)
Array#-=     1.428345   0.004802   1.433147 (  1.439785)
$ cat test.rb
require 'benchmark'

b_array = [2, 2, 2, 2, 2, 5, 5, 5, 5, 5, 99]

n = 5_000_000
Benchmark.bm do |x|
  x.report("Array#concat") { n.times do; a = [1, 2, 3]; a.concat(b_array); end }
  x.report("Array#+=    ") { n.times do; a = [1, 2, 3]; a += b_array  ; end }
end
$ make runruby
compiling version.c
generating vm_call_iseq_optimized.inc
vm_call_iseq_optimized.inc unchanged
linking miniruby
/bin/sh ./tool/ifchange "--timestamp=.rbconfig.time" rbconfig.rb rbconfig.tmp
rbconfig.rb unchanged
creating verconf.h
verconf.h updated
compiling loadpath.c
builtin_binary.inc updated
compiling builtin.c
linking static-library libruby.3.1-static.a
linking ruby
RUBY_ON_BUG='gdb -x ./.gdbinit -p' ./miniruby -I./lib -I. -I.ext/common  ./tool/runruby.rb --extout=.ext  -- --disable-gems  ./test.rb
       user     system      total        real
Array#concat  0.785914   0.004957   0.790871 (  0.794673)
Array#+=      0.752269   0.007611   0.759880 (  0.767160)
  • 『このメソッドを使うと高速化する』っていうのを意識して書きたくないので -=演算子自体が高速化しないですかねー

[PR debug #408] The console prevents Zeitwerk from autoloading certain constants

  • debug.gem のコンソールで Zeitwerk の autoload が動作しないというバグ報告
  • これは TracePoint がネストして呼び出されるような形になっているのが原因ぽい
  • これに対応する手段として『 TracePoint をネストしても呼び出せるようにする』方向で議論が進んでるっぽい?

[Feature #15912] Allow some reentrancy during TracePoint events

  • 上で書いた TracePoint のネストを許容するチケット
  • 元々は byebug x Zeitwerk での問題が起因だったが debug.gem でも同様の問題が発生しているのでこちらで議論されている
  • Ruby 3.1 までに結論が出てくれると嬉しいですが時期的にどうだろうなあ…

[Bug #18385] Refinement#import_methods(Enumerable) doesn't work

  • 次のように String クラスに対して import_methods Enumerable するとエラーになるというバグ報告
module M
  refine String do
    # error: `import_methods': Can't import method: Enumerable#drop (ArgumentError)
    import_methods Enumerable
  end
end
  • これは import_methods が C拡張で定義されたモジュールに対応していない事が原因らしい
  • このチケットではこのバグを明確にするためにエラーメッセージを修正して対応している
module M
  refine String do
    # error `import_methods': Can't import method which is not defined with Ruby code: Enumerable#drop (ArgumentError)
    import_methods Enumerable
  end
end
  • 根本的な対応はどうするんですかね、これ

[Feature #18367] Stop the interpreter from escaping error messages

  • エラーメッセージをエスケープしないようにする提案
class MyError < StandardError
  def message
    "foo\\bar"
  end
end

raise MyError
#=> current:  test.rb:7: in `<main>': foo\\bar (MyError)
#=> excepted: test.rb:7: in `<main>': foo\bar (MyError)
  • これによってエスケープシーケンスもエスケープされるのでエラーメッセージをハイライトする事ができない
# エラーメッセージを赤色で表示したいができない
$ ruby -e 'raise "\e[31mRed\x1b[0m error"'
-e:1:in `<main>': \e[31mRed\x1b[0m error (RuntimeError)
  • 他には "\\" に対するエラーメッセージが紛らわしかったり error_highlight の位置もずれてしまう問題があるみたい
$ ruby -e '"\\".no_method'
-e:1:in `<main>': undefined method `no_method' for "\\\\":String (NoMethodError)

"\\\\".no_method
    ^^^^^^^^^^

[Feature #18279] ENV.merge! support multiple arguments as Hash.merge!

  • Hash#merge! と同様に ENV.merge! に複数の Hash を渡せるようにするチケット
  • 以下のようなケースで使いたいらしい
require 'yaml'
env_files = ['config.yml', 'config.local']
envs = env_files.filter_map {|file| YAML.load_file(file)['env'] if File.file?(file) }
ENV.merge!(*envs) # Raise wrong number of arguments (given 2, expected 1)

# 現状はこういうコードを書いているらしい
# ENV.merge!({}.merge!(*envs))
  • Hash でできることは ENV でもしたいと思うのでこの対応はよさそうですね

[Misc #18354] Lazily create singletons on instance_{exec,eval}

  • instance_{exec, eval} を呼び出した時に特異クラスが生成され、ブロック内でメソッドを定義するとその特異クラスにメソッドが追加される
obj = Object.new
obj.instance_eval {
  # このメソッドは obj の特異クラスに定義される
  def hoge
    "hoge"
  end
}
p obj.hoge
# => "hoge"
  • このチケットはメソッド定義に必要な場合を除いて特異クラスの生成を遅延させる内容になっている
  • 遅延することによって Railsベンチマークが 1.09倍早くなり、YJIT だと 1.16倍早くなったらしい
  • また今回の対応によって TrueCLass/FalseClass/NilClass が他のクラスと異なる挙動になっていたがこれも同じ挙動になるようになる
String::Foo = "foo"
p "".instance_eval("Foo")
# => "foo"

Integer::Foo = "foo"
p 123.instance_eval("Foo")
# => "foo"

TrueClass::Foo = "foo"
p true.instance_eval("Foo")
# Ruby 3.0 => NameError: uninitialized constant Foo
# Ruby 3.1 => "foo"

【一人 bugs.ruby Advent Calendar 2021】[Feature #12495] Make "private" return the arguments again, for chaining【9日目】

一人 bugs.ruby Advent Calendar 2021 9日目の記事になります。
public / private / protected の戻り値をレシーバから引数を返すようにする提案です。

[Feature #12495] Make "private" return the arguments again, for chaining

public / private / protected の戻り値をレシーバから引数を返すようにする提案です。

class X
  def hoge; end
  p private :hoge
  # 現在の挙動   => X
  # 変更する挙動 => :hoge
end

これは以下のように private の戻り値を別のメソッドに渡してチェーンしたい、ということらしい。

def cached(name)
  # Rewrite method to include a cache
  return name
end

# これは OK
private cached def foo() end

# こうもかけるようにしたい
cached private def foo() end

確かにこういうのを書く時に順番で悩まなくてもよいのはよさそうな気がする。
あとは英文的にも後者のほうが自然、みたいな意見も聞きましたね。
これはマージされて Ruby 3.1 から以下のように挙動が変わるので注意する必要はあります。

class X
  def hoge; end
  p private :hoge
  # Ruby 3.0 => X
  # Ruby 3.1 => :hoge
end

【一人 bugs.ruby Advent Calendar 2021】[Feature #18035] Introduce general model/semantic for immutable by default.【8日目】

一人 bugs.ruby Advent Calendar 2021 8日目の記事になります。
今回は freeze されている事を明示化する Immutable モジュールの提案です。

[Feature #18035] Introduce general model/semantic for immutable by default.

Immutable モジュールで freeze されていることを明示化する提案です。
以下のように Immutable モジュールを mixin すると明示的に freeze されるようになります。

module Immutable
  def new(...)
    super.freeze
  end
end

class MyImmutableObject
  extend Immutable

  def initialize(x)
    @x = x
  end

  def freeze
    return self if frozen?

    @x.freeze

    super
  end
end

o = MyImmutableObject.new([1, 2, 3])
puts o.frozen?
# => true

提案された内容では dupclone した場合も freeze になる。
どう freeze するのかは freeze メソッドを再定義してクラスごとに定義するようにするみたいです。
めっちゃ議論が進んでいてここではまとめきれてないんですが #freeze#deep_freeze の違いや #deep_freeze すると singleton_class まで不変になってしまい困るのでは?みたいな話や 不変 にしたときの利点はなにか、みたいな話を議論してるぽいですねえ。
freeze とは別に Ractor での共有可能オブジェクトという概念もありいろいろと考慮点が多そうで大変そう

以下、議論箇所のピックアップ。

興味がある人はぜひチケットを読んでみよう!

【一人 bugs.ruby Advent Calendar 2021】[Feature #14579] Hash value omission【7日目】

一人 bugs.ruby Advent Calendar 2021 7日目の記事になります。
今回は { x:, y: }{ x: x, y: y } のショートハンドにする提案です。

[Feature #14579] Hash value omission

{ x:, y: }{ x: x, y: y } のショートハンドにする提案です。
これ以外にも似たような提案は無限にされていたけど matz を説得できずに長年入っていなかった経緯があります。
しかし、今年の RubyKaigi の感想戦でこれに関する議論がされて、matz を説得して無事に accepts されました!やったー! これは以下のように x:x: x に展開するようなシンタックスシュガーになります。

name = "homu"
age = 14

# { name: name, age: age } のシンタックスシュガー
{ name:, age: }
# => {:name=>"homu", :age=>14}

# 一部だけ値を割り当てることもできる
{ name:, age: 16 }
# => {:name=>"homu", :age=>16}


def tokyo
  "東京"
end

# メソッド呼び出しも可能
{ tokyo: }
# => {:tokyo=>"東京"}


def user(name:, age:)
  { name:, age: }
end

# user(name: name, age: age) になる
user(name:, age:)


# 現時点では定数も展開できる
X = 42
{ X: }
# => {:X=>42}

現状は { X: } みたいな定数も展開されるがもしかしたらリリース時点でまた仕様が変わっている可能性があるので注意(そういえばこれはこのままいくんですかね? ちなみにこれを利用すると予約語と同名の変数に対して高速にアクセスする事ができます。

def foo(if:)
  # if はキーワードなので変数にアクセスする時は Binding#local_variable_get を使う必要があった
  if_ = binding.local_variable_get(:if)

  # このショートハンドを使うとこうかけるようになる
  if_ = {if:}[:if]
end

いやーこれすごく欲しかったので Ruby 3.1 で使えるようになっためっちゃいいですね。
早く Ruby 3.1 使いたい。