NativeMemoryArray - .NET 6 APIをフル活用した2GB超えの巨大データを扱うライブラリ

.NET 6 Advent Calendar 2021の12日の代理投稿となります。プレゼント付きですと!?BALMUDA The Brew STARBUCKS RESERVE LIMITED EDITIONが欲しいです!

さて、先程NativeMemoryArrayという新しいライブラリを作成し、公開しました。.NET Standard 2.0でも動作しますが、全体的に .NET 6 の新API群(NativeMemory, Scatter/Gather I/O)を活かすための作りになっていますので、今回のAdvent Calendarにもピッタリ。実用性も、ある……!あります……!もちろんUnity版も用意してあります(NativeArrayと何が違うって?まぁ違うと言えば違います)。

C#には配列、特にbyte[]を扱う上で大きな制約が一つあります。それは、一次元配列の上限値が0x7FFFFFC7(2,147,483,591)ということ。int.MaxValueよりちょっと小さめに設定されていて、ようするにざっくり2GBちょいが限界値になっています。

この限界値は、正確には .NET 6 でひっそり破壊的変更が行われましたので、.NET 6とそれ以外で少し異なります。詳しくは後で述べます。

この2GBという値は、int Lengthの都合上しょうがない(intの限界値に引っ張られている)のですが、昨今は4K/8Kビデオや、ディープラーニングの大容量データセットや、3Dスキャンの巨大点群データなどで、大きな値を扱うことも決して少ないわけではないため、2GB制約は正直厳しいです。そして、この制約はSpan<T>Memory<T>であっても変わりません(Lengthがintのため)。

ちなみにLongLengthは多次元配列における全次元の総数を返すためのAPIのため、一次元配列においては特に意味をなしません。.NET Frameworkの設定であるgcAllowVeryLargeObjectsも、構造体などを入れた場合の大きなサイズを許容するものであり(例えば4バイト構造体の配列ならば、2GB*4のサイズになる)、要素数の限界は超えられないため、byte[]としては2GBが限界であることに変わりはありません。

こうした限界に突き当たった場合は、ストリーミング処理に切り替えるか、またはポインタを使って扱うかになりますが、どちらもあまり処理しやすいとは言えませんし、必ずしもインメモリで行っていた操作が代替できるわけではありません(ポインタなら頑張れば最終的にはなんとでもなりますが)。

そこで、2GB制約を超えつつも、新しいAPI群(Span<T>, IBufferWriter<T>, ReadOnlySequence<T>, RandomAccess.Write/Read, System.IO.Pipelinesなど)と親和性の高いネイティブメモリを裏側に持つ配列(みたいな何か)を作りました。

これによって、例えば巨大データの読み込み/書き込みも、 .NET 6の新Scatter/Gather APIのRandomAccessを用いると、簡単に処理できます。

// for example, load large file.
using var handle = File.OpenHandle("4GBfile.bin", FileMode.Open, FileAccess.Read, options: FileOptions.Asynchronous);
var size = RandomAccess.GetLength(handle);

// via .NET 6 Scatter/Gather API
using var array = new NativeMemoryArray<byte>(size);
await RandomAccess.ReadAsync(handle, array.AsMemoryList(), 0);

// iterate Span<byte> as chunk
foreach (var chunk in array)
{
    Console.WriteLine(chunk.Length);
}

Scatter/Gather APIに馴染みがなくても、IBufferWriter<T>IEnumerable<Memory<T>> を経由してStreamで処理する手法も選べます。

public static async Task ReadFromAsync(NativeMemoryArray<byte> buffer, Stream stream, CancellationToken cancellationToken = default)
{
    var writer = buffer.CreateBufferWriter();

    int read;
    while ((read = await stream.ReadAsync(writer.GetMemory(), cancellationToken).ConfigureAwait(false)) != 0)
    {
        writer.Advance(read);
    }
}

public static async Task WriteToAsync(NativeMemoryArray<byte> buffer, Stream stream, CancellationToken cancellationToken = default)
{
    foreach (var item in buffer.AsMemorySequence())
    {
        await stream.WriteAsync(item, cancellationToken);
    }
}

あるいはSpan<T>のSliceを取り出して処理してもいいし、ref T this[long index]によるインデクサアクセスやポインタの取り出しもできます。 .NET 6時代に完全にマッチしたAPIを揃えることで、標準の配列と同等、もしくはそれ以上の使い心地に仕上げることによって、C#の限界をまた一つ超える提供できたと思っています。

とはいえもちろん、 .NET Standard 2.0/2.1 にも対応しているので、非 .NET 6なAPIでも大丈夫です、というかScatter/Gather API以外は別に今までもありますし普通に使えますので。

普通の配列的にも使えます。GC避けには、こうした普通のAPIを使っていくのでも便利でしょう、

// call ctor with length, when Dispose free memory.
using var buffer = new NativeMemoryArray<byte>(10);

buffer[0] = 100;
buffer[1] = 100;

// T allows all unmanaged(struct that not includes reference type) type.
using var mesh = new NativeMemoryArray<Vector3>(100);

