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

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

# benchmark\_driver.gem とは

https://github.com/k0kubun/benchmark_driver

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

また、このgemは[Ruby Association開発助成金2017](http://www.ruby.or.jp/ja/news/20171206)に採択されたプロジェクトとして開発されています。

## 何が便利なのか
### Procの起動を行なわない精度の良い計測ができる

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

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

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

```rb
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 を使った以下のベンチマークだと、

```rb
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`に使うのと同じ名前で簡単に指定することが可能です。

```rb
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を出力することも可能です。

```rb
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ファイルを書き、

```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ファイルを書くと以下のようになります。

```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:`の部分にはいくつか糖衣構文があり、上記と同じ意味で以下のようにも書けます。

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

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

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

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

```yaml 
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を使ってベンチマークセットを増やしていく間に必要だと思った機能はどんどん追加していこうと思っています。