🗂️

NativeContainerを自作したい

2024/08/09に公開

はじめに

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

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

NativeContainerという単語にたどり着いた方なら読める内容を目指していますが。不明な点等ありましたら、お気軽にコメントで聞いてください。

ソースコード

大体のことはリファレンスに書いてあったりします。かなり読みづらいけど
https://docs.unity3d.com/ScriptReference/Unity.Collections.LowLevel.Unsafe.NativeContainerAttribute.html

また、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

NativeArray2D.cs
[NativeContainer]

Marks our struct as a NativeContainer.

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


NativeContainerSupportsMinMaxWriteRestriction

NativeArray2D.cs
[NativeContainerSupportsMinMaxWriteRestriction]

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

IJobParallelForNativeArray<T>を使うとExecute(int index)の引数で指定されたインデックスにしかアクセスできませんよね。これも安全性チェックの一つなのですが踏み込むと長くなるので読みたい人だけ。

安全性の実現

このアクセス制限を実現しているのは以下の3つのフィールドです。

NativeArray2D.cs

        internal int m_MinIndex;
        internal int m_MaxIndex;

        internal AtomicSafetyHandle m_Safety;

コンストラクタでは以下のように初期値が設定されているため、全ての要素にアクセス可能です。

NativeArray2D.cs

        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となり、読み込み・書き込みともに次の処理が通ります。

NativeArray2D.cs

#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の実装を見てみましょう。
https://github.com/Unity-Technologies/UnityCsReference/blob/master/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)で渡されたインデックスの要素にしかアクセスできないということになります。


NativeArray2D.cs
    [DebuggerDisplay("Length = {Length}")]
    [DebuggerTypeProxy(typeof(NativeArray2DDebugView<>))]

この二つはVisual Studio等でのデバッガに使う属性です。動作に影響はしませんので割愛します。

Fields/Properties

NativeArray2D.cs

        [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

NativeArray2D.cs

        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を無効化するだけで普通の配列とさして変わらないことが分かったと思います。

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

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

参考・関連リンク

https://qiita.com/mao_/items/220ccf3b2ef929388036

https://annulusgames.com/blog/unity-nativearray/

https://qiita.com/mao_/items/fc9b4340b05e7e83c3ff

Unity

https://docs.unity3d.com/ScriptReference/Unity.Collections.LowLevel.Unsafe.NativeContainerAttribute.html

https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Jobs/Managed/IJobParallelFor.cs/

https://youtu.be/rvBpUPFN5_I?si=poti50ExEjrJ-jzA

Discussion