49
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

.NET の新しい高性能低遅延 Satori GC が気になります

Last updated at Posted at 2025-05-25

GC の STW 問題

GC(ガベージコレクション)は、プログラムが確保したメモリを自動管理 する仕組みです。この方式は多くの言語で採用され、開発者は細かいメモリ管理を意識せずに済みます。ただ、その分メモリの制御は GC に委ねられます。C# では、一部の場面でメモリを手動管理する選択肢もありますが、基本は自動管理が中心です。

GC の動作時、プログラムは一時停止し、生存しているオブジェクトをスキャン します。不要なオブジェクトの解放や、メモリの断片化を抑えるためのコンパクション(圧縮) を実行することもあります。このようにプログラム全体を停止する処理は 「STW(Stop-The-World)」 と呼ばれ、停止時間が長くなるほどアプリケーションのパフォーマンスに影響 を及ぼします。

.NET の GC は、長年にわたり スループット性能とメモリ効率 を向上させる方向で改良されてきました。これは特に Web アプリやコンテナベースのサービスには適しています。一方で、クライアントアプリ、ゲーム、金融システムでは、GC の影響をより慎重に考慮する必要があります。例えば、オブジェクトプールの活用、値型の使用、ネイティブメモリの使用を取り入れる ことで、GC の負荷を軽減し、不要なメモリアロケーションを抑えることが重要です。

ゲーム開発では、60fps を維持するために 1フレームの処理時間を 16ms 以内に収める必要があります。この時間内に GC の停止が長すぎると、フレーム落ちやカクつき が目立ち、ユーザーのプレイ体験に悪影響を及ぼします。そのため、GC の挙動を考慮したメモリ管理が欠かせません。

Workstation GC?Server GC?DATAS GC?

.NET には、長年にわたり Workstation GCServer GC の2種類の GC モードが存在していました。

Workstation GC は .NET で最も古い GC モードのひとつで、その目的のひとつはメモリ使用量の最小化です。リソースが限られた環境に適応するため、この GC は 単一の CPU コア しか使用しません。たとえ複数コアを持つ環境でも、Workstation GC はそれを活用せず、メモリアロケーションの最適化も行いません。バックグラウンド GC をサポートしていますが、それでも単一のスレッドしか使用しないため、処理能力が大きく制限されます。大量のメモリアロケーションや頻繁な GC が必要なケースでは、Workstation GC では対処しきれません。しかし、アプリが軽量で、頻繁にメモリを確保しない場合には、適した選択肢になります。

その後に登場した Server GC は、マルチコア環境を積極的に活用 し、CPU コアの数に応じてマネージドヒープを管理することで、スループットを大幅に向上させました。ただし、この GC の欠点も明確です。メモリ使用量が多いことです。また、Server GC は並行 GC などの最適化により、GC の一部の作業を STW(Stop-The-World)の外に移動し、アプリケーションと並行して GC を実行できるように改善されました。それでも、一時停止時間は短いとは言えません。Web サービスのようなユースケースでは十分な性能を発揮しますが、場合によっては100ms以上の停止が発生することもあります。

この Server GC の欠点をさらに改善するため、.NET 9 では DATAS GC が導入されました。これは、メモリ使用量を最適化しつつ、一時停止時間も改善することを目的とした GC です。DATAS GC は、さまざまなヒューリスティックアルゴリズムを採用し、アプリケーションの特性に適応することで、メモリ使用量を最小限に抑えつつ、停止時間を短縮しています。テスト結果では、DATAS GC は Server GC と比較し、スループット性能を 数パーセント程度犠牲にする代わりに、メモリ使用量を70~90%削減し、停止時間も 1/3 に短縮されました。

とはいえ、これでも開発者の「高スループット、短い停止時間、低メモリ消費のGCが欲しい」という要求を完全に満たせたわけではありません。

そして、.NET の GC はさらなる進化を遂げました。.NET Runtime のコアメンバーの Vladimir 氏による数年にわたる努力の末、ついに新しい GC が誕生したのです! その名は Satori GC

Satori GC

