NativeContainerを自作したい
はじめに
みなさん高速化、はかどっていますか?こんなマイナーな単語にたどり着いたということはつまりそういうことですね。
C# Job SystemやBurst Compilerで配列を扱う場合NativeArray<T>
やSpan<T>
等を使用しますが、絶妙に自分と使い方が合わない場合がありますよね。そんなときはNativeContainer
を自作してしまいましょう。 大丈夫、怖くない。 今回は二次元配列版NativeArray<T>
、NativeArray2D<T>
を作ります。
NativeContainer
という単語にたどり着いた方なら読める内容を目指していますが。不明な点等ありましたら、お気軽にコメントで聞いてください。
ソースコード
大体のことはリファレンスに書いてあったりします。かなり読みづらいけど
また、NativeArray<T>
と比較して読むと分かりますが、変更する要素はほぼないです。
NativeArray<T>
のソースコードをコピペして少し手入れすればたいていの用途に対して応えられると思います。
using System;
using System.Diagnostics;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Collections;
using Unity.Burst;
[NativeContainer]
[NativeContainerSupportsMinMaxWriteRestriction]
[DebuggerDisplay("Length = {Length}")]
[DebuggerTypeProxy(typeof(NativeArray2DDebugView<>))]
public unsafe struct NativeArray2D<T> : IDisposable where T : unmanaged
{
[NativeDisableUnsafePtrRestriction]
// 変数名は「m_Buffer」で固定
internal void* m_Buffer;
// 変数名は「m_Length」で固定
internal int m_Length;
#if ENABLE_UNITY_COLLECTIONS_CHECKS
// 変数名は「m_MinIndex」で固定
internal int m_MinIndex;
// 変数名は「m_MaxIndex」で固定
internal int m_MaxIndex;
// 変数名は「m_Safety」で固定
internal AtomicSafetyHandle m_Safety;
internal static readonly SharedStatic<int> s_staticSafetyId =
SharedStatic<int>.GetOrCreate<NativeArray2D<T>>();
#endif
// 変数名は「m_AllocatorLabel」で固定
internal Allocator m_AllocatorLabel;
public NativeArray2D(int height, int width, Allocator allocator)
{
long size = UnsafeUtility.SizeOf<T>() * width * height;
#if ENABLE_UNITY_COLLECTIONS_CHECKS
if (allocator <= Allocator.None)
throw new ArgumentException("Allocator must be Temp, TempJob or Persistent", nameof(allocator));
if (!UnsafeUtility.IsBlittable<T>())
throw new ArgumentException(string.Format("{0} used in NativeCustomArray<{0}> must be blittable", typeof(T)));
if (width < 0)
throw new ArgumentOutOfRangeException(nameof(width), "Width must be >= 0");
if (height < 0)
throw new ArgumentOutOfRangeException(nameof(height), "Height must be >= 0");
#endif
m_Buffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf<T>(), allocator);
UnsafeUtility.MemClear(m_Buffer, size);
Width = width;
Height = height;
m_Length = width * height;
m_AllocatorLabel = allocator;
#if ENABLE_UNITY_COLLECTIONS_CHECKS
m_MinIndex = 0;
m_MaxIndex = m_Length - 1;
m_Safety = CollectionHelper.CreateSafetyHandle(allocator);
CollectionHelper.SetStaticSafetyId<NativeArray2D<T>>(ref m_Safety, ref s_staticSafetyId.Data);
#endif
}
public int Length { get { return m_Length; } }
// NativeArray2D独自のプロパティ
public int Width { get; }
// NativeArray2D独自のプロパティ
public int Height { get; }
public unsafe T this[int y, int x]
{
get
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
AtomicSafetyHandle.CheckReadAndThrow(m_Safety);
if (y * Width + x < m_MinIndex || y * Width + x > m_MaxIndex) OutOfRangeError(y * Width + x);
#endif
return UnsafeUtility.ReadArrayElement<T>(m_Buffer, y * Width + x);
}
set
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
AtomicSafetyHandle.CheckWriteAndThrow(m_Safety);
if (y * Width + x < m_MinIndex || y * Width + x > m_MaxIndex) OutOfRangeError(y * Width + x);
#endif
UnsafeUtility.WriteArrayElement(m_Buffer, y * Width + x, value);
}
}
public T[,] ToArray()
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
AtomicSafetyHandle.CheckReadAndThrow(m_Safety);
#endif
T[,] array = new T[Height, Width];
for (int i = 0; i < Height; ++i)
{
for (int j = 0; j < Width; ++j)
{
array[i, j] = UnsafeUtility.ReadArrayElement<T>(m_Buffer, i * Width + j);
}
}
return array;
}
public bool IsCreated
{
get { return m_Buffer != null; }
}
public void Dispose()
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
CollectionHelper.DisposeSafetyHandle(ref m_Safety);
#endif
UnsafeUtility.FreeTracked(m_Buffer, m_AllocatorLabel);
m_Buffer = null;
m_Length = 0;
Width = 0;
Height = 0;
}
#if ENABLE_UNITY_COLLECTIONS_CHECKS
private void OutOfRangeError(int index)
{
if (index < m_Length && (m_MinIndex != 0 || m_MaxIndex != m_Length - 1))
throw new IndexOutOfRangeException(string.Format(
"Index {0} is out of restricted IJobParallelFor range [{1}...{2}] in ReadWriteBuffer.\n" +
"ReadWriteBuffers are restricted to only read & write the element at the job index. " +
"You can use double buffering strategies to avoid race conditions due to " +
"reading & writing in parallel to the same elements from a job.",
index, m_MinIndex, m_MaxIndex));
throw new IndexOutOfRangeException(string.Format("Index {0} is out of range of '{1}' Length.", index, Length));
}
#endif
}
internal sealed class NativeArray2DDebugView<T> where T : unmanaged
{
private NativeArray2D<T> m_Array;
public NativeArray2DDebugView(NativeArray2D<T> array)
{
m_Array = array;
}
public T[,] Items
{
get { return m_Array.ToArray(); }
}
}
主に安全性チェックが長い。UnsafeUtilityすき
でも固定の変数名が多く、テンプレートさえ覚えてしまえば作るのは意外と簡単。
解説
Attribute
NativeContainer
[NativeContainer]
Marks our struct as a NativeContainer.
要するにNativeContaner
を作るなら付けてということです。
NativeContainerSupportsMinMaxWriteRestriction
[NativeContainerSupportsMinMaxWriteRestriction]
The [NativeContainerSupportsMinMaxWriteRestriction] enables a common jobification pattern where an IJobParallelFor is split into ranges
IJobParallelFor
でNativeArray<T>
を使うとExecute(int index)
の引数で指定されたインデックスにしかアクセスできませんよね。これも安全性チェックの一つなのですが踏み込むと長くなるので読みたい人だけ。
安全性の実現
このアクセス制限を実現しているのは以下の3つのフィールドです。
internal int m_MinIndex;
internal int m_MaxIndex;
internal AtomicSafetyHandle m_Safety;
コンストラクタでは以下のように初期値が設定されているため、全ての要素にアクセス可能です。
public NativeArray2D(int height, int width, Allocator allocator)
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
m_MinIndex = 0;
m_MaxIndex = m_Length - 1;
#endif
}
また、以下のようにJobs > Burst > Safety Checks > On
でSafety Checks
を有効にしておくとENABLE_UNITY_COLLECTIONS_CHECKS
がtrue
となり、読み込み・書き込みともに次の処理が通ります。
#if ENABLE_UNITY_COLLECTIONS_CHECKS
AtomicSafetyHandle.CheckReadAndThrow(m_Safety);
if (y * Width + x < m_MinIndex || y * Width + x > m_MaxIndex)
OutOfRangeError(y * Width + x);
#endif
NativeArray
のコードだけではm_MinIndex
およびm_MaxIndex
の値がコンストラクタで設定された値から変化することはありません。ですのでIJobParallelFor
でExecute
メソッドが呼び出される前までは通常の配列だった場合と同様のアクセス範囲となります。ではいつm_MinIndex
およびm_MaxIndex
の値は変更されるのでしょうか?
ここでIJobParallelFor
の実装を見てみましょう。
JobsUtility.PatchBufferMinMaxRanges(bufferRangePatchData, UnsafeUtility.AddressOf(ref jobData), begin, end - begin);
怪しいメソッド名ですね...定義を見てみましょう。
public unsafe static extern void PatchBufferMinMaxRanges(IntPtr bufferRangePatchData, void* jobdata, int startIndex, int rangeSize);
Injects debug checks for min and max ranges of native array.
そうです、このメソッドによってm_MinIndex
がbeginの値に置き換わるわけです。
m_MaxIndex
についてはPatchBufferMinMaxRanges
の4つめの引数はrangeSize
、すなわちアクセス可能な要素数を渡します。ただし、end - begin
の値は通常1
になります。
そのため、IJobParallelFor
を継承したstruct
内のNativeArray<T>
はExecute(int index)
で渡されたインデックスの要素にしかアクセスできないということになります。
[DebuggerDisplay("Length = {Length}")]
[DebuggerTypeProxy(typeof(NativeArray2DDebugView<>))]
この二つはVisual Studio等でのデバッガに使う属性です。動作に影響はしませんので割愛します。
Fields/Properties
[NativeDisableUnsafePtrRestriction]
internal void* m_Buffer;
internal int m_Length;
internal Allocator m_AllocatorLabel;
#if ENABLE_UNITY_COLLECTIONS_CHECKS
internal int m_MinIndex;
internal int m_MaxIndex;
internal AtomicSafetyHandle m_Safety;
internal static readonly SharedStatic<int> s_staticSafetyId =
SharedStatic<int>.GetOrCreate<NativeArray2D<T>>();
#endif
m_Buffer
m_Buffer
に付与されている[NativeDisableUnsafePtrRestriction]
についてですが、以下のような説明がなされています。
By default unsafe Pointers are not allowed to be used in a job since it is not possible for the Job Debugger to gurantee race condition free behaviour. This attribute lets you explicitly disable the restriction on a job.
なんだか恐ろしい説明ですね。でもNativeContainerの安全性チェックをなめてはいけません。
安心して付与してあげましょう。
Indexer
public unsafe T this[int y, int x]
{
get
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
AtomicSafetyHandle.CheckReadAndThrow(m_Safety);
if (y * Width + x < m_MinIndex || y * Width + x > m_MaxIndex) OutOfRangeError(y * Width + x);
#endif
return UnsafeUtility.ReadArrayElement<T>(m_Buffer, y * Width + x);
}
set
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
AtomicSafetyHandle.CheckWriteAndThrow(m_Safety);
if (y * Width + x < m_MinIndex || y * Width + x > m_MaxIndex) OutOfRangeError(y * Width + x);
#endif
UnsafeUtility.WriteArrayElement(m_Buffer, y * Width + x, value);
}
}
AtomicSafetyHandle.CheckReadAndThrow
メソッドは引数に渡されたAtomicSafetyHandle
を用いて、そのメモリが読み取り可能であるかをチェックします。そのメモリがUnsafeUtility.Free
等で解放されているか、Jobが書き込み中の場合は例外をスローします。
Checks if the handle can be read from. Throws an exception if already destroyed or a job is currently writing to the data.
handle: The AtomicSafetyHandle to check.
AtomicSafetyHandle.CheckWriteAndThrow
メソッドは書き込みに関して、AtomicSafetyHandle.CheckReadAndThrow
メソッドと同様の動きをします。
Checks if the handle can be written to. Throws an exception if already destroyed or a job is currently reading or writing to the data.
handle: The AtomicSafetyHandle to check.
ToArray
、Dispose
、OutOfRange
メソッドに関しては、変更要素も取り立てて解説する要素も少ないので割愛します。
おわりに
安全性チェックが長いというだけで、頭の中でSafety Checks
を無効化するだけで普通の配列とさして変わらないことが分かったと思います。
また、初めての記事投稿で拙い部分も多くみられたと思いますが、不明な点等ありましたら、お気軽にコメントで聞いてください。というか皆さんがどんな高速化手法をしているか気になるのでぜひお願いします。
皆さんもよい高速化ライフを!
参考・関連リンク
Unity
Discussion