きにきじ

今日の気になる記事とか学びとか

Writing Fast Ruby を検証してみた

| Comments

少し前に Writing Fast Rubyというスライドが良い | mah365 という記事が話題になっていました。内容は Writing Fast Ruby というスライドの紹介で、リーダビリティを維持しながらパフォーマンスを出せるよという話です。おもしろいなーと流してしまってもよかったのですが、どうせなら手元でも確かめてみようというのが今回のこの記事です。

使った Ruby のバージョン

最初は普段よく使っている Ruby 1.8.6-p369 でやってみたんですが(バージョンアップしたいですがなかなかリソースを確保できず……)、一部スライドと違う結果が出たので、バージョンの違いがあるのかもということで以下のバージョンで試してみました。

  • Ruby 1.8.7-p375
  • Ruby 1.9.3-p545
  • Ruby 2.1.2

便利メソッドを定義

毎回計測用のコードを書くのがだるいので、次のような便利メソッドを定義しました。

1
2
3
4
5
6
7
8
9
require "benchmark/ips"

def __benchmark__(labels_and_procs)
  Benchmark.ips do |x|
    labels_and_procs.each do |label, _proc|
      x.report("#{label}:"){ _proc.call }
    end
  end
end

この記事で __benchmark__ というメソッド呼び出しが出てきたらこれのことです。

benchmark-ips gem いいですね。普通の Benchmark だと、何回処理を繰り返すかを決めないといけませんが、適切な回数がいまいちわからなかったりします。その点、benchmark-ips なら1秒間に何回処理できたかという評価なのでやりやすいです。

比較結果

Proc#call vs. yield

使用したコード

1
2
3
4
5
6
7
8
9
10
11
12
def slow(&block)
  block.call
end

def fast
  yield
end

__benchmark__(
  block: proc{ slow{ 1 + 1 } },
  yield: proc{ fast{ 1 + 1 } }
)

結果

Ruby のバージョン block yield
2.1.2 1,104,005.6 (±1.9%) i/s 4,310,444.0 (±5.2%) i/s
1.9.3 1,212,795.3 (±9.9%) i/s 4,189,680.6 (±1.5%) i/s
1.8.7 273,890.7 (±5.4%) i/s 955,918.6 (±3.7%) i/s

Block vs. Symbol#to_proc

使用したコード

1
2
3
4
__benchmark__(
  block:   proc{ (1 .. 100).map{|i| i.to_s } },
  to_proc: proc{ (1 .. 100).map(&:to_s) }
)

結果

Ruby のバージョン block to_proc
2.1.2 62,191.0 (±2.3%) i/s 71,240.2 (±3.1%) i/s
1.9.3 61,063.0 (±2.3%) i/s 74,238.2 (±1.4%) i/s
1.8.7 25,609.1 (±1.6%) i/s 18,905.6 (±3.1%) i/s

1.8.7 では逆の結果になりました。

Enumerable#map and Array#flatten vs. Enumerable#flat_map

使用したコード

1
2
3
4
__benchmark__(
  map_then_flatten: proc{ (1 .. 100).map{|i| [i.to_s, i] }.flatten },
  flat_map:         proc{ (1 .. 100).flat_map{|i| [i.to_s, i] } }
)

結果

Ruby のバージョン map_then_flatten flat_map
2.1.2 27,736.4 (±1.4%) i/s 42,792.6 (±0.9%) i/s
1.9.3 27,390.5 (±1.5%) i/s 42,583.9 (±2.7%) i/s
1.8.7 - -

1.8.7 には Enumerable#flat_map がないので対象外としました。

Hash#merge vs. Hash#merge!

使用したコード

1
2
3
4
5
__benchmark__(
  merge:               proc{ [1,2,3,4,5].inject({}){|h, e| h.merge(e => e) } },
  merge_bang:          proc{ [1,2,3,4,5].inject({}){|h, e| h.merge!(e => e) } },
  merge_bang_with_dup: proc{ [1,2,3,4,5].inject({}){|h, e| _h = h.dup; _h.merge!(e => e) } }
)

Hash#mergeHash#merge! ではオブジェクト生成分のコストが違うのは当たり前なので、比較のためにわざわざ dup してから merge! するケースも加えてみました。

結果

Ruby のバージョン merge merge_bang merge_bang_with_dup
2.1.2 135,788.7 (±3.4%) i/s 274,784.1 (±3.8%) i/s 135,854.6 (±3.1%) i/s
1.9.3 121,393.1 (±5.4%) i/s 216,329.6 (±7.2%) i/s 118,098.3 (±3.2%) i/s
1.8.7 95,286.3 (±3.4%) i/s 147,247.5 (±3.8%) i/s 88,805.2 (±3.6%) i/s

やはり mergedup + merge! は同じくらいですね。Hash#merge が内部的に dup しているので、その分のコストが影響しているんだと思います。

Hash#merge! vs. Hash#[]=

使用したコード

1
2
3
4
__benchmark__(
  :merge! => proc{ [1,2,3,4,5].each_with_object({}){|e, h| h.merge!(e => e) } },
  :[]=    => proc{ [1,2,3,4,5].each_with_object({}){|e, h| h[e] = e } }
)

1.8.7 では Enumerable#each_with_object が使えないので、次のモンキーパッチをあてました。

