LoginSignup
49
30

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-02-28

Unity2018.x系統からNativeContainerが入り、それに伴う機能としてかUnityのエンジン側で確保したネイティブメモリ領域(アンマネージドメモリ)を操作すること等が出来る「UnsafeUtility と言うAPIが追加されました。

UnsafeUtility自体はNativeConteinerの内部実装などで使用されている機能であり、使い方を把握すれば自身でNaticeContainerを自作すると言った事も可能となります。1

NativeConteinerの性質上、ひょっとしたらECS/JobSystemと言ったアンマネージドメモリに対する操作を必要とする処理以外ではあまり使う機会が無いかもしれませんが...必要とする所で使い方を把握しておくとコンテナの自作以外にも色々と応用を利かせられるかもしれません。
そこで今回は自分が趣味でECSやJobSystemを使う上で応用してみた実装例などを踏まえつつ、知見の方を備忘録序にメモしていこうと思います。

実装/動作環境

  • Unity 2018.3.6f1

※その他注意点

  • UnsafeUtility自体まだあまり情報が出ていないために、今回の解説や実装例に関しても割と手探りな部分が多い内容となります。(ひょっとしたら推奨される使い方をしていないと言った可能性も無きにしもあらず...)
  • 前提としてポインタの知識は必要となります。こちらの記事中ではポインタについての解説まではそこまで触れていないのでご了承下さい。。

Unityに於けるメモリ領域について

以降の解説でも深く関わってくる内容となるので、先にUnityに於けるメモリ領域について軽くおさらいしておきます。
Unityにはスタック領域を除くと大きく分けて以下2種類のメモリ領域があります。

マネージドメモリ (マネージドヒープ)

  • C#側で使用されているメモリであり、GC(Garbage Collection)の対象となるメモリ領域
  • メモリが確保出来なくなると自動的にヒープを拡張する
    • 拡張されたヒープは基本返ってこない (※後に大きな割当が行われた際にヒープを再拡張しなくても済むようにする為)
  • GCのアルゴリズム(Boehm GC)の性質上、ヒープが拡張するのに応じてパフォーマンスが劣化するので圧迫には注意する必要がある

詳細については以下のドキュメントを御覧ください。

ネイティブメモリ (アンマネージドメモリ)

  • Unityのエンジン側が確保するネイティブメモリ領域 (Textureと言ったAsset等が含まれる)
  • GCの対象外
    • 確保したら自分で解放する必要がある
  • NativeArrayを含めたNativeContainerで確保するメモリもこちらに属する
    • 内部的にはUnsafeUtility.Mallocが呼び出される (詳細は後述)

ネイティブメモリの確保と解放

ネイティブメモリの確保と解放はMallocFreeから行えます。

この時に必要となるsizeof(型のメモリサイズを取得する物)やalignof(型のメモリアライメント2を取得する物)も同様にUnsafeUtilityのメソッドとして実装されてます。
※確保したメモリの使いみちと言った具体例については後述

以下に簡単なサンプルコードを載せます。

// 確保するサイズ
var size = UnsafeUtility.SizeOf<int>();
// メモリアライメント
var alignment = UnsafeUtility.AlignOf<int>();

// ネイティブメモリの確保
void* ptr = UnsafeUtility.Malloc(size, alignment, Allocator.Persistent);

// 確保したメモリの解放
UnsafeUtility.Free(ptr, Allocator.Persistent);

上記でやっていることは単純にSizeOfAlignOfからサイズ/アライメントを取得し、それらをMallocの引数として確保を行ってます。
後は必要に応じたAllocatorの方も指定します。(Allocatorについては後述)

確保したメモリについては汎用ポインタ(void*)が戻り値として返ってくるので、基本的にはこちらを経由してデータのアクセスを行なうこととなり、後は不要になったタイミングでFreeを呼び出して解放を行えばokです。

※不正なメモリ操作に注意

ポインタについては操作をミスるとクラッシュしたりする事もあるので、取り扱いには注意する必要があります。

