RecyclableMemoryStreamを使ったメモリプールのご紹介

こんにちは、エンジニアの庭山です。

Microsoftが公開している Microsoft.IO.RecyclableMemoryStream というC# 用のライブラリの紹介をさせて頂きます。

目次

System.IO.MemoryStreamに特化したプーリング

  • 最大利用サイズの指定
  • SmallプールとLargeプールの2つの領域から構成
  • MemoryStreamの参照先となる byte[] へのアクセスも可能
    • *Largeプールに連続したデータとしてコピーされた後に参照可能

コードの典型例

基本は以下の手順です。

  • RecyclableMemoryStreamManager クラスのインスタンスを作成
  • 使用する時に PGetStreamP メソッドでストリームを取得する
  • Streamを使用し終えたら必ずDisposeメソッドを呼び出す(呼ばないとプール領域から空き状態にならない=メモリリークになる)
// 既定のバッファサイズでプール領域を確保する RecyclableMemoryStreamManager を作成
RecyclableMemoryStreamManager memoryStreamManager = new RecyclableMemoryStreamManager()

// 1024バイト分アクセス可能なMemoryStreamを得る
// ”使い終わったら必ずDisposeして返却する必要”があるので、ここでは using var としている
using var stream = memoryStreamManager.GetStream(1024);

// 得たストリームは通常のMemoryStreamと同様にアクセスできる
stream.Write(data, 0, data.Length);

RecyclableMemoryStreamManagerインスタンス生成後プロパティでも後から変更は可能ですが、コンストラクタに渡す引数である各種サイズ指定をユースケースに合わせた適切なサイズに指定することがパフォーマンスの最大化の肝となります。

そこまで凝った管理じゃなくても…という場合であれば ArrayPool<T>でも正直OKな気もします…。

ArrayPool<T>の実装コードはこちら github.com

活用例

低レベルな byte[] 、MemoryStreamをたくさん使いそうなケースで効果が発揮できます。例えば

  • Encoding.GetStringでエンコードしたバイト列の書き込み
  • メモリ上にTextWriterを使った書き込み
  • 画像や圧縮ファイルの展開処理用のワークエリアとして
  • ファイルの内容をオンメモリに読み込みたい場合

など都度バイト列を確保したりするコストや、特にメモリコンパクションを サポートしない実行環境における長時間のメモリ確保・解放を繰り返すことで発生するメモリの断片化防止など。

メモリコンパクションをサポートしないUnity上では、なにかと都合が良さそうです。

メモリ管理方法

引用元:https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream

  • Small
  • Large

という2つのプール領域から構成されます。 *Largeは初期化の指定の仕方次第で Linear Large または Expotential Large を選択できます。

スロット

上図で1つ1つの四角に対しバイトサイズが書かれている部分をスロット と表現します。

ブロック

複数のスロットをチェーンさせてバッファ領域を表現することをブロックと表現します。

スロットのサイズ内に収まるサイズのMemoryStreamを使う場合は単一のスロット(バッファ)に対するMemoryStreamを得ることが出来ます。 逆に、スロットサイズを超える大きなサイズのMemoryStreamを使用する場合、このスロットを数珠つなぎにして1つのバッファかのように見えるように振る舞います。

Smallプール

