ネコのために鐘は鳴る

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

(C#) ランタイムが Blazor WebAssembly か否かを取得する

実行中のOS が何であるかを取得する時はSystem.Environment.OSVersion.Platformを使えそうかと思ってました。

using System;

// Windows 10 なら "Win32NT"
Console.WriteLine(Environment.OSVersion.Platform);

ところが、これを Mac .NET Core で取得すると"MacOSX"ではなく"Unix"と返ってきます。 それはともかく、MSDNを見ると何とも説明が古臭いです。SilverlightとかXboxとかはまだしも、"Win32S"っていつの時代だ……私生まれてない。(知らなくてググったら16bitのOSがうんぬんかんぬん)

deprecated が多すぎる。

時代は進んで Blazor なんていうものが出てきて、そもそもそんなものは想定されていない。Blazor WebAssembly (client-side のやつ) でこれを実行するとUnixと出てきます。自分のことを Unix だと思い込んでいる一般人かな。

 

代わりにSystem.Runtime.InteropServices.RuntimeInformation.OSDescriptionを使います。

using System.Runtime.InteropServices;

// Blazor WebAssembly で実行すると "web"
Console.WriteLine(RuntimeInformation.OSDescription);

ちゃんと"web"と出てきました。WebAssembly か否かが取得できました。

(C#) エンディアン固定でシリアライズ

今回のオチ

  • System.Buffers.Binary.BinaryPrimitives クラスを使いましょう

エンディアンを固定でシリアライズしたい

longの値をbyte[]にしたい時、普通はSystem.BitConverter.GetBytes(long)で困らないんですが、BitConverterエンディアンは固定じゃないんですよね。ランタイム依存です。普通はリトルエンディアンなんですが、一部 mono 環境なんかでビッグエンディアンな環境があるらしいんですよ、奥さん。

実行時にリトルエンディアン環境なのか、ビッグエンディアン環境なのかはBitConverter.IsLittleEndianプロパティで確認できます。ついでにこのプロパティは Intrinsic なので JIT 定数になり、if文を書くと JIT 時に分岐が消えます。愛してる定数。

たとえばファイルフォーマットなんかでエンディアンが決まってるもの扱う時に、BitConverterを使うと厳密にはランタイム依存で正しく動きません。雑に動けばいいならばBitConverterでいいんです、だって普通はリトルエンディアンだもん。

逆に TCP/IP なんかはビッグエンディアン固定です。(まあここは普通ライブラリ使うので直に書くことはないですが。)

 

長々と書いて何が言いたいのかというと、System.Buffers.Binary.BinaryPrimitivesを使うと、エンディアン固定で直列化できますよという話です。使い方はメソッド名見れば一目瞭然なんですが、例えばlongをリトルエンディアンでSpan<byte>にする時は

long value = 100L;
Span<byte> buf = stackalloc byte[8];
BinaryPrimitives.WriteInt64LittleEndian(buf, value);

です。ビッグエンディアン用のメソッドもちゃんとあります。

1つだけ残念なことは、BinaryPrimitivesクラスは .NET Standard 2.1, .NET Core 2.1 以降でしか使えないことです。 .NET Framework にはありません。だって Windows はリトルエンディアンだもん (2回目)。

(C#) static コンストラクタを手動で呼ぶ

方法だけ知りたい生き急いでいる人用

// using System.Runtime.CompilerServices;
RuntimeHelpers.RunClassConstructor(typeof(Hoge).TypeHandle);

以下説明

static コンストラクタが呼ばれない状況

C# の構造体やクラスには、インスタンスのコンストラクタとは別に、static コンストラクタを書くことができます。

public class Hoge
{
    // インスタンスコンストラクタ
    public Hoge() { ... }

    // static コンストラクタ
    static Hoge() { ... }
}

通常、static コンストラクタは、初めてインスタンスコンストラクタが呼ばれた時に最初の1回だけ実行されます。つまり、型に対して静的な初期化処理を書くことができます。

さらに、言語仕様として必ず1度のみ呼ばれることが保証されているためマルチスレッドにおいても排他処理などをする必要がなく、スレッドセーフに実行されます。

クラスの場合は最初のインスタンスnewされた時に必ず呼ばれますが、構造体の場合、実はインスタンスがあるにもかかわらず static コンストラクタが実行されない場合があります。

public struct MyData
{
    public int Value;

    // static コンストラクタ
    static MyData() => Console.WriteLine("static ctor called !!");
}

上記のような構造体があったとして、以下のコードは static コンストラクタが呼ばれません。

// Case 1
var array = new MyData[10];
Console.WriteLine(array[3].Value);  // Value : 0

// Case 2
var data = new MyData();
Console.WriteLine(data.Value);  // Value : 0

Case1 と Case 2 のどちらも、コンパイルが通って実行でき、MyData型のインスタンスが存在しているにもかかわらず static コンストラクタは実行されません。構造体なので、どちらのケースもメモリのゼロ初期化が行われているだけだからです。

ちなみにクラスの場合は、Case1は null で、インスタンスが存在していないため static コンストラクタが呼ばれないのは当然ですし、Case2 はきちんと呼ばれます。

構造体の静的な初期化処理を static コンストラクタで行っている場合、インスタンスを使う前に呼ばれていてほしいのに、これでは困るので手動で呼びたい。

手動で呼ぶ

以下の方法で手動で呼ぶことができます。

// using System.Runtime.CompilerServices;
RuntimeHelpers.RunClassConstructor(typeof(MyData).TypeHandle);

メソッド名がRunClassConstructorとか書かれていますが、構造体にも使えます。

この手動で呼ぶ方法は自動で呼ばれる条件と同じように、ただ1度のみスレッドセーフに実行されます。複数回呼んでも2回目以降は無視されます。また、既に自動で呼ばれた状態で実行しても、何も実行されません。

つまり、必ず static コンストラクタが呼ばれていてほしい場所の前にこれを書いておくと、実行されていることが保証できます。

(C#) ArrayPool<T>.Shared 解体新書

ArrayPool<T>.Shared

みなさんはSystem.Buffers.ArrayPool<T>.Shared使ってますか?使ってない?なら使いましょう。 ArrayPool<T>.Sharedは短期間だけ利用するようなバッファを貸してくれるものです。 new T[N]と違い、一度使った配列を使いまわすことができるのでガベージにならず、メモリ効率がよいです。

// Length = 20 以上のバッファを取得
byte[] array = ArrayPool<byte>.Shared.Rent(20);

// 借りたバッファを返す
ArrayPool<byte>.Shared.Return(array);

ArrayPool<T>のミソは、長さ20を要求した時に20以上の長さの配列が返ってくるのは保証されてますが、長さ20の配列が来るとは限らない点。要求した以上の長さの配列が返ってきても、目的はバッファなので困らないです。また、一つ注意点があり、ここで取得できる配列は0初期化されていないということです。たまたま全部0だった場合、あなたの今日のラッキーナンバーは0です。

ArrayPool<T>.Shared.Rentで取得できる配列の長さはランダムではなく実は決まっていて、先ほどの20を要求した場合は配列長32の配列が返ってきます。とりあえず、要求した長さ以上の長さの良い感じの配列が返ってきます。

「良い感じの長さ」という説明で納得できた方は、この記事をここまで読んでいただきありがとうございます、お疲れさまでした。ブラウザの戻るボタンを押していただいて大丈夫です。

納得できない方は続きをどうぞ。

内部実装

中がどうなっているのか、解体新書と名乗ったからにはArrayPool<T>の中身を見ます。 実際のソースコードは以下のリンクからどうぞ。

github.com

ArrayPool<T>.SharedインスタンスTlsOverPerCoreLockedStacksArrayPool<T>クラスで、重要なとこだけ分かりやすく抜き出すとこんな感じ。

class TlsOverPerCoreLockedStacksArrayPool<T> : ArrayPool<T>
{
    [ThreadStatic]
    private static T[]?[]? t_tlsBuckets= new T[17][];
}

バケツは長さ17で、ここに17個の配列がプールされています。

ここについているThreadStaticAttributeは、ざっくりいうとフィールドをスレッド内シングルトンにできる属性。 ThreadStaticAttributeをつけたフィールドは OS の Thread Local Storage メモリ領域に割り当てられ、C#的にはスレッドごとに独立した変数が存在している状態になるため簡単にスレッドセーフにできる優れモノ。

このバケツにプールされる17個の配列が、ArrayPool<T>.Shared.Rent(int)で貸し出され、ArrayPool<T>.Shared.Return(T[])でプールに戻ってきます。

Rent/Returnメソッド

Rentメソッドは、まず要求された配列長にあういい感じの配列をバケツから探します。

17個の配列はそれぞれ異なる配列長になっており、具体的には、長さが16, 32, 64, ..., 220 の17個の配列がプールされています。先ほど長さ20を要求した場合、長さ32の配列が返ってくるのはこのため。

プールされている配列が2nなのは、要求に合う配列をビット演算で高速に見つけられるため (だと思う)。 要求された長さmに合う配列がバケツの何番目にあるかは、ビット演算でmの上位ビット側に0がいくつあるかを数えることで求められます。そしてなんと、上位ビットの0の個数を数えるのは、たとえば x86 だとLZCNTというCPU のハードウェア命令1語でできて超高速。ただしここはハードウェア依存なのでIntrinsicで実装されています。

Hardwere Intrinsic については以下に記事を参照。

ufcpp.net

ArrayPool<T>.Share.Rent(int)は長さの合う配列を見つけると、その配列が貸し出されます。

ReturnメソッドはRentの逆の操作を行います。戻ってきた Pooled な配列がバケツの何番目に戻るかを求めてバケツに配列を格納します。

一度生成した配列をプールして使いまわすため、ゼロアロケーションでパフォーマンスが良いのですが、必ずしも速いかというと実はそうではない場合もあります。

以前の記事で実際にベンチマークを取ったものを見ると、単純な速度では1024バイト程度までならnew T[N]する方が速かったりします。ただし、このベンチマークGC にかかる時間は含まれていません。というのも、GC にかかる時間はメモリの状態によっていろいろ変わるので、ここで生成したnew T[N]単体を回収するのにかかる時間というのは測定できるようなものではないためです。

gen0 の GC はかなり高速なのでサイズが小さい場合はそこまで厳密に気にしなくてもいいのですが、ゼロアロケーションというのはとりあえず気持ちがいいですし、ArrayPool<T>.Sharedが遅いかというと、別に遅くはないです。バッファ目的で配列が欲しい時は、とりあえず何も考えずArrayPool<T>.Sharedを積極的に使ってください。

もっと解体新書

ArrayPool<T>.Sharedをもっと知りたい。

  • Rentで既に貸し出されている状態でさらにRentするとどうなる?
  • Rentから取得したものではない配列をReturnするとどうなる?

前述の通り、ArrayPool<T>.Sharedの中身は、それぞれ長さの違う17個の配列をプールしており、それぞれの配列は独立しています。つまり、

var array16 = ArrayPool<int>.Shared.Rent(16);
var array32 = ArrayPool<int>.Shared.Rent(32);
var array64 = ArrayPool<int>.Shared.Rent(64);
ArrayPool<int>.Shared.Return(array16);
ArrayPool<int>.Shared.Return(array32);
ArrayPool<int>.Shared.Return(array64);

は何の不都合もなく、問題ありません。しかし、以下の借り方は多少のロスが出ます。

var array20 = ArrayPool<int>.Shared.Rent(20);
var array30 = ArrayPool<int>.Shared.Rent(30);
ArrayPool<int>.Shared.Return(array20);
ArrayPool<int>.Shared.Return(array30);

長さ20で配列を要求した時に実際に得られる配列は、前述の通り長さ32です。同様に30を要求した時も32です。この時、2回目のRentでは32の配列は既に貸し出されており、ArrayPool<T>.Sharedは持っていません。この場合、新たに長さ32の配列を生成して返します。

この時、長さ32の配列は2つ貸し出されている状態ですが、ArrayPool<T>.Sharedはこの2つの配列を特に区別はしません。配列の長さのみによって区別され、先に返却された方が再びプールされます。そして、2つ目の配列が返却されたときは、既に内部に別の長さ32の配列がプールされているため、無視されます。特に例外が発生したりはしません。その後、返却はされた2つ目の配列はプールされることなくめでたくガベージとなり…………ません。(!?) ArrayPool<T>.Sharedはそう簡単にはガベージにさせてくれません。(後述)

では、借りていない配列を返した場合はどうなるのでしょうか?

// 借り物ではない配列を返す (配列長 32)
ArrayPool<int>.Shared.Return(new int[32]);

// 借り物ではない配列を返す (配列長 30)
ArrayPool<int>.Shared.Return(new int[30]);

1つ目の長さ32の配列の方は、特に問題ありません。先ほどと同様、配列は長さのみによって区別されるため、長さ32の配列が内部にない場合はこの借り物ではないのに返却した配列がプールされ、既に内部にプールされている場合は無視されます。

2つ目の長さ30の配列は例外が発生します。プールされる配列は長さが 2n (n=4, ... , 20) の物しか受け付けません。とはいえ、よっぽど酔っぱらってもいない限り、借りてもいない配列を返すようなことは普通しないため、問題にはなりません。

TlsOverPerCoreLockedStacksArrayPool<T>

最初の方にサラッと名前を出しましたが、ArrayPool<T>.Sharedの実体はTlsOverPerCoreLockedStacksArrayPool<T>という名前長すぎのインスタンスです。

'Tls' はおそらく Thread Local Storage のことで、スレッドごとに独立してる、つまりThreadStatic属性の意味するところであり、先ほどまで説明していたArrayPool<T>の基本的な機能のことでしょう。

そして、’PerCoreLockedStacks’ ってなんぞ?って話ですが、実装を見れば本当に per core で locked な stacks の ArrayPool という名前のまんまです。そして、これがArrayPool<T>.Sharedの2段目のプールで、1段目のプールからこぼれ落ちた配列を受け止めます。

2段目のプールの機能だけを簡略化すると、下のような実装です。

class TlsOverPerCoreLockedStacksArrayPool<T> : ArrayPool<T>
{
    const int NumBuckets = 17;  // 2^4 ~ 2^20 の17個
    private PerCoreLockedStacks?[] _buckets
        = new PerCoreLockedStacks[NumBuckets];
}

class PerCoreLockedStacks
{
    private LockedStack[] _perCoreStacks
        = new LockedStack[Math.Min(Environment.ProcessorCount, 64)];

    public void TryPush(T[] array) { ... }
    public T[]? TryPop() { ... }
}

class LockedStack
{
    private T[]?[] _arrays = new T[8][];

    public void TryPush(T[] array)
    {
        lock(this) { ... }
    }

    public T[]? TryPop()
    {
        lock(this) { ... }
    }
}

つまり、TlsOverPerCoreLockedStacksArrayPool<T>の中に17個 (各配列の長さ用) のPerCoreLockedStacksがあり、それぞれその中には CPU のコアの数だけLockedStackがあり、さらにそれぞれのその中に8個まで配列を保持でき、それがlockによってスレッドセーフに守られているというとんでもない構造です。

ここまで見ればTlsOverPerCoreLockedStacksArrayPool<T>という名前が名前の通りの機能すぎて笑います。 (よい命名だと思います)

とにかく執拗なまでのプールの頑張りなのですが、プールから目的の配列を取り出したり戻したりするまでに必要な処理が多く、またlockも入っている (実際のソースはMonitorで書かれてましたが糖衣構文なので同じ) ので、1段目よりも遅いです。1段目のプールで済むならそれに越したことはなく、そのためには、同じ長さの配列を二重に借りない、きちんと返す、をしていれば2段目には入りません。

また、2段目のプールが1段目と異なる点として、gen2 の GC が発生するたびに少しずつガベージになって消えていくということです。gen2 の GC が発生したタイミングで2段目に保持されている配列を全てガベージに流してしまうと、GC の停止時間を大幅に増加させてしまうことになるため、良い感じにちょっとずつ放流されていくようになっています。実装が気になる人はソースを見てください。

余談ですが、この gen2 の GC を検知する方法が面白くて、ファイナライザを実装したゴミオブジェクトを適当に捨て、ファイナライザが呼ばれるたびにコールバックを発火しつつ、GC.ReRegisterForFinalizeメソッドで再び復活するという、永遠にメモリの生死をさまよい続けるゾンビみたいなオブジェクトを使っています。

 

ということで、明日から使えるArrayPool<T>.Shared解体新書でした。

(C#) #if DEBUG を使わないデバッグ分岐

デバッグとリリースで処理を変えたいときは普通は大体以下のように書きます。

public void Foo()
{
#if DEBUG
    Console.WriteLine("This is Debug.");
#else
    Console.WriteLine("This is Release");
#endif
}

正直見にくいです。コンパイルされない側の記述は Visual Studio のコードアナリティクスもシンタックスハイライトもなくなり、非常に使いにくい。当然、関数名変更などのリファクタリングも効かなくなり、いつの間にかリリースビルドが通らないコードになっていたり、なんてこともあり得ます。

これぐらいの分岐なら全然いいですが、もっといくつもシンボルがあって大量に分岐があちこちに書かれているコードは非常に苦痛。おいおいここはC++じゃないんだぜ

なので、以下のように書きます。

internal static class AssemblyState
{
    public const bool IsDebug = 
#if DEBUG
    true;
#else
   false;
#endif
}

public void Foo()
{
    if(AssemblyState.IsDebug)
    {
        Console.WriteLine("This is Debug.");
    }
    else
    {
        Console.WriteLine("This is Release.");
    }
}

プリプロセッサディレクティブをまとめて書くために1つクラスを用意しておいて、そこに定数で定義しておくと、あとは普通にbool型として分岐できます。分岐したい部分がいくつあっても、#ifを書くのは一ヵ所で済みます。

なにより、普通のC#のコードになっているので、シンタックスハイライトが消えたり参照が効かなかったりなんてならないのがいいです。

この場合、実行時に分岐処理は発生しません。実行時に分岐が定数の場合はJITが不要なifと分岐を削除し、機械語レベルで最初のコードと同じになります。つまり、IL には分岐が存在するが、機械語には分岐がないため実行速度も落ちません。

ほんとに大丈夫なのか

将来的に JIT コンパイルの分岐削除がなくなるかもしれないじゃないですかヤダーって話ですが、たぶんそれはないです。

なぜなら .NET の中にも JIT の分岐削除を前提としたコードがpublicで存在しているからです。C#は言語的に非常に破壊的変更に敏感で、特にpublicで公開されているコードの挙動なんかはまず変わりません。

具体的に何かというと、CPU固有命令を提供している部分などです。

たとえばSystem.Runtime.Intrinsics.X86.LzcntクラスにIsSupportedというプロパティがありますが、これは実行環境の CPU が x86 の lzcnt 命令が使える場合は true になり、

if(Lzcnt.IsSupported)
    return Lzcnt.LeadingZeroCount(value);

のように分岐でき、JIT が分岐を消すことを前提にした使い方をします。わざわざ CPU 命令を持ち出すぐらいですから超パフォーマンス重視の部分に使われる想定で、分岐を消さないなんていう無駄はまずありえないです。 つまり分岐は消えます。

 

というように、うまく JIT の挙動を活用すればあちこちに#if DEBUGを書く見にくいコードから解放されます。

ただしプリプロセッサディレクティブを使えばメソッド定義自体の存在をなかったことにしたり、 C#の文法的に無理なこともメタ的に書けるので全てのケースで今回のように書けるわけではないので必要なときもありますけどねー。

Unity (.netstandard2.0) でSpan<T>を使う

Unity でAPIターゲットを.netstandard2.0にしてSpan<T>を使うときに必要なライブラリたち。

.net 4.x系をターゲットにしても使える気はする(が面倒なので確認していない)。まあ、多少依存関係が違うのでnugetのパッケージのdependencyを見て適当に必要なの持ってこればOK。monoおよびIL2CPP環境で動かなさそうなものがあったら知らぬ。

必要な物 (.net standard2.0)

nuget から必要なものを持ってくる

Span<T>自体は System.Memory.dll 内にあるが、依存関係で下の二つのdllも必要。 依存関係自体はもう1つ System.Numerics.Vectors.dll もあるがUnityの場合これはnugetから取らなくても初めから参照されている。

Unityはそのままではnugetと連携できないので上記のリンクから .nupkg ファイルを直接ダウンロードしてdllを引っこ抜いてプロジェクトに置く。.nupkg はただのzipなのでzipにリネームして解凍すればいい。

 

Span<T>だけでなくArrayPool<T>やらUnsafeやらもついてくるので楽しさ嬉しさ3倍増し。

(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の分多少はオーバーヘッドは増える)