GC がオブジェクトを正しく追跡するため、多くの言語では 書き込みバリア を利用します。書き込みバリアでは、オブジェクトの参照を更新し、GC がすべてのオブジェクトを確実に把握できるようにします。これは、書き込み操作の頻度が読み込み操作よりも少ない ため、メモリ管理のオーバーヘッドを最小化する合理的な手法です。しかし、欠点もあります。GC が コンパクション(メモリ圧縮) を実行する際、無効なメモリアクセスを防ぐためにプログラムを完全に停止する必要があります。

一方で、一部の JVM の低遅延 GC は書き込みバリアを使わず、読み込みバリア を採用しています。これは、メモリアクセス時にバリアを挿入し、常に最新のメモリアドレスを取得できるようにする方式です。この方法では、GC のコンパクション時にアプリケーションを停止する必要がなくなります。しかし、読み込み操作の頻度は非常に高いため、パフォーマンスの低下が避けられません

GC のコンパクションは確かに処理負荷が高いですが、メモリ解放の頻度に比べれば少数派 です。そのため、一部の操作を並列化するためにすべての読み込み操作を遅くするのは非効率です。また、.NET は 内部ポインタオブジェクトの固定メモリアドレス をサポートしているため、読み込みバリアを導入するとスループット性能が大幅に低下し、現実的ではありません。

こうした背景から、.NET の低遅延・高スループット・適応型 GC「Satori GC」 は、Dijkstra 方式の書き込みバリア を採用し、Server GC と同等のスループット性能 を維持しています。

さらに、Satori GC は 世代別管理・増分並列 GC 設計 を採用しており、ヒープサイズに比例する主要な GC 処理はアプリケーションのスレッドと並列実行 されます。そのため、コンパクションを除いてアプリケーションの停止を必要としません。ただし、コンパクションは必須ではなく、オプションの処理です。例えば、C++ や Rust のメモリアロケータはコンパクションを行いませんが、正常に動作します。Go の GC も同様です。

また、Satori GC には 低遅延モード があります。このモードでは、コンパクションを無効化 し、わずかにメモリ使用量を増やす代わりに遅延を大幅に削減 します。状況によっては、GC がより頻繁に実行されることでメモリ使用量が逆に減る ケースもあります。特に Web サービスでは、短命なオブジェクトが多数生成される ため、どうせすぐに解放されるメモリを圧縮する必要はありません。

Go の GC は 完全にコンパクションを行わない 方式ですが、Satori GC はアプリケーションの特性に応じて動的に切り替えることが可能 です。低遅延モードを有効にするには、次の設定を行うだけです:

GCSettings.LatencyMode = GCLatencyMode.LowLatency;

この設定は、高フレームレートのゲームや金融のリアルタイム取引システム など、極端に短い GC 停止時間が求められる環境 で有効です。

さらに、Satori GC は Gen 0 の無効化 も可能です。すべてのアプリケーションが Gen 0 の恩恵を受けるわけではなく、Gen 0 の管理のために追加の書き込みバリア処理を実行することが逆にスループット低下につながる場合もあります。現時点では、次の環境変数を設定することで Gen 0 を無効化できます:

DOTNET_gcGen0=0

今後、Satori GC は アプリケーションの特性に応じて Gen 0 の有効・無効を自動調整 できるように設計が進められています。

性能テスト

ここまで Satori GC の特長を説明してきましたが、実際の性能 はどうでしょうか?テスト結果を見てみましょう。

まず、テスト前に分層コンパイルを無効化する 必要があります:<TieredCompilation>false</TieredCompilation> とすることで tier-0 の未最適化コードがオブジェクトのライフサイクルに影響を与え、GC の動作に偏りが生じるのを防ぎます。

テストケース 1

Unity には GC 負荷テスト があります。通常、ゲームは各フレームごとに 画面を描画 しますが、このテストでは 毎フレーム大量のデータを割り当てつつ描画は行わない という条件を設定します。こうすることで、GC の実際の停止時間 を測定できます。ここでは、フレームごとに大量なアロケーションと処理をすることでシミュレーションします。

コードは以下の通りです:

class Program
{
    const int kLinkedListSize = 1000;
    const int kNumLinkedLists = 10000;
    const int kNumLinkedListsToChangeEachFrame = 10;
    private const int kNumFrames = 100000;
    private static Random r = new Random();

    class ReferenceContainer
    {
        public ReferenceContainer rf;
    }

    static ReferenceContainer MakeLinkedList()
    {
        ReferenceContainer rf = null;
        for (int i = 0; i < kLinkedListSize; i++)
        {
            ReferenceContainer link = new ReferenceContainer();
            link.rf = rf;
            rf = link;
        }

        return rf;
    }

    static ReferenceContainer[] refs = new ReferenceContainer[kNumLinkedLists];

    static void UpdateLinkedLists(int numUpdated)
    {
        for (int i = 0; i < numUpdated; i++)
        {
            refs[r.Next(kNumLinkedLists)] = MakeLinkedList();
        }
    }

    static void Main(string[] args)
    {
        GCSettings.LatencyMode = GCLatencyMode.LowLatency;
        
        float maxMs = 0;
        UpdateLinkedLists(kNumLinkedLists);

        Stopwatch totalStopWatch = new Stopwatch();
        Stopwatch frameStopWatch = new Stopwatch();
        totalStopWatch.Start();
        for (int i = 0; i < kNumFrames; i++)
        {
            frameStopWatch.Start();
            UpdateLinkedLists(kNumLinkedListsToChangeEachFrame);
            frameStopWatch.Stop();
            if (frameStopWatch.ElapsedMilliseconds > maxMs)
                maxMs = frameStopWatch.ElapsedMilliseconds;
            frameStopWatch.Reset();
        }

        totalStopWatch.Stop();
        
        Console.WriteLine($"Max Frame: {maxMs}, Avg Frame: {(float)totalStopWatch.ElapsedMilliseconds/kNumFrames}");
    }
}

テスト結果:

GC モード 最大フレーム時間 平均フレーム時間 メモリピーク使用量
Server GC 323 ms 0.049 ms 5071.906 MB
DATAS GC 139 ms 0.146 ms 1959.301 MB
Workstation GC 23 ms 0.563 ms 563.363 MB
Satori GC 26 ms 0.061 ms 1449.582 MB
Satori GC(低遅延) 8 ms 0.050 ms 1540.891 MB
Satori GC(低遅延・Gen 0 無効) 3 ms 0.042 ms 1566.848 MB

この結果を見ると、Satori GC は Server GC に匹敵するスループット性能(平均フレーム時間)を維持しながら、最大停止時間を大幅に短縮(最大フレーム時間)しています。特に 低遅延モード + Gen 0 無効化 の組み合わせでは、最小の停止時間(3ms) を実現しており、リアルタイム性が求められる環境にとって非常に有効な選択肢と言えるでしょう。

テストケース 2

このテストでは、Gen 2 → Gen 0 の逆方向参照 を大量に生成し、GC の処理を高負荷状態 にします。さらに、短命なオブジェクトを大量に確保 することで、Gen 0 の GC を頻繁にトリガーします。

using System.Diagnostics;
using System.Runtime;
using System.Runtime.CompilerServices;

object[] a = new object[100_000_000];
var sw = Stopwatch.StartNew();
var sw2 = Stopwatch.StartNew();
var count = GC.CollectionCount(0) + GC.CollectionCount(1) + GC.CollectionCount(2);
for (var iter = 0; ; iter++)
{
    // Gen 2 → Gen 0 の参照を大量に作成し、GC に負荷をかける
    object o = new object();
    for (int i = 0; i < a.Length; i++)
    {
        a[i] = o;
    }
    sw.Restart();
    // オブジェクトを使用し、ライフタイムを維持
    Use(a, o);
    // 短命なオブジェクトを大量に作成し、Gen 0 GC を頻繁にトリガー
    for (int i = 0; i < 1000; i++)
    {
        GC.KeepAlive(new string('a', 10000));
    }
    var newCount = GC.CollectionCount(0) + GC.CollectionCount(1) + GC.CollectionCount(2);
    if (newCount != count)
    {
        Console.WriteLine($"Gen0: {GC.CollectionCount(0)}, Gen1: {GC.CollectionCount(1)}, Gen2: {GC.CollectionCount(2)}, Pause on Gen0: {sw.ElapsedMilliseconds}ms, Throughput: {(iter + 1) / sw2.Elapsed.TotalSeconds} iters/sec, Max Working Set: {Process.GetCurrentProcess().PeakWorkingSet64 / 1048576.0} MB");
        count = newCount;
        iter = -1;
        sw2.Restart();
    }
}