Unityのバージョンや指定するAllocatorによって挙動が変わるかもしれませんが...組み合わせが悪いと解放済みのポインタに対してもう一度Freeを呼び出すことで確実にクラッシュさせることが出来ます。

もし設計上、メモリ解放処理が複数回呼び出される可能性のある様な作りになっていたりすると、ロジックのミスなどで解放済みのポインタに対し再度解放を行ってしまってクラッシュ...なんて事が起こりえるかもしれないので、これらを踏まえて設計/取り扱いには注意する必要があります。

sizeof演算子SizeOf<T>の違いについて

自分も詳細までは把握しきれてませんが...UnsafeUtility.SizeOf<T>をRiderを経由してdecompileされた結果を見た感じだと、内部的にはsizeof演算子が呼ばれているように見受けられました。

public static int SizeOf<T>() where T : struct => sizeof (T);

このことからMarshal.SizeOfの様に挙動そのものに違いが出てくることは無いかと思われます。
参考: Marshal.SizeOfとsizeofの違い

強いて言うならUnsafeUtility.SizeOfunsafeコンテキスト外でも呼び出すことが可能です。(sizeof演算子で任意の参照型を含まない型のサイズを返すにはunsafeコンテキストが必要)

後は以下のForumを参照したところだと、Burstとそれ以外でも挙動が変わるみたいです。

Allocatorについて

指定できるAllocatorの種類についても軽く補足しておきます。
Allocatorは以下の5種類から指定することが可能ですが、実際に使うのは太字の3種類になるかと思われます。
※ちなみにNativeArray生成時に前者2つを指定するとArgumentExceptionが投げられる。

  • Invalid
    • 無効な割り当て?
    • 正しいやり方かは分からないが...NativeArrayUnsafeUtility.ConvertExistingDataToNativeArrayを用いて既に割り当て済みのポインタをNativeArrayに変換する際に使用する例を見た覚えがある.. :thinking:
  • None
    • 割り当て無し。どのタイミングで使うかについては調べきれておらず...
  • Temp
    • メモリの割り当てと解放が最も高速
    • そのフレームのみで有効
  • TempJob
    • 割り当てと解放はTempよりは遅い
    • 4フレーム以内に解放しないとエラー
    • JobのフィールドにDeallocateOnJobCompletion属性をセットしておく事でJob終了時に自動で解放する事が可能
  • Persistent
    • 割り当てと解放は最も遅い
    • 永続的に使用可能

※ちなみに補足情報として@pCYSl5EDgoさんから教えて貰ったこちらのForumに以下の書き込みがあり、中の人曰く↓との事です。

UnsafeUtility.Malloc has different allocators with massively different performance characteristics.

Temp is a stack allocator per thread. TempJob is reusing on a per frame basis across jobs. (Both of those are very fast and meant for allocations every frame)

Persistent is a TLSF allocator when lifetime is unknown.
All of them significantly faster than system allocation.

Essentially using Marshal.AllocHGlobal is always a bad idea.

Allocator.PersistentについてはTLSFメモリアロケータになるみたいですね。

メモリリークに注意

Mallocで確保したメモリについては当然ながら解放を忘れるとメモリリークします。
※その上で挙動を見た感じだとEditor上でメモリリークしたらEditorを再起動するまで返ってこない気がする...。

自前メモリ管理だとどうしても付き纏ってきてしまう問題ではありますが...
NativeArray等についてはEditor環境のみではあるもののリークを監視する機能が予め備わっており、仮に解放が抜けていてメモリリークを起こしても以下のようなエラーが出力されて検知できるようになっております。

A Native Collection has not been disposed, resulting in a memory leak. It was allocated at (発生したソースのパス):(行数).