1
2
3
4
5
6
7
8
9
10
11
12
# http://apidock.com/rails/Enumerable/each_with_object
Enumerable.module_eval do
  def each_with_object(memo)
    return to_enum :each_with_object, memo unless block_given?

    each do |element|
      yield element, memo
    end

    memo
  end
end

結果

Ruby のバージョン merge! []=
2.1.2 280,923.3 (±4.8%) i/s 682,806.6 (±3.4%) i/s
1.9.3 217,844.0 (±5.6%) i/s 486,892.0 (±7.4%) i/s
1.8.7 133,301.3 (±3.8%) i/s 212,748.6 (±1.7%) i/s

Hash#fetch vs. Hash#fetch with block

使用したコード

1
2
3
4
5
6
__benchmark__(
  arg_found:       proc{ { :rails => :club }.fetch(:rails, (0 .. 9).to_a) },
  block_found:     proc{ { :rails => :club }.fetch(:rails){ (0 .. 9).to_a } }
  arg_not_found:   proc{ { :rails => :club }.fetch(:rail, (0 .. 9).to_a) },
  block_not_found: proc{ { :rails => :club }.fetch(:rail){ (0 .. 9).to_a } }
)

Hash#fetch は第1引数に与えたキーが見つかれば対応するバリューを、見つからなければ第2引数またはブロックの結果を返します。元記事ではキーが見つかるケースしかなかったので、見つからないケースも試してみました。

結果

Ruby のバージョン arg_found block_found arg_not_found block_not_found
2.1.2 750,170.2 (±4.5%) i/s 1,391,370.0 (±7.9%) i/s 546,238.9 (±12.7%) i/s 562,627.9 (±15.0%) i/s
1.9.3 737,928.4 (±5.2%) i/s 1,494,539.6 (±10.2%) i/s 741,229.0 (±5.0%) i/s 705,287.3 (±5.5%) i/s
1.8.7 449,828.5 (±2.4%) i/s 770,142.5 (±3.8%) i/s 447,578.8 (±2.4%) i/s 419,708.7 (±3.6%) i/s

キーが見つからなかったときの代替値をブロックで与える場合、キーが見つかればブロックの処理が評価されないので、必ず評価されてしまう第2引数に与える場合よりも高速です。

ただ、キーが見つからないように第1引数を与えると結果は変わります。ほとんど変わらないか、キーが見つかるケースと逆の結果になります。ブロックの処理を評価する分のコストが影響しているのでしょうか。

String#gsub vs. String#tr

使用したコード

1
2
3
4
__benchmark__(
  gsub: proc{ "Hello world. Today is a happy day!!".gsub(" ", "_") },
  tr:   proc{ "Hello world. Today is a happy day!!".tr(" ", "_") }
)

結果

Ruby のバージョン gsub tr
2.1.2 294,032.8 (±1.7%) i/s 1,177,266.7 (±2.3%) i/s
1.9.3 315,169.3 (±1.7%) i/s 1,213,505.5 (±2.1%) i/s
1.8.7 214,505.2 (±2.1%) i/s 781,031.4 (±4.6%) i/s

Parallel vs. sequential assignment

使用したコード

1
2
3
4
__benchmark__(
  parallel:   proc{ a, b = 1, 2 },
  sequential: proc{ a = 1; b = 2 }
)

結果

Ruby のバージョン parallel sequential
2.1.2 3,546,816.4 (±14.0%) i/s 4,685,811.3 (±17.1%) i/s
1.9.3 4,913,857.6 (±3.3%) i/s 5,871,697.0 (±1.7%) i/s
1.8.7 1,042,336.0 (±3.8%) i/s 1,265,156.7 (±3.9%) i/s

Array#each_with_index vs. while loops

使用したコード

1
2
3
4
5
6
array = [1,2,3,4,5]

__benchmark__(
  each_with_index: proc{ array.each_with_index{|i, j| i + j } },
  while:           proc{ i = 0; while i < array.size; i + array[i]; i += 1; end }
)

結果

Ruby のバージョン each_with_index while
2.1.2 987,188.3 (±8.8%) i/s 1,993,586.8 (±14.9%) i/s
1.9.3 1,570,283.7 (±2.5%) i/s 2,631,052.1 (±3.4%) i/s
1.8.7 339,777.5 (±1.9%) i/s 391,679.9 (±1.8%) i/s

まとめ

だいたいは元記事と同じ結果になりました。ただ、

  • Block vs. Symbol#to_proc では 1.8.7 を使うと元記事と逆の結果
  • Hash#mergeHash#merge! と比べて dup でオブジェクトを生成する分が遅そう
  • Hash#fetch vs. Hash#fetch with block は第1引数に与えたキーが見つからないようにすると、ほとんど変わらないか元記事と逆の結果

ということもわかりました。まぁ2個目と3個目は予想できることですが……。

個人的には Block vs. Symbol#to_proc に Ruby のバージョンによる違いが出るというのが興味深かったです。今度なぜ差が出るのか調べてみようと思います(実装が変わったのかな? 1.9 から C 実装になったとか)。それ以外のところも、なぜ処理速度に差が出るのか理解できるとプログラマとしてレベルアップできそうです。

Comments