Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

RubyのRefinementsは本当に遅いのか?ベンチマークで徹底検証してみた

「Ruby refinements」をGoogleで検索すると、経緯を知らないまま読むと、refinements(リファインメント)は「遅い機能だ」という印象を持ってしまうかもしれません。

実際、当初提案されていた仕様では、refinementsは確かに遅くなるはずでした。インタプリタによるメソッド探索などの最適化が不可能になってしまうためです。

しかし、現在のRubyに実装されているrefinementsは、当初の提案よりも制限された形になっています。そこで本記事では、現代のRubyにおけるrefinementsの実性能を知るために、一連のベンチマークを実行してみました。

TL;DR(結論)

Refinementsは遅くありません。 少なくとも、「通常の」メソッドと比べて遅いということはなさそうです。

ベンチマーク用のダミーメソッド

今回ベンチマークするのはメソッド呼び出しなので、いくつかメソッドを用意する必要があります。

ここでは、ちょっとしたネタ的なメソッドを2つのバージョンで作成します。1つは「通常の」メソッドとして、もう1つはrefinementの中に定義したものです。

# 「ダミー処理」として、シュラッグ(肩すくめ顔文字)を生成するメソッドを作ります。
# 1 shrug == "¯\_(ツ)_/¯"
# 2 shrugs == "¯\_(ツ)_/¯¯\_(ツ)_/¯"
# ...以下同様

SHRUG = "¯\_(ツ)_/¯"

# シュラッグを生成するrefinementを定義
module Shruggable
  refine Fixnum do
    def shrugs
      SHRUG * self
    end
  end
end

# 同じ処理を行う通常のメソッドも定義
def integer_to_shrugs(n)
  SHRUG * n
end

なお、refinementは直接呼び出すことができず、usingステートメントによって有効化する必要があります。そこで、まったく同じ振る舞いをする2つのクラスを用意しました。片方はrefinementを使用し、もう片方は使用しません。

class TestUsing
  using Shruggable
  def noop
  end

  def shrug
    10.shrugs
  end
end

class TestWithoutUsing
  def noop
  end

  def shrug
    integer_to_shrugs(10)
  end
end

※ 当時のコードのため Fixnum を使用していますが、Ruby 2.4以降では整数クラスは Integer に統合されています。現代の環境で試す場合は適宜読み替えてください。

ベンチマーク結果

検証したいポイントは次の2つです。

  • refinementを使っているクラスのオブジェクト生成は遅くなるのか
  • refinement経由で追加されたメソッドの呼び出しは遅くなるのか

すべてのベンチマークは、OS X El Capitan上のMRI(CRuby)2.2.2で実行しています。

オブジェクト生成

usingキーワードがあると、クラスの初期化は遅くなるのでしょうか? ――いいえ、なりません。

Benchmark.ips do |bm|
  bm.report("class initialization") { TestUsing.new }
  bm.report("class initialization WITH using") { TestWithoutUsing.new }
  bm.compare!
end

# Calculating -------------------------------------
# class initialization   142.929k i/100ms
# class initialization WITH using
#                        145.323k i/100ms
# -------------------------------------------------
# class initialization      5.564M (± 8.3%) i/s -     27.728M
# class initialization WITH using
#                           5.619M (± 7.4%) i/s -     28.047M
# Comparison:
# class initialization WITH using:  5618601.3 i/s
# class initialization:  5564116.5 i/s - 1.01x slower

メソッド呼び出し

refinementsは「通常の」メソッド探索の速度に影響を与えるのでしょうか? ――いいえ、与えません。

Benchmark.ips do |bm|
  bm.report("run method") { TestUsing.new.noop }
  bm.report("run method in class WITH using") { TestWithoutUsing.new.noop }
  bm.compare!
end

# Calculating -------------------------------------
#           run method   141.905k i/100ms
# run method in class WITH using
#                        144.435k i/100ms
# -------------------------------------------------
#           run method      5.010M (± 6.4%) i/s -     24.975M
# run method in class WITH using
#                           5.086M (± 5.3%) i/s -     25.421M
# Comparison:
# run method in class WITH using:  5086262.3 i/s
#           run method:  5010273.6 i/s - 1.02x slower

では、refinementで追加されたメソッドの利用は、同等の「通常の」メソッドよりも遅いのでしょうか? ――これもいいえです。

Benchmark.ips do |bm|
  bm.report("shrug") { TestUsing.new.shrug }
  bm.report("shrug via refinement") { TestWithoutUsing.new.shrug }
  bm.compare!
end

# Calculating -------------------------------------
#                shrug    96.089k i/100ms
# shrug via refinement    95.559k i/100ms
# -------------------------------------------------
#                shrug      1.825M (± 9.3%) i/s -      9.128M
# shrug via refinement      1.929M (± 6.2%) i/s -      9.651M

# Comparison:
# shrug via refinement:  1928841.5 i/s
#                shrug:  1825069.4 i/s - 1.06x slower

無理やり遅くできるか?

何か工夫をして、比較対象(コントロール)よりも遅いベンチマーク結果を出せるでしょうか? 答えは ¯\_(ツ)_/¯