[MethodImpl(MethodImplOptions.NoInlining)]
static void Use(object[] arr, object obj) { }

このテストは Gen 0 の回収性能 を測定することが目的なので、Gen 0 を無効化したケースは含まれません。

テスト結果:

GC モード 一回当たりの停止時間 スループット メモリ使用量
Server GC 59 ms 7.485 iter/s 1286.898 MB
DATAS GC 60 ms 6.362 iter/s 859.722 MB
Workstation GC 1081 ms 0.804 iter/s 805.453 MB
Satori GC 0 ms 4.448 iter/s 801.441 MB
Satori GC(低遅延) 0 ms 4.480 iter/s 804.761 MB

このテストでは Satori GC が圧倒的な結果を出しました。スループット性能を維持しながら、単回の GC 停止時間がほぼ 0ms(サブミリ秒) となっています。つまり、Satori GC はこのテスト環境ではまったくアプリケーションを停止することなく動作した と言えます。

テストケース 3

今回は BinaryTree Benchmark を使用します。このベンチマークでは 短時間で大量のオブジェクトを割り当てる ため、GC にとって非常に負荷の高いテストとなります。

using System.Diagnostics;
using System.Diagnostics.Tracing;
using System.Runtime;
using System.Runtime.CompilerServices;
using Microsoft.Diagnostics.NETCore.Client;
using Microsoft.Diagnostics.Tracing;
using Microsoft.Diagnostics.Tracing.Analysis;
using Microsoft.Diagnostics.Tracing.Parsers;
class Program
{
    [MethodImpl(MethodImplOptions.AggressiveOptimization)]
    static void Main()
    {
        var pauses = new List<double>();

        var client = new DiagnosticsClient(Environment.ProcessId);
        EventPipeSession eventPipeSession = client.StartEventPipeSession([new("Microsoft-Windows-DotNETRuntime",
            EventLevel.Informational, (long)ClrTraceEventParser.Keywords.GC)], false);
        var source = new EventPipeEventSource(eventPipeSession.EventStream);

        source.NeedLoadedDotNetRuntimes();
        source.AddCallbackOnProcessStart(proc =>
        {
            proc.AddCallbackOnDotNetRuntimeLoad(runtime =>
            {
                runtime.GCEnd += (p, gc) =>
                {
                    if (p.ProcessID == Environment.ProcessId)
                    {
                        pauses.Add(gc.PauseDurationMSec);
                    }
                };
            });
        });

        GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
        GC.WaitForPendingFinalizers();
        GC.WaitForFullGCComplete();
        Thread.Sleep(5000);

        new Thread(() => source.Process()).Start();

        pauses.Clear();

        Test(22);
        
        source.StopProcessing();
        Console.WriteLine($"Max GC Pause: {pauses.Max()}ms");
        Console.WriteLine($"Average GC Pause: {pauses.Average()}ms");
        pauses.Sort();
        Console.WriteLine($"P99.9 GC Pause: {pauses.Take((int)(pauses.Count * 0.999)).Max()}ms");
        Console.WriteLine($"P99 GC Pause: {pauses.Take((int)(pauses.Count * 0.99)).Max()}ms");
        Console.WriteLine($"P95 GC Pause: {pauses.Take((int)(pauses.Count * 0.95)).Max()}ms");
        Console.WriteLine($"P90 GC Pause: {pauses.Take((int)(pauses.Count * 0.9)).Max()}ms");
        Console.WriteLine($"P80 GC Pause: {pauses.Take((int)(pauses.Count * 0.8)).Max()}ms");

        using (var process = Process.GetCurrentProcess())
        {
            Console.WriteLine($"Peak WorkingSet: {process.PeakWorkingSet64} bytes");
        }
    }

