ねののお庭。

かりかりもふもふ。

【C#】zero-byte reads という最適化とピン留めについて。

現代の .NET では "zero-byte reads" という最適化が随所で行われています。 この記事ではその "zero-byte reads" とはなんなのか、という事についてつらつら書いていこうと思います。 そしてそれに深く絡むピン留めのお話も。

どんな最適化か。

端的にいうと、SocketStream がネイティブとデータのやりとりする際に、managed heap に確保されているメモリを長時間にわたりピン留めしないようにするための最適化です。

どういう事か。 たとえば C# の Socket.ReceiveAsync は Windows 上では Win32 API の WSARecv をラップしたような形になっています。 C# の Socket.ReceiveAsync には Memory<byte> などのバッファを渡します。 Win32 API の WSARecv には受信したデータを書き込むバッファへのポインタと、処理が完了したときに呼び出されるコールバックのポインタを渡します。 要するに C# の Socket.ReceiveAsync で受け取ったバッファは WSARecv に渡されるわけです。 Socket.ReceiveAsync には大概 managed heap で確保したバッファを渡しますが、managed heap で確保したバッファという事は当然ながら GC の対象です。 そして GC の対象という事はメモリ上で移動する可能性があるため、ネイティブに渡している間はピン留めする必要があります。 つまり WSARecv の処理が完了されるまで、そのバッファはピン留めされたままになる他ないのです。 これがつらい。主につらいポイントはふたつ。

  • 同期的なピン留めではなく、非同期なピン留め。
    • GC に優しくない。詳細は後述。
  • 長時間にわたるピン留めによるメモリの断片化。

この問題を回避するための最適化が "zero-byte reads" です。 "zero-byte reads" では、 「空のバッファを渡した場合、読み取り可能なデータが存在するまで待機し、読み取り可能なデータが存在するようになれば完了する。そしてデータは一切消費しない(バッファに読み込まない)」 という挙動をします。

つまるところ、 「データの読み込みが完了するまで待機」を 「1. データの読み取りが可能になるまで待機」と「2. データの読み込み」の2つの工程に分解したという事です。

Socket の例に特化してもう少し具体的に書くと、 「カーネルのデータ (=受信したデータ) が読み取り可能になり、そのデータを managed heap に確保したバッファに読み込み (=コピー)、それが完了するまで待機」を 「1. カーネルのデータの読み取りが可能になるまで待機」と「2. カーネルのデータを managed heap に確保したバッファへ読み込み」に分解するということです。

このように分解する事で、長時間ピン留めされた巨大なバッファが発生する事もなくなり、GC 負荷が下がり、メモリの断片化が発生しなくなるため何かと嬉しいわけです。

なおこの最適化は、.NET 「内部」で多用されている最適化であり、.NET のバージョンを上げるだけで内部的 "zero-byte reads" で最適化されている箇所が増えるため、高速化が期待できます。

また "zero-byte reads" は .NET runtime を Windows で動かす場合向けの最適化であり、Linux 上で動かす場合は関係ないものだったりします。 何故なら、.NET runtime は Linux 上で動く場合 epoll を用いるわけですが、epoll を使う場合は自然と「1. データの読み取りが可能になるまで待機」と「2. データの読み込み」の2つの工程に分かれる事になるからです。

ピン留め手段と GC 負荷。

さて、疑問に思う人は思うのではないでしょうか? 「どちらにせよネイティブからコピーするためにピン留めはするのでしょう?同期だろうと非同期だろうとピン留めはピン留めなのだからそんなに負荷変わらないのでは?」と。

ピン留めする手段は幾つかありますが、実質的には以下の2つです。

  • fixed statement
  • GCHandle.Alloc()

BCL の API 的には別のものもあったりするのですが(Memory<T>.Pin() 等)、それらは内部的に GCHandle.Alloc() を使っていたりするので、実質上記2つという感じ。

使い分けは簡単で、同期であれば fixed statement、非同期であれば GCHandle.Alloc()

気になるのはこれらの使い分けと、どちらがパフォーマンス上良いの?という事。 パフォーマンス上好ましいのは fixed statement です。 fixed statement の方が好ましいというお話は Performance Improvements in .NET 8dotnet/runtime の issue や PR でさんざん語られています。

"zero-byte reads" では「データの読み込みが完了するまで待機」という一つの非同期の操作を 「① データの読み取りが可能になるまで待機」という非同期の操作と「② データの読み込み」という同期の操作の2つの工程に分解しました。 なので ① の待機時はピン留めは不要となり(空のバッファをピン留めする必要はないため)、② のデータの読み込みは同期で済むため fixed statement が使えるようになりました。 なので完全に GCHandle.Alloc() を排除出来た形となり、嬉しい。

