アジョブジ星通信

進捗が出た頃に更新されるブログ。

さぁ fixed を捨てて Unsafe だ

f:id:azyobuzin:20170929224302p:plain

早すぎる最適化が好きな人のための C# 7 の有効活用ガイドです。

ある構造体をそのまま byte 配列に突っ込みたくなるとき、ありますよね?構造体ならメンバーに名前がついていて書きやすい、でも相手が byte 配列だから 1 バイトずつ手書きするしかないのか……?そんなときにおすすめの技を紹介します。

達成目標

例を用意しましょう。 X Window Systemプロトコルは C 言語などでクライアントを実装しやすいように、適度にアライメントされたデータをやりとりします。しかもエンディアンもクライアント側が指定することができるので、クライアントはまさに構造体を直接送受信することができます。そこで、クライアントが X サーバに接続して、最初に送信するメッセージを構造体を使って中身を用意し、 byte 配列に書き込むことを目標にしていきましょう。

(この目標設定は、ちょうど X クライアントを C# で書いていたからなのですが、完成したころに公開します。公開しないかもしれません。)
公開してあります → X11Client.cs

問題の構造体を用意します。 unused 部分を考えて LayoutKind.Explicit を使っていきます。

[StructLayout(LayoutKind.Explicit, Pack = 1, Size = 12)]
public struct SetupRequestData
{
    [FieldOffset(0)]
    public byte ByteOrder;
    [FieldOffset(2)]
    public ushort ProtocolMajorVersion;
    [FieldOffset(4)]
    public ushort ProtocolMinorVersion;
    [FieldOffset(6)]
    public ushort LengthOfAuthorizationProtocolName;
    [FieldOffset(8)]
    public ushort LengthOfAuthorizationProtocolData;
}

愚直な例

まずはパッと思いつく書き方で書いてみましょう。 C# には配列を生ポインタとして操作するための fixed ステートメントがありますね。そこで byte 配列のポインタを取得して、それを構造体のポインタにキャストして、代入してしまえば良いのでは?と思います。やってみましょう。

public static unsafe void FixedInitializer(byte[] bs)
{
    fixed (byte* p = bs)
    {
        *(SetupRequestData*)p = new SetupRequestData()
        {
            ByteOrder = BitConverter.IsLittleEndian ? (byte)0x6c : (byte)0x42,
            ProtocolMajorVersion = 11,
            ProtocolMinorVersion = 0,
            LengthOfAuthorizationProtocolName = 0,
            LengthOfAuthorizationProtocolData = 0,
        };
    }
}

さっそく unsafe を指定することになってしまいましたが、良さそうです。良さそうですよね?ついでにコンパイル結果の IL も見てみましょう。

.method public hidebysig static void  FixedInitializer(uint8[] bs) cil managed
{
  // コード サイズ       96 (0x60)
  .maxstack  3
  .locals init (uint8& pinned V_0,
           uint8[] V_1,
           valuetype SetupRequestData V_2)

  // fixed 前処理
  IL_0000:  ldarg.0
  IL_0001:  dup
  IL_0002:  stloc.1
  IL_0003:  brfalse.s  IL_000a
  IL_0005:  ldloc.1
  IL_0006:  ldlen
  IL_0007:  conv.i4
  IL_0008:  brtrue.s   IL_000f
  IL_000a:  ldc.i4.0
  IL_000b:  conv.u
  IL_000c:  stloc.0
  IL_000d:  br.s       IL_0017
  IL_000f:  ldloc.1
  IL_0010:  ldc.i4.0
  IL_0011:  ldelema    [System.Runtime]System.Byte
  IL_0016:  stloc.0

  // fixed ブロック内
  IL_0017:  ldloc.0
  IL_0018:  conv.i
  IL_0019:  ldloca.s   V_2
  IL_001b:  initobj    SetupRequestData
  IL_0021:  ldloca.s   V_2
  IL_0023:  ldsfld     bool [System.Runtime.Extensions]System.BitConverter::IsLittleEndian
  IL_0028:  brtrue.s   IL_002e
  IL_002a:  ldc.i4.s   66
  IL_002c:  br.s       IL_0030
  IL_002e:  ldc.i4.s   108
  IL_0030:  stfld      uint8 SetupRequestData::ByteOrder
  IL_0035:  ldloca.s   V_2
  IL_0037:  ldc.i4.s   11
  IL_0039:  stfld      uint16 SetupRequestData::ProtocolMajorVersion
  IL_003e:  ldloca.s   V_2
  IL_0040:  ldc.i4.0
  IL_0041:  stfld      uint16 SetupRequestData::ProtocolMinorVersion
  IL_0046:  ldloca.s   V_2
  IL_0048:  ldc.i4.0
  IL_0049:  stfld      uint16 SetupRequestData::LengthOfAuthorizationProtocolName
  IL_004e:  ldloca.s   V_2
  IL_0050:  ldc.i4.0
  IL_0051:  stfld      uint16 SetupRequestData::LengthOfAuthorizationProtocolData
  IL_0056:  ldloc.2
  IL_0057:  stobj      SetupRequestData // V_2 を V_0 のアドレスにコピー

  // fixed 後処理
  IL_005c:  ldc.i4.0
  IL_005d:  conv.u
  IL_005e:  stloc.0
  IL_005f:  ret
} // end of method TanoshiiUnsafe::FixedInitializer

このコードを見ると fixed がどのように動いているのかわかりますね。ローカル変数 V_0pinned 属性がついているので、この変数に入っているアドレスは GC で動かせなくなります。そこで、 fixed ブロックに入るときに V_0 に引数 bs の 0 番目の要素のアドレスが代入され、 fixed ブロックを出るときには 0 (null) が代入されることで、アドレス固定する必要がなくなったことを通知しています。

さて、早すぎる最適化というわけだし、ブログだし、もう少し張り切って JIT コンパイル結果も見てみましょう。皆さんのお手元にはもちろん CoreCLR のソースコードがあると思いますので、 Debug ビルドして Viewing JIT Dumps に従って COMPlus_JitDisasm 環境変数を設定してアセンブリを吐かせてみましょう。

; Assembly listing for method TanoshiiUnsafe:FixedInitializer(ref)
; Emitting BLENDED_CODE for X64 CPU with SSE2
; optimized code
; rsp based frame
; partially interruptible
; Final local variable assignments
;
;  V00 arg0         [V00,T00] (  3,  3   )     ref  ->  rcx         class-hnd
;  V01 loc0         [V01    ] (  5,  3.50)   byref  ->  [rsp+0x30]   must-init pinned
;  V02 loc1         [V02,T01] (  6,  4   )     ref  ->  rcx         class-hnd
;  V03 loc2         [V03    ] (  7,  7   )  struct (16) [rsp+0x20]   do-not-enreg[XSFB] must-init addr-exposed ld-addr-op
;  V04 tmp0         [V04,T06] (  3,  2   )    long  ->  rsi        
;  V05 tmp1         [V05,T04] (  3,  2   )   byref  ->  rdi        
;  V06 tmp2         [V06,T07] (  3,  2   )    long  ->  rsi        
;  V07 tmp3         [V07,T05] (  3,  2   )   byref  ->  rdi        
;  V08 tmp4         [V08,T08] (  3,  2   )     int  ->  rax        
;* V09 tmp5         [V09    ] (  0,  0   )    long  ->  zero-ref   
;* V10 tmp6         [V10    ] (  0,  0   )    long  ->  zero-ref   
;* V11 tmp7         [V11    ] (  0,  0   )    long  ->  zero-ref   
;* V12 tmp8         [V12    ] (  0,  0   )    long  ->  zero-ref   
;* V13 tmp9         [V13    ] (  0,  0   )    long  ->  zero-ref   
;  V14 tmp10        [V14,T02] (  2,  4   )    long  ->  rsi        
;  V15 OutArgs      [V15    ] (  1,  1   )  lclBlk (32) [rsp+0x00]  
;  V16 cse0         [V16,T03] (  6,  3   )     int  ->  rdx        
;
; Lcl frame size = 56

G_M53718_IG01:
       57                   push     rdi
       56                   push     rsi
       4883EC38             sub      rsp, 56
       488BF1               mov      rsi, rcx
       488D7C2420           lea      rdi, [rsp+20H]
       B906000000           mov      ecx, 6
       33C0                 xor      rax, rax
       F3AB                 rep stosd 
       488BCE               mov      rcx, rsi

G_M53718_IG02:
       4885C9               test     rcx, rcx
       7407                 je       SHORT G_M53718_IG03
       8B5108               mov      edx, dword ptr [rcx+8]
       85D2                 test     edx, edx
       7509                 jne      SHORT G_M53718_IG04

G_M53718_IG03:
       33D2                 xor      rdx, rdx
       4889542430           mov      bword ptr [rsp+30H], rdx
       EB12                 jmp      SHORT G_M53718_IG05

G_M53718_IG04:
       83FA00               cmp      edx, 0
       0F8684000000         jbe      G_M53718_IG09
       4883C110             add      rcx, 16
       48894C2430           mov      bword ptr [rsp+30H], rcx

G_M53718_IG05:
       33C9                 xor      rcx, rcx
       488D542420           lea      rdx, bword ptr [rsp+20H]
       48890A               mov      qword ptr [rdx], rcx
       894A08               mov      dword ptr [rdx+8], ecx
       488B742430           mov      rsi, bword ptr [rsp+30H]
       488D7C2420           lea      rdi, bword ptr [rsp+20H]
       48B928308E1CFC7F0000 mov      rcx, 0x7FFC1C8E3028
       BA58000000           mov      edx, 88
       E8D4923E5F           call     CORINFO_HELP_GETSHARED_NONGCSTATIC_BASE
       803D8632E8FF00       cmp      byte  ptr [reloc classVar[0x7b306068]], 0
       7507                 jne      SHORT G_M53718_IG06
       B842000000           mov      eax, 66
       EB05                 jmp      SHORT G_M53718_IG07

G_M53718_IG06:
       B86C000000           mov      eax, 108

G_M53718_IG07:
       8807                 mov      byte  ptr [rdi], al
       66C74424220B00       mov      word  ptr [rsp+22H], 11
       66C74424240000       mov      word  ptr [rsp+24H], 0
       66C74424260000       mov      word  ptr [rsp+26H], 0
       66C74424280000       mov      word  ptr [rsp+28H], 0
       488B442420           mov      rax, qword ptr [rsp+20H]
       488906               mov      qword ptr [rsi], rax
       8B442428             mov      eax, dword ptr [rsp+28H]
       894608               mov      dword ptr [rsi+8], eax
       33C0                 xor      rax, rax
       4889442430           mov      bword ptr [rsp+30H], rax

G_M53718_IG08:
       4883C438             add      rsp, 56
       5E                   pop      rsi
       5F                   pop      rdi
       C3                   ret      

G_M53718_IG09:
       E8CFE9E75E           call     CORINFO_HELP_RNGCHKFAIL
       CC                   int3     

; Total bytes of code 194, prolog size 26 for method TanoshiiUnsafe:FixedInitializer(ref)

Q. 読めるか?
A. 読めない

ざっくり説明すると、 G_M53718_IG01 ~ G_M53718_IG04 のブロックで fixed の前処理をして、 G_M53718_IG05 の最初 4 行で構造体の領域をゼロクリアして、そこから先は構造体の各フィールドへの代入。 G_M53718_IG07 の 6 ~ 9 行目で構造体の中身を V_0 のアドレスにコピーして、次の 2 行で fixed の後処理をしています。 IL そのまんまですね。

ここで生成されたコードで気づいた点が 2 つ。

  1. 構造体は rsp+0x20 に書き込まれたあと代入先アドレス(V_0)にコピーされているが、最初から V_0 に書き込めばよくない?
  2. pinned のためにメモリに書き込んでるのつらくない?

というわけで、ひとつずつ解消していきましょう。

初期化子を使うのをやめる

初期化子を使うと別のローカル変数が用意されてしまうことがわかったので、愚直に代入しまくるようにします。生成されたデータを安全側に倒すためにゼロクリアはするようにします。

public static unsafe void FixedAssign(byte[] bs)
{
    fixed (byte* p = bs)
    {
        var req = (SetupRequestData*)p;
        *req = default;
        req->ByteOrder = BitConverter.IsLittleEndian ? (byte)0x6c : (byte)0x42;
        req->ProtocolMajorVersion = 11;
        req->ProtocolMinorVersion = 0;
        req->LengthOfAuthorizationProtocolName = 0;
        req->LengthOfAuthorizationProtocolData = 0;
    }
}

-> 演算子が出てくると一気に C# 感がなくなりますね。とにかく、これで構造体のコピーはなくなっているはずです。 IL を見てみましょう。

.method public hidebysig static void  FixedAssign(uint8[] bs) cil managed
{
  // コード サイズ       85 (0x55)
  .maxstack  2
  .locals init (uint8& pinned V_0,
           uint8[] V_1,
           valuetype SetupRequestData* V_2)

  // fixed 前処理
  IL_0000:  ldarg.0
  IL_0001:  dup
  IL_0002:  stloc.1
  IL_0003:  brfalse.s  IL_000a
  IL_0005:  ldloc.1
  IL_0006:  ldlen
  IL_0007:  conv.i4
  IL_0008:  brtrue.s   IL_000f
  IL_000a:  ldc.i4.0
  IL_000b:  conv.u
  IL_000c:  stloc.0
  IL_000d:  br.s       IL_0017
  IL_000f:  ldloc.1
  IL_0010:  ldc.i4.0
  IL_0011:  ldelema    [System.Runtime]System.Byte
  IL_0016:  stloc.0

  // fixed ブロック内
  IL_0017:  ldloc.0
  IL_0018:  conv.i
  IL_0019:  stloc.2 // これで byte* が SetupRequestData* に変換されたことになってる
  IL_001a:  ldloc.2
  IL_001b:  initobj    SetupRequestData // ゼロクリア
  IL_0021:  ldloc.2
  IL_0022:  ldsfld     bool [System.Runtime.Extensions]System.BitConverter::IsLittleEndian
  IL_0027:  brtrue.s   IL_002d
  IL_0029:  ldc.i4.s   66
  IL_002b:  br.s       IL_002f
  IL_002d:  ldc.i4.s   108
  IL_002f:  stfld      uint8 SetupRequestData::ByteOrder
  IL_0034:  ldloc.2
  IL_0035:  ldc.i4.s   11
  IL_0037:  stfld      uint16 SetupRequestData::ProtocolMajorVersion
  IL_003c:  ldloc.2
  IL_003d:  ldc.i4.0
  IL_003e:  stfld      uint16 SetupRequestData::ProtocolMinorVersion
  IL_0043:  ldloc.2
  IL_0044:  ldc.i4.0
  IL_0045:  stfld      uint16 SetupRequestData::LengthOfAuthorizationProtocolName
  IL_004a:  ldloc.2
  IL_004b:  ldc.i4.0
  IL_004c:  stfld      uint16 SetupRequestData::LengthOfAuthorizationProtocolData

  // fixed 後処理
  IL_0051:  ldc.i4.0
  IL_0052:  conv.u
  IL_0053:  stloc.0
  IL_0054:  ret
} // end of method TanoshiiUnsafe::FixedAssign

SetupRequestData を触るコードを書いても、 SetupRequestData* を触るコードを書いてもほぼ同じコードが生成されることがわかると思います。構造体のフィールドアクセスには構造体のアドレスが必要なので、 C# 上では全然違うような気がしても、 IL になってしまえば同じということですね。

というわけで無駄なコピーがなくなったのに他の部分は変わっていないのでスッキリしました。

一応参考のためというか取得しておいたので JIT コンパイル結果も貼っておきます。

; Assembly listing for method TanoshiiUnsafe:FixedAssign(ref)
; Emitting BLENDED_CODE for X64 CPU with SSE2
; optimized code
; rsp based frame
; partially interruptible
; Final local variable assignments
;
;  V00 arg0         [V00,T01] (  3,  3   )     ref  ->  rcx         class-hnd
;  V01 loc0         [V01    ] (  5,  3.50)   byref  ->  [rsp+0x20]   must-init pinned
;  V02 loc1         [V02,T02] (  6,  4   )     ref  ->  rax         class-hnd
;  V03 loc2         [V03,T00] (  8,  8   )    long  ->  rax        
;* V04 tmp0         [V04,T05] (  0,  0   )    long  ->  zero-ref   
;* V05 tmp1         [V05,T06] (  0,  0   )    long  ->  zero-ref   
;* V06 tmp2         [V06,T07] (  0,  0   )     int  ->  zero-ref   
;  V07 tmp3         [V07,T03] (  2,  4   )    long  ->  rax        
;  V08 OutArgs      [V08    ] (  1,  1   )  lclBlk (32) [rsp+0x00]  
;  V09 cse0         [V09,T04] (  6,  3   )     int  ->  rdx        
;
; Lcl frame size = 40

G_M5773_IG01:
       4883EC28             sub      rsp, 40
       33C0                 xor      rax, rax
       4889442420           mov      qword ptr [rsp+20H], rax

G_M5773_IG02:
       488BC1               mov      rax, rcx
       4885C0               test     rax, rax
       7407                 je       SHORT G_M5773_IG03
       8B5008               mov      edx, dword ptr [rax+8]
       85D2                 test     edx, edx
       7509                 jne      SHORT G_M5773_IG04

G_M5773_IG03:
       33D2                 xor      rdx, rdx
       4889542420           mov      bword ptr [rsp+20H], rdx
       EB0E                 jmp      SHORT G_M5773_IG05

G_M5773_IG04:
       83FA00               cmp      edx, 0
       763D                 jbe      SHORT G_M5773_IG07
       4883C010             add      rax, 16
       4889442420           mov      bword ptr [rsp+20H], rax

G_M5773_IG05:
       488B442420           mov      rax, bword ptr [rsp+20H]
       33D2                 xor      rdx, rdx
       488910               mov      qword ptr [rax], rdx
       895008               mov      dword ptr [rax+8], edx
       C6006C               mov      byte  ptr [rax], 108
       66C740020B00         mov      word  ptr [rax+2], 11
       66C740040000         mov      word  ptr [rax+4], 0
       66C740060000         mov      word  ptr [rax+6], 0
       66C740080000         mov      word  ptr [rax+8], 0
       33C0                 xor      rax, rax
       4889442420           mov      bword ptr [rsp+20H], rax

G_M5773_IG06:
       4883C428             add      rsp, 40
       C3                   ret      

G_M5773_IG07:
       E846E9E75E           call     CORINFO_HELP_RNGCHKFAIL
       CC                   int3     

; Total bytes of code 107, prolog size 11 for method TanoshiiUnsafe:FixedAssign(ref)

各フィールドに代入し終わった後の 2 回の mov がなくなって、実際にコピーがなくなったことが確認できました。あと BitConverter.IsLittleEndian の判定コードがなくなってハードコーディングされていますが、これはコンパイル順の問題のようで、 FixedAssignコンパイルする前に FixedInitializer を実行しているので BitConverter.IsLittleEndian の値が確定しているからっぽいです。 readonly フィールドが絡むと後からコンパイルしたほうが有利なんですね。

fixed をやめる

次は fixed を使っていることによって、レジスタだけで済みそうな配列ポインタがメモリに保存されているのを解消しましょう。ここでついに System.Runtime.CompilerServices.Unsafe の登場です。 Unsafe クラスには次のメソッドがあります。

public static ref TTo As<TFrom, TTo>(ref TFrom source);

これで何ができるかというと、ある参照を別の型の参照として扱うことができます。ソースコードを見るととてもイカしていることがわかると思います。

ここでは bs[0]、つまり byte 型の参照を SetupRequestData の参照に変換してしまえば、 fixed しなくても配列の中身をいじくれるという算段です。

fixed を使わないことにすると GC で配列が移動されたときに参照が追従されるのか不安でしたが、 Unsafe.As を通した参照もちゃんと書き換わっていることが確認できました。

というわけで、 Unsafe.As を使って書いてみましょう。

public static void AsAssign(byte[] bs)
{
    ref var req = ref Unsafe.As<byte, SetupRequestData>(ref bs[0]);
    req = default;
    req.ByteOrder = BitConverter.IsLittleEndian ? (byte)0x6c : (byte)0x42;
    req.ProtocolMajorVersion = 11;
    req.ProtocolMinorVersion = 0;
    req.LengthOfAuthorizationProtocolName = 0;
    req.LengthOfAuthorizationProtocolData = 0;
}

C# らしいコードに戻ってきましたね。ついでに unsafe キーワードが要らなくなりました。 Unsafe クラスを使っているのでどっちもどっちですが。

それでは IL を見てみましょう。

.method public hidebysig static void  AsAssign(uint8[] bs) cil managed
{
  // コード サイズ       67 (0x43)
  .maxstack  3
  IL_0000:  ldarg.0
  IL_0001:  ldc.i4.0
  IL_0002:  ldelema    [System.Runtime]System.Byte
  IL_0007:  call       !!1& [System.Runtime.CompilerServices.Unsafe]System.Runtime.CompilerServices.Unsafe::As<uint8,valuetype SetupRequestData>(!!0&)
  IL_000c:  dup
  IL_000d:  initobj    SetupRequestData
  IL_0013:  dup
  IL_0014:  ldsfld     bool [System.Runtime.Extensions]System.BitConverter::IsLittleEndian
  IL_0019:  brtrue.s   IL_001f
  IL_001b:  ldc.i4.s   66
  IL_001d:  br.s       IL_0021
  IL_001f:  ldc.i4.s   108
  IL_0021:  stfld      uint8 SetupRequestData::ByteOrder
  IL_0026:  dup
  IL_0027:  ldc.i4.s   11
  IL_0029:  stfld      uint16 SetupRequestData::ProtocolMajorVersion
  IL_002e:  dup
  IL_002f:  ldc.i4.0
  IL_0030:  stfld      uint16 SetupRequestData::ProtocolMinorVersion
  IL_0035:  dup
  IL_0036:  ldc.i4.0
  IL_0037:  stfld      uint16 SetupRequestData::LengthOfAuthorizationProtocolName
  IL_003c:  ldc.i4.0
  IL_003d:  stfld      uint16 SetupRequestData::LengthOfAuthorizationProtocolData
  IL_0042:  ret
} // end of method TanoshiiUnsafe::AsAssign

Release ビルドのおかげか、なんとローカル変数が 1 つもない!でも dup で持って回っているだけなので、やっていることは同じだということはわかるでしょう。しかし面倒なことがすべて call に詰まっているおかげで、見た目はかなりスッキリしました。でも逆に call があることで遅くならない?と思うかもしれませんが、 Unsafe.Asaggressiveinlining 属性がついているので JIT コンパイル時には展開されるはずです。実際にどうなったか見てみましょう。

; Assembly listing for method TanoshiiUnsafe:AsAssign(ref)
; Emitting BLENDED_CODE for X64 CPU with SSE2
; optimized code
; rsp based frame
; partially interruptible
; Final local variable assignments
;
;  V00 arg0         [V00,T01] (  6,  6   )     ref  ->  rcx         class-hnd
;* V01 tmp0         [V01    ] (  0,  0   )   byref  ->  zero-ref   
;  V02 tmp1         [V02,T03] (  2,  2   )   byref  ->  rax        
;  V03 tmp2         [V03,T04] (  2,  2   )   byref  ->  rax        
;  V04 tmp3         [V04,T00] (  7,  7   )   byref  ->  rax        
;* V05 tmp4         [V05,T05] (  0,  0   )   byref  ->  zero-ref   
;* V06 tmp5         [V06,T06] (  0,  0   )     int  ->  zero-ref   
;  V07 tmp6         [V07,T02] (  4,  8   )   byref  ->  rax        
;  V08 OutArgs      [V08    ] (  1,  1   )  lclBlk (32) [rsp+0x00]  
;
; Lcl frame size = 40

G_M39466_IG01:
       4883EC28             sub      rsp, 40

G_M39466_IG02:
       83790800             cmp      dword ptr [rcx+8], 0
       762C                 jbe      SHORT G_M39466_IG04
       488D4110             lea      rax, bword ptr [rcx+16]
       33D2                 xor      rdx, rdx
       488910               mov      qword ptr [rax], rdx
       895008               mov      dword ptr [rax+8], edx
       C6006C               mov      byte  ptr [rax], 108
       66C740020B00         mov      word  ptr [rax+2], 11
       66C740040000         mov      word  ptr [rax+4], 0
       66C740060000         mov      word  ptr [rax+6], 0
       66C740080000         mov      word  ptr [rax+8], 0

G_M39466_IG03:
       4883C428             add      rsp, 40
       C3                   ret      

G_M39466_IG04:
       E8F5E8E75E           call     CORINFO_HELP_RNGCHKFAIL
       CC                   int3     

; Total bytes of code 60, prolog size 4 for method TanoshiiUnsafe:AsAssign(ref)

前処理は G_M39466_IG02 1,2 行目の配列の境界チェック(bs[0] なので 1 件以上ないといけない)だけ、あとはゼロクリアとフィールド代入ですね。すごいぞ、完璧だ。

ベンチマーク

完璧だとは言いましたが、生成されたコードを見て喜んでいても机上の空論なわけです。パフォーマンスは測定が命、というわけで BenchmarkDotNet でベンチマークしてみた結果がこちらです。コンパイル順によって生成されるコードが違うという話をしましたが、 BenchmarkDotNet はベンチマークごとにビルドからやり直しているので多分影響はないでしょう。詳しくないのでわかりませんが。

BenchmarkDotNet=v0.10.9, OS=Windows 10.0.17004
Processor=Intel Core i7-3770 CPU 3.40GHz (Ivy Bridge), ProcessorCount=8
Frequency=3312784 Hz, Resolution=301.8609 ns, Timer=TSC
.NET Core SDK=2.0.0
  [Host]     : .NET Core 2.0.0 (Framework 4.6.00001.0), 64bit RyuJIT
  DefaultJob : .NET Core 2.0.0 (Framework 4.6.00001.0), 64bit RyuJIT
MethodMeanErrorStdDev
FixedInitializer12.554 ns0.0515 ns0.0456 ns
FixedAssign5.326 ns0.0756 ns0.0707 ns
AsAssign4.714 ns0.0338 ns0.0316 ns

ちゃんと狙った通りの速度順になっていますね。良かった。

おわり

というわけで、 C# 7 の refSystem.Runtime.CompilerServices.Unsafe を組み合わせると、今まで遠回りしないと実現できなかったコードが狙った通りに書けるようになり、コンパイルされるようになります。これは気持ちいい。完全に早すぎる最適化の味方ですね!(早すぎる最適化をやめろ)