前書き
この記事は、2023のUnityアドカレの12/7の記事です。
今年は、完走賞に挑戦してみたいと思います。Qiita君ぬい欲しい!
はじめに
プログラミングでは、連続したメモリ領域を扱うことが頻繁にあります。いわゆる「配列」というものです。
C#で最もベーシックな配列と言えば、その名の通り、Arrayクラスです。最低限このArrayさえあれば、すべて事足ります。しかし、効率の面や、可読性の観点から、様々な「配列(連続したメモリ領域)」を扱う型が存在します。C#組み込みのもの、Unity提供のものがあるので、それらを紹介したいと思います。
Array(T[N]
)
説明不要の基本のArrayクラスです。(一応説明します)
C#の組み込みとして提供されています。確保には特別な記法を用います。Arrayの実態はGC対象のヒープに確保され、参照が無くなると、そのうちGCによって解放されます。また、GC対象のヒープではありますが、ピン止めという、でフラグメントを抑制する機能により、ポインタを取得することができます。また、refをつけることで、参照を取得することができます。(refに関しては明示的にピン止めの必要性はありません)
var array = new T[N];
fixed(T* p = array)
{
Console.Log($"{p:X8}");
}
ref var reference = ref array[3];
array = null; // そのうちGCによって解放される
参照が無くなるというのは、static変数やローカル変数からたどったときの参照が無くなったときのことを指しています。ときたま、使い終わったらnullを代入しておけという方が居ますが、ローカル変数の場合は、スコープを抜けたら参照は消滅しますし、フィールド変数の場合、フィールドを持つオーナーオブジェクトへの参照が無くなれば、GC対象となります。ローカル変数や、オーナーオブジェクトの寿命が長く、それ以前にGCに開放してほしい場合などにはnull代入が有効です。これはArrayに限った話ではなく、GC対象ヒープを用いる参照型全般に共通した話です。
Span<T>/ReadOnlySpan<T>
.NET standard2.1から追加された構造体です。
Spanはメモリの確保/解放とは切り離された存在です。実体を持つ何らかのクラスなどの領域から生成します。代表的な使い方は、Arrayやスタック配列からキャストします。
Span<T> span = new T[100];
Span<T> slice = span[0..10]; // Span自体は実体を持たないので、コピーはされない
Span<T> span = stackalloc T[100];
var span = (stackalloc T[100]); // 括弧で囲むと、T*ではなく、Span<T>が優先される
特に、スタック配列を使うときは、Spanが非常に役立ちます。Spanを使わないと、ポインタになってしまうのでunsafeコンテキストになってしまいますが、Spanによって自然に使えるようになりました。
スタック配列に対応するために、Spanはref構造体という特別な構造体になっています。ref構造体は、スタック上にしか置くことができません。ref構造体以外のフィールドはもちろん、Asyncメソッドやイテレータメソッドなど、フィールドに昇格しうるローカル変数にも利用できません。
Memory<T>/ReadOnlyMemory<T>
Memoryもメモリの確保/解放とは切り離された存在です。実体を持つ何らかのクラスなどの領域から生成します。Arrayを実体とすることが多いですがMemoryMangerを使うことで、任意の実体を指定できます。
Spanと異なり、スタック配列からのキャストはできない代わりに、どこにでも置くことができます。
Memory<T> memory = new T[100];
Memory<T> slice = memory.Slice(0, 10); // Memory自体は実体を持たないので、コピーはされない
// Memory<T> memory = stackalloc T[100]; // 不可
var item = memoey.Span[3]; // 使うときは一時的にSpanを経由してアクセス
MemoryManager<T>
MemoryManagerは、メモリの実体を確保/解放するクラスです。
abstractになっており、その実装は、ユーザーが自分で実装する必要があります。
using var memoryEntity = new MyMemoryManager(256);
Memory<byte> memory = memoryEntity.Memory;
class MyMemoryManager : MemoryManager<byte>
{
readonly int m_Size;
readonly IntPtr m_Ptr;
public unsafe MyMemoryManager(int size) => (m_Ptr, m_Size) = (ClangBindings.malloc(size), size);
public override Span<T> GetSpan() => new Span<byte>(m_Ptr, m_Size);
public override MemoryHandle Pin(int elementIndex = 0) => default;
public override void Unpin() {}
protected override void Dispose(bool disposing) => ClangBindings.free(m_Ptr);
}
呼び出し側と、呼び出され側
C#の組み込みでは、メモリ実体を確保/解放する機能(MemoryManager, Array, stackalloc)と、それの範囲を指し示すための構造体(Span, Memory)が分かれていました。
関数を呼び出すとき、呼び出し側でメモリを確保し、その領域だけを関数に渡すということがあります。メモリを確保したら必ず解放しなきゃいけないので、責任を明確にしようってことですね。呼び出す側がメモリの確保/解放の責任を持つ、呼び出される側は確保/解放を意識しないということです。
{
int[] outBuff = new int[100];
GetData(outBuff);
} // スコープを抜けたことで、outBuff変数が死ぬので、メモリ実体もGC対象に
void GetData(Span<int> outData);
このように、呼び出し側では実体型、引数は範囲指定型というようにして、確保/解放の責任を明確化します。
NativeArray<T>
NativeArrayは、基本、前者のことが多いですが、後者のような使い方をされることもあります。このことをしっかり理解し、どちらの役割を担っているかをしっかり意識する必要があります。
メモリ実体を確保/解放する機能とする場合、NativeArrayのnew/Disposeを使います。UnityEngineが管理しているヒープから確保/解放されます。AllocatorをTempにしておくとDisposeしなくても、フレームの終わりに勝手に解放されます。
var nativeArray = new NativeArray<T>(100, Allocator.Presist);
nativeArray.Dispose();
using var nativeArray = new NativeArray<T>(100, Allocator.Presist);
var nativeArray = new NativeArray<T>(100, Allocator.Temp); // 一時バッファならこれでいいかも
後者のメモリの範囲を指し示すための構造体として使う場合は、NativeArrayUnsafeUtility
を使います。Unsafeとあるようにイレギュラーではあるのですが、UnityAPIはNativeArrayを引数にとるAPIが多いので、データコピーを避けるために、この方法を多様する羽目になります。
また、NativeArrayはref構造体ではなく、スタック以外にも置くことができるので、SpanというよりはMemoryに近いです。よくNativeArrayとSpanの比較や変換について語られることが多い印象ですが。同期APIは、スタック上のバッファと取ろうがヒープ上のバッファを受け取ろうが制約はないので、スーパーセットであるSpanでいいのですが。
NativeSlice<T> range = NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray(ptr, szie, Allocator.Presist);
一応Unityにも、NativeArrayを実体としたとき、範囲指定用のNativeSliceがあるのですが、こちらはあまりつかわれていません。むしろ、Unity2022で、NativeArrayに、AsSpanが追加されました。NativeSliceのことは忘れましょう。これで、引数にSpanを受け取る関数とはうまくつながるようになりました。しかし、引数にNativeArrayを取るAPIは相変わらずなので、こいつらもSpanを受け取るようにして欲しいですね。
NativeArrayとSpanの変換
Unity2022からは、NativeArrayにAsSpanがついていますが、Unity2021やほかの変換もあります。
これらは、後者のメモリの範囲を指し示すための構造体間での変換です。
NativeArray→Span
using var nativeArray = new NativeArray<T>(100, Allocator.Presist);
var span = new Span<T>(nativeArray.GetUnsafePtr(), nativeArray.Length);
Span→NativeArray
Span<T> span = stackalloc T[100];
fixed(T* p = span)
{
using var nativeArray = NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray(p, span.Length, Allocator.Presist);
}
NativeArray→Memory
using var nativeArray = new NativeArray<T>(100, Allocator.Presist);
var memory = new UnsafeMemoryManager(nativeArray.GetUnsafePtr(), nativeArray.Length);
class UnsafeMemoryManager<T> : MemoryManager<T>
{
readonly int m_Size;
readonly IntPtr m_Ptr;
public unsafe MyMemoryManager(IntPtr ptr, int size) => (m_Ptr, m_Size) = (ptr, size);
public override Span<T> GetSpan() => new Span<byte>(m_Ptr, m_Size);
public override MemoryHandle Pin(int elementIndex = 0) => default;
public override void Unpin() {}
protected override void Dispose(bool disposing) => throw new NotSupportedException();
}
Memory→NativeArray
Memory<T> memory = new T[100];
fixed(T* p = memory.Span)
{
using var nativeArray = NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray(p, span.Length, Allocator.Presist);
}
まとめ
メモリ関係の型には、2つの役割を意識する必要がありました。
- メモリの実体を確保/解放する役割
- Array(
T[]
) - スタック配列(
stackalloc T[]
) - MemoryManager
- NativeArray
- Array(
- メモリの特定の範囲を指し占める役割
- Span
- Memory
- NativeArray(やむを得ず)
- NativeSlice
前者がいろいろあるのは普通のことです。メモリの実体にはいろいろな種類がありますから。(GCヒープ、スタック、GC外ヒープ、デバイスアドレス、etc.)
後者は、本来SapnとMemoryがあれば十分なはずです。しかしUnityのガラパゴス化によって、NativeArray(範囲指定用途で利用時)やNativeSliceというものが爆誕してしまいました。CLR(C#ランタイム)やUnityAPIの世代間で絶妙に噛み合っていませんが…NativeArrayにAsSpanが追加され、今後は同期APIはSpanに統一していこうという流れになっていくんじゃないかと思います。
Comments
Let's comment your feelings that are more than good