Ruby高速化のためのベンチマークツール benchmark_driver.gem

この記事はRuby Advent Calendar 2017 17日目の記事です。

benchmark_driver.gem とは

https://github.com/k0kubun/benchmark_driver

Rubyの処理系を高速化していく上で重要な計測環境を改善するため、Ruby本体のリポジトリにあるbenchmark/driver.rbの後継として作られたベンチマークツールです。普通にRubyのスクリプトのパフォーマンスを比較するのにも使えます。

また、このgemはRuby Association開発助成金2017に採択されたプロジェクトとして開発されています。

何が便利なのか

Procの起動を行なわない精度の良い計測ができる

皆さんがベンチマークによく使うのは、標準ライブラリの benchmark.rb か、見易い比較結果を得られる benchmark-ips.gem 等でしょう。

benchmark-ips.gem でよく使われるインターフェースや benchmark.rb はベンチマーク対象をブロックで受け取ります。
そのため、ベンチマーク対象を一回実行する度に、その実行にかかる時間にブロックの起動時間が含まれてしまうため、ブロックの呼び出しのオーバーヘッドよりも計測対象の処理にかかる時間が短いベンチマーク用途には向きません。

例えば以下の benchmark-ips.gem を使ったベンチマークだと、

require 'benchmark/ips'

class Array
  alias_method :blank?, :empty?
end

Benchmark.ips do |x|
  array = []
  x.report('Array#empty?') { array.empty? }
  x.report('Array#blank?') { array.blank? }
  x.compare!
end

例えばRuby 2.4.2で以下のような結果になってしまいます。

Warming up --------------------------------------
        Array#empty?   486.620k i/100ms
        Array#blank?   475.833k i/100ms
Calculating -------------------------------------
        Array#empty?     15.284M (± 1.4%) i/s -     76.886M in   5.031272s
        Array#blank?     14.060M (± 1.4%) i/s -     70.423M in   5.009599s

Comparison:
        Array#empty?: 15284435.8 i/s
        Array#blank?: 14060239.1 i/s - 1.09x  slower

一方、benchmark_driver.gem を使った以下のベンチマークだと、

require 'benchmark/driver'

class Array
  alias_method :blank?, :empty?
end

Benchmark.driver do |x|
  x.prelude %{ array = [] }
  x.report 'Array#empty?', %{ array.empty? }
  x.report 'Array#blank?', %{ array.blank? }
  x.compare!
end

以下のように大きく差が出ます。

Warming up --------------------------------------
        Array#empty?    21.099M i/100ms
        Array#blank?     8.733M i/100ms
Calculating -------------------------------------
        Array#empty?   211.921M i/s -      1.055B in 4.977938s
        Array#blank?    77.929M i/s -    436.628M in 5.602891s

Comparison:
        Array#empty?: 211920655.7 i/s
        Array#blank?:  77929094.1 i/s - 2.72x  slower

これはベンチマーク対象をブロックではなく文字列で与えているおかげで、
ベンチマーク対象一回の実行にかかるオーバーヘッドが小さくなるようなスクリプトを動的に作って実行することで実現しています。

なお、実は benchmark-ips.gem にも似たような機能はあるのですが、その機能だと benchmark_driver.gem における prelude に該当する部分が指定できないため、今回の用途に使うと [] のオブジェクト生成のオーバーヘッドが毎回かかるか、あるいはローカル変数から[]を取得するのを諦めることになるでしょう。

複数バイナリでの実行

普通のRubyのベンチマークツールでは、そのベンチマークツールを起動したRubyで計測対象のスクリプトを実行すると思います。

benchmark_driver.gem は別のRubyバイナリを指定して計測をすることが可能で、複数のRuby処理系のパフォーマンス比較をする用途に使えます。

例えば、以下のようにすると、実行すべきRubyバイナリをRBENV_VERSIONに使うのと同じ名前で簡単に指定することが可能です。

require 'benchmark/driver'

Benchmark.driver do |x|
  x.rbenv '2.0.0', '2.4.2'
  x.prelude %{
    class Array
      alias_method :blank?, :empty?
    end
    array = []
  }
  x.report 'Array#empty?', %{ array.empty? }
  x.report 'Array#blank?', %{ array.blank? }
  x.compare!
