⏱️

ローカルとNuGetパッケージの処理を同時にベンチマーク実行する方法

に公開

はじめに

プログラムを実装する上で重要な要素の一つとしてどれだけパフォーマンスが優れているかというのがあげられると思います。
そのため、パフォーマンス改善できないか考える毎日ですが、実際にパフォーマンスが改善したかどうか判断するためにはベンチマークは計測する必要があります。
.NET であれば、おなじみのBenchmarkDotNetでベンチマークを実行することが多いと思います。

私の場合、OSSのパフォーマンス改善などで対応前と対応後でベンチマーク結果を比較するということをよく行います。その際に、最近まで対応前バージョンで計測後に対応後バージョンで計測するといった工程を行っていましたが、これが意外に面倒なので良い方法が無いかなと思っていたところ、簡単な方法があったので、今回はその方法について紹介しようと思います。

前提

SampleというライブラリのSample.Methodメソッドのベンチマークを実行するとします。
構成自体は、SampleプロジェクトとSampleプロジェクトを参照したBenchmarkプロジェクトが以下のような感じで配置していると思ってもらえば大丈夫です。
そしてBenchmarkプロジェクトには、BenchmarkDotNetは追加済みという体で進めます。

プロジェクト構成例
Sample/
├── Sample/
│   ├── Sample.csproj     // ライブラリ
│   └── Sample.cs
└── Benchmark/
    ├── Benchmark.csproj  // Sampleのベンチマークプロジェクト
    └── Program.cs

Sample.Methodメソッドも、ただ定義しただけの何もしないメソッドとしておきます。

public class Sample
{
    public void Method()
    {}
}

NuGetパッケージ作成

対応前バージョンとして、まずSampleライブラリのNuGetパッケージを作成します。
この作業はすでにOSSで公開されているパッケージなどがある場合にはしなくても問題ないです。

./publishディレクトリにSample.1.0.0.nupkgを作成
> dotnet pack -c Release -o ./publish

ベンチマーク構成

ベンチマークであるBenchmarkプロジェクトで、Sample.Methodメソッドを計測する処理を実装します。

Sample.Methodメソッド
public class SampleBenchmark
{
    [Benchmark]
    public void Execute()
    {
        Sample.Sample sample = new Sample.Sample();
        sample.Method();
    }
}

そして今回の本題であるNuGetパッケージのSample.Methodメソッドとローカルプロジェクト参照しているSample.Methodメソッドを同時にベンチマーク実行できるようにベンチマーク構成をカスタマイズします。

NuGetパッケージ版は、Jobの拡張メソッドであるWithCustomBuildConfigurationメソッドを用いてカスタムビルド構成でベンチマークを実行するようにします。ここでは、NuGetVersionというビルド構成を定義しています。
あとは実行結果を見やすくするため、ベンチマーク名をWithIdメソッドと用いてNuGetVersionと設定しています。
それ以外の設定については、必要に応じた設定などを追加してもらえればと思います。

NuGetパッケージ版のベンチマーク構成
// NuGetパッケージのSample.Methodメソッドを実行するJOB
var nugetVersionJob = Job.ShortRun
            .WithStrategy(RunStrategy.Throughput)
            .DontEnforcePowerPlan()
            .WithToolchain(CsProjCoreToolchain.NetCoreApp10_0)
            .WithCustomBuildConfiguration("NuGetVersion")
            .WithId("NuGetVersion");

ローカルプロジェクト参照版のSample.Methodメソッドでは、WithCustomBuildConfigurationメソッドを使用せずにデフォルトのRelaseビルド構成で実行するようにします。ベンチマーク名は、LocalVersionと設定しています。

ローカルプロジェクト参照版のベンチマーク構成
// ローカルプロジェクト参照のSample.Methodメソッドを実行するJOB
var localVersionJob = Job.ShortRun
            .WithStrategy(RunStrategy.Throughput)
            .DontEnforcePowerPlan()
            .WithToolchain(CsProjCoreToolchain.NetCoreApp10_0)
            .WithId("LocalVersion");

