メソッド分割してインライン化を効かせようという話
NOTE: こちらは以前Qiitaに投稿した記事のバックアップです。
ことのあらまし
『System.LazyLazySlim
をつくり、BenchmarkDotNetで初期化コストと値アクセスのコストを比較した。
using System; namespace MyBenchmark { public class LazySlim1<T> { public bool HasValue { get; private set; } private T _Value; public readonly Func<T> Initializer; public T Value { get { if(HasValue) return _Value; _Value = Initializer(); HasValue = true; return _Value; } } public LazySlim1(Func<T> initializer) { HasValue = false; Initializer = initializer; } } }
using System; namespace MyBenchmark { public class LazySlim2<T> { public bool HasValue { get; private set; } private T _Value; public readonly Func<T> Initializer; public T Value => HasValue ? _Value : CreateValue(); public LazySlim2(Func<T> initializer) { HasValue = false; Initializer = initializer; } private T CreateValue() { _Value = Initializer(); HasValue = true; return _Value; } } }
using System; using BenchmarkDotNet.Attributes; namespace MyBenchmark { public class LazyTest { private const int _Iterations1 = 1000000; private const int _Iterations2 = 100000000; public class Foo { public DateTime InitializedAt { get; } public Foo() => InitializedAt = DateTime.Now; } [Benchmark] public long Initialize_LazySlim1() { var dummy = 0L; for(var i = 0; i < _Iterations1; ++i) { var lazy = new LazySlim1<Foo>(() => new Foo()); // LazySlim2, Lazyに差し替えた版も用意 unchecked{ dummy += lazy.Value.InitializedAt.Ticks; } } return dummy; } // ~~~ [Benchmark] public long AccessValue_LazySlim1() { var dummy = 0L; var lazy = new LazySlim1<Foo>(() => new Foo()); // LazySlim2, Lazyに差し替えた版も用意 for (var i = 0; i < _Iterations2; ++i) { unchecked { dummy += lazy.Value.InitializedAt.Ticks; } } return dummy; } // ~~~ } }
で、以下がその結果。
Method | Mean | Error | StdDev |
---------------------- |----------:|----------:|----------:| Initialize_LazySlim1 | 52.96 ms | 0.0781 ms | 0.0693 ms | Initialize_LazySlim2 | 52.91 ms | 0.0568 ms | 0.0503 ms | Initialize_Lazy | 73.60 ms | 0.1516 ms | 0.1344 ms | AccessValue_LazySlim1 | 150.10 ms | 0.6381 ms | 0.5657 ms | AccessValue_LazySlim2 | 75.14 ms | 0.0575 ms | 0.0538 ms | AccessValue_Lazy | 74.76 ms | 0.4697 ms | 0.4393 ms |
Lazy<T>
のスレッドセーフ化コストはどうやら初回アクセスのときだけ生じるらしい(約1.5倍)・・・
ということがわかったのはいいとして、注意を引くのがAccessValue_LazySlim1とAccessValue_LazySlim2の差。
AccessValueはValue
プロパティにアクセスするだけなので、if文でHasInitialized
を判定するコストしかないはずである。
にもかかわらず、初期化処理をgetter内にベタ書きしたLazySlim1は2倍近く遅くなってしまった。
何が違うのか?
結局、その答えはリリースビルドのJITアセンブリを見なければわからない。
public static class Program { public static void Main() { TestLazySlim1(); TestLazySlim2(); } public static void TestLazySlim1() { var lazy1 = new LazySlim1<int>(() => 0); Console.WriteLine(lazy1.Value); } public static void TestLazySlim2() { var lazy2 = new LazySlim2<int>(() => 0); Console.WriteLine(lazy2.Value); } }
Program.TestLazySlim1() L0000: in al,dx L0001: push edi L0002: push esi L0003: mov eax,dword ptr ds:[03743540h] L0008: mov esi,eax L000A: test eax,eax L000C: jne 0046 L000E: mov ecx,717063BCh L0013: call 00A930F4 L0018: mov esi,eax L001A: mov edi,dword ptr ds:[374353Ch] L0020: test edi,edi L0022: jne 002B L0024: mov ecx,esi L0026: call 71D6CFFC L002B: lea edx,[esi+4] L002E: call 72CEE780 L0033: mov eax,2520110h L0038: mov dword ptr [esi+0Ch],eax L003B: lea edx,ds:[3743540h] L0041: call 72CEE750 L0046: mov ecx,0AA4ED0h L004B: call 00A930F4 L0050: mov ecx,eax L0052: mov byte ptr [ecx+0Ch],0 L0056: lea edx,[ecx+4] L0059: call 72CEE750 L005E: call dword ptr ds:[0AA4F20h] L0064: mov ecx,eax L0066: call 71D7452C L006B: pop esi L006C: pop edi L006D: pop ebp L006E: ret Program.TestLazySlim2() L0000: in al,dx L0001: push edi L0002: push esi L0003: mov eax,dword ptr ds:[03743544h] L0008: mov esi,eax L000A: test eax,eax L000C: jne 0046 L000E: mov ecx,717063BCh L0013: call 00A930F4 L0018: mov esi,eax L001A: mov edi,dword ptr ds:[374353Ch] L0020: test edi,edi L0022: jne 002B L0024: mov ecx,esi L0026: call 71D6CFFC L002B: lea edx,[esi+4] L002E: call 72CEE780 L0033: mov eax,2520168h L0038: mov dword ptr [esi+0Ch],eax L003B: lea edx,ds:[3743544h] L0041: call 72CEE750 L0046: mov ecx,0AA5064h L004B: call 00A930F4 L0050: mov ecx,eax L0052: mov byte ptr [ecx+0Ch],0 L0056: lea edx,[ecx+4] L0059: call 72CEE750 L005E: cmp byte ptr [ecx+0Ch],0 L0062: jne 0252062E L0064: call dword ptr ds:[0AA50BCh] L006A: jmp 02520631 L006C: mov eax,dword ptr [ecx+8] L006F: mov ecx,eax L0071: call 71D7452C L0076: pop esi L0077: pop edi L0078: pop ebp L0079: ret
違いはL005E
以降。LazySlim2
を使っている方はValue
プロパティのアクセスにインライン化が効いている。
このインライン化のおかげで2倍もの速度差が出ていたわけである。
という訳で
こういった、ほとんどは値を返すだけだが極稀に処理をする系のプロパティでは積極的にメソッド分割していこう。 式表現と三項演算子を意識して使っていけば自ずとインライン化が効くコードサイズに収まるはずだ。
ちなみに
C#ではインライン化の制御にMethodImpl
属性が利用できるが、プロパティアクセサには適用できない。
潔くコードサイズで勝負しよう。
もっとちなみに
冒頭のLazy
について、コンストラクタにLazyThreadSafetyMode.None
を渡してやればスレッドアンセーフで高速な遅延初期化オブジェクトにできることを後で知った。わざわざLazySlim
を実装する必要はなかったというオチ。