概要
.NET (C#) のパフォーマンス改善に関する記事にて、クロージャを利用した場合 と そうでない場合 における
パフォーマンスの差異を指摘するような情報を拝見しました。
普段、コードを書く際には配慮しないような事項だったため、勉強の意味も込めて実際に測定してみました。
情報整理
匿名関数でローカル変数をキャプチャした (取り込んだ) 場合、その関数を クロージャ と呼びます。
C#においてクロージャを作成した場合、コンパイル後に生成されるコード (IL = 中間言語) にて、
クロージャが取り込んだ ローカル変数 を保持するようなクラス構造が、暗黙的に生成されます。
クロージャの外部 (呼び出し側) でローカル変数が変更された場合でも、
クロージャの内部でローカル変数の変更が共有出来るのは、このような仕組みによるものです。
下記ページに非常に判りやすい解説があります。
https://ufcpp.net/study/csharp/sp2_anonymousmethod.html#static
環境
- Windows10
- Visual Studio 2019
- .NET Core 2.2
- BenchmarkDotNet
コードサンプル
サンプルコードを Github にアップしています。
https://github.com/tYoshiyuki/dotnet-core-benchmark-test
尚、今回使用したプログラムサンプルは、下記スライド (41スライド目) を参考にさせていただきました。
https://www.slideshare.net/xin9le/dotnetperformancetips-170268354
解説
Linq の FirstOrDefault を例として、ローカル変数をキャプチャした場合 と そうでない場合 とでベンチマークを測定してみます。
ポイントとなるコードは以下の通りです。
private readonly List<Sample> _samples = new List<Sample>
{
new Sample{ Id = 1, Name = "One"},
new Sample{ Id = 2, Name = "Two"},
new Sample{ Id = 3, Name = "Three"}
};
public Sample GetByLambda(int id)
{
// 普通にラムダ式で呼び出し、変数のキャプチャ有り
return _samples.FirstOrDefault(_ => _.Id == id);
}
public Sample GetByExtension(int id)
{
// 拡張メソッドを利用、変数のキャプチャ無し
return _samples.FirstOrDefault((x, state) => x.Id == state, id);
}
public Sample GetByLocalFunction(int id)
{
// ローカル関数経由で呼び出し、変数のキャプチャ有り
bool Func(Sample s) => s.Id == id;
return _samples.FirstOrDefault(Func);
}
変数をキャプチャするパターンを2つ、拡張メソッド経由で変数をキャプチャしないパターンを1つ実装しました。
実際のRepositoryでは、DBのようなデータストアからデータを取得しますが、今回はメモリ上より取得するようにしています。
呼び出し側のコードを下記の通りです。
public BenchmarkMain()
{
_sampleRepository = new SampleRepository();
_indexes = Enumerable.Range(0, 10000).ToArray();
}
[Benchmark]
public void CaseLambda()
{
// 普通にラムダ式で呼び出し、変数のキャプチャ有り
foreach (var i in _indexes)
{
var id = i % 3 + 1;
var sample = _sampleRepository.GetByLambda(id);
if (sample == null) Console.WriteLine("No data");
}
}
・・・以下略
それぞれのパターンで 10,000回 の呼び出しを行っています。
ベンチマークの測定には BenchmarkDotNet を利用しました。
以下、測定結果を掲載します。
Method | Mean | Error | StdDev | Median |
---|---|---|---|---|
CaseLambda | 1,234.8 us | 35.82 us | 101.04 us | 1,198.6 us |
CaseExtension | 782.1 us | 23.95 us | 69.86 us | 771.0 us |
CaseLocalFunction | 1,401.2 us | 38.64 us | 113.32 us | 1,371.5 us |
やはり、ローカル変数をキャプチャしない方が パフォーマンス的に有利 であるようです。
実践では、コードの可読性やパフォーマンスをどこまで配慮すべきかという観点もあるとは思いますが、
実装上のポイントとして、認識しておいた方が良いかなと感じました。
おまけ
暗黙的に生成されたクラスを IL で確認してみました。
素人には読めるような内容ではないのですが、キャプチャされたローカル変数を保持するクラス構造が
生成されているという点は確認出来ます。
.class nested private sealed auto ansi beforefieldinit
'<>c__DisplayClass1_0'
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.field public int32 id // ★★★ この部分 ★★★
.method public hidebysig specialname rtspecialname instance void
.ctor() cil managed
{
.maxstack 8
IL_0000: ldarg.0 // this
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: nop
IL_0007: ret
} // end of method '<>c__DisplayClass1_0'::.ctor
.method assembly hidebysig instance bool
'<GetByLambda>b__0'(
class DotNetCoreBenchmarkTest.Repository.Sample _
) cil managed
{
.maxstack 8
// [19 49 - 19 59]
IL_0000: ldarg.1 // _
IL_0001: callvirt instance int32 DotNetCoreBenchmarkTest.Repository.Sample::get_Id()
IL_0006: ldarg.0 // this
IL_0007: ldfld int32 DotNetCoreBenchmarkTest.Repository.SampleRepository/'<>c__DisplayClass1_0'::id
IL_000c: ceq
IL_000e: ret
} // end of method '<>c__DisplayClass1_0'::'<GetByLambda>b__0'
} // end of class '<>c__DisplayClass1_0'