    static void Test(int size)
    {
        var bt = new BinaryTrees.Benchmarks();
        var sw = Stopwatch.StartNew();
        bt.ClassBinaryTree(size);
        Console.WriteLine($"Elapsed: {sw.Elapsed.TotalMilliseconds}ms");
    }

}

public class BinaryTrees
{
    class ClassTreeNode
    {
        class Next { public required ClassTreeNode left, right; }
        readonly Next? next;
        ClassTreeNode(ClassTreeNode left, ClassTreeNode right) =>
            next = new Next { left = left, right = right };
        public ClassTreeNode() { }
        internal static ClassTreeNode Create(int d)
        {
            return d == 1 ? new ClassTreeNode(new ClassTreeNode(), new ClassTreeNode())
                          : new ClassTreeNode(Create(d - 1), Create(d - 1));
        }

        internal int Check()
        {
            int c = 1;
            var current = next;
            while (current != null)
            {
                c += current.right.Check() + 1;
                current = current.left.next;
            }
            return c;
        }
    }

    public class Benchmarks
    {
        const int MinDepth = 4;
        public int ClassBinaryTree(int maxDepth)
        {
            var longLivedTree = ClassTreeNode.Create(maxDepth);
            var nResults = (maxDepth - MinDepth) / 2 + 1;
            for (int i = 0; i < nResults; i++)
            {
                var depth = i * 2 + MinDepth;
                var n = 1 << maxDepth - depth + MinDepth;

                var check = 0;
                for (int j = 0; j < n; j++)
                {
                    check += ClassTreeNode.Create(depth).Check();
                }
            }

            return longLivedTree.Check();
        }
    }
}

今回は Microsoft.Diagnostics.NETCore.Client を使用し、GC の停止時間を正確に測定 しています。

テスト結果:

メトリクス Workstation GC Server GC DATAS GC Satori GC Satori GC(低遅延) Satori GC(Gen 0 無効)
実行時間 (ms) 63,611.3954 22,645.3525 24,881.6114 41,515.6333 40,642.3008 13,528.3383
メモリピーク使用量 (bytes) 1,442,217,984 4,314,828,800 2,076,291,072 1,734,955,008 1,537,855,488 1,541,136,384
最大停止時間 (ms) 48.9107 259.9675 197.7212 6.5239 4.0979 1.2347
平均停止時間 (ms) 6.1173 12.0078 3.3040 0.6734 0.4378 0.1391
P99.9 停止時間 (ms) 46.8537 243.2844 172.3259 5.8535 3.6835 0.9887
P99 停止時間 (ms) 44.0532 207.3627 57.4681 5.2661 3.2012 0.5814
P95 停止時間 (ms) 39.4903 48.7269 8.9200 3.0054 1.3854 0.3536
P90 停止時間 (ms) 23.1327 21.4588 2.8013 1.7859 0.9204 0.2681
P80 停止時間 (ms) 8.3317 4.7577 1.7581 0.8009 0.6006 0.1942

Satori GC は標準モードでも低遅延モードでも極めて短い停止時間を達成 しました。特に Gen 0 を無効化した場合、Satori GC は 高スループットを維持しつつ、Workstation GC に匹敵するメモリ使用量 に加えて、最大 STW 時間をサブミリ秒レベルにまで削減 しています。

テストケース 4

今回のテストは コミュニティ提供の GC ベンチマーク を使用しました:GCBurn

このテストでは、異なるメモリアロケーション戦略 をシミュレーションし、以下の 3 つのシナリオを想定しています:

  • Cache Server:使用メモリは約 16GB、約 1.86 億個のオブジェクトを確保
  • Stateless Server:ステートレスな Web サーバーのシミュレーション
  • Worker Server:常にメモリの 20%(約 6GB)を占有 しながら 約 7400 万個のオブジェクトを確保

アロケーションレートの比較

three_allocation_rate.png

