F#でベンチマークをとる

ベンチマークをとるのは難しい作業です。それらの作業を担う便利なライブラリーとして BenchmarkDotNet があります。本稿では、これを使ってベンチマークをとる例をやっていき、 minimum viable introduction (実用最低限の導入) となることを目指します。

1. 要約

  • 書き出し
    • ベンチマークは意外と大変という話について軽く触れる
  • 導入
    • 本稿の構成
    • 開発環境の明記
    • BenchmarkDotNet のサンプル
    • 一例として「素数判定」を実装する
    • 素数判定の簡単なベンチマークを書く
    • ベンチマークを実行する
  • おまけ
    • 軽くて速いベンチマークに設定する
    • ベンチマークを watch する

2. 開発環境

F# の開発環境は Getting Started を参照: Ionide - Crossplatform F# Editor Tools

今回は次を使ってやっていきます。 (執筆日: 2018年8月9日)

  • Windows 10
  • .NET Core Cli Tools 2.1
  • F# 4.1
  • Visual Studio Code
    • ionide-fsharp

3. BenchmarkDotNet にあるサンプルコード

F# 用のサンプルをみるとだいたいのイメージはつかめます。

BenchmarkDotNet/samples/BenchmarkDotNet.Samples.FSharp at v0.11.0 · dotnet/BenchmarkDotNet

4. サンプルプロジェクトでやってみる

例として「素数判定」のベンチマークをやってみましょう。

まずサンプルプロジェクトを普通のクラスライブラリーとして作ります。

dotnet new classlib -lang F# --name PrimeNum

そしてがんばって実装を書く:

// PrieNum/Library.fs
module PrimeNum

// 2 以上 p 未満の整数で割り切れなければ素数、と判定する。
// 最大 p - 2 回のループ
let isPrimeBruteForce (p: int): bool =
    if p < 2 then
        false
    else
        // go m ⇔ p が m 以上 p 未満の整数で割り切れない
        let rec go m = m >= p || p % m <> 0 && go (m + 1)
        go 2

// 2 以上 √p 以下の整数で割り切れなければ素数、と判定する。(証明略)
// 最大 √p - 1 回のループなので速いはず
let isPrime (p: int): bool =
    if p < 2 then
        false
    else
        // √p
        let r = p |> float |> sqrt |> int
        // go m ⇔ p が m 以上 r 以下の整数で割り切れない
        let rec go m = m > r || p % m <> 0 && go (m + 1)
        go 2

で、ベンチマークです。「実行するとベンチマーク処理を行うようなコンソールアプリ」を F# で作るという形になります。(ユニットテストでいうと expecto 方式)

コンソールアプリのプロジェクトを作って、そこに BenchmarkDotNet をインストールします。(最新バージョンは NuGet で確認しよう。)

# ベンチマークするためのプロジェクトはコンソールアプリとして作る。
dotnet new console -lang F# --name PrimeNumBench

# PrimeNumBench が PrimeNum を参照するようにする。
dotnet add PrimeNumBench reference PrimeNum

# BenchmarkDotNet をインストールする。 (※やや時間がかかる)
dotnet add PrimeNumBench package BenchmarkDotNet --version 0.11.0

# インテリセンスが効くように、ここで一度ビルドしておく (※やや時間がかかる)
dotnet build PrimeNumBench

試しに大きめの素数 10000019 (≒100万) が素数かどうかの判定にかかる時間を測定してみましょう。

  • 測定したい計算をクラスのメソッドとして定義する。
    • モジュールではなく。このあたりは C#-er の気持ちになる。
  • メソッドがベンチマーク対象であると分かるように BenchmarkAttribute をつける。
  • main でベンチマークを実行する関数を呼ぶ。
// PrimeNumBench/Program.fs
module Program

open BenchmarkDotNet
open BenchmarkDotNet.Attributes

type Benchmarks() =
    [<Benchmark>]
    member this.IsPrimeBruteForceBench() =
        PrimeNum.isPrimeBruteForce 10_000_019

    [<Benchmark>]
    member this.IsPrimeBench() =
        PrimeNum.isPrime 10_000_019