指定しない場合、 1スロットあたりのサイズは128KiBです。 (コンストラクタ引数:blockSizeGetStreamメソッドでMemoryStreamを取得する場合はこのSmallプール内からブロックを構成してストリームを得ることが出来ます。

  • MemoryStreamしか使用しない場合はこのSmallプール領域を常に使用します。
  • MemoryStreamのGetBuffer()で byte[] を得る際、スロットサイズに収まる場合もこのSmallプールの領域を使用します。

Largeプール

取得したMemoryStreamの GetBuffer() メソッドを使用した際、上図のブロックのようにチェーンされたデータである場合は連続したデータ(byte[])を表現するための領域として使用されます。

つまり、Smallプールのスロットサイズに収まる場合はSmallプールでやりくりができるのでこのLargetプールは使われません…。(それでもこのプーリングの仕組み自体の恩恵は受けられます)

Largetプールは管理の仕方が2つあり、初期化の時に指定することが出来ます。

  • Linear
  • Exponential

Linearの場合

倍数(等倍のときのサイズ)と最大のサイズを指定する必要があります。(コンストラクタ引数:largeBufferMultiple

(最大サイズ/等倍のときのサイズ) という計算から確保するブロックサイズの個数を決め、各ブロックに割り当てられる領域は等倍の時のサイズ * 倍数 になります。

例:1スロットの最小(等倍時)のサイズを1MiB、最大サイズを4MiBとした場合

スロットは4つで、スロット毎に倍のサイズの割当てを行います

  • Slot0 = 1MiB
  • Slot1 = 2MiB
  • Slot2 = 3MiB
  • Slot3 = 4MiB

Exponentialの場合

スロットサイズが動的に変動をさせてバッファ領域の管理をします。(コンストラクタ引数:useExponentialLargeBuffer) こちらは整数倍の増え方ではなく、指数関数的な増加を行います。

例:1ブロックの最小のサイズを256KiB、最大サイズを8MiBとした場合

スロットは6つで、スロット毎に1倍, 2倍, 4倍 ...といったサイズの割当てを行います

  • Slot0 = 256KiB
  • Slot1 = 512KiB
  • Slot2 = 1MiB
  • Slot3 = 2MiB
  • Slot4 = 4MiB
  • Slot5 = 8MiB

Linear か Exponential どちらが良いか

扱うバッファサイズの傾向によって違ってくるのでどちらか一方が有利、というわけで無さそうです。

ドキュメントには 予測できない大きいバッファが必要な状況であればLinear、 あまり巨大なバッファが必要になることはなく、比較的小さいサイズのバッファが多く必要そうになることが分かっている場合であればExponential が向いている と書かれています。(意訳)

Which one should you use? That depends on your usage pattern. If you have an unpredictable large buffer size, perhaps the linear one will be more suitable. If you know that a longer stream length is unlikely, but you may have a lot of streams in the smaller size, picking the exponential version could lead to less overall memory usage (which was the reason this form was added).

注意点

  • 再利用されるバッファは クリアされていないので必要であれば自分で 0クリアする必要があります。
  • RecyclableMemoryStreamManager.GetStream() で得られたストリームは使い終わったら Dispose メソッドを呼び出して必ず返却する必要があります。

最後に

MemoryStream に特化しているので IO系の System.IO.Stream を用いる各種StreamやWriter、Reaer、JSONなどのシリアライザ等々の用途のプーリングに向いていると思われます。

備考:ベンチマーク

using System.Buffers;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.IO;

[MemoryDiagnoser]
public class Benchmark
{
    private const int Count = 10;
    private const int BufferSize = 1 * 1024 * 1024;
    private static readonly byte[] Buffer = new byte[BufferSize];

    private static readonly int[] WriteLength = Enumerable.Range(0, Count)
        .Select(x => Random.Shared.Next(1024, BufferSize))
        .ToArray();

    [Benchmark(Baseline = true)]
    public long MemoryStreamBenchmark()
    {
        var sum = 0L;

        foreach (var length in WriteLength)
        {
            using var memoryStream = new MemoryStream(length);
            memoryStream.Write(Buffer, 0, length);
            sum += memoryStream.Length;
        }

        return sum;
    }

    [Benchmark]
    public long RecyclableMemoryStreamBenchmark()
    {
        var manager = new RecyclableMemoryStreamManager();
        var sum = 0L;

        foreach (var length in WriteLength)
        {
            using var memoryStream = manager.GetStream("tag", length);
            memoryStream.Write(Buffer, 0, length);
            sum += memoryStream.Length;
        }

        return sum;
    }
}

public class BenchMarkProgram
{
    public static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<Benchmark>();
    }
}
BenchmarkDotNet=v0.13.5, OS=macOS Monterey 12.6.3 (21G419) [Darwin 21.6.0]
Apple M1 Max, 1 CPU, 10 logical and 10 physical cores
.NET SDK=7.0.101
  [Host]     : .NET 7.0.1 (7.0.122.56804), Arm64 RyuJIT AdvSIMD
  DefaultJob : .NET 7.0.1 (7.0.122.56804), Arm64 RyuJIT AdvSIMD

Method Mean Error StdDev Ratio Gen0 Gen1 Gen2 Allocated Alloc Ratio
MemoryStreamBenchmark 557.7 μs 10.87 μs 12.08 μs 1.00 1029.2969 1026.3672 1026.3672 5.95 MB 1.00
RecyclableMemoryStreamBenchmark 203.9 μs 3.96 μs 5.01 μs 0.37 250.0000 248.0469 247.8027 1.01 MB 0.17