【一人 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)
-=
と比較して新しい変数を生成しない分高速なんですかね?- ちなみに
Array#concat
は+=
よりも遅いらしい…Array#concat
は複数の引数を受け取るのが原因かも…?とのこと- https://github.com/ruby/ruby/pull/5110#issuecomment-967276896
$ 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 がネストして呼び出されるような形になっているのが原因ぽい
- debug.gem 上で
TracePoint
を有効にしている時に Zeitwerk のTracePoint
が発動しない的な - byebug でも同様の問題があるっぽい
- 参照:Rails6でbyebugを利用してデバッグするときに気をつけたいこと - SmartHR Tech Blog
- debug.gem 上で
- これに対応する手段として『
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 ^^^^^^^^^^
- これはよさそうな変更ですね
- 移行パス的には Ruby 3.1 に入るというよりかはまず警告を出すようにする感じなんですかね?
- 少なくとも Ruby 3.1 には入らないっぽい
- https://bugs.ruby-lang.org/issues/18367#note-10
[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
提案された内容では dup
や clone
した場合も freeze
になる。
どう freeze
するのかは freeze
メソッドを再定義してクラスごとに定義するようにするみたいです。
めっちゃ議論が進んでいてここではまとめきれてないんですが #freeze
と #deep_freeze
の違いや #deep_freeze
すると singleton_class
まで不変になってしまい困るのでは?みたいな話や 不変
にしたときの利点はなにか、みたいな話を議論してるぽいですねえ。
freeze
とは別に Ractor での共有可能オブジェクトという概念もありいろいろと考慮点が多そうで大変そう。
以下、議論箇所のピックアップ。
- https://bugs.ruby-lang.org/issues/18035#note-6
- https://bugs.ruby-lang.org/issues/18035#note-12
- https://bugs.ruby-lang.org/issues/18035#note-17
- https://bugs.ruby-lang.org/issues/18035#note-25
- https://bugs.ruby-lang.org/issues/18035#note-27
興味がある人はぜひチケットを読んでみよう!
【一人 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 使いたい。