「NativeArray等については」上記のように保守性の高い実装となっているので、漏れた際のエラーハンドリングも比較的行いやすくなっているかと思われますが...UnsafeUtility.Mallocを直で叩いて確保するのはまた別の話であり、Mallocの内部実装自体にリーク検知が実装されていないので仮に解放を忘れてしまうと無言のままリークしてしまうことになります。その為に直で確保する際には特に注意する必要があります。

とは言え...注意すれと言えども限界があるかと思われるので、使う際にはMallocを直に叩かずにNativeArray同様にリーク検知機能を付ける形でラップしてから使うように検討してみても良いかもしれません。

以下のコードは私の方で試しに実装してみたものとなります。
ぶっちゃけやっている事としては長さが1のNativeArrayと変わりない感ありますが...ご参考までに。
※ちなみにリーク検知用にDisposeSentinel(参照型)をフィールドに持つためにNativeArrayと同様に非Blittableな構造体となっている点だけ注意。その為にBlittable制限のある構造体のフィールドに渡す際にはGetUnsafePtr経由でポインタを渡す必要があったりする。

// https://gist.github.com/mao-test-h/f1ed901083426d539afb823449e5a1b8
using System;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;

public unsafe struct NativeObject<T> : IDisposable
    where T : unmanaged
{
    [NativeDisableUnsafePtrRestriction] readonly T* _buffer;
    readonly Allocator _allocatorLabel;

#if ENABLE_UNITY_COLLECTIONS_CHECKS
    [NativeSetClassTypeToNullOnSchedule] DisposeSentinel _disposeSentinel;
    AtomicSafetyHandle _safety;
#endif

    public T Value
    {
        get
        {
#if ENABLE_UNITY_COLLECTIONS_CHECKS
            AtomicSafetyHandle.CheckReadAndThrow(_safety);
#endif
            return *_buffer;
        }
        set
        {
#if ENABLE_UNITY_COLLECTIONS_CHECKS
            AtomicSafetyHandle.CheckWriteAndThrow(_safety);
#endif
            *_buffer = value;
        }
    }

    public bool IsCreated => _buffer != null;

    public T* GetUnsafePtr
    {
        get
        {
#if ENABLE_UNITY_COLLECTIONS_CHECKS
            AtomicSafetyHandle.CheckWriteAndThrow(_safety);
#endif
            return _buffer;
        }
    }

    public T* GetUnsafeReadOnlyPtr
    {
        get
        {
#if ENABLE_UNITY_COLLECTIONS_CHECKS
            AtomicSafetyHandle.CheckReadAndThrow(_safety);
#endif
            return _buffer;
        }
    }

    public T* GetUnsafeBufferPointerWithoutChecks => _buffer;



    public NativeObject(Allocator allocator, T value = default)
    {
#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 NativeObject<{0}> must be blittable", typeof(T)));
        }
#endif

        var size = UnsafeUtility.SizeOf<T>();
        this._buffer = (T*) UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf<T>(), allocator);
        this._allocatorLabel = allocator;
        *this._buffer = value;

#if ENABLE_UNITY_COLLECTIONS_CHECKS
        DisposeSentinel.Create(out _safety, out _disposeSentinel, 0, allocator);
#endif
    }

    public void Dispose()
    {
#if ENABLE_UNITY_COLLECTIONS_CHECKS
        if (!UnsafeUtility.IsValidAllocator(_allocatorLabel))
        {
            throw new InvalidOperationException("Can not be Disposed because it was not allocated with a valid allocator.");
        }
        DisposeSentinel.Dispose(ref _safety, ref _disposeSentinel);
#endif

        UnsafeUtility.Free(_buffer, _allocatorLabel);
    }
}

使用例

メモリの確保周りの解説が終わった所で使用例についても軽く纏めておきます。
※例と言えども解説するのはあくまで私個人の考察となるのでご了承下さい。

Blittable制限がある構造体に持たせられる共通データとして使用

フィールドにBlittable制限がある構造体として、例えばNativeArrayとして持つ想定のデータやECSのIComponentDataを実装したデータなどが挙げられますが、これらは参照型をフィールドの持たせることが出来ないために基本的にはBlittable型をベースとした値のやり取りが発生します。
※Blittable型の詳細については後述の「Blittable型について」にて解説しているのでそちらを参照。