あとは、デフォルト構成に上記の二つのベンチマーク構成を追加したベンチマーク実行処理を実装します。

// デフォルト構成に上記の二つの構成を追加する
var config = DefaultConfig.Instance
    .AddJob(nugetVersionJob)
    .AddJob(localVersionJob);

// 作成したconfigを設定して、ベンチマークを実行する
var summaries = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly)
    .Run(args, config)
    .ToArray();

独自のビルド構成に応じた条件分岐

NuGetパッケージ版は、NuGetVersionというカスタムビルド構成で実行するように設定しました。プロジェクトファイル(.csproj)だとConfigurationパラメータにNuGetVersionが設定されるため、このパラメータを使って参照方法を変更します。

具体的には、MSBuildの <Choose>/<When> を使った条件分岐を使い、ビルド構成がNuGetVersionの場合には、NuGetパッケージ経由にするように<PackageReference>を使用するように変更し、それ以外の場合にはローカルプロジェクト参照でSampleライブラリを参照します。

Benchmark.csproj
  <Choose>
    <When Condition="'$(Configuration)'=='NuGetVersion'">
      <ItemGroup>
        <PackageReference Include="Sample" Version="1.0.0" />
      </ItemGroup>
    </When>
    <Otherwise>
      <ItemGroup>
        <ProjectReference Include="..\Sample\Sample.csproj" />
      </ItemGroup>
    </Otherwise>
  </Choose>

https://learn.microsoft.com/ja-jp/aspnet/web-forms/overview/deployment/web-deployment-in-the-enterprise/understanding-the-project-file
https://learn.microsoft.com/ja-jp/visualstudio/msbuild/when-element-msbuild?view=visualstudio

ベンチマーク実行

これで準備はできましたが、このままだと同じ処理になり比較できないので、ローカルプロジェクト参照側は多少時間がかかる処理に変更しておきます。

時間がかかる処理に変更
public class Sample
{
    public void Method()
    {
        // 時間がかかる処理
        int count = 0;
        foreach(var i in Enumerable.Range(1, 10000))
        {
            count++;
        }
    }
}

そして、ベンチマークを実行すると、

実行結果
> dotnet run -c Release --filter SampleBenchmark*

...
PowerPlanMode=00000000-0000-0000-0000-000000000000  Toolchain=.NET 10.0  IterationCount=3  
LaunchCount=1  RunStrategy=Throughput  WarmupCount=3  

| Method  | Job          | BuildConfiguration | Mean          | Error         | StdDev      | Ratio      | RatioSD   |
|-------- |------------- |------------------- |--------------:|--------------:|------------:|-----------:|----------:|
| Execute | LocalVersion | Default            | 4,963.6782 ns | 7,436.6421 ns | 407.6274 ns | 202,074.34 | 51,243.65 |
| Execute | NuGetVersion | NuGetVersion       |     0.0261 ns |     0.1548 ns |   0.0085 ns |       1.06 |      0.40 |

NuGetパッケージ版とローカルプロジェクト参照版が両方実行されたベンチマーク結果が出力することができました。
これで片方ずつベンチマークを計測する工程がなくなりました!

まとめ

BenchmarkDotNetでカスタムビルド構成を使用することで、NuGetパッケージ版とローカルプロジェクト参照版のパフォーマンスを1回の実行で同時に比較できます。

この方法自体は、色々調べてみるとBenchmarkDotNetのissueにも同じようなこと試みている方やOSS(例:ZLinq)のベンチマークプロジェクトでも実装されているので目新しいものではないですが、パフォーマンス改善前後の比較や、リリース版と開発版の比較が効率的に行えます。

https://github.com/dotnet/BenchmarkDotNet/issues/2425

https://github.com/Cysharp/ZLinq/blob/main/sandbox/Benchmark/BenchmarkDotNet/BenchmarkConfigs/NuGetVersionsBenchmarkConfig.cs#L11-L47

参考URL

https://learn.microsoft.com/ja-jp/dotnet/core/tools/dotnet-run

Discussion