Benchee

どの機能が速くて、どの機能が遅いのか、推測することはできません。気になるときには実測が必要です。 そこで、ベンチマークの出番です。 このレッスンでは、コードの速度を測ることがいかに簡単かを学びます。

Bencheeについて

Erlangの関数は関数の実行時間の基本的な測定に使えますが、利用できるツールの中では使い勝手が悪く、有用な統計を取るために複数の測定値を得ることができません。そこで、Benchee を使うことにします。 Bencheeは、シナリオ間の比較を容易にするさまざまな統計、ベンチマークしている関数への異なる入力をテストできる素晴らしい機能、結果の表示に使用できるいくつかの異なるフォーマッター、さらに必要に応じて独自のフォーマッターを作成する機能を提供してくれます。

使用方法

Bencheeをプロジェクトに追加するには、mix.exs ファイルに依存関係として追加してください。

defp deps do
  [{:benchee, "~> 1.0", only: :dev}]
end

そして、次のように呼び出します。

$ mix deps.get
...
$ mix compile

最初のコマンドは、Bencheeをダウンロードし、インストールします。Hexも一緒にインストールするように言われるかもしれません。2つ目はBencheeのアプリケーションをコンパイルします。これで最初のベンチマークを書く準備ができました。

始めるにあたって重要な注意: ベンチマークを行う場合、iexを使わないことが重要です。なぜなら、iexはあなたのコードが実運用環境でどのように使用されているかとは異なる挙動をし、しばしば非常に遅くなるからです。 そこで、benchmark.exsと呼ぶファイルを作成し、その中に以下のコードを追加してみましょう。

list = Enum.to_list(1..10_000)
map_fun = fn i -> [i, i * i] end

Benchee.run(%{
  "flat_map"    => fn -> Enum.flat_map(list, map_fun) end,
  "map.flatten" => fn -> list |> Enum.map(map_fun) |> List.flatten() end
})

続いて、ベンチマークを実行するために、次のように呼び出します。

$ mix run benchmark.exs

そして、あなたのコンソールに次のような出力が表示されるはずです。

Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
Number of Available Cores: 8
Available memory: 15.61 GB
Elixir 1.8.1
Erlang 21.3.2

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 14 s

Benchmarking flat_map...
Benchmarking map.flatten...

Name                  ips        average  deviation         median         99th %
flat_map           2.40 K      416.00 μs    ±12.88%      405.67 μs      718.61 μs
map.flatten        1.24 K      806.20 μs    ±20.65%      752.52 μs     1186.28 μs

Comparison:
flat_map           2.40 K
map.flatten        1.24 K - 1.94x slower +390.20 μs

もちろん、ベンチマークを実行しているマシンの仕様によって、システム情報や結果は異なるかもしれませんが、このような一般的な情報はすべて揃っているはずです。

一見したところ、 Comparison セクションでは、私たちの map.flatten バージョンが flat_map より 1.94 倍も遅いことが示されています。また、平均して約390マイクロ秒遅くなっていることもわかり、物事を考えるきっかけになります。知っておくと便利なことばかりです。しかし、他の統計も見てみましょう。

他にも利用可能な統計はありますが、この5つがもっとも有用でベンチマークによく使われるため、デフォルトのフォーマッターで表示されるようになっています。 他の利用可能なメトリクスについてもっと知りたい場合は、 hexdocs のドキュメントをチェックしてください。

設定

Bencheeの優れている点の1つは、利用可能なすべての設定オプションです。 ここでは、コード例を必要としないので、まず基本的なことを説明し、その後、Bencheeのもっとも優れた機能の1つであるinputsの使い方を紹介します。

基本

Bencheeは豊富な設定オプションを受け取ります。 もっとも一般的な Benchee.run/2 インターフェイスでは、これらはオプションのキーワードリストの形で第2引数として渡されます。

Benchee.run(%{"example function" => fn -> "hi!" end},
  warmup: 4,
  time: 10,
  inputs: nil,
  parallel: 1,
  formatters: [Benchee.Formatters.Console],
  print: [
    benchmarking: true,
    configuration: true,
    fast_warning: true
  ],
  console: [
    comparison: true,
    unit_scaling: :best
  ]
)

利用可能なオプションは以下の通りです(hexdocsにも記載があります)。

入力

