はじめに
今回はUnityで使用できるコレクションである NativeArray
について調査します。
趣旨としては配列やSpan等の通常のコレクションとの速度比較から、NativeArray
の使いどころについて探っていくような感じです。
NativeArrayとは?
NativeArray
とは、ひとことで言えばアンマネージドメモリ上にメモリ確保される値型の配列構造です。
アンマネージドメモリを使用するため、GCによって NativeArray
のメモリは解放されません。
個人的に触ってみたイメージとしては、C++でのメモリ確保/解放の流れに似ていると思いました。
C++にわかなので違うかもしれませんが、実際中身を見るとC++のDLLで確保したメモリを使用できるようにしているようです。
コンストラクタで実行される処理の引用(展開)
private unsafe static void Allocate(int length, Allocator allocator, out NativeArray<T> array)
{
long size = (long)UnsafeUtility.SizeOf<T>() * (long)length;
CheckAllocateArguments(length, allocator);
array = default(NativeArray<T>);
IsUnmanagedAndThrow();
array.m_Buffer = UnsafeUtility.MallocTracked(size, UnsafeUtility.AlignOf<T>(), allocator, 0);
array.m_Length = length;
array.m_AllocatorLabel = allocator;
array.m_MinIndex = 0;
array.m_MaxIndex = length - 1;
AtomicSafetyHandle.CreateHandle(out array.m_Safety, allocator);
InitStaticSafetyId(ref array.m_Safety);
InitNestedNativeContainer(array.m_Safety);
}
以上のコードの UnsafeUtility.MallocTracked()
がメモリ確保処理なのですが、これはUnityエンジン内部のC++コードを呼び出しているようです。
[MethodImpl(MethodImplOptions.InternalCall)]
[ThreadSafe(ThrowsException = true)]
public unsafe static extern void* MallocTracked(long size, int alignment, Allocator allocator, int callstacksToSkip);
そして話を戻すと NativeArray
がアンマネージドメモリを使用する=いくら使ってもGCが起きないなら全部コレで良くない? と私は考えたわけです。
ArrayPoolとかバッファ用のフィールド配列でいい気もしますが。
↑サイズをその時々で変えられるのは NativeArray
の特権!
検証
1.検証方法について
まず検証方法について説明します。
NativeArray
の性能を知るために、私は以下の内容で検証を行います。
- テスト環境はUnityのユニットテスト環境であるTestRunerとPerformance Testing Extension。
- Unity(ver 6000.0.35f1)で実行。
- int配列、Spanと比較。
- それぞれ要素数100000に設定し、for文で全要素をLong型の変数に合計していく処理の速度を競う。
テストコードのイメージ(展開)
long sum = 0;
// NativeArrayに連続アクセス
for ( int i = 0; i < nativeArray.Length; i++ )
{
sum += nativeArray[i];
}
UnityEngine.Debug.Log($"NativeArray Sum: {sum}");
このテストで分かることは NativeArray
の連続アクセスの速度です。
そして NativeArray
は多くの場合JobSystemと共に用いられるものであることから、JobSystem(IJobForとIJobParalleForの二通り)で同じ操作のテストも行います。
2.検証結果
続いて、前項の条件でテストした結果が以下になります。
検証結果 |
---|
![]() |
結果はSpanとNativeArrayが競っていますね。
Jobもなかなか速いようです。
しかしIJobParalleForがかなり遅いですね。
それに、ワーカーが複数(今回は5000)で配列にアクセスするせいか合計計算の結果も不正確でした。
ちなみにIJobParalleforの検証コードは以下です。
IJobParalleForのコード(展開)
[BurstCompile]
public struct ProcessArrayParallelJob : IJobParallelFor
{
[ReadOnly] public NativeArray<int> Input;
// 各スレッドの部分結果用配列(スレッドごとに異なるインデックスに書き込む)
[NativeDisableParallelForRestriction]
public NativeArray<long> PartialResults;
public void Execute(int index)
{
// 結果返却用の部分配列に足し込む
PartialResults[index % PartialResults.Length] += Input[index];
}
}
合計結果が不正確であることは予想していたのですが、速度がこうも遅いとは思いませんでした。
私のコードが悪いのもありそうですが……。
ちょっと気になったので色々と条件を変えてやってみました。
ワーカー数2500(ワーカー数減少) |
---|
![]() |
ワーカー数1万 |
---|
![]() |
ワーカー数10万 |
---|
![]() |
ワーカー数10万 & 配列サイズ50万 |
---|
![]() |
だめそう……。
また、画像取ってない状態でも色々試した結果、ワーカー数に比例して速度が微増するものの、5000~10000をピークに落ちていく印象でした。(10万ワーカーの結果はやはり速度が落ちていますね)
というか NativeArray
さん速いですね。
終わりに
今回の検証で NativeArray
さんの優秀性は証明できたのではないかと思います。
しかしやはり、こんな危険な代物を使うくらいならArrayPoolとかバッファ用のフィールド配列でいい気がするんですね。
検証しておいてなんですが、Spanとの差がこの程度なら使いどころは……。
せめてPhysics.RayCastNonAlloc()とかの引数に渡せるようになればよいのですが。
なので、どちらかというと必要だったのは NativeList<T>
や Hashmap<T>
の検証だった気がします。
上記二つはリストとDictionaryにおけるNativeArrayの立ち位置にいるやつら、といった物です。
もしこの二つが速いならかなりの価値があると思います。
リストは CollectionsMarshal.AsSpan<T>
でSpan化できるので差し迫った必要性はないかもしれないですが、高速でアロケーションなしのDictionaryなんかあったら大事件ですね。
自分も自作ゲームのキャラデータ管理用に< Key,Value >を< string,int >か< int,int >に限定して、色々と制約をつける代わりに高速な連想配列を作れないか考えていたところだったため、タイムリーな話題です。
Hashmapさんを使えよ、で終われるなら素晴らしい話ですそれはそれで。
なので次はこっちのテストをしてみたいと思います。
それでは今回の記事は終わりです。
ご意見やご指摘いただけるとすごく嬉しいです。
参考文献
NativeArrayの公式ドキュメント
Scripting API: NativeArray
UnityのTestRunnerの参考記事