// AsSpan() can create Span view so you can use all Span APIs(CopyTo/From, Write/Read etc.).
var otherMeshArray = new Vector3[100];
otherMeshArray.CopyTo(mesh.AsSpan());

NativeMemoryArray<T>

NativeMemoryArray<T>はwhere T : unmanagedです。つまり、参照型を含まない構造体にしか使えません。まぁ巨大配列なんて使う場合には参照型含めたものなんて含めてんじゃねーよなので、いいでしょうきっと。巨大配列で使えることを念頭においてはいますが、別に普通のサイズの配列として使っても構いません。ネイティブメモリに確保するので、ヒープを汚さないため、適切な管理が行える箇所では便利に使えるはずです。

Span<T>との違いですが、NativeMemoryArray<T>そのものはクラスなので、フィールドに置けます。Span<T>と違って、ある程度の長寿命の確保が可能ということです。Memory<T>のSliceが作れるため、Async系のメソッドに投げ込むこともできます。また、もちろん、Span<T>の長さの限界はint.MaxValueまで(ざっくり2GB)なので、それ以上の大きさも確保できます。

UnityにおけるNativeArray<T>との違いですが、NativeArray<T>はUnity Engine側との効率的なやりとりのための入れ物なので、あくまでC#側で使うためのNativeMemoryArray<T>とは全然役割が異なります。まぁ、必要に思えない状況ならば、おそらく必要ではありません。

主な長所は、以下になります。

  • ネイティブメモリから確保するためヒープを汚さない
  • 2GBの制限がなく、メモリの許す限り無限大の長さを確保できる
  • IBufferWriter<T> 経由で、MessagePackSerializer, System.Text.Json.Utf8JsonWriter, System.IO.Pipelinesなどから直接読み込み可能
  • ReadOnlySequence<T> 経由で、MessagePackSerializer, System.Text.Json.Utf8JsonReaderなどへ直接データを渡すことが可能
  • IReadOnlyList<Memory<T>>, IReadOnlyList<ReadOnlyMemory<T>> 経由で RandomAccess(Scatter/Gather API)に巨大データを直接渡すことが可能

あまりピンと来ない、かもしれませんが、使ってみてもらえれば分かる、かも。

NativeMemoryArray<T>の全APIは以下のようになっています。

  • NativeMemoryArray(long length, bool skipZeroClear = false, bool addMemoryPressure = false)
  • long Length
  • ref T this[long index]
  • ref T GetPinnableReference()
  • Span<T> AsSpan()
  • Span<T> AsSpan(long start)
  • Span<T> AsSpan(long start, int length)
  • Memory<T> AsMemory()
  • Memory<T> AsMemory(long start)
  • Memory<T> AsMemory(long start, int length)
  • bool TryGetFullSpan(out Span<T> span)
  • IBufferWriter<T> CreateBufferWriter()
  • SpanSequence AsSpanSequence(int chunkSize = int.MaxValue)
  • MemorySequence AsMemorySequence(int chunkSize = int.MaxValue)
  • IReadOnlyList<Memory<T>> AsMemoryList(int chunkSize = int.MaxValue)
  • IReadOnlyList<ReadOnlyMemory<T>> AsReadOnlyMemoryList(int chunkSize = int.MaxValue)
  • ReadOnlySequence<T> AsReadOnlySequence(int chunkSize = int.MaxValue)
  • SpanSequence GetEnumerator()
  • void Dispose()

AsSpan(), AsMemory()はスライスのためのAPIです。取得したSpanやMemoryは書き込みも可能なため、 .NET 5以降に急増したSpan系のAPIに渡せます。SpanやMemoryには最大値(int.MaxValue)の限界があるため、lengthの指定がない場合は、例外が発生する可能性もあります。そこでTryGetFullSpan()を使うと、単一Spanでフル取得が可能かどうか判定できます。また、AsSpanSequence(), AsMemorySequence()でチャンク毎のforeachで全要素を列挙することが可能です。直接foreachした場合は、AsSpanSequence()と同様の結果となります。

long written = 0;
foreach (var chunk in array)
{
    // do anything
    written += chunk.Length;
}

ポインタの取得は、配列とほぼ同様に、そのまま渡せば0から(これはGetPinnableReference()の実装によって実現できます)、インデクサ付きで渡せばそこから取れます。

fixed (byte* p = buffer)
{
}

fixed (byte* p = &buffer[42])
{
}

CreateBufferWriter() によって IBufferWriter<T>を取得できます。これはMessagePackSerializer.Serializeなどに直接渡すこともできるほかに、先の例でも出しましたがStreamからの読み込みのように、先頭からチャンク毎に取得して書き込んでいくようなケースで便利に使えるAPIとなっています。

AsReadOnlySequence() で取得できるReadOnlySequence<T>は、MessagePackSerializer.Deserializeなどに直接渡すこともできるほかに .NET 5から登場した SequenceReaderに通すことで、長大なデータのストリーミング処理をいい具合に行える余地があります。

