さぁ fixed を捨てて Unsafe だ

早すぎる最適化が好きな人のための 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_0 は pinned 属性がついているので、この変数に入っているアドレスは 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 つ。
- 構造体は
rsp+0x20に書き込まれたあと代入先アドレス(V_0)にコピーされているが、最初からV_0に書き込めばよくない? - 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::FixedAssignSetupRequestData を触るコードを書いても、 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 を通した参照もちゃんと書き換わっていることが確認できました。
CLR のソース読むまでもなく実験してみればわかることだったんですけど、 Unsafe.As で型変えても GC でトラックされる https://t.co/WcTFf8goa1
— オタク老害卍 (@azyobuzin) 2017年9月29日
というわけで、 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::AsAssignRelease ビルドのおかげか、なんとローカル変数が 1 つもない!でも dup で持って回っているだけなので、やっていることは同じだということはわかるでしょう。しかし面倒なことがすべて call に詰まっているおかげで、見た目はかなりスッキリしました。でも逆に call があることで遅くならない?と思うかもしれませんが、 Unsafe.As は aggressiveinlining 属性がついているので 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
| Method | Mean | Error | StdDev |
|---|---|---|---|
| FixedInitializer | 12.554 ns | 0.0515 ns | 0.0456 ns |
| FixedAssign | 5.326 ns | 0.0756 ns | 0.0707 ns |
| AsAssign | 4.714 ns | 0.0338 ns | 0.0316 ns |
ちゃんと狙った通りの速度順になっていますね。良かった。