内容
関数のパフォーマンスが計測できるBenchmarkDotNetというライブラリを知ったので
以下の二つについて計測してみる。
計測1
いきさつ
paizaのコードをAIにレビューさせていると、より効率が良い改善案を提案された。
どの程度パフォーマンスがよくなるのか調べるため計測してみる。
指摘されたコード
まず、paizaで書いていたコードがこちら
// === 出力部分 ===
for(int i = 0; i < H;i++)
{
for(int j = 0; j < W; j++)
{
// gridはchar型の他次元配列
Console.Write(grid[i,j]);
}
Console.Write("\n");
}
文字列を出力するコードですね
そして、AIの改善案がこちら

うん、至極真っ当な改善
というわけで計測用にこのコードと改善したコードを実装したクラスを作成します
計測するコード
改善案通りにStringBuilderを使用して文字列を整形、その後出力する関数になります。
標準出力の処理に時間がかかるのはわかり切っているので出力先を変更し、
Console.Write関数の呼び出しコストのみを計測するようにしました。
(単純に出力で計測結果が見ずらかったのもある)
using System.Text;
using BenchmarkDotNet.Attributes;
namespace SpeedTest;
public class GridOutput
{
private int H = 1000;
private int W = 1000;
private char[,] grid;
[GlobalSetup]
public void Setup()
{
Console.SetOut(TextWriter.Null);
grid = new char[H,W];
for(int i = 0; i < H;i++)
{
for(int j = 0; j < W; j++)
{
grid[i,j] = '*';
}
}
}
// ループ内で整形してから出力
[Benchmark(Baseline = true)]
public void WithStringBuilder()
{
var sb = new StringBuilder();
for(int i = 0; i < H;i++)
{
for(int j = 0; j < W; j++)
{
sb.Append(grid[i,j]);
}
sb.Append("\n");
}
Console.Write(sb.ToString());
}
// 毎ループ出力
[Benchmark]
public void ConsoleWrite()
{
for(int i = 0; i < H;i++)
{
for(int j = 0; j < W; j++)
{
Console.Write(grid[i,j]);
}
Console.Write("\n");
}
}
}
これをMain関数から呼び出す
using BenchmarkDotNet.Running;
namespace SpeedTest;
class Program
{
static void Main(string[] args)
{
var summary = BenchmarkRunner.Run<GridOutput>();
}
}
結果がこちら
| Method | Mean | Error | StdDev | Ratio | RatioSD |
|--------------------- |---------:|----------:|----------:|------:|--------:|
| WithStringBuilder | 3.428 ms | 0.0563 ms | 0.0526 ms | 1.00 | 0.02 |
| ConsoleWrite | 3.988 ms | 0.0347 ms | 0.0290 ms | 1.16 | 0.02 |
微弱…
まあ、I/Oにかかる時間を考慮した指摘だっただろうし、コンセプトが悪かった
ちょっと解説
一応技術記事なので、技術的なことを
- GlobalSetup属性
- 計測に含まない初期化などをするための関数に付ける属性です。 これ使わずにコンストラクタで初期化しても動きはするんですが、重すぎると計測のノイズになるのでこれ使ったほうが良いらしいです
- Benchmark属性
- BenchmarkDotNetでは測定する関数にBenchmark属性を付けますが、このときBaselineプロパティをtrueとすることで、その関数を比較の基準として結果にRatioが追加されます。
計測2
いきさつ
「ListのAdd関数は要素数を増やすときに内部的に新しい配列を作るので、要素の最大数がわかっている場合は初期化のときに最大要素数を指定したほうが良い」という話は聞いたことがあるが、実感したことはないので計測してみる。
詳細は実装読んでください
計測するコード
初期化時に要素数を指定する良いリストに10000回要素を追加する関数と
初期化時に要素数を指定せしない悪いリストに10000回要素を追加する関数を実装しました
(Main関数は省略)
using BenchmarkDotNet.Attributes;
namespace SpeedTest;
[ShortRunJob,MemoryDiagnoser]
public class ListTest
{
[Benchmark(Baseline = true)]
public void GoodList()
{
var list = new List<int>(10000);
for(int i = 0; i < 10000; i++)
{
list.Add(i);
}
}
[Benchmark]
public void BadList()
{
var list = new List<int>();
for(int i = 0; i < 10000; i++)
{
list.Add(i);
}
}
}
結果はこちら
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
|--------- |---------:|---------:|---------:|------:|--------:|----------:|------------:|
| GoodList | 20.76 us | 1.574 us | 0.086 us | 1.00 | 12.6343 | 39.12 KB | 1.00 |
| BadList | 30.59 us | 4.209 us | 0.231 us | 1.47 | 41.6260 | 128.32 KB | 3.28 |
検証1よりはっきりした結果がでましたね。
実行時間よりメモリ効率に差が出てるのがおもろいですね。
聞いた話通り、要素数がわかっているListは初期化時に指定したほうがよさそうです。
ちょっと解説
- ShortRunJob属性
- デフォルトだと計測時にBenchmarkDotNet側で最適な実行回数で計測してくれるのですが、この属性をつけることで実行回数が事前に決められた短縮版になります。今回では実行回数が約1/5になりました。
- MemoryDiagnoser属性
- メモリ効率を計測するための属性です。結果にGen0~Gen2のGC回数、Allocated、(基準がある場合は)Alloc Ratioが追加されます
感想
BenchmarkDotNetで遊んでみました。
今回はただ計測したかったので属性を使いましたが、開発で使うときは最適化の段階になるのでオブジェクトで計測することになりそうだと思いました。(オブジェクトの方が計測関数とか細かく設定できそう?)
記事にはしませんでしたが、有名な string vs StringBuilder を計測してみるとメモリ効率が1915倍くらいの差になって面白かったです。
参考にさせていただいたサイト
BenchmarkDotNet公式ドキュメント
【.NET/C#】BenchmarkDotNetを使って、メソッドのパフォーマンスを簡単に集計する
BenchmarkDotNetを使ってみる。
C#のベンチマークドリブンで同一プロジェクトの性能向上を比較する方法