[<EntryPoint>]
let main _ =
    let _summary = Running.BenchmarkRunner.Run<Benchmarks>()
    0

ベンチマークアプリを Release モードでビルドして 実行します。

dotnet run -p PrimeNumBench -c Release

1分ぐらいかかるので待つと、結果が (ログの海に溺れて) 出てきます。

                 Method |         Mean |         Error |        StdDev |
----------------------- |-------------:|--------------:|--------------:|
 IsPrimeBruteForceBench | 69,125.13 us | 1,118.0498 us | 1,045.8244 us |
           IsPrimeBench |     21.40 us |     0.1367 us |     0.1279 us |

平方根を取るだけでかなり良い最適化になってるっぽい。なお 1秒 = 1000 ms (ミリ秒) = 10万 us (マイクロ秒) です。

5. ベンチマークを軽量にする

試行錯誤している段階ではもうちょっと早く結果がほしいので、ベンチマークの設定を変えて計測精度を下げる代わりに、ベンチマークにかかる時間を短くしてみます。

ベンチマークをどのように実行するかの設定は Jobs にあるようです。参照: Jobs | BenchmarkDotNet

ウォームアップや反復の回数を固定するより、パラメーターをいじってアルゴリズムに任せたほうがいいらしいです。具体的にどうするか分からないので、デフォルトの設定でそういうのないかなと思ったんですが、よさげなプルリクが出ているので参考にします:

Accuracy based job attributes by Zhentar · Pull Request #825 · dotnet/BenchmarkDotNet

相対誤差の上限を増やせばよさそう。

// PrimeNumBench/Program.fs
module Program

open BenchmarkDotNet
open BenchmarkDotNet.Attributes
open BenchmarkDotNet.Configs
open BenchmarkDotNet.Jobs

type Benchmarks() =
    [<Benchmark>]
    member this.IsPrimeBruteForceBench() =
        PrimeNum.isPrimeBruteForce 10_000_019

    [<Benchmark>]
    member this.IsPrimeBench() =
        PrimeNum.isPrime 10_000_019

[<EntryPoint>]
let main _ =
    let config =
        let rough = AccuracyMode(MaxRelativeError = 0.1)
        let quickRoughJob = Job("QuickRough", rough, RunMode.Short)

        let c = ManualConfig()
        c.Add(quickRoughJob)

        // その他の設定をデフォルトから継承する。
        ManualConfig.Union(DefaultConfig.Instance, c)

    let _summary = Running.BenchmarkRunner.Run<Benchmarks>(config)
    0

ビルドしてからベンチマークの完了まで20秒ぐらいになりました。

6. ベンチマークを自動実行する

.NET Core 2.1 から標準入りした dotnet-watch ツールを使うと、ソースコードを更新するたびにベンチマークを自動実行できます。

dotnet watch -p ./PrimeNumBench -- run -c Release

7. レポートを公開する

結果を公開するには、 BenchmarkDotNetArtifacts/results/*.md にマークダウン(GFM)形式で出力されているのを貼っつける。環境の情報が自動で載るので楽です。(Qiita だと微妙に手直しが必要)

BenchmarkDotNet=v0.11.0, OS=Windows 10.0.16299.371 (1709/FallCreatorsUpdate/Redstone3)
Intel Core i3-2310M CPU 2.10GHz (Sandy Bridge), 1 CPU, 4 logical and 2 physical cores
Frequency=2046136 Hz, Resolution=488.7261 ns, Timer=TSC
.NET Core SDK=2.1.302
  [Host]     : .NET Core 2.1.2 (CoreCLR 4.6.26628.05, CoreFX 4.6.26629.01), 64bit RyuJIT DEBUG
  DefaultJob : .NET Core 2.1.2 (CoreCLR 4.6.26628.05, CoreFX 4.6.26629.01), 64bit RyuJIT
Method Mean Error StdDev
IsPrimeBruteForceBench 69,125.13 us 1,118.0498 us 1,045.8244 us
IsPrimeBench 21.40 us 0.1367 us 0.1279 us

8. おわりに

よい計測ライフを!

A. その他

B. 関連リンク

関連記事