さぁ 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::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
を通した参照もちゃんと書き換わっていることが確認できました。
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::AsAssign
Release ビルドのおかげか、なんとローカル変数が 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 |
ちゃんと狙った通りの速度順になっていますね。良かった。