メソッド分割してインライン化を効かせようという話

NOTE: こちらは以前Qiitaに投稿した記事のバックアップです。


ことのあらまし

『System.Lazyはスレッドセーフのためにどれほどコストを払っているのか?』ということを疑問に思い、ベンチマークを取ってみることにしたときのこと。以下のような2つのLazySlimをつくり、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を実装する必要はなかったというオチ。