Active Supportを使うと Range#=== が遅くなる
IRCログを記録するRailsアプリの開発中、IRCメッセージの解析において、文字が制御文字か判断する必要があった。その際に case-when
で文字列の Range
とマッチさせる(Range#===
を呼び出す)と、予想外に遅くなった。ruby-profでボトルネックを調べると、ActiveSupport::CompareWithRange#===
という見慣れないメソッドが実行されて、時間がかかっているようだった。そこで、通常の Range#===
と実行速度がどのくらい違うか、ベンチマークをとってみた。
ベンチマークのコード
https://github.com/ochaochaocha3/char_range_eq3_benchmark
IRCメッセージの解析では、各文字が制御文字か調べることを行う。そこで、String#each_char
の中で ActiveSupport::CompareWithRange#===
または Range#===
を呼び出すプログラムの実行速度を計測することにした。コードは以下のとおり。
# frozen_string_literal: true require 'benchmark_driver' Benchmark.driver do |x| x.prelude <<~RUBY class Range alias old_eq3 === end require 'active_support/core_ext/range/compare_range' r = "\\x00"..."\\x20" # Example from https://modern.ircdocs.horse/formatting.html#examples s = "Rules: Don't spam 5\\x0313,8,6\\x03,7,8, and especially not \\x029\\x02\\x1D!" RUBY x.report 'Default ===', %q! s.each_char { |c| r.old_eq3(c) } ! x.report 'Active Support ===', %q! s.each_char { |c| r === c } ! end
このスクリプトでは、MJITのk0kubunさんが作られたgemのBenchmarkDriverを利用して、オーバーヘッドを少なくしている。繰り返し前に実行される x.prelude
において、Active Supportの読み込み前に alias
を使用して、通常の Range#===
に old_eq3
という別名をつけておく。2つの x.report
が、繰り返し実行される処理。解析対象の文字列は、IRCメッセージの形式の説明に例として載っていたもの。
実行環境
実行結果
実行結果を以下に示す。
$ bundle exec ruby char_range_eq3_benchmark.rb Warming up -------------------------------------- Default === 55.360k i/s - 58.740k times in 1.061047s (18.06μs/i) Active Support === 44.636k i/s - 48.455k times in 1.085554s (22.40μs/i) Calculating ------------------------------------- Default === 55.020k i/s - 166.081k times in 3.018555s (18.18μs/i) Active Support === 45.137k i/s - 133.908k times in 2.966715s (22.15μs/i) Comparison: Default ===: 55020.0 i/s Active Support ===: 45136.8 i/s - 1.22x slower
「ActiveSupport::CompareWithRange#===
は通常の Range#===
よりも1.22倍遅い」という結果が得られた。alias
で別名をつけたメソッドの呼び出しでこの結果なので、Active Supportによって Range#===
が上書きされていないときに ===
を呼び出したら、もっと速くなるのかもしれない(未確認)。
原因
ActiveSupport::CompareWithRange#===
が遅い原因は、右辺に Range
を指定できるように処理が追加されていること。右辺値が Range
かどうかで分岐する。今回は String
を渡しているので else
節で単に super
が呼び出されるだけだが、比較に時間がかかるのだろう。
def ===(value) if value.is_a?(::Range) is_backwards_op = value.exclude_end? ? :>= : :> return false if value.begin && value.end && value.begin.public_send(is_backwards_op, value.end) # 1...10 includes 1..9 but it does not include 1..10. # 1..10 includes 1...11 but it does not include 1...12. operator = exclude_end? && !value.exclude_end? ? :< : :<= value_max = !exclude_end? && value.exclude_end? ? value.max : value.last super(value.first) && (self.end.nil? || value_max.public_send(operator, last)) else super end end
しかし、Ruby 2.6以降では、標準の Range#===
も Range
を受け取れるようになっている(Feature #14473)。したがって、この部分は、Ruby 2.5以下でも同じ挙動となるように残されているものと思われる。将来はこの部分が削除されて、実行速度が向上する(標準の Range#===
と同等になる)のかもしれない。
まとめ
Active Supportを使うと Range#===
が遅くなる。原因は、ActiveSupport::CompareWithRange#===
において、右辺に Range
を指定できるように処理が追加されているためだった。