ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(C#) メモリ確保ベンチマーク 6種盛り

バッファの確保用にnew byte[N]なんて書いたらモテませんよ。とはいえ正直確保するバイト数次第。ベンチマーク見ましょう。

メモリ確保 6種盛り

メモリ確保(+破棄)の方法を6種用意した。

// (1)  new
byte[] MarshalAlloc()
{
    return new byte[N];
}

// (2)  ArrayPool
void SharedPool()
{
    var array = ArrayPool<byte>.Shared.Rent(N);
    ArrayPool<byte>.Shared.Return(array);
}

// (3)  Marshal から確保
void MarshalAlloc()
{
    var array = Marshal.AllocHGlobal(N);
    Marshal.FreeHGlobal(array);
}

// (4)  (3) + Memory Pressure
void MarshalAlloc_WithGCPressure()
{
    var array = Marshal.AllocHGlobal(N);
    GC.AddMemoryPressure(N);
    Marshal.FreeHGlobal(array);
    GC.RemoveMemoryPressure(N);
}

// (5)  C++ の "std::malloc", "std::free" の呼び出し
void NativeAlloc()
{
    var array = NativeAllocator.Alloc(N);
    NativeAllocator.Free(array);
}

// (6)  (5) + Memory Pressure
void NativeAlloc_WithGCPressure()
{
    var array = NativeAllocator.Alloc(N);
    GC.AddMemoryPressure(N);
    NativeAllocator.Free(array);
    GC.RemoveMemoryPressure(N);
}

まず(1)は普通のマネージドな配列です。これをベースに他の方法の速度を比較していきます。 (2)はArrayPool<T>.Sharedからの配列取得と返却。 (3)と(4)はMarshal.AllocHGlobal(int)からのアンマネージドメモリの取得と解放で、(4)は GC に MemoryPressure をかけています。 (5)と(6)はアンマネージドメモリの確保を、C++std::mallocstd::freeするだけのdllを用意して P/Invoke しています。(3)と(4)との速度比較のために用意しました。

ベンチマーク結果

ベンチマークは BenchmarkDotNet で測定。環境は .Net Framework 4.8 (x86) です。なんで .NET Core 3.1 (x64) じゃないんだよとツッコミを入れられそうですが、たまたまローカルに転がってた過去のベンチマーク用のプログラムを適当に流用して測定したためです。Core 3.1 で測定したら全体的に1割程度は速くなりそうな気がしますが、各手法の速度比はあまり変わらない(はず)なので許して。

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.18362.778 (1903/May2019Update/19H1)
Intel Core i5-4300U CPU 1.90GHz (Haswell), 1 CPU, 4 logical and 2 physical cores
  [Host]     : .NET Framework 4.8 (4.8.4121.0), X86 LegacyJIT
  DefaultJob : .NET Framework 4.8 (4.8.4121.0), X86 LegacyJIT

f:id:ikorin2:20200513022105p:plain

(1) が線形、(2)がN=2^20までは定数なのは想定通り。(2)のArrayPool<T>はそこまで高速な処理ではないのでN=1024程度まではnew byte[N]に単純な速度では負けることが分かります。ただしガベージは0。 ArrayPool<T>N=2^20まではプールされているが、N=2^20+1からはプールされない配列を新たに生成しているため、速度が約千倍に低下していることがわかります。(プールされないためガベージになる上、85000 byte 以上なので LOH (Large Object Heap) に入り gen2 になる最悪のシナリオを踏んでいる)

(3)と(5)、および(4)と(6)はほぼ同じ動きをしており、Marshal.AllocHGlobalの中身がほぼstd::mallocと同じであると推測できます。Marshal.AllocHGlobalの中身を追うと、windows の場合 P/Invokekernel32.dllLocalAllocを呼んでいます。一方std::mallocコンパイルされたときにどうなるか正直詳しく知らないが、ベンチマークのグラフがぴったりシンクロしているの見る限りほぼ同じことをしていると思う。

(4)と(6)が(3)と(5)に比べて明らかに遅いのは、単純に処理が増えているのと、MemoryPressureがGCを誘発しているからです。

C#のレイヤーからだけ見るとアンマネージドメモリの確保も (1) と同様線形になりそうな気がするが、アンマネージドメモリはOSのメモリ管理の影響を直に受けるのでそんな単純にはいかない模様。ベンチマークを見る限りはN=2^14程度までは定数、N=2^19程度以上ではほぼ線形になってる感じがする。

最適解

  • そんなもんはない。

最適解なんてものがあれば誰もメモリ管理で苦労しない。各種方法の特性を適切に覚えておいて柔軟に使い分けしましょうねという話。しかしあまりに行き過ぎると目的に応じて自作GCに片足突っ込む不毛な沼になりかねないので、まあ柔軟に対応しましょう。大抵の目的なら .NETGC は十分高性能だし gen0 の回収は非常に速い。ただし LOH に配列確保するのはやめましょうねぇ~

余談 (Unity)

Unity の場合って GC はどうなってるんですかね。2017年ぐらいの時点ではUnityのランタイムのGCは世代別GCになってなくて、毎回フルGCが走るとかいうドン引き仕様になっていた気がするんですが、2020年現在変わってたりするんですかね。

[追記 2020/06/08]

UnityのGCは特に世代別に変わったわけではないし、実際世代別だから高性能でいいって訳でもない。とはいえUnityも一気にGCが走ることは問題になりうると認識してるのでインクリメンタルGCを取り入れた。Unity2019.1a10からインクリメンタルGCは実験的機能として実装されているが、将来的にはこちらをメインに据えたい模様。

ゲームとしての使用方法を考えると各フレームに負荷を分散させることで1回あたりのStop the Worldを軽減させるのが目的なので正しいのかなとは思う。 現在のBoehm GCをそのままにインクリメンタルGCを取り入れた理由や、他のGCアルゴリズムを採用しなかった理由については公式から一応説明が出ている。

https://blogs.unity3d.com/jp/2018/11/26/feature-preview-incremental-garbage-collection/

簡単に言うとGCアルゴリズム変更のコストとパフォーマンスを加味した結果、インクリメンタルGCにしたっぽい。

インクリメンタルGCについて特にここで解説はしないが、Mark and Sweep の処理を一気にせず細切れにすることで停止時間を分散させるもの。なので総停止時間自体は変わらない (write barrierの分多少はオーバーヘッドは増える)