Server GC は スループット向上に最適化 されているため、最も高速なオブジェクトアロケーションを実現しているのは予想通りです。一方、Satori GC は Cache Server のケースで若干の性能低下 を見せました。ただし、現実的には 1 秒間に 2000 万個以上のオブジェクトを確保する場面はほぼ存在しない ため、実際のユースケースで性能のボトルネックになることはないでしょう。

GC 停止時間の比較

three_pause.png

時間単位は マイクロ秒(0.001ms) であり、縦軸は 対数スケール になっています。Satori GC は サブミリ秒(1000 マイクロ秒以下)レベルの停止時間 を達成しました。

メモリ使用量の比較

three_peak_mem.png

Satori GC は 他の GC に比べて圧倒的に低いメモリ消費 を記録しました。すべてのテスト環境で 最もメモリ効率が良い GC となっています。

まとめ

以上の結果を総合すると、Satori GC は わずかなスループット性能の犠牲と引き換えに、サブミリ秒レベルの停止時間と低メモリ消費を実現 しています。まさに 見事な成果 です。

大量アロケーションレートテスト

このテストも コミュニティ提供の結果 です。すべてのスレッドで 大量のオブジェクトを確保し、即座に解放 することで、アロケーション性能を測定しました。

allocation_rate.png

この結果を見ると、Satori GC のデフォルトモードが最も優れたアロケーションスループット を持つことが分かります。1 秒間に 20 億個のオブジェクトを割り当てる 性能を達成しています。

最終考察

Satori GC は .NET に新たな低遅延・高スループットの適応型 GC をもたらしました。
アロケーションスループットが優秀なだけでなく、サブミリ秒レベルの停止時間と低メモリ消費を実現 し、さらに 設定不要で簡単に導入可能 です。

現在、Satori GC は まだ実験段階 にありますが、以下のような課題の解決が進められています:

  • 実行時に Gen 0 の有効・無効を動的に決定
  • スループットとメモリ消費のバランスをさらに最適化
  • 最新の .NET バージョンへの適応

とはいえ、実際のアプリケーションでの使用はすでに可能 です。試してみたい場合は、導入方法を参考にして設定できます。

また、osu!(C# で構築された音ゲー)は Satori GC を採用し、選曲リストのスクロールテストでフレームレートが大幅に向上 されることを確認できました

今後、Satori GC が正式に .NET のデフォルト GC となれば、.NET のパフォーマンスは大幅に向上し、より多くのユースケースに対応できるでしょう。

Satori GC の導入方法

2025年5月22日時点で、Satori GC は .NET 8 のアプリケーションでのみ利用可能 です。現在の最新 LTS(長期サポート)バージョンなので、実用上は十分でしょう。
また、Satori GC の開発チームは .NET 9 への適応 を進めています。

osu! チームが CI を構築 し、常に最新の Satori GC をビルドしているため、.NET Runtime のソースコードを手動でビルドする必要はありません。
公開されたバイナリをダウンロードすれば、そのまま使用可能 です。

.NET 8 アプリでの導入方法

  1. アプリを自己完結型で公開 する
    以下のコマンドを使用:

    dotnet publish -c Release -r <rid> --self-contained
    

    例えば dotnet publish -c Release -r win-x64 --self-contained のように指定。

  2. Satori GC の最新ビルドをダウンロード
    https://github.com/ppy/Satori/releases から 対応するプラットフォーム用のバージョン(例: win-x64.zip)を取得。

  3. ファイルを解凍
    含まれるファイルは以下の通り:

    • Windowscoreclr.dllclrjit.dllSystem.Private.CoreLib.dll
    • Linuxlibcoreclr.solibclrjit.soSystem.Private.CoreLib.dll
    • macOSlibcoreclr.dyliblibclrjit.dylibSystem.Private.CoreLib.dll
  4. 公開されたアプリのディレクトリ内でファイルを置き換え
    一般的なパス: bin/Release/net8.0/<rid>/publish(例:bin/Release/net8.0/win-x64/publish

これで Satori GC の低遅延性能 を体験できます。

フィードバック方法

使用中に問題が発生した場合は、以下のリソースを参照してください:

49
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
49
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?