end

この実行結果は以下のようになります。

Warming up --------------------------------------
        Array#empty?    17.075M i/100ms
        Array#blank?     8.965M i/100ms
Calculating -------------------------------------
                          2.0.0       2.4.2
        Array#empty?   203.906M    210.936M i/s -    683.019M in 3.349673s 3.238042s
        Array#blank?    90.759M     75.405M i/s -    358.585M in 3.950967s 4.755474s

Comparison:
Array#empty? (2.4.2): 210935840.5 i/s
Array#empty? (2.0.0): 203906226.4 i/s - 1.03x  slower
Array#blank? (2.0.0):  90758927.4 i/s - 2.32x  slower
Array#blank? (2.4.2):  75404777.6 i/s - 2.80x  slower

Ruby 2.4 では Array#empty? が速くなっているのに対し、単にエイリアスした Array#blank? は遅くなってることが簡単にわかります。

また、x.bundlerという指定を加えると、そのRubyをbundle execで起動したのと同じ効果が得られると同時に、bundle checkが通らなかった場合は自動でそのバージョンに対しbundle installも行ないます。

アウトプットの柔軟な指定/拡張が可能

今までは全て benchmark-ips.gem 互換の出力でしたが、output: :markdownのようにオプションを指定すると以下のようにmarkdownを出力することも可能です。

require 'benchmark/driver'

Benchmark.driver(output: :markdown) do |x|
  x.rbenv '2.0.0', '2.4.2'
  x.prelude %{
    class Array
      alias_method :blank?, :empty?
    end
    array = []
  }
  x.report 'Array#empty?', %{ array.empty? }
  x.report 'Array#blank?', %{ array.blank? }
  x.compare!
end
|            |2.0.0      |2.4.2      |
|:-----------|:----------|:----------|
|Array#empty?|1.00       |1.03       |
|Array#blank?|1.00       |0.92       |

x.compare!を指定しているので、一番左の列の2.0.0との速度比が出ています。

また、output: :memoryと指定するとメモリ使用量も計測することが可能です。

max resident memory (KB):
ruby          2.0.0   2.4.2
Array#empty?  8764    8844
Array#blank?  8984    9056

全てのフォーマットが、計測が終わり次第結果を逐次出力することに対応しています。

また、この記事ではプラグインの実装方法については触れませんが、アウトプットに関して外部gemとして簡単にプラグインを作ることが可能です。

YAMLフォーマットの使い方

benchmark_driver.gem には benchmark-driver というコマンドが同梱されています。

$ benchmark-driver -h
Usage: benchmark-driver [options] [YAML]
    -e, --executables [EXECS]        Ruby executables (e1::path1,arg1,...; e2::path2,arg2;...)
        --rbenv [VERSIONS]           Ruby executables in rbenv (x.x.x,arg1,...;y.y.y,arg2,...;...)
    -o, --output [TYPE]              Specify output type (ips, time, memory, markdown)
    -c, --compare                    Compare results (currently only supported in ips output)
    -r, --repeat-count [NUM]         Try benchmark NUM times and use the fastest result
        --filter [REGEXP]            Filter out benchmarks with given regexp
        --bundler                    Install and use gems specified in Gemfile
        --dir                        Override __dir__ from "/tmp" to actual directory of YAML

これは以下のようなYAMLファイルを書き、

prelude: |
  class Array
    alias_method :blank?, :empty?
  end
  array = []
benchmark:
  Array#empty?: array.empty?
  Array#blank?: array.blank?

以下のようにbenchmark-driverコマンドに渡して使用します。

$ benchmark-driver benchmark.yml
Warming up --------------------------------------
        Array#empty?    16.570M i/100ms
        Array#blank?     9.001M i/100ms
Calculating -------------------------------------
        Array#empty?   166.750M i/s -    828.479M in 4.968389s
        Array#blank?    90.835M i/s -    450.054M in 4.954660s

YAMLファイルのシンタックス

