この記事はRuby Advent Calendar 2017 17日目の記事です。
benchmark_driver.gem とは
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のスクリプト
- name (
- 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を使ってベンチマークセットを増やしていく間に必要だと思った機能はどんどん追加していこうと思っています。