この際に出てくる課題として「変更する可能性のある共通のデータ」を持たせたいとした場合には共通データとして参照型を渡すことが出来ないので、やるとしたら主に以下の実装などが考えられるかと思われます。

  • 愚直に値をインスタンス毎にコピーして渡す
    • → 値が不変であるならまだしも、変更が掛かるなら反映のコストが掛かる可能性
    • → メモリ使用量が多くなる
  • static領域に置いて参照
    • → データ管理の観点やJobに於けるBurstCompilerが使えなくなると言った懸念点がある
  • 共通データのポインタを渡して参照させる
    • → メモリリークに気を付ける必要がある。

幾つかの例はあれど今回解説する内容としては一番下の項目にあるポインタ渡しによる共通データ管理について軽く掘り下げていきます。

共通データ自体は上述のUnsafeUtility.Mallocでメモリを確保することでGCの影響が掛からないメモリのポインタを取得する事が可能です。ポインタ型自体はBlittable型となるので、後は取得したポインタを必要とする構造体のフィールドに渡す事で運用が可能となります。
※補足としてメモリの確保についてはMallocによるネイティブメモリの確保でなくとも、マネージド側にてGCHandleでアドレスと固定しポインタを取得すると言った手も考えられる。(但し長く持ってるとGCの効率が悪くなりそうな予感..使い所によりそう)

簡単な実装例としては以下な感じとなります。
NativeArray<Bullet>として持つstruct BulletのフィールドにSharedBulletParam*を持たせております。
後はロジック側でポインタから値を参照するだけです。

// 弾の共通データ
public struct SharedBulletParam
{
    public float Speed;
    public float Damage;
}

// 個別に持つ弾データ(NativeArrayとして持つのでBlittable型である必要)
public unsafe struct Bullet
{
    public SharedBulletParam* SharedBulletParamPtr;
    public float Angle;        // 角度
    public float Lifespan;     // 生存時間
}

void Initialize()
{
    // 確保するメモリサイズ
    var size = UnsafeUtility.SizeOf<SharedBulletParam>();
    // メモリアライメント
    var alignment = UnsafeUtility.AlignOf<SharedBulletParam>();
    // ネイティブメモリの確保
    var sharedBulletPtr = (SharedBulletParam*)UnsafeUtility.Malloc(
        size, alignment, Allocator.Persistent);

    const int BulletCount = 1000;
    var bullets = new NativeArray<Bullet>(BulletCount, Allocator.Persistent);
    for (int i = 0; i < BulletCount; i++)
    {
        bullets[i] = new Bullet
        {
            // 各インスタンスに共通データのポインタを持たせる
            SharedBulletParamPtr = sharedBulletPtr,
        };
    }

    // ※使い終わったらNativeArrayとMallocしたポインタを解放すること
}

実際に上記の管理方法自体は「ECSで弾幕STGを実装した際の共通データの管理周り」や「ECS/JobSystemベースのSpringBoneを実装した際の共通データの管理」などで使用しております。
以下に参考記事や公開プロジェクトのリンクを載せておくので宜しければ御覧ください。

※補足 : ISharedComponentDataとの違い

ECSの話題にはなりますが、こちらには共通データとして持たせる想定のISharedComponentDataという物があります。
その為に「ECSならポインタでなくても共通データはISharedComponentDataで管理すれば良いのでは?」と思わなくも無かったので、両者の違いについて軽く纏めてみました。

  • ISharedComponentData
    • フィールドに参照型を持てる。(故にMaterial等を必要とする既存の描画システム(Hybrid Renderer)辺りでは使用されていたりする)
    • 値が変化する想定の作りでは無いように思われる。
    • BurstCompilerを有効にしたJobのフィールドにSharedComponentDataArrayを渡すことが出来ない?
  • ポインタ型
    • Blittable型の制限は付くものの要件を満たせるなら使い勝手は良い。
      • ポインタが指すデータを書き換えることで動的な変更も可能。
      • BurstCompilerを有効にしたJobでも使用可能。
    • メモリ管理に注意する必要がある。(解放忘れなど)