AsMemoryList(), AsReadOnlySequence()は .NET 6から登場したRandomAccessRead/Writeに渡すのに都合の良いデータ構造です。プリミティブな処理なので使いにくいと思いきや、意外とすっきりと処理できるので、File経由の処理だったらStreamよりもいっそもうこちらのほうがいいかもしれません。

NativeMemory

.NET 6からNativeMemoryというクラスが新たに追加されました。その名の通り、ネイティブメモリを扱いやすくするものです。今までもMarshal.AllocHGlobalといったメソッド経由でネイティブメモリを確保することは可能であったので、何が違うのか、というと、何も違いません。実際NativeMemoryArrayの .NET 6以前版はMarshalを使ってますし。そして .NET 6 では Marshal.AllocHGlobal は NativeMemory.Alloc を呼ぶので、完全に同一です。

ただしもちろん .NET 6 実装時にいい感じに整理された、ということではあるので、NativeMemory、いいですよ。NativeMemory.Allocがmalloc、NativeMemory.AllocZeroedがcalloc、NativeMemory.Freeがfreeと対応。わかりやすいですし。

ちなみにゼロ初期化する NativeMemory.AllocZeroed に相当するものはMarshalにはないので、その点でも良くなったところです。NativeMemoryArray<T>では、コンストラクタのskipZeroClear(public NativeMemoryArray(long length, bool skipZeroClear = false))によってゼロ初期化する/しないを選べます。デフォルトは(危ないので)初期化しています。非.NET 6版では、メモリ確保後にSpan<T>.Clear()経由で初期化処理を入れています。

真のArray.MaxValue

.NET 6以前では、配列の要素数はバイト配列(1バイト構造体の配列)と、それ以外の配列で異なる値がリミットに設定されていました。例えばSystem.Arrayのドキュメントを引いてくると

配列のサイズは、合計で40億の要素に制限され、任意の次元の0X7FEFFFFF の最大インデックス (バイト配列の場合は0X7FFFFFC7、1バイト構造体の配列の場合) に制限されます。

つまり、0X7FFFFFC7の場合と、0X7FEFFFFFの場合がある、と。

と、いうはずだったのですが、.NET 6からArray.MaxLengthというプロパティが新規に追加されて、これは単一の定数を返します。その値は、0X7FFFFFC7です。よって、いつのまにかひっそりと配列の限界値は(ちょびっと大きい方に)大統一されました。

この変更は意外とカジュアルに行われ、まず最大値を取得する、ただし単一じゃないため型によって結果の変わる Array.GetMaxLength<T>() を入れよう、という実装があがってきました。そうしたら、そのPR上での議論で、そもそも当初は最適化を期待したけど別にそんなことなかったし、統一しちゃってよくね?という話になり、そのまま限界値は統一されました。そして新規APIも無事、Array.MaxLengthという定数返しプロパティになりました。

まぁ、シンプルになって良いですけどね。大きい方で統一されたので実害も特にないでしょうし。前述のSystem.Arrayのドキュメントは更新されてないということで、正しくは、.NET 6からは0x7FFFFFC7が限界で、その値はArray.MaxLengthで取れる。ということになります。

Span<T>の限界値はint.MaxValueなので、限界に詰め込んだSpan<T>をそのままToArray()すると死ぬ、という微妙な問題が発生することがあるんですが、まぁそこはしょうがないね。

まとめ

NativeArrayという名前にしたかったのですがUnityと被ってしまうので避けました。しょーがない。

着手当時はマネージド配列のチャンクベースで作っていたのですが(LargeArray.cs)、Sliceが作りづらいし、ネイティブメモリでやったほうが出来ること多くて何もかもが圧倒的にいいじゃん、ということに作業進めている最中に気づいて、破棄しました。参照型の配列が作れるという点で利点はありますが、まぁ参照型で巨大配列なんて作らねーだろ、思うと、わざわざ実装増やして提供するメリットもないかな、とは。

配列はもう昔からあるのでint Lengthなのはしょうがないのですが、Span<T>, Memory<T>のLengthはlongであって欲しかったかなー、とは少し思っています。2016年の段階でのSpanのAPIどうするかドキュメントによると、候補は幾つかあったけど、結果的に配列踏襲のint Lengthになったそうで。2GBでも別に十分だろ、みたいなことも書いてありますが、いや、そうかなー?年にそこそこの回数でたまによく引っかかるんだけどねー?

そして2016年の議論時点ではなかった、C# 9.0でnuint, nuintが追加されたので、nuint Span<T>/Memory<T>.Lengthはありなんじゃないかな、と。

ただNativeMemoryArrayの開発当初はnuint Lengthで作っていたのですが、AsSpan(nuint start, nuint length)みたいなAPIは、カジュアルにintやlongを突っ込めなくて死ぬほど使いづらかったので、最終的にlongで統一することにしました。ので、nuint Lengthは、なしかな。つまり一周回って現状維持。そんなものかー、そんなもんですねー。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2022

Twitter:@neuecc GitHub:neuecc

Archive