NativeContainerを自作したい


はじめに

みなさん高速化、はかどっていますか?こんなマイナーな単語にたどり着いたということはつまりそういうことですね。

C# Job SystemBurst Compilerで配列を扱う場合NativeArray<T>Span<T>等を使用しますが、絶妙に自分と使い方が合わない場合がありますよね。そんなときはNativeContainerを自作してしまいましょう。 大丈夫、怖くない。 今回は二次元配列版NativeArray<T>NativeArray2D<T>を作ります。

NativeContainerという単語にたどり着いた方なら読める内容を目指していますが。不明な点等ありましたら、お気軽にDM等で訊いていただけると幸いです。

ソースコード

大体のことはリファレンスに書いてあったりします。かなり読みづらいけど NativeContainerAttribute

また、NativeArray<T>と比較して読むと分かりますが、変更する要素はほぼないです。 NativeArray<T>のソースコードをコピペして少し手入れすればたいていの用途に対して応えられると思います。

NativeArray2D.cs
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.

要するにNativeContainerを作るなら付けてということです。


NativeContainerSupportsMinMaxWriteRestriction

[NativeContainerSupportsMinMaxWriteRestriction]

The [NativeContainerSupportsMinMaxWriteRestriction] enables a common jobification pattern where an IJobParallelFor is split into ranges

IJobParallelForNativeArray<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 > OnSafety Checksを有効にしておくとENABLE_UNITY_COLLECTIONS_CHECKStrueとなり、読み込み・書き込みともに次の処理が通ります。

![[images/create-native-container/burst-settings.jpg|UnityでのSafety Checksを有効にする方法]]

#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の値がコンストラクタで設定された値から変化することはありません。ですのでIJobParallelForExecuteメソッドが呼び出される前までは通常の配列だった場合と同様のアクセス範囲となります。ではいつm_MinIndexおよびm_MaxIndexの値は変更されるのでしょうか?

ここでIJobParallelForの実装を見てみましょう。 Runtime/Jobs/Managed/IJobParallelFor.cs

IJobParallelFor.cs L.44
JobsUtility.PatchBufferMinMaxRanges(bufferRangePatchData, UnsafeUtility.AddressOf(ref jobData), begin, end - begin);

怪しいメソッド名ですね…定義を見てみましょう。

JobsUtility
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)で渡されたインデックスの要素にしかアクセスできないということになります。

Debugger

[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.

ToArrayDisposeOutOfRangeメソッドに関しては、変更要素も取り立てて解説する要素も少ないので割愛します。

おわりに

安全性チェックが長いというだけで、頭の中でSafety Checksを無効化するだけで普通の配列とさして変わらないことが分かったと思います。

また、初めての記事投稿で拙い部分も多くみられたと思いますが、不明な点等ありましたら、お気軽にコメントで聞いてください。というか皆さんがどんな高速化手法をしているか気になるのでぜひお願いします。

皆さんもよい高速化ライフを!

参考・関連リンク

【Unity】NativeArrayについての解説及び実装コードを読んでみる

【Unity】NativeArrayを使いこなせ

【Unity】UnsafeUtilityについて纏めてみる

Unity

NativeContainerAttribute

IJobParallelFor.cs

今日から速い!Unity の Burst + C# Job System