その他
    ホーム 技術発信 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