このYAMLファイルには、以下のものを指定します。

  • prelude (String): 速度の計測の対象にしないRubyのスクリプト
  • benchmark (Array<Hash{ Symbol => String }>) (糖衣構文: Hash{ Symbol => String }, Array<String>, String))
    • name (String): 計測結果を表示する時の名前
    • script (String): 計測対象にするRubyのスクリプト
  • loop_count (Integer): 計測対象を一回の計測に使うスクリプト内で何回ループするか (省略すると "Warming up" により自動で決まる)

この構造の通りにYAMLファイルを書くと以下のようになります。

prelude: |
  class Array
    alias_method :blank?, :empty?
  end
  array = []
benchmark:
  - name: Array#empty?
    script: array.empty?
  - name: Array#blank?
    script: array.blank?
loop_count: 100000000

benchmark:の部分にはいくつか糖衣構文があり、上記と同じ意味で以下のようにも書けます。

prelude: |
  class Array
    alias_method :blank?, :empty?
  end
  array = []
benchmark:
  Array#empty?: array.empty?
  Array#blank?: array.blank?
loop_count: 100000000

また、name:script:と同じにしても良い場合は、以下のような指定でも問題ありません。

prelude: |
  class Array
    alias_method :blank?, :empty?
  end
  array = []
benchmark:
  - array.empty?
  - array.blank?
loop_count: 100000000

計測対象が1つでいい(異なるRubyバージョン間でそれを比較したい)場合、以下のようなString一つだけの指定も可能です。

prelude: array = []
benchmark: array.empty?
loop_count: 100000000

便利なコマンドラインオプション

--rbenv

上の方で説明したx.rbenvと同じです。違いは、複数指定する時に;区切りにするか、オプション自体を繰り返す必要があることです。

$ benchmark-driver --rbenv '2.4.2;2.5.0-rc1'
$ benchmark-driver --rbenv 2.4.2 --rbenv 2.5.0-rc1

また、rbenvを使わない場合は、-e, --executablesオプションを使ってください。

$ benchmark-driver -e '2.4.2::/home/k0kubun/.rbenv/versions/2.4.2/bin/ruby;2.5.0-rc1::/home/k0kubun/.rbenv/versions/2.5.0-rc1/bin/ruby'
$ benchmark-driver -e '2.4.2::/home/k0kubun/.rbenv/versions/2.4.2/bin/ruby' -e '2.5.0-rc1::/home/k0kubun/.rbenv/versions/2.5.0-rc1/bin/ruby'

--bundler

$ benchmark-driver --rbenv '2.4.2;2.5.0-rc1' --bundler

--rbenv-e, --executablesで指定したそれぞれのRubyバイナリに関して、そのRubyバイナリを叩く際に-rbundler/setupがつき、またbundle checkに失敗した場合bundle installが実行されます。

内部的にoptparseを使っているので、--bundleでも使えます。

-o, --output

上の方で説明したoutput: :markdownのような指定ができます。

$ benchmark-driver -o ips
$ benchmark-driver -o time
$ benchmark-driver -o memory
$ benchmark-driver -o markdown

-r, --repeat-count

正確に計測するため、計測を何度か試行して最良の結果を採用するオプションです。平均ではなく最良にしているのは、遅かった場合の原因はRuby自体のせいではなく外部要因であり、それは計測してもしょうがないと考えているためです。

以下のような指定をすると、各スクリプトを3セット実行します。

$ benchmark-driver -r 3

名前が紛らわしいですが、1セットの中で計測対象をループする回数がloop_countで、--repeat-countとは別の値です。
loop_countが10000で--repeat-countが3なら10000回ループするスクリプトが3回実行されるので、合計30000回スクリプトが実行されます。

今後の展望

プラグインのインターフェースがちょっとイケてないのでその辺を整理しようと思っています。
また、Rubyバイナリを起動する計測方法でオーバーヘッドを吸収する方法が雑めなので、より"Warming up"に時間がかからず、かつ変な計測結果が発生しにくくしようと思っています。

あと、Ruby Association開発助成金2017のプロジェクトとしてはベンチマークセットの拡充を含んでいるので、来年benchmark_driver.gemを使ってベンチマークセットを増やしていく間に必要だと思った機能はどんどん追加していこうと思っています。