SharedComponentDataについては完全に調べきれていないところもありますが...見た感じだとお互いに向き不向きがある印象なので、どちらか一方だけで済ませずに状況に応じて使い分けていけば良いという感じはしました。

データのキャッシュに使用

以前「【Unity】ファイルを非同期で読み込んでアンマネージドメモリに展開できるAsyncReadManagerを試してみた」という記事を書きました。

簡単に概要を説明すると、Unity2018.3辺りからAsyncReadManagerと言うAPIが追加されたので、こちらを用いて非同期且つ読み込んだファイルデータをネイティブメモリに乗っける形でキャッシュ出来ないか検証してみたという内容となります。3

記事中での検証内容としては巨大なCSVをパースして読み込んだデータをネイティブメモリにキャッシュする所までとなりますが、もう少し発展させて「Jobベース行う様々なデータ形式のシリアライズ/デシリアライズ」「暗号化/復号化処理」と言った所を検証して使えるところまで調べきれると、非同期且つマネージドヒープを圧迫しないデータ管理が出来るかもしれないと言った可能性があります。

あくまで仮説を前提とした内容且つ簡単な検証までとなりますが、詳細に関しては記事の方を御覧下さい。

補足情報

Blittable型について

Blittable型とは「マネージド(C#)とネイティブ(C++)でメモリレイアウトが同じになる型」の事を指します。
該当する型について簡単に纏めると以下の型などが該当します。

注意点として値型とは必ずしもイコールの関係ではなく、例えばchar型bool型などは非Blittable型に属します。

  • byte
  • sbyte
  • short
  • ushort
  • int
  • uint
  • long
  • ulong
  • IntPtr
  • UIntPtr
  • float
  • double
  • Blittable型の固定長一次元配列
  • Blittable型のみを含む構造体

こちらを指定する意味合いとしては、マネージド側とネイティブ側でやり取りをする際のマーシャリングコスト(メモリコピー)を回避する意義があります。
詳細については以下の記事が参考になります。

※追記

Unity2019系統からは特別に?boolcharがBlittable型として扱われるようになりました。

ただ、広義の意味でのBlittable型の定義が変わるわけでは無いかと思われるので、Unityのネイティブメモリ内部に限定される仕様と捉えておくのが安全かもしれません。
(ここらについては少し分からない点も多いので、分かり次第追って追記します :bow: :sweat_drops: )

Unity 2019.1 Release note
Scripting: Added ability to create NativeArrays of bool and char and types containing bool and char. (1127499, 1129523)

データのコピー周りに関するTips

UnsafeUtilityにはデータのコピー周りに関するAPIが幾つか存在します。
例えば個人的によく使うものとして、構造体の値をポインタにコピー出来るCopyStructureToPtrとその逆を行えるCopyPtrToStructureがあります。
以下に簡易実装例を載せます。

struct SampleStr
{
    public int Param1;
    public float Param2;
    public SampleStr(int index)
    {
        Param1 = index;
        Param2 = index + index / 10f;
    }
    public override string ToString()
    {
        return $"{Param1}, {Param2}";
    }
}

void Start()
{
    // 確保するメモリサイズ
    var size = UnsafeUtility.SizeOf<SampleStr>();
    // メモリアライメント
    var alignment = UnsafeUtility.AlignOf<SampleStr>();
    // ネイティブメモリの確保
    var ptr = (SampleStr*)UnsafeUtility.Malloc(size, alignment, Allocator.Persistent);

    // ----------------------------------------
    // 構造体の値をポインタにコピー
    SampleStr sample = new SampleStr(8);
    UnsafeUtility.CopyStructureToPtr(ref sample, ptr);

    // > 8, 8.88
    Debug.Log(*ptr);


    // ----------------------------------------
    // ポインタが指す値を構造体にコピー
    var dest = new SampleStr();
    UnsafeUtility.CopyPtrToStructure(ptr, out dest);

    // > 8, 8.88
    Debug.Log(dest);


    UnsafeUtility.Free(ptr, Allocator.Persistent);
}

その他UnsafeUtility

UnsafeUtilityは他にも幾つかの種類があり、例えばNativeArrayに関する操作に特化したNativeArrayUnsafeUtilityと言うAPI等があったりします。

出来る事の一例を上げるとGetUnsafePtrと言う拡張メソッドを呼び出すことでNativeArrayのポインタを取得することが出来ます。
→ こちらについては上述の「Blittable制限がある構造体に持たせられる共通データとして使用」でもご紹介したBlittable制限のある構造体のフィールドにNativeArrayを持たせる際などに使えたりします。

以下にポインタの取得からフィールドのアクセスを踏まえたサンプルコードを載せておきます。

struct SampleStr
{
    public int Param1;
    public float Param2;
    public SampleStr(int index)
    {
        Param1 = index;
        Param2 = index + index / 10f;
    }
    public override string ToString()
    {
        return $"{Param1}, {Param2}";
    }
}

void Start()
{
    // SampleStrを8個生成
    var nativeArray = new NativeArray<SampleStr>(8, Allocator.Persistent);
    for (var i = 0; i < nativeArray.Length; i++)
    {
        nativeArray[i] = new SampleStr(i);
    }

    // 拡張メソッドとして実装されているのでこれで取得可能
    var ptr = (SampleStr*) nativeArray.GetUnsafePtr();

    // > "0, 0"
    // ※"Debug.Log(nativeArray[0])"と同等
    Debug.Log(*(ptr));

    // > "2, 2.2"
    // ※"Debug.Log(nativeArray[2])"と同等
    Debug.Log(*(ptr + 2));

    // > "3.3"
    // ※アロー演算子(->)でフィールドにアクセスできる
    // ※"Debug.Log(nativeArray[3].Param2)"と同等
    Debug.Log((ptr + 3)->Param2);

    nativeArray.Dispose();
}

ちなみにNativeArrayUnsafeUtilityには上記で説明したGetUnsafePtr以外にもGetUnsafeReadOnlyPtrGetUnsafeBufferPointerWithoutChecksと言う拡張メソッドも存在します。
こちらについて調べてみた所、実装としては以下のようになっていたので、主な違いとしてはメモリチェックの有無のようでした。
最終的に取得できる値自体に違いは無いものの、可能であれば明示的に指定した方が安全性/コードの見通しを含めて良いかもしれません。

public static unsafe void* GetUnsafePtr<T>(this NativeArray<T> nativeArray) where T : struct
{
    AtomicSafetyHandle.CheckWriteAndThrow(nativeArray.m_Safety);
    return nativeArray.m_Buffer;
}

public static unsafe void* GetUnsafeReadOnlyPtr<T>(this NativeArray<T> nativeArray) where T : struct
{
    AtomicSafetyHandle.CheckReadAndThrow(nativeArray.m_Safety);
    return nativeArray.m_Buffer;
}

public static unsafe void* GetUnsafeBufferPointerWithoutChecks<T>(NativeArray<T> nativeArray) where T : struct
{
    return nativeArray.m_Buffer;
}

参考/関連サイト

自作NativeContainerについて


  1. 例えばNativeArrayも内部実装で使用している。詳細はNativeArrayの公開ソースを参照。 

  2. メモリアライメントについてはこちらの記事がわかりやすいです。 

  3. ネイティブメモリに乗っける意味合いとしては、サイズの大きいデータを取り扱う際のI/Oのメモリ負荷は地味に高く、場合によってはマネージドヒープを圧迫してしまう事が考えられたのでこの問題を回避するため。 

49
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
49
30