関数のベンチマークは、その関数が実世界で実際に動作しそうなデータを使って行うことが重要です。 小さなデータセットと大きなデータセットでは、関数の動作が異なることがよくあります。そこで、Bencheeの inputs 設定オプションの出番です。 これにより、同じ関数を好きなだけ異なる入力でテストすることができ、それぞれの関数のベンチマークの結果を見ることができます。

では、もう一度元の例を見てみましょう。

list = Enum.to_list(1..10_000)
map_fun = fn i -> [i, i * i] end

Benchee.run(%{
  "flat_map"    => fn -> Enum.flat_map(list, map_fun) end,
  "map.flatten" => fn -> list |> Enum.map(map_fun) |> List.flatten() end
})

この例では、1から10,000までの整数のリストを1つだけ使っています。 これを更新して、いくつかの異なる入力を使用し、より小さいリストとより大きいリストで何が起こるかを見てみましょう。 このファイルを開いて、次のように変更してみましょう。

map_fun = fn i -> [i, i * i] end

inputs = %{
  "small list" => Enum.to_list(1..100),
  "medium list" => Enum.to_list(1..10_000),
  "large list" => Enum.to_list(1..1_000_000)
}

Benchee.run(
  %{
    "flat_map" => fn list -> Enum.flat_map(list, map_fun) end,
    "map.flatten" => fn list -> list |> Enum.map(map_fun) |> List.flatten() end
  },
  inputs: inputs
)

2つの違いにお気づきでしょう。 まず、関数への入力情報を含む inputs マップを持っています。 その入力マップを設定オプションとして Benchee.run/2 に渡しています。

そして、関数が引数を取る必要があるので、ベンチマーク関数も引数を取るように更新する必要があります。

fn -> Enum.flat_map(list, map_fun) end

このようにします。

fn list -> Enum.flat_map(list, map_fun) end

もう一度実行してみましょう。

$ mix run benchmark.exs

これで、コンソールに次のような出力が表示されるはずです。

Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
Number of Available Cores: 8
Available memory: 15.61 GB
Elixir 1.8.1
Erlang 21.3.2

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: large list, medium list, small list
Estimated total run time: 42 s

Benchmarking flat_map with input large list...
Benchmarking flat_map with input medium list...
Benchmarking flat_map with input small list...
Benchmarking map.flatten with input large list...
Benchmarking map.flatten with input medium list...
Benchmarking map.flatten with input small list...

##### With input large list #####
Name                  ips        average  deviation         median         99th %
flat_map            13.20       75.78 ms    ±25.15%       71.89 ms      113.61 ms
map.flatten         10.48       95.44 ms    ±19.26%       96.79 ms      134.43 ms

Comparison:
flat_map            13.20
map.flatten         10.48 - 1.26x slower +19.67 ms

##### With input medium list #####
Name                  ips        average  deviation         median         99th %
flat_map           2.66 K      376.04 μs    ±23.72%      347.29 μs      678.17 μs
map.flatten        1.75 K      573.01 μs    ±27.12%      512.48 μs     1076.27 μs

Comparison:
flat_map           2.66 K
map.flatten        1.75 K - 1.52x slower +196.98 μs

##### With input small list #####
Name                  ips        average  deviation         median         99th %
flat_map         266.52 K        3.75 μs   ±254.26%        3.47 μs        7.29 μs
map.flatten      178.18 K        5.61 μs   ±196.80%        5.00 μs       10.87 μs

Comparison:
flat_map         266.52 K
map.flatten      178.18 K - 1.50x slower +1.86 μs

これで、入力ごとにグループ化されたベンチマークの情報を見ることができます。 この単純な例では、驚くような洞察は得られませんが、入力サイズによって性能が大きく異なることに驚かれることでしょう。

フォーマッター

これまで見てきたコンソール出力は、関数の実行時間を測定するのに便利な始まりですが、唯一の選択肢ではありません このセクションでは、他の3つのフォーマッターについて簡単に説明し、あなたが好きなようにフォーマッターを書くために必要なことについても触れます。

他のフォーマッター

Bencheeはコンソールフォーマッターを内蔵しており、これはすでに見たとおりですが、その他に公式にサポートされているフォーマッターは以下の3つです。

それぞれ、期待通りの働きをします。つまり、結果を指定されたファイル形式に書き出すので、好きな形式で結果をさらに処理できます。