# `using`キーワードの繰り返し評価はパフォーマンスに影響するか? → わずかに影響あり。
# これは不公平なテストですが、「どうしても一部のユースケースで
# refinementsを遅くしたい」という筆者の願いです :)
Benchmark.ips do |bm|
  bm.report("inline shrug") { integer_to_shrugs(10) }
  bm.report("inline shrug via refinement") do
    using Shruggable
    10.shrugs
  end
  bm.compare!
end

# Calculating -------------------------------------
#         inline shrug   100.460k i/100ms
# inline shrug via refinement
#                         72.131k i/100ms
# -------------------------------------------------
#         inline shrug      2.507M (± 5.2%) i/s -     12.557M
# inline shrug via refinement
#                           1.498M (± 4.3%) i/s -      7.502M

# Comparison:
#         inline shrug:  2506663.9 i/s
# inline shrug via refinement:  1497747.6 i/s - 1.67x slower

唯一差が出たのは、ブロックの中で毎回 using を評価させるという、意図的に不公平な条件にしたケースのみでした。これはrefinement自体のオーバーヘッドというより、スコープ解決の繰り返しにかかるコストによるものです。

検証に使った完全なコード

ご自身でもベンチマークを実行してみたい方は、以下のコードをそのままお使いください(benchmark-ips gemが必要です)。

require 'benchmark/ips'


# 「ダミー処理」として、シュラッグ(肩すくめ顔文字)を生成するメソッドを作ります。
# 1 shrug == "¯\_(ツ)_/¯"
# 2 shrugs == "¯\_(ツ)_/¯¯\_(ツ)_/¯"
# ...以下同様

SHRUG = "¯\_(ツ)_/¯"

# シュラッグを生成するrefinementを定義
module Shruggable
  refine Fixnum do
    def shrugs
      SHRUG * self
    end
  end
end

# 同じ処理を行う通常のメソッドも定義
def integer_to_shrugs(n)
  SHRUG * n
end

# 2つのクラスを定義。最初のクラスはrefinementsを使用し、2つ目は使用しない
class TestUsing
  using Shruggable
  def noop
  end

  def shrug
    10.shrugs
  end
end

class TestWithoutUsing
  def noop
  end

  def shrug
    integer_to_shrugs(10)
  end
end

# `using`キーワードがあるとクラスの初期化は遅くなるか? → ならない
Benchmark.ips do |bm|
  bm.report("class initialization") { TestUsing.new }
  bm.report("class initialization WITH using") { TestWithoutUsing.new }
  bm.compare!
end

# refinementsは「通常の」メソッド探索の速度に影響するか? → しない
Benchmark.ips do |bm|
  bm.report("run method") { TestUsing.new.noop }
  bm.report("run method in class WITH using") { TestWithoutUsing.new.noop }
  bm.compare!
end

# refinementのメソッド利用は、同等の「通常の」メソッドより遅いか? → 遅くない
Benchmark.ips do |bm|
  bm.report("shrug") { TestUsing.new.shrug }
  bm.report("shrug via refinement") { TestWithoutUsing.new.shrug }
  bm.compare!
end

# `using`キーワードの繰り返し評価はパフォーマンスに影響するか? → わずかに影響あり。
# 不公平なテストだが、どうしてもあるユースケースで遅くしたかった :)
Benchmark.ips do |bm|
  bm.report("inline shrug") { integer_to_shrugs(10) }
  bm.report("inline shrug via refinement") do
    using Shruggable
    10.shrugs
  end
  bm.compare!
end

まとめ

ベンチマークの結果、オブジェクト生成・通常メソッド呼び出し・refinementメソッド呼び出しのいずれにおいても、refinementsの有無による有意な性能差は確認できませんでした。誤差の範囲内(±1〜6%)で推移しており、「refinementsは遅い」という通説は、現代のRuby実装においては当てはまらないと言えます。モンキーパッチの代わりとして、影響範囲を限定できる安全なrefinementsを、性能面の懸念なく活用してよいでしょう。


  1. Rubyで学ぶ挿入ソート:仕組みから計算量まで徹底解説

    ※本記事は、Rubyでさまざまなソートアルゴリズムを実装するシリーズの第4回です。第1回ではバブルソート、第2回では選択ソート、第3回ではマージソートを取り上げました。データのソート手法をさまざまな角度から探っていくシリーズもいよいよ折り返し地点。今回は挿入ソート(Insertion Sort)に焦点を当てます。挿入ソートには魅力的な特徴がたくさんあります。まず、挿入ソートは安定(stable)なアルゴリズムです。つまり、同じキーを持つ要素同士の相対的な順序が入れ替わることがありません。また、インプレース(in-place)アルゴリズムでもあるため、ソート結果を保存するための新しい配列を作成す

  2. Ruby 2.6の新機能9選|コード例でわかる注目ポイントを徹底解説

    Ruby 2.6には、開発者の生産性を高める新しい機能やパフォーマンス改善が多数盛り込まれています。 本記事では、Ruby 2.6で導入された9つの注目新機能を、実際のコード例とともにわかりやすく紹介します。最新のRuby動向をキャッチアップしたい方は、ぜひ最後までご覧ください。 1. 無限Range(Endless Range) Ruby 2.5以前でもFloat::INFINITYを使えば終端のない範囲を表現できましたが、Ruby 2.6ではさらに直感的な記法が使えるようになりました。 新しい無限Rangeは次のように書きます。 (1..) 通常のRangeが(1..10)のように終端