少し前に 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#merge
と Hash#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 |
やはり merge
と dup
+ 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#merge
は Hash#merge!
と比べて dup
でオブジェクトを生成する分が遅そう
-
Hash#fetch
vs. Hash#fetch
with block は第1引数に与えたキーが見つからないようにすると、ほとんど変わらないか元記事と逆の結果
ということもわかりました。まぁ2個目と3個目は予想できることですが……。
個人的には Block
vs. Symbol#to_proc
に Ruby のバージョンによる違いが出るというのが興味深かったです。今度なぜ差が出るのか調べてみようと思います(実装が変わったのかな? 1.9 から C 実装になったとか)。それ以外のところも、なぜ処理速度に差が出るのか理解できるとプログラマとしてレベルアップできそうです。