これらのフォーマッターはそれぞれ別のパッケージなので、それらを使用するには mix.exs ファイルに依存関係として以下のように追加する必要があります。

defp deps do
  [
    {:benchee_csv, "~> 1.0", only: :dev},
    {:benchee_json, "~> 1.0", only: :dev},
    {:benchee_html, "~> 1.0", only: :dev}
  ]
end

benchee_jsonbenchee_csv はシンプルですが、benchee_html は実はとても充実した機能を備えています! また、PNG画像としてエクスポートすることもできます。 もし興味があれば、htmlレポートの例をチェックしてみてください。このようなグラフが含まれています。

benchee_html graph export sample

3つのフォーマッターは、それぞれのGitHubのページで十分に説明されているので、ここではその詳細については説明しません。

独自のフォーマッター

もし、提供されている4つのフォーマッターで物足りない場合は、カスタムフォーマッターを作成することも可能です。 フォーマッターを書くのはとても簡単です。 必要なのは、 %Benchee.Suite{} 構造体を受け取る関数を書くことで、そこから好きな情報を引き出すことができます。 この構造体の中身については、GitHubHexDocs で見ることができます。 このコードベースは十分に文書化されており、カスタムフォーマッターを書くためにどのような種類の情報が利用できるかを確認したい場合には、簡単に読むことができます。

また、Benchee.Formatter behaviour を採用した、よりフルに機能を持ったフォーマッターを書くこともできますが、ここではより単純な関数バージョンにこだわることにします。

とりあえず、カスタムフォーマッターの簡単な例として、以下のようなものを紹介します。 たとえば、各シナリオの平均実行時間を表示する、非常にシンプルなフォーマッターが欲しいとしましょう。これは次のようになります。

defmodule Custom.Formatter do
  def output(suite) do
    suite
    |> format
    |> IO.write()

    suite
  end

  defp format(suite) do
    Enum.map_join(suite.scenarios, "\n", fn scenario ->
      "Average for #{scenario.job_name}: #{scenario.run_time_data.statistics.average}"
    end)
  end
end

そして、このようにベンチマークを実行できます。

list = Enum.to_list(1..10_000)
map_fun = fn i -> [i, i * i] end

Benchee.run(
  %{
    "flat_map" => fn -> Enum.flat_map(list, map_fun) end,
    "map.flatten" => fn -> list |> Enum.map(map_fun) |> List.flatten() end
  },
  formatters: [&Custom.Formatter.output/1]
)

そして、独自のフォーマッターで実行すると、次のようになります。

Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
Number of Available Cores: 8
Available memory: 15.61 GB
Elixir 1.8.1
Erlang 21.3.2

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 14 s

Benchmarking flat_map...
Benchmarking map.flatten...
Average for flat_map: 419433.3593474056
Average for map.flatten: 788524.9366408596

メモリ

ここまで来て、Bencheeのもっともクールな機能の1つであるメモリ測定をお見せせずに終わってしまいました。

Bencheeはメモリ消費を測定できますが、それはベンチマークが実行されているプロセスに限定されます。他のプロセス(ワーカープールなど)でのメモリ消費を追跡することは今のところできません。

メモリ消費量には、ベンチマークシナリオが使用したすべてのメモリが含まれ、ガベージコレクションされたメモリも含まれるため、必ずしもプロセスの最大メモリサイズを表しているわけではありません。

どのように使用するのですか?それは、:memory_time オプションを使うだけです。

Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
Number of Available Cores: 8
Available memory: 15.61 GB
Elixir 1.8.1
Erlang 21.3.2

Benchmark suite executing with the following configuration:
warmup: 0 ns
time: 0 ns
memory time: 1 s
parallel: 1
inputs: none specified
Estimated total run time: 2 s

Benchmarking flat_map...
Benchmarking map.flatten...

Memory usage statistics:

Name           Memory usage
flat_map          624.97 KB
map.flatten       781.25 KB - 1.25x memory usage +156.28 KB

**All measurements for memory usage were the same**

見ての通り、Bencheeは採取したサンプルがすべて同じであるため、わざわざすべての統計情報を表示する必要はないのです。これは、関数にランダム性が含まれていない場合、実はよくあることなのです。もし、統計値がいつも同じであれば、何の役に立つでしょうか?

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!