で、同じピン留めなのになんで fixed statement の方が好ましいのか?という点については、(少なくとも自分の知る限りでは)明確に記述されている公式ドキュメントは無さそうです。 fixed statement の方が好ましい!というのは至るところで dotnet の中の人たちが言っているのですけどね...理由までは語られていない...。 しかしどうして好ましいのか、という点は気になるのでもうちょっと深堀りましょう。

とりあえず、fixed statement と GCHandle.Alloc() のそれぞれがどんな IL を吐き出すのか見てみましょう。 まずは fixed statement を使った以下のようなコードを書いたとします。

class C
{
    unsafe void M(byte[] bytes)
    {
        fixed (byte* ptr = bytes)
        {
            ptr[2] = 99;
        }
    }
}

すると IL はこんなのが出てきます。

.class private auto ansi beforefieldinit C
    extends [System.Runtime]System.Object
{
    // Methods
    .method private hidebysig 
        instance void M (
            uint8[] bytes
        ) cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 01 00 00
        )
        // Method begins at RVA 0x20a0
        // Code size 33 (0x21)
        .maxstack 2
        .locals init (
            [0] uint8* ptr,
            [1] uint8[] pinned // <- 注目!
        )

        // いろいろ省略

    } // end of method C::M

    // いろいろ省略

} // end of class C

一方以下のように GCHandle.Alloc() を使う C# コードを書いた場合は....

class C
{
    unsafe void M(byte[] bytes)
    {
        var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
        var ptr = (byte*)GCHandle.ToIntPtr(handle);
        ptr[2] = 99;
        handle.Free();
    }
}

このような IL が出力されます。

.class private auto ansi beforefieldinit C
    extends [System.Runtime]System.Object
{
    // Methods
    .method private hidebysig 
        instance void M (
            uint8[] bytes
        ) cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 01 00 00
        )
        // Method begins at RVA 0x20a0
        // Code size 29 (0x1d)
        .maxstack 2
        .locals init (
            [0] valuetype [System.Runtime]System.Runtime.InteropServices.GCHandle handle,
            [1] uint8* ptr
        )

        // いろいろ省略

    } // end of method C::M

    // いろいろ省略

} // end of class C

大きな違いは IL に pinned が現われているかどうか。 IL にこのメソッドではピン留めしてるやつがいるぞ、という情報があれば確かに runtime は効率的な振る舞いが出来そうな気がしてくる。 とはいえ、そんな気がしてくるだけかもしれないので、もうちょっと確かな情報を求め探ってみます。

pinned については、ECMA の CLI の仕様に記述されているところがあるのですが、こんな事が書かれています。

The signature encoding for pinned shall appear only in signatures that describe local variables (§II.15.4.1.3). While a method with a pinned local variable is executing, the VES shall not relocate the object to which the local refers. That is, if the implementation of the CLI uses a garbage collector that moves objects, the collector shall not move objects that are referenced by an active pinned local variable. [Rationale: If unmanaged pointers are used to dereference managed objects, these objects shall be pinned. This happens, for example, when a managed object is passed to a method designed to operate with unmanaged data. end rationale]

まぁ当然といえば当然な事しか書いていないのですが、重要なのはローカル変数にのみ適用されるという事でしょう。 GCHandle.Alloc() と違って fixed statement でピン留めしているやつは必ずスタックに存在して、それがピン留めされているという情報が pinned 識別子によって残りますから、スタックをもとに判断可能という事になります。 これは確かに GC ビリティが向上しそうな気がしますね。

Q「気がしますね、でお茶を濁すつもりか?」
A「これ以上は深淵すぎて確証が得られなかったから...」

これ以上の真の確証を得たくば runtime の実装(しかも GC の実装)を読め!というスーパーハードコアな深淵に突入してしまうので(だってそれらしいドキュメント無いんだもの、実装読むしかないじゃん...!)、そこまでは踏み込まない事としておきます。 GC の実装読むのは標準ライブラリの実装読むのとは難易度が違い過ぎる...。

まぁでも fixed statement の方が runtime にとって都合が良さそうな理由 (=効率的に扱える理由) の雰囲気はつかめたのではないでしょうか。

まとめ。

  • "zero-byte reads" という最適化を紹介
    • 要するに「データの読み込みが完了するまで待機(非同期)」を「1. データの読み取りが可能になるまで待機(非同期)」と「2. データの読み込み(同期)」の2つの工程に分解したよという最適化
  • ピン留めしたい場合 fixed statement 推奨
    • fixed statement の方が runtime に優しい。
    • 非同期にピン留めしたいなら GCHandle.Alloc() を使う他ないけど避けられるなら避けよう。

ちなみに、深淵そのものなコードを覗かなくても確証が得られるドキュメントとか存在していたら是非教えて欲しい。 コメント欄でも twitter X でのどこでもいいので何卒...!

豆知識。

これは豆知識というか完全に裏仕様な感じですが、interop で C# の Span<T> をネイティブに渡す場合 Span は自動的に fixed されるようなので、明示的な fixed は不要みたいです。

References