ホーム DoRuby Rubyで速いプログラムを書くために
Rubyで速いプログラムを書くために
 

Rubyで速いプログラムを書くために

この記事はアピリッツの技術ブログ「DoRuby」から移行した記事です。情報が古い可能性がありますのでご注意ください。

Rubyが遅いというのはよく聞く話ですが、ボトルネックがRubyそのものでもないのにRubyのせいにするのは非常にナンセンスです。最低限Rubyで読みやすく速いコードを書いてからRubyのせいにしましょう。本記事では何が速いコードなのかを知る手助けとなる色々なBenchmarkの取り方と、それを使ったString同士の結合メソッド比較を紹介します。

Measure Don’t Guess

はじめに

今年5度目の武道館公演で灼熱に飲まれました、くろすです。
鍋食べにいくなら喜んでついていきます。

コードに対する感性やセンスは一朝一夕で身につくものじゃありません。
それを磨くためには、たくさんの良いコードに触れ、良いコードとは何かを理解する必要があります。
速いコードが良いコードというわけではありませんが、遅いコードは悪いコードなので測り倒して抹殺しましょう。

benchmark をとる

最も単純なベンチマークの取り方というと以下のようなコードになると思います。

time = Time.now
'hoge'
puts "Benchmark : #{Time.now - time}s"

#=> Benchmark : 2.0e-06 s

これをそのまま使うのはあまりにもナンセンスなので普通なら以下のようなコードになるでしょう。

def benchmark
  time = Time.now
  yield
  puts "Benchmark : #{Time.now - time}s"
end

benchmark { 'hoge' }

#=> Benchmark : 2.0e-06 s

ただ、このbenchmarkメソッドもあまりセンスよくないです。
だってrubyにはBenchmarkモジュールがあるから。

require 'benchmark'

puts "Benchmark : #{Benchmark.realtime { 'hoge' } s}"

#=> Benchmark : 1.00000761449337e-06 s

ミリ秒で値が欲しいなぁという場合はこちら
ActiveSupportの中で拡張されています。

require 'active_support'
require 'active_support/core_ext/benchmark'

puts "Benchmark : #{Benchmark.ms { 'hoge' } ms}"

#=> Benchmark : 0.00200001522898674 ms

自分で作ったbenchmarkメソッドとruby標準のBenchmark.realtimeメソッドの違いですが、
Benchmark.realtimeの方はTime.now に当たる部分がProcess.clock_gettime(Process::CLOCK_MONOTONIC) になっています。
この Process.clock_gettime(Process::CLOCK_MONOTONIC)とTime.nowの違いは面倒くさくて追いきれてないので気が向いたら追記します。(どっちもsource_locationでnilが帰ってくるのでパッと調べるのがめんd..)

ちなみにですがruby標準のbenchmarkにはbenchmarkメソッドが存在します。

require 'benchmark'

Benchmark.bm do |x|
  x.report('hoge') { 'hoge' }
end

#=>
       user     system      total        real
hoge  0.000000   0.000000   0.000000 (  0.000002)

いろんな情報見れて便利ですよね。

さて、少し話が変わりますがRailsのcontributorになりたいと思ったことはありますか?
そう思ったら使わなきゃいけないgemがある(Benchmark Your Code)って知ってますか?
benchmark-ipsです。

require 'benchmark/ips'

Benchmark.ips do |x|
  x.report('hoge') { 'hoge' }
end

#=>
Warming up --------------------------------------
                       244.639k i/100ms
Calculating -------------------------------------
                          8.302M (± 6.6%) i/s -     41.344M in   5.002986s

gemの説明にもある通りiterations per secondを返してくれます。
1秒間に何回回るかってことですね。ウォーミングアップもやってくれるんで使い勝手が非常に良いです。

ここまでrubyのbenchmarkについて追ってきました。

ちょっと待ってくれ、Rails環境下でbenchmark do的な感じでbenchmark使ったことあるけど、あれはなんだ?と思った人にはこちら
ActiveSupport::Benchmarkableのインスタンスメソッドにbenchmarkがあります。
ソース読めばわかりますが、内部的には前述のBenchmark.msを使ってるだけですね。
オプションでloggerを切れるので、詳しい情報が必要じゃないけど時間だけ知りたい場合のRails環境下だとこっちの方が使い勝手いいです。

String同士の結合メソッド比較

さて、本題は終わったのでここからは実際の使用例的な感じで調べてみます。
ここで注意しておいてもらいたいのはString#concatString#<<のaliasであるということです。
リファレンスの説明もまとめられています。

require 'benchmark/ips'

Benchmark.ips do |x|
  x.config(time: 5, warmup: 2)
  x.report('String#+') { '' + 'hoge' }
  x.report('String#<<') { '' << 'hoge' }
  x.report('String#concat') { ''.concat('hoge') }
  x.compare!
end

#=>
Warming up --------------------------------------
            String#+   197.224k i/100ms
           String#<<   187.204k i/100ms
       String#concat   141.180k i/100ms
Calculating -------------------------------------
            String#+      4.383M (± 6.3%) i/s -     21.892M in   5.014485s
           String#<<      4.608M (± 6.0%) i/s -     23.026M in   5.016537s
       String#concat      2.983M (± 5.2%) i/s -     14.965M in   5.031350s

Comparison:
           String#<<:  4608081.5 i/s
            String#+:  4382769.8 i/s - same-ish: difference falls within error
       String#concat:  2982639.2 i/s - 1.54x  slower

'''hoge'のString.newのコストは全て同じため考えません。
こうやってみると単純な結合にはString#+String#<<どっちも良さそうですね。
String#+String#<<の比較はこちらが参考になりますが、メモリ確保の挙動と破壊的か非破壊的かが違うようです。
どうもString#concatは単発で呼ぶとaliasの分String#<<より遅いようですね。
ただ個人的は、Stringに<<を生やすのは間違えてArray触っている気持ちになって気持ち悪いのでconcatを使いたいです。

それでは次のbenchmarkをみてみましょう

require 'benchmark/ips'

@a = ''
def a
  @a += 'hoge'
end

@b = ''
def b
  @b << 'hoge'
end

@c = ''
def c
  @c.concat('hoge')
end

Benchmark.ips do |y|
  y.config(time: 5, warmup: 0)
  y.report('String#+')      { a }
  y.report('String#<<')     { b }
  y.report('String#concat') { c }
  y.compare!
end

stringをどんどん長くしていくコードですね。
今回はウォーミングアップの時間を作ると@a @b @cの中身が書き変わりフェアではなくなるので直で実行するために、configで時間を設定しています。
結果はこちら。

#=>
Warming up --------------------------------------
            String#+     1.000  i/100ms
           String#<<     1.000  i/100ms
       String#concat     1.000  i/100ms
Calculating -------------------------------------
            String#+     74.156k (±146.7%) i/s -     70.018k in   4.972166s
           String#<<    986.464k (±10.0%) i/s -      2.885M
       String#concat    980.709k (±11.8%) i/s -      3.182M

Comparison:
           String#<<:   986463.5 i/s
       String#concat:   980708.6 i/s - same-ish: difference falls within error
            String#+:    74156.1 i/s - 13.30x  slower

こうやってみてみるとString#<<が優秀ですね。

まとめ

普段から触っているStringのメソッドにもこのような違いがあるのですから、普段書いているプログラムが常に最善の中での最速になるわけありません。
プロファイリングツールを使い全体のボトルネックを把握しつつ、このような細かい部分は自分でベンチとって蓄積していきましょう。
計って測って図り倒して自分のセンスを磨いていきたいところです。

最後に

Measure Don’t Guess

記事を共有

最近人気な記事