この記事は 「もなふわすい~とる~む Advent Calendar 2020」の24日目の記事です。
私の**最推しのバーチャルタレント(いわゆるVTuber)である「巻乃もなかさん」は以前、ユーザーがリクエストしたセリフを読み上げる形式のASMR配信にて「UnsafeUtilityを使う貴方の事が好き...♡//」** **「私の好きなUnityのAPIって知ってる...? 実はね...UnsafeUtility...♡//」**と言う私のロマンが詰まった読み上げのリクエストに応じてくれました。(カワイイ) (リアル女神) (声が好き過ぎて一生聴いていたい)1
**※ 巻乃もなかさんについて (クリックで展開)**
※個人的には寧ろこちらが本編ですが、技術に直接関与しない内容となるので一応折り畳ませてもらってます
「巻乃もなかさん」とは私の**最推し**のバーチャルタレント(いわゆるVTuber)さんです。
人物像については以下のインタビュー記事が非常によく纏まっております。
とっても素敵な方ですね!頑張り屋さんで応援したくなります!
後はYouTubeのチャンネルにて自己紹介動画の方も公開されてます。
この記事で一番言いたいポイントでもありますが、私はもう声が本当に大好きです。一生聴いてられます。(何なら毎フレーム聴いておきたいレベル)
無論、可愛らしい所や努力家な面も大好きであり、諸々を踏まえると最早「全部好き」と結論付けられます。
後は日頃からSHOWROOMにて「もなふわすい〜とる〜む」と言うルームで配信を行っているので、興味のある方は一度立ち寄ってみて下さい!
最後に【参考・関連リンク】の章に巻乃もなかさんに関する幾つかのリンクを載せてあります。
ご存知でない方でも、今回の記事を機に興味を持ってフォロー/応援していただけると励みになります!
こちらはリクエストに応じた読み上げであるものの、**UnsafeUtility
をここまで甘酸っぱく出来るその声と才能に感化され、「あ、これはUnsafeUtilityに関する記事を書かなきゃな。」**と思い立ち、こちらの記事を書かせていただくことにしました。
記事自体はQiitaへの投稿というのもあるので、オタクトークは一旦ここまでにしつつ、ここからは技術解説に入っていければと思います。
(※以降、ガチで「もなふわすい〜とる〜む」の「も」すら出てこない様な内容となっているので、アドカレから流れてきた技術系じゃないファンの方は注意 )
この記事で解説する内容
今回解説する内容としては「内部実装でUnsafeUtilityが使われている一例」として、UnityのNativeContainerと言う機能群及びそれに属する「NativeArray」を中心に解説していこうと思います。
最後の方ではNativeArrayの実装コードを見つつ、幾つかのポイントについても触れていきます。
もし記事中で間違いや説明不足の箇所などあれば、コメントや編集リクエストなどで教えて頂けると幸いです。
記事のターゲット
この記事は主に以下の疑問を整理する目的として纏めています。
-
NativeContainer
やNativeArray
って聞いたことあるけど何なのかよく分からない- C#との配列の違いは何? 利点は何? 相互のやり取りって出来るの?
- どこで使われてるの? DOTS?
- DOTS以外でも使われてるの?
-
NativeContainer
って自作できるの?
これらを踏まえると、ターゲットとしては以下の方にリーチする内容になるかな思います。
-
NativeArray
を通してUnityのメモリ領域について知りたい-
UnsafeUtility
を用いたネイティブメモリ操作について知りたい
-
-
NativeArray
の用途について知りたい - Unityの
NativeArray
を始めとしたNativeContainer
の内部実装について知りたい - Unityの
DOTS(Data-Oriented Tech Stack)
技術に興味があり、その一環として理解しておきたい
必要となる前知識
解説を進めていく上では幾つかの前知識が必要となってきます。
→ e.g. Unityに於けるメモリ領域, 値型/参照型の理解, Blittable型, etc...
必要な箇所については記事中でも随時補足していきますが、詳しくない方は先ずは以下の講演から目を通してみるのが良いかもしれません。
-
たのしいDOTS 〜初級から上級まで〜
- ※前半のメモリ周りの話
-
Understanding C# Struct All Things
- メモリ管理や構造体の理解など
この記事中でも上記の講演などを引用しつつも、以下のポイントについて順に解説していきます。
- Unityに於けるメモリ領域
- NativeContainerについて
- UnsafeUtilityについて
- NativeArrayについて
- NativeArrayの実装コードを読んでみる
UnityCsReferenceについて
因みにNativeArrayの実装コードは「Unity-Technologies/UnityCsReference」にて公開されており、実際にコードを見て中でどうやって「ネイティブメモリの確保と解放を行っているのか?」「マネージヒープの配列との値のやり取りはどうしているのか?」と言った要点を知ることが出来ます。
この記事中では2019.4ブランチの内容をベースに要点を掻い摘んで解説していきます。
Unityに於けるメモリ領域
以降の解説でも深く関わってくる内容となるので、先にUnityに於けるメモリ領域について軽くおさらいしておきます。
Unityにはスタック領域を除くと大きく分けて以下2種類のメモリ領域があります。
マネージドメモリ (マネージドヒープ)
- C#側で使用されているメモリであり、GC(Garbage Collection)の対象となるメモリ領域
- メモリが確保出来なくなると自動的にヒープを拡張する
- 拡張されたヒープは基本返ってこない (※後に大きな割当が行われた際にヒープを再拡張しなくても済むようにする為)
- GCのアルゴリズム(
Boehm GC
)の性質上、ヒープが拡張するのに応じてパフォーマンスが劣化するので圧迫には注意する必要がある
詳細については以下のドキュメントを御覧ください。
- [マネージヒープ] (https://docs.unity3d.com/ja/2019.4/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html)
- 自動メモリ管理
ネイティブメモリ (アンマネージドメモリ)
- Unityのエンジン側が確保するネイティブメモリ領域 (Textureと言ったAsset等が含まれる)
-
GCの対象外
- 確保したら自分で解放する必要がある
-
今回話すNativeContainerで確保したメモリもこちらに属する
- 内部的には
UnsafeUtility.Malloc
が呼び出される (詳細は後述)
- 内部的には
Profiler上での表記
UnityのMemoryProfilerからそれぞれのメモリ領域の使用量を確認することが可能です。
以下で言う赤線を引いた箇所は「ネイティブメモリ」を指し、青線を引いた箇所は「マネージドヒープ」を指します。
NativeContainerについて
簡単に言えばUnityのネイティブメモリ領域を利用したコンテナの総称を指します。
この章では一部を掻い摘んで説明するだけとなるので、詳細についてはドキュメントの方も合わせて御覧ください。
どこで使われるのか?
JobSystemに於けるメインスレッドとの共有メモリとして利用
NativeContainer
はUnityのDOTS(Data-Oriented Technology Stack)に属する機能の一つであり、主にC# JobSystem
(以降、JobSystem
と記載)と言ったマネージドヒープを利用することが出来ない機能に於いて、メインスレッドとの共有メモリとして利用されると言った用途があります。
※JobSystemについて
同じくDOTSに属する機能の一つであるJobSytem
は、Unityが提供する並列化(マルチスレッドプログラミング)のインターフェースの一つであり、NativeContainerと合わせて利用することでマルチスレッドプログラミングに於いての課題の一つである**「データの安全性(メモリの範囲外アクセス、データの競合チェック、メモリリークリークチェックなど)を担保して実装できる」**と言った利点が出てきます。
JobSystem
の概念や安全性、パフォーマンスへの利点などについては以下の公式ドキュメントを御覧ください。
他にもUnityのネイティブメモリを安全に扱うためのAPIとしても利用される
こちらは主にNativeArray
で扱われることが多いですが、最近だとマネージドヒープで扱うにはデータ容量が大きい物(例えばテクスチャの全ピクセル情報など)をNativeArrayで扱えるようにしたAPIが増えつつあります。
これにより、マネージドヒープの拡張を抑えることが可能となります。
詳細についてはNativeArrayの章にて後述します。
ネイティブメモリ領域へアクセスする際の安全性を担保
Unityのネイティブメモリ領域を自前で扱っていくにはUnsafeUtilityと言うAPIを利用する必要があり、通常はunsafeコードを伴うポインタを用いた処理を実装する必要が出てきます。
NativeContainer
の意図の一つとして、その危険なunsafeコードをNativeContainerの中で閉じてしまうことで、利用側にネイティブメモリ領域の操作(unsafeコード)を意識させずに安全に利用できるようにすると言ったものがあります。
また、安全性を担保する上で必要となる機能も幾つか提供されており、これらはUnityEditor上で利用することが可能です。
(以降、これらの機能の総称をセーフティーシステム
として記載)
- メモリリーク追跡
- 競合状態チェック
これらの安全性に於ける仕組みはunsafeコードを書く上では重要な機能となってきます。
確保したメモリは自分で解放する必要がある
説明の順番が前後しましたが、NativeContainerはネイティブメモリ領域を扱う性質上、確保したメモリはGCでは破棄されないので自前で解放してやる必要があります。
また、当然ながら解放を忘れるとメモリリークします。
これらを防ぐためにNativeContainer内部では上述のセーフティーシステムを用いて、万が一解放忘れがあったとしてもエラーとして通知できるような仕組みを用意する必要があります。
補足: unsafeコードは何故危険か?
少し余談にはなりますが、unsafeコードがなぜ危険なのか?について自分が思っていることを幾つか記載します。
(普通にC#を書いていく上では基本遭遇することの無い問題かと思われるが...)
因みに、似たような話題は以前↓の講演にて話しているので、興味のある方は御覧ください。
例外が飛んでこない
unsafeコード(と言うよりかはポインタの操作)の危険性を何点か挙げると...普通にC#を書いていく上で例外が飛んでくるような処理を実装しても「例外が飛ばずに」処理が動き続けたり、それが影響して場合によってはクラッシュすると言った厄介な不具合が発生する可能性があります。2
例えばC#の配列は範囲外にアクセスするとIndexOutOfRangeException
がスローされますが、生ポインタから直に範囲外にアクセスした際には基本的に例外やエラーが飛んでこなかったりします。2
これらの挙動は発生してしまうと、不具合の原因特定を困難にさせてくれます。
無言でメモリリークし続ける危険性
他にもUnityはUnsafeUtility.Mallocを叩くことで任意でネイティブメモリを確保することが出来ますが、こちら当然ながらUnsafeUtility.Freeを叩き忘れるとメモリリークします。
更に言うとUnity標準で用意されているNativeArray
には予めリーク検知などのセーフティーシステムが組み込まれているので、仮に実装ミスでリークしてもエラーとして通知されるようになってますが...Malloc
を直に叩いて確保する形だとリークしても一切エラーが出ないので、メモリ不足の問題が発生するなどして気付くまでは無言でメモリリークし続けることになります。
危険性を下げるために
もしもunsafeなコードを利用側にも伝播するような実装にしてしまうと、あらゆる所で上記の問題を気にする必要が出てきたり、処理のミスによって特定困難な厄介な不具合が発生すると言った危険度が増してきます。
これを避けるために「危険地帯(unsafe)のスコープはなるべく狭める → 言い換えるとunsafeなコードはNaticeContainerの実装部だけに留める」と言った対策が必要となってきます。
Assembly Definition Fileを活用していく
Unityはunsafeコードを書く際には以下の設定を適用する必要があります。
- Project Settings -> Player Settingsより
Allow 'unsafe' Code
を有効にする - Assembly Definition File(以降、
ADF
と記載)の設定項目からAllow 'unsafe' Code
を有効にする
前者はAssembly-CSharp
といった従来のアセンブリ全域に対する影響力を持ち、後者はADFで分割したアセンブリ内のみの影響力を持ちます。
前者はその性質から広範囲に対してunsafeコードを許可してしまうことになるので、可能であればunsafeコードはADFで分割したアセンブリレベルでスコープを狭めてやるのが安全面でも見通しの面でも良いかもしれません。
UnsafeUtilityについて
既に何度か登場してきたUnsafeUtility
ですが、こちらを利用することでUnityのネイティブメモリ領域を確保したり、関連するメモリ操作を行なうことができます。
UnsafeUtilityに関する幾つかの詳細やTipsについては以前記載した以下の記事にて纏めているので、詳しいことについてはこちらを御覧ください。
安全に書くには
安全に書くには色々とやり方があるかと思われますが、例えば以下の点について見直してみると良いかもしれません。
-
Allocatorに応じて処理を使い分けてメモリの寿命をはっきりさせる (確定的なメモリ管理を心がける)
- ※以下の例に挙げているAllocatorの種類については後述
- e.g.
-
Allocator.Temp
ならそのフレーム内でスコープを明示的にする -
Allocator.TempJob
ならJob構造体にDeallocateOnJobCompletion
属性を付けて自動的に破棄されるようにする -
Allocator. Persistent
ならIDisposable
を実装したり、クラスのデストラクタを通して確実に破棄するようにする
-
- セーフティーシステムをフル活用する
思想的な所については以下の講演が参考になります。
-
たのしいDOTS 〜初級から上級まで〜
- ※前半の「メモリの戦い 〜その歴史〜」と「ネイティブコンテナのトリック 〜Unmanagedに挑戦〜」辺りが参考になります
NativeContainerを自作するには
NativeContainer
はある程度自作できるような思想となっており、以下のドキュメントにサンプルコード含めて記述があります。
-
NativeContainerAttribute
- ※簡易的なカスタムコンテナの実装例が載っている
-
Unity Jobs Package →
Custom job types
の項目を参照
少し前の講演にはなりますが、CEDEC2018で行われた以下の講演でも自作NativeContainerについての解説があります。
-
【CEDEC2018】CPUを使い切れ! Entity Component System(通称ECS) が切り開く新しいプログラミング
- →
17.自作せよ! Nativeコンテナ
を参照 - 講演動画はこちら
- →
NativeArrayについて
Unityに於けるメモリ領域とNativeContainerの概要について触れてきましたが、ここからは今回の題材である「NativeArray
とは何か?」についてもう少し掘り下げていきます。
C#の配列との違い
「そもそもC#の配列とは何が違うのか?」について、先ずは大雑把に比較する為のリストを用意しました。
※ここで言う配列は通常の一次元配列を指す
C#の配列 | NativeArray | |
---|---|---|
メモリ空間 | マネージドヒープ | ネイティブメモリ |
保持できる型 | 値型・参照型 | 値型(Blittable型のみ) |
多次元配列 | 可 | 不可(自作すれば可) |
雑に言ってしまうと**「C#の配列は参照型としてメモリの確保にマネージドヒープを利用するが、NativeArrayはメモリの確保にネイティブメモリを利用する」**と言った違いがあり、それに応じて利用できる型などにも違いが生じています。
C#の配列
-
メモリ空間
- マネージドヒープから確保
- GCの対象であり、不必要にインスタンスを生成しまくるとGCの負荷に繋がり得る
-
保持できる型
- 値型の他にも参照型も配列に保持可能
-
多次元配列
- 言語レベルで多次元配列がサポートされている
NativeArray
- メモリ空間
-
保持できる型
- 保持できる型は**制約のある値型(いわゆる
Blittable型
)**である必要がある (後述)- 構造体の場合にはフィールドの全てが値型でなきゃいけない
- → 例えば「フィールドに参照型を持つ構造体」は
非Blittable型
となるので不可
- → 例えば「フィールドに参照型を持つ構造体」は
- 構造体の場合にはフィールドの全てが値型でなきゃいけない
- 保持できる型は**制約のある値型(いわゆる
-
多次元配列
- NativeArrayを普通に使う上では不可
- ※ 「NativeArrayにNativeArrayを持たせれば行けるのでは?」と思うかもしれないが、NativeArrayはフィールドに参照型を持っており、型としては
非Blittable型
となるので持つことが出来ない。これについては後ほど実装コードを踏まえて解説していく。 - ※ C# 8から利用可能なアンマネージなジェネリック構造体を活用してBlittableなNativeArray互換のNativeContainerを実装すればそれっぽいことは出来るかもしれない。(要調査)
- ※ 「NativeArrayにNativeArrayを持たせれば行けるのでは?」と思うかもしれないが、NativeArrayはフィールドに参照型を持っており、型としては
- 若しくは自分でポインタのポインタ(所謂
void**
)として持てるNativeContainerを自作
- NativeArrayを普通に使う上では不可
Allocatorについて
ネイティブメモリを割り当てる際にはインスタンスの寿命に応じて割り当てるAllocatorを指定する必要があります。
NativeArrayを普通に利用する場合だと、主に使うのは以下の3つになるかと思います。
-
Temp
- 寿命は1フレーム
- 言い換えるとそのフレーム内でのみ有効
- 割り当てと解放に要する速度は最も高速
- 寿命は1フレーム
-
TempJob
- 寿命は4フレーム
- 4フレ以内に解放しないとエラー
- 割り当てと解放に要する速度は
Temp
よりは遅い - 主にJobSystemのJobに渡す際などに利用
- 例えばDeallocateOnJobCompletionと言う属性をJobSystemで利用する構造体にセットしておくことで、Job終了時に自動的で解放してくれると言った機能が備わっている
- 寿命は4フレーム
-
Persistent
- 寿命は無制限であり、永続的に使用可能
- 自身で解放するまでずっと保持
- 割り当てと解放に要する速度は一番遅い
- 寿命は無制限であり、永続的に使用可能
もう少し加味した情報を以下の記事で記載しているので、興味ある方は合わせて御覧下さい。
制約のある値型(いわゆるBlittable型
)である必要性
一言で言うと**「データがメモリ上に連続して並ぶ(ひと塊になる)」**必要があります。
C#は言語の特性上、参照型はマネージドヒープを利用することとなり、こちらを配列として扱うと以下のように「参照が配列に並んでいるだけ」の状態となり、実態が散らばってしまいます。
→ Managed heap側にあるオレンジの矩形がデータの実体
他にもマネージドヒープはそもそもとしてGCの管理対象であったり、参照の保持だとJobSystemと言ったlockを前提としない並列化の仕組みの上では競合の問題が発生すると言った懸念が出てくるかと思われます。
一方で値型の場合には、以下のようにデータの実態をメモリ上に連続(ひと塊)にして配置することが出来るようになります。
→ Arrayに並んでいる水色の矩形がデータの実体
JobSystemは実行スレッドにデータがコピーされる性質があるので、データの競合が発生する懸念はなくなります。
構造体は値型のみで構成される必要性がある
任意の構造体(値型)を定義する際には**「全てのフィールドが値型
である」**事に準拠する必要があります。
逆に言うと「フィールドに参照型を持つような構造体」は利用することが出来ません。
これ自体は広義の言葉に置き換えると**「Blittable型
であるかどうか?」**とも言えるかもしれません。
(因みにUnsafeUtility
にはIsBlittableと言う判定用のメソッドが実装されている)
-
Blittable 型と非 Blittable 型
- ※以降、便宜的に上記の定義を
広義のBlittable型
として記載します
- ※以降、便宜的に上記の定義を
Unity2019系統からは少し扱いが異なってきている
Unity2019系統からは広義のBlittable型
とは若干異なる形式になりそうな仕様が追加されてます。
広義の定義では「char
とbool
は非Blittable型」として定義されてますが、Unity2019系統からは以下の変更が入っており、「char
とbool
もBlittable型」として扱われるようになってます。
Unity 2019.1 Release note
Scripting: Added ability to create NativeArrays of bool and char and types containing bool and char. (1127499, 1129523)
これによりNativeArray
からchar
とbool
も扱えるようになりました。
(余談だが...それまではこれら2点は定義できず、例えばbool
はbyte
を代わりに使っていた)
ただ、この変更によって「広義のBlittable型
の定義」そのものが変わるわけでは無いかと思われるので、Unityのネイティブメモリ内部に限定される仕様と捉えておくのが安全かもしれません。
※因みに「渡された型がBlittable型かどうか?」を判定するメソッドとしてUnsafeUtility.IsBlittableと言うAPIが生えてますが、2019系統?からは新規でUnsafeUtility.IsValidNativeContainerElementTypeと言うAPIが生えてきており、NativeArrayの内部実装などではこちらで型チェックを行う形になってます。
NativeArrayを使う利点
NativeArray
は上手く活用していくことで、マネージドヒープで管理するには重いデータ(例えばテクスチャの全ピクセル情報など)をネイティブメモリ側で管理できるようになります。
対応APIは増えてきている
分かりやすい例を一つ挙げると「AsyncGPUReadbackでRenderTextureをTexture2Dに変換する」が有り、読み込まれた結果はAsyncGPUReadbackRequest.GetDataと言うAPIからNativeArray
形式で返されます。
これを用いることでRenderTexture
をTexture2D
形式に変換する際に、以下の手順を踏むことでマネージドヒープ側に全ピクセル情報をコピーすること無くTexture2Dに読み込ませることが出来るようになります。
(これによりヒープ全体が膨らむのを抑えることが出来る)
-
-
AsyncGPUReadback.Requestで
RenderTexuture
を読み込むリクエストを生成
-
AsyncGPUReadback.Requestで
-
-
AsyncGPUReadbackRequest.GetDataから
NativeArray<Color32>
形式でピクセル情報を受取る
-
AsyncGPUReadbackRequest.GetDataから
-
- 受けっ取った
NativeArray
をTexture2D.LoadRawTextureDataに渡してTexture2D
を生成
- 受けっ取った
他の対応APIも徐々に増えてきている印象はあり、例えばUnity 2021.1.0 Alpha 2のリリースノートのAPI Changesには以下の記載があります。
Networking: Added: UnityWebRequest now supports sending/receiving data using NativeArray, avoiding managed allocations (UploadHandlerRaw and DownloadHandlerBuffer). (1138156)
そのまま読み取ると「UnityWebRequestでNativeArrayを用いたデータの送受信が可能になる」とのことであり、大きいデータを取り扱った際のマネージドヒープへの影響を抑えられそうな印象はあります。
その他応用例
最近参考にさせていただいた応用例の一つとしては、以下の記事にあるように「事前にGPU向けにエンコードしたテクスチャをマネージドヒープを介さずに読み込む」と言った手法があります。
こちらで行われている手順を簡単に纏めると以下の手順となります。(色々端折っているので、詳細はソースの記事の方を御覧ください。)
-
- テクスチャを事前にASTC形式に圧縮
-
- 圧縮したASTC画像をAsyncReadManagerからネイティブメモリ領域に読み込んで、テクスチャのポインタを取得
-
- 取得したテクスチャのポインタをTexture2D.LoadRawTextureDataでロード
※AsyncReadManager
とは?
簡単に言えば「ファイルを非同期かつアンマネージドメモリ領域に読み込むことが出来るAPI」になるかと思います。
少し古いですが、以前記事を書いたので参考までにリンクを載せておきます。
NativeArray以外のNativeContainerについて
UnityにはNativeArray
の他にも「NativeArrayから指定した範囲を切り出すためのNativeSlice
」と言う実装も含まれてます。
他にもUPMを経由してUnity Collections Packageと言うパッケージを入れることで、NativeList
, NativeQueue
, NativeHashMap
と言ったコレクションを利用することが可能になります。
以下、ドキュメントにも記載のある一部のコレクションを引用します。
- NativeList - サイズ変更可能な NativeArray
- NativeHashMap - キーと値のペア
- NativeMultiHashMap - 各キーに複数の値
- NativeQueue - 先入れ先出し (FIFO) キュー
Span<T>
との違い
以下の講演にあるSpan vs NativeArray
が参考になります。
どちらも性質的には似たものではありますが、対応APIなどに応じて変換が必要となってくるので、機能に応じて使い分けていくのが良いかと思います。
NativeArrayの実装コードを読んでみる
最後にUnityCsReferenceにあるNativeArrayの実装コードを読みながら、幾つかのポイントについて補足していきます。
コード全てについて頭から解説すると長くなるので...今回は必要そうな要点のみ掻い摘んで解説していきます。
定義に付いている各種属性について
色々とくっついてますね。
特にNative~
から始まるものはScripting API
を直接見ても情報が揃っていないことが多いですが、調べると情報自体は出てくるので以下に纏めます。
[StructLayout(LayoutKind.Sequential)]
[NativeContainer()]
[NativeContainerSupportsMinMaxWriteRestriction()]
[NativeContainerSupportsDeallocateOnJobCompletion()]
[NativeContainerSupportsDeferredConvertListToArray()]
[DebuggerDisplay("Length = {Length}")]
[DebuggerTypeProxy(typeof(NativeArrayDebugView<>))]
public unsafe struct NativeArray<T> : IDisposable, IEnumerable<T>, IEquatable<NativeArray<T>> where T : struct
{
[StructLayout(LayoutKind.Sequential)]
こちらは構造体のメモリレイアウトの指定であり、LayoutKind.Sequential
はフィールドを定義した順番通りに並べていく性質があります。
(因みに構造体のデフォルトはSequential
が適用される)
他のLayoutKind
含めて以下の記事が参考になります
[NativeContainer]
こちらについてはScripting API
に詳細の記述があり、自作NativeContainerの簡易的な実装例も合わせて記載されてます。
ドキュメントの内容を要約すると、JobSystemで利用する上でのセーフティシステムを機能させるために必要となり、属性を付けることでJobDebugger3がコンテナに対する全てのアクセスの安全性の保証し、データの競合や非決定的な振る舞いを確認した際に例外がスローされる様になります。
他にも幾つかの要点があるので、箇条書きにて纏めます。
詳細につきましてはドキュメントの方を御覧ください。
- 含まれるデータは全てBlittable型である必要がある
-
セーフティシステムについて
- 競合状態を検出できるようにするためには
AtomicSafetyHandle
を埋め込む必要がある - リークの検出には
DisposeSentinel
が使用される
- 競合状態を検出できるようにするためには
-
テストはしっかり書こう
- 自作コンテナを作る上では、競合防止のために全てのシナリオのテストカバレッジを追加して、競合状態が確実に防止されるようにする事を強く推奨する
セーフティシステムの変数名について
Scripting APIにあるサンプルコードには以下のコメントの記述があります。
// Marks our struct as a NativeContainer.
// If ENABLE_UNITY_COLLECTIONS_CHECKS is enabled,
// it is required that m_Safety & m_DisposeSentinel are declared, with exactly these names.
[NativeContainer()]
要約すると、セーフティ機能であるAtomicSafetyHandle
とDisposeSentinel
は以下の名称で定義されている必要があるみたいです。
詳細については不明ですが...名称を指定する辺りからしてReflectionの香りはしてきます。
#if ENABLE_UNITY_COLLECTIONS_CHECKS
internal AtomicSafetyHandle m_Safety;
internal DisposeSentinel m_DisposeSentinel;
#endif
因みに、ENABLE_UNITY_COLLECTIONS_CHECKS
と言うシンボルはEditor実行時に自動で定義される「セーフティーシステムの有効/無効」を示す物らしいです。
セーフティーシステムは基本的にはEditorOnlyを想定しているので、NativeContainerを自作する際には例に習って分けるようにしたほうが良さそうです。
その他Native~
から始まる属性について
これらについてはScriptin API
側に記述は無いものの、Unity Jobs Package
側のドキュメントに情報が纏められています。
-
Available attributes
- ※リンクは
Jobs 0.7.0-preview.17
にある記述を参照
- ※リンクは
[NativeContainerSupportsMinMaxWriteRestriction]
こちらを付けることで自身がアクセスできる範囲を取得出来るようになります。
範囲を取得するにあたっては、属性を付けたNativeContainer側で以下の順でフィールドが定義されている必要があります。
(こちらも恐らくはReflectionを使ってる...?)
internal int m_Length;
internal int m_MinIndex;
internal int m_MaxIndex;
もう少し踏み込んで説明すると、NativeContainerはIJobParallelForに渡した際に分割される可能性があります。
※IJobParallelFor
についてはこちらを参照。
→ IJobParallelForで並列処理させるジョブを作ってみる
分割された状態で好き勝手にランダムアクセスを行ってしまうと、不正な範囲外アクセスに繋がり得るので[NativeContainerSupportsMinMaxWriteRestriction]
から自身がアクセスできる範囲を取得して範囲外チェックを行う必要があります。
(分割された際の範囲については恐らくはJob実行時にNativeArrayがコピーされる際に代入されていると予想)
例としてNativeArray
のインデクサーに関する実装を見ると、以下のようにCheckElementReadAccess
及びCheckElementWriteAccess
でチェック処理が入っていることを確認できます。
[Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")]
void CheckElementReadAccess(int index)
{
if (index < m_MinIndex || index > m_MaxIndex)
FailOutOfRangeError(index);
var versionPtr = (int*)m_Safety.versionNode;
if (m_Safety.version != ((*versionPtr) & AtomicSafetyHandle.ReadCheck))
AtomicSafetyHandle.CheckReadAndThrowNoEarlyOut(m_Safety);
}
[Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")]
void CheckElementWriteAccess(int index)
{
if (index < m_MinIndex || index > m_MaxIndex)
FailOutOfRangeError(index);
var versionPtr = (int*)m_Safety.versionNode;
if (m_Safety.version != ((*versionPtr) & AtomicSafetyHandle.WriteCheck))
AtomicSafetyHandle.CheckWriteAndThrowNoEarlyOut(m_Safety);
}
public T this[int index]
{
get
{
CheckElementReadAccess(index);
return UnsafeUtility.ReadArrayElement<T>(m_Buffer, index);
}
[WriteAccessRequired]
set
{
CheckElementWriteAccess(index);
UnsafeUtility.WriteArrayElement(m_Buffer, index, value);
}
}
[NativeContainerSupportsDeallocateOnJobCompletion]
こちらを付けることで、NativeContainerはAllocatorの項目でも記載したDeallocateOnJobCompletion
を利用することが可能になります。
但し、使うためには以下のフィールドを持っている必要があるみたいです。
(前に倣って名前を指定している辺りからして、こちらも恐らくはReflectionを使ってる...?)
internal IntPtr m_Buffer;
internal DisposeSentinel m_DisposeSentinel;
internal Allocator m_AllocatorLabel;
[NativeContainerSupportsDeferredConvertListToArray]
こちらのみ情報が見つからなかったので不明...
(名前的に遅延変換を意味する..?)
[DebuggerDisplay]
と[DebuggerTypeProxy]
最後にNativeArrayに付いている表題の属性についてですが、こちらは主にVisualStudioなどのツール上での可視化の際に利用されるみたいです。
参考: DebuggerDisplay 属性もいいけど DebuggerTypeProxy 属性もね
[DebuggerDisplay("Length = {Length}")]
[DebuggerTypeProxy(typeof(NativeCustomArrayDebugView<>))]
DebuggerTypeProxy
に渡されているNativeCustomArrayDebugView
は、NativeArray.cs
内にて以下のように定義されてます。
/// <summary>
/// DebuggerTypeProxy for <see cref="NativeArray{T}"/>
/// </summary>
internal sealed class NativeArrayDebugView<T> where T : struct
{
NativeArray<T> m_Array;
public NativeArrayDebugView(NativeArray<T> array)
{
m_Array = array;
}
public T[] Items => m_Array.ToArray();
}
フィールドについて
先ず「NativeArrayが内部的にどのようにしてデータを保持するのか?」を見るために、フィールドの定義部から見ていきます。
データ自体はUnsafeUtility.Malloc
から確保したネイティブメモリを汎用ポインタ(void*
)形式で持っており、それ以外は配列の長さやセーフティーシステムで使われる変数などになります。
上述の属性の項目を踏まえつつ補足をコメントに補足を追記していきます。
public unsafe struct NativeArray<T> : IDisposable, IEnumerable<T>, IEquatable<NativeArray<T>> where T : struct
{
// UnsafeUtility.Mallocで確保したネイティブメモリのポインタ
// ※[NativeDisableUnsafePtrRestriction]属性については後述
[NativeDisableUnsafePtrRestriction]
internal void* m_Buffer;
// 配列の長さや、分割された際にWorkerThread上からアクセス可能な配列の範囲など
// 詳しくは[NativeContainerSupportsMinMaxWriteRestriction]の項目を参照
internal int m_Length;
internal int m_MinIndex;
internal int m_MaxIndex;
// 各種セーフティーシステムで用いられる変数
// 詳しくは[NativeContainer]の項目を参照
// ※[NativeSetClassTypeToNullOnSchedule]については後述
internal AtomicSafetyHandle m_Safety;
// NOTE: DisposeSentinelは参照型なので注意。
// その影響でNativeArrayは非Bllitableとなっている。
[NativeSetClassTypeToNullOnSchedule]
internal DisposeSentinel m_DisposeSentinel;
// メモリを確保する際のAllocator
// [NativeContainerSupportsDeallocateOnJobCompletion]の都合上、`m_AllocatorLabel`の名称で定義する必要がありそう
internal Allocator m_AllocatorLabel;
NativeArrayは構造体 → 値渡し時にコピーされる点に注目
NativeArrayは構造体です。
そのために値渡し時にはデータがコピーされます。
後はJobが実行される際にも実行スレッドにコピーされた値が渡されます。
(Job実行時にコピーされる理由については安全上の理由であり、詳細については以下を参照)
NativeArrayはデータ本体がポインタとなるので、コピーされても参照先が変わりません。
この様に全体的にコピーされても問題のない作りになってますが、NativeContainerを自作する際にはこの性質を念頭に置いといたほうが良いかもしれません。
(※NativeArrayのフィールドの大体は初期化時に不変値が決定される + 一部の値については上述の属性が自動で値を設定してくれるものかと思われる)
// NativeArrayの生成
var monafuwaArray = new NativeArray<int>(7, Allocator.Persistent);
// この時、`sweetRoomArray`には`monafuwaArray`のコピーが入る
// 但しNativeArrayの本体はポインタであり、参照先は変わらないので気にせずに使える
var sweetRoomArray = monafuwaArray;
[NativeDisableUnsafePtrRestriction]
について
JobSystemを使う上では基本的にポインタを直接持つことが禁止されています。
(理由としては、ポインタを直接持ってしまうことで安全性を保証出来なくなるから)
但し、こちらを明示的に定義することでその制限を無効化することが出来るようになります。
一件挙動だけ見ると怖そうな設定ではありますが、NativeContainerについてはAtomicSafetyHandle
やDisposeSentinel
と言ったセーフティーシステムで安全面を担保している面があるので、こちらで制限を外しても問題は無いのかと思います。
[NativeSetClassTypeToNullOnSchedule]
について
JobSystemは基本的には参照型を持つことは許可されていません。
もし参照型を持たせる必要がある場合には、こちらの属性を設定する必要があります。
こちらを付けることでJobSystem側で構造体がコピーされる際に、参照型にnullが設定されるようになります。
セーフティーシステムの一つであるDisposeSentinel
は参照型となるので、属性が付与されています。
補足: NativeArray<T>
が非Blittableな構造体である理由
既にこちらでチラッと記述してますが、NativeArrayのフィールドに含まれるDisposeSentinel
は参照型となります。
これがNativeArrayが非Blittable型である所以です。
コンストラクタ時にメモリを確保
メモリの確保は基本的にはコンストラクタ時に行われます。
その上でNativeArrayのコンストラクタの定義を見ると、以下3点のオーバーロードが含まれます。
- 配列の長さを指定して初期化
- C#の配列からコピーして作成
- NativeArrayからコピーして作成
どの処理に於いても先ずは「配列の長さからメモリの確保(Allocate
)」が呼び出されており、コピー関連は確保された後のメモリに対して行われる形となってます。
先ずはメモリの確保から解説していきます。
// 配列の長さを指定して初期化
public NativeArray(int length, Allocator allocator, NativeArrayOptions options = NativeArrayOptions.ClearMemory)
{
Allocate(length, allocator, out this);
if ((options & NativeArrayOptions.ClearMemory) == NativeArrayOptions.ClearMemory)
UnsafeUtility.MemClear(m_Buffer, (long)Length * UnsafeUtility.SizeOf<T>());
}
// C#の配列からコピーする形で初期化
public NativeArray(T[] array, Allocator allocator)
{
if (array == null)
throw new ArgumentNullException(nameof(array));
Allocate(array.Length, allocator, out this);
Copy(array, this);
}
// 既存のNativeArrayからコピーする形で初期化
public NativeArray(NativeArray<T> array, Allocator allocator)
{
Allocate(array.Length, allocator, out this);
Copy(array, this);
}
Allocate
の内部実装については以下の様になっており、以前書いたUnsafeUtilityの記事とほぼ同様の内容になります。
※但し事前に幾つかの例外処理が入っていたり、DisposeSentinel
の初期化などが行われている。
// メモリ確保
static void Allocate(int length, Allocator allocator, out NativeArray<T> array)
{
var totalSize = UnsafeUtility.SizeOf<T>() * (long)length;
// Native allocation is only valid for Temp, Job and Persistent.
if (allocator <= Allocator.None)
throw new ArgumentException("Allocator must be Temp, TempJob or Persistent", nameof(allocator));
if (length < 0)
throw new ArgumentOutOfRangeException(nameof(length), "Length must be >= 0");
IsUnmanagedAndThrow();
// Make sure we cannot allocate more than int.MaxValue (2,147,483,647 bytes)
// because the underlying UnsafeUtility.Malloc is expecting a int.
// TODO: change UnsafeUtility.Malloc to accept a UIntPtr length instead to match C++ API
if (totalSize > int.MaxValue)
throw new ArgumentOutOfRangeException(nameof(length), $"Length * sizeof(T) cannot exceed {int.MaxValue} bytes");
array = default(NativeArray<T>);
array.m_Buffer = UnsafeUtility.Malloc(totalSize, UnsafeUtility.AlignOf<T>(), allocator);
array.m_Length = length;
array.m_AllocatorLabel = allocator;
array.m_MinIndex = 0;
array.m_MaxIndex = length - 1;
DisposeSentinel.Create(out array.m_Safety, out array.m_DisposeSentinel, 1, allocator);
}
// NOTE: NativeArrayで確保される方が許可されたものかどうかをチェック
[BurstDiscard]
internal static void IsUnmanagedAndThrow()
{
// NOTE: 因みに2019系統より前は`IsBlittable`にて判定を行っており、char/boolは扱えなかった
// ref: https://github.com/Unity-Technologies/UnityCsReference/blob/2018.4/Runtime/Export/NativeArray/NativeArray.cs#L97
if (!UnsafeUtility.IsValidNativeContainerElementType<T>())
{
throw new InvalidOperationException(
$"{typeof(T)} used in NativeArray<{typeof(T)}> must be unmanaged (contain no managed types) and cannot itself be a native container type.");
}
}
配列のコピーについて
NativeArrayには配列のコピー関数として以下のメソッドが実装されています。
これらは同じ型であるNativeArray同士のコピーは勿論のこと、メモリ空間が異なるC#配列とも相互に取り扱えるための仕組みが用意されてます。
-
Copy
- NativeArrayのstatic methodとして実装されている
- 性質的にはSystem.Array.Copyに近い
-
CopyTo
- 自身の内容を渡された同じ長さの配列にコピーする
-
CopyFrom
- 渡された同じ長さの配列の内容を自身にコピーする
コピー関連メソッドの定義の大半はオーバーロードとなるので、実装の肝となる内部実装のみ抜粋して解説していきます。
NativeArray間のコピー
やっていることは非常に単純で「引数の不正チェックを行った後にMemCpy
を呼び出している」だけです。
NativeArray同士の場合には、元から同じネイティブメモリ空有上でのやり取りとなるのでシンプルな実装で済みます。
因みにAtomicSafetyHandle.CheckReadAndThrowとAtomicSafetyHandle.CheckWriteAndThrowは、「ハンドルの読み書きが出来るかどうかを確認し、メモリが破棄済み又はJobSystem側で書き込まれている場合」には例外がスローされます。
public static void Copy(NativeArray<T> src, int srcIndex, NativeArray<T> dst, int dstIndex, int length)
{
AtomicSafetyHandle.CheckReadAndThrow(src.m_Safety);
AtomicSafetyHandle.CheckWriteAndThrow(dst.m_Safety);
if (length < 0)
throw new ArgumentOutOfRangeException(nameof(length), "length must be equal or greater than zero.");
if (srcIndex < 0 || srcIndex > src.Length || (srcIndex == src.Length && src.Length > 0))
throw new ArgumentOutOfRangeException(nameof(srcIndex), "srcIndex is outside the range of valid indexes for the source NativeArray.");
if (dstIndex < 0 || dstIndex > dst.Length || (dstIndex == dst.Length && dst.Length > 0))
throw new ArgumentOutOfRangeException(nameof(dstIndex), "dstIndex is outside the range of valid indexes for the destination NativeArray.");
if (srcIndex + length > src.Length)
throw new ArgumentException("length is greater than the number of elements from srcIndex to the end of the source NativeArray.", nameof(length));
if (dstIndex + length > dst.Length)
throw new ArgumentException("length is greater than the number of elements from dstIndex to the end of the destination NativeArray.", nameof(length));
UnsafeUtility.MemCpy(
(byte*)dst.m_Buffer + dstIndex * UnsafeUtility.SizeOf<T>(),
(byte*)src.m_Buffer + srcIndex * UnsafeUtility.SizeOf<T>(),
length * UnsafeUtility.SizeOf<T>());
}
NativeArray
とC#配列
間のコピー
src
をC#配列とし、dstをNativeArray
とした実装例を用いて解説します。
(因みにsrcとdstが逆の場合も大したコードは変わらない)
最初に引数の不正チェックを行なうところまではNativeArray同士と変わりませんが、C#配列はそのままの形ではコピー出来ないので、一度GCHandle.Alloc(src, GCHandleType.Pinned)
で固定されたマネージドオブジェクトのハンドルを取得し、AddrOfPinnedObject
でハンドル内のオブジェクトのアドレスを取得した上でMemCpy
が行われてます。
public static void Copy(T[] src, int srcIndex, NativeArray<T> dst, int dstIndex, int length)
{
AtomicSafetyHandle.CheckWriteAndThrow(dst.m_Safety);
if (src == null)
throw new ArgumentNullException(nameof(src));
if (length < 0)
throw new ArgumentOutOfRangeException(nameof(length), "length must be equal or greater than zero.");
if (srcIndex < 0 || srcIndex > src.Length || (srcIndex == src.Length && src.Length > 0))
throw new ArgumentOutOfRangeException(nameof(srcIndex), "srcIndex is outside the range of valid indexes for the source array.");
if (dstIndex < 0 || dstIndex > dst.Length || (dstIndex == dst.Length && dst.Length > 0))
throw new ArgumentOutOfRangeException(nameof(dstIndex), "dstIndex is outside the range of valid indexes for the destination NativeArray.");
if (srcIndex + length > src.Length)
throw new ArgumentException("length is greater than the number of elements from srcIndex to the end of the source array.", nameof(length));
if (dstIndex + length > dst.Length)
throw new ArgumentException("length is greater than the number of elements from dstIndex to the end of the destination NativeArray.", nameof(length));
// Pinnedで固定されたマネージドオブジェクトのハンドルを取得
var handle = GCHandle.Alloc(src, GCHandleType.Pinned);
// ハンドル内のオブジェクトのアドレスを取得
var addr = handle.AddrOfPinnedObject();
UnsafeUtility.MemCpy(
(byte*)dst.m_Buffer + dstIndex * UnsafeUtility.SizeOf<T>(),
(byte*)addr + srcIndex * UnsafeUtility.SizeOf<T>(),
length * UnsafeUtility.SizeOf<T>());
handle.Free();
}
IDisposable
でメモリを破棄
NativeArrayにはIDisposable
が実装されており、Dispose
内にてメモリの破棄は行われます。
Allocatorの不正チェックなどを行った後に、メモリ解放処理(Free
)が走ります。
void Deallocate()
{
UnsafeUtility.Free(m_Buffer, m_AllocatorLabel);
m_Buffer = null;
m_Length = 0;
}
[WriteAccessRequired]
public void Dispose()
{
if (!UnsafeUtility.IsValidAllocator(m_AllocatorLabel))
throw new InvalidOperationException("The NativeArray can not be Disposed because it was not allocated with a valid allocator.");
DisposeSentinel.Dispose(ref m_Safety, ref m_DisposeSentinel);
Deallocate();
}
JobSystemで破棄することも可能
因みにDispose
にはジョブをスケジュールしつつ、JobHandle
を返す形式のものも存在するみたいです。
引数から前後関係のJobHandle
を渡すことが出来るので、「一連のシーケンスを追えた後に破棄」と言った処理に使えるかもしれません。
/// <summary>
/// Safely disposes of this container and deallocates its memory when the jobs that use it have completed.
/// </summary>
/// <remarks>You can call this function dispose of the container immediately after scheduling the job. Pass
/// the [JobHandle](https://docs.unity3d.com/ScriptReference/Unity.Jobs.JobHandle.html) returned by
/// the [Job.Schedule](https://docs.unity3d.com/ScriptReference/Unity.Jobs.IJobExtensions.Schedule.html)
/// method using the `jobHandle` parameter so the job scheduler can dispose the container after all jobs
/// using it have run.</remarks>
/// <param name="jobHandle">The job handle or handles for any scheduled jobs that use this container.</param>
/// <returns>A new job handle containing the prior handles as well as the handle for the job that deletes
/// the container.</returns>
public JobHandle Dispose(JobHandle inputDeps)
{
// [DeallocateOnJobCompletion] is not supported, but we want the deallocation
// to happen in a thread. DisposeSentinel needs to be cleared on main thread.
// AtomicSafetyHandle can be destroyed after the job was scheduled (Job scheduling
// will check that no jobs are writing to the container).
DisposeSentinel.Clear(ref m_DisposeSentinel);
var jobHandle = new DisposeJob { Container = this }.Schedule(inputDeps);
AtomicSafetyHandle.Release(m_Safety);
m_Buffer = null;
m_Length = 0;
return jobHandle;
}
// [BurstCompile] - can't use attribute since it's inside com.untity.collections.
struct DisposeJob : IJob
{
public NativeArray<T> Container;
public void Execute()
{
Container.Deallocate();
}
}
NativeArrayUnsafeUtlity
について
NativeArray.cs
には、NativeArrayに関するunsafeな操作のモジュール群であるNativeArrayUnsafeUtlity
と言う拡張メソッド群も実装されてます。
あまり使うことは無いかと思われますが、こちらを用いることで「既存のメモリ確保済みのポインタからNativeArrayを構築」したり、「NativeArrayからポインタを抜き出す」と言った操作を行なうことが出来ます。
※それ故に使う上ではunsafeコンテキストが必要となってくる
namespace Unity.Collections.LowLevel.Unsafe
{
public static class NativeArrayUnsafeUtility
{
public static AtomicSafetyHandle GetAtomicSafetyHandle<T>(NativeArray<T> array) where T : struct
{
return array.m_Safety;
}
public static void SetAtomicSafetyHandle<T>(ref NativeArray<T> array, AtomicSafetyHandle safety) where T : struct
{
array.m_Safety = safety;
}
/// Internal method used typically by other systems to provide a view on them.
/// The caller is still the owner of the data.
public static unsafe NativeArray<T> ConvertExistingDataToNativeArray<T>(void* dataPointer, int length, Allocator allocator) where T : struct
{
if (length < 0)
throw new ArgumentOutOfRangeException(nameof(length), "Length must be >= 0");
NativeArray<T>.IsUnmanagedAndThrow();
var totalSize = UnsafeUtility.SizeOf<T>() * (long)length;
// Make sure we cannot allocate more than int.MaxValue (2,147,483,647 bytes)
// because the underlying UnsafeUtility.Malloc is expecting a int.
// TODO: change UnsafeUtility.Malloc to accept a UIntPtr length instead to match C++ API
if (totalSize > int.MaxValue)
throw new ArgumentOutOfRangeException(nameof(length), $"Length * sizeof(T) cannot exceed {int.MaxValue} bytes");
var newArray = new NativeArray<T>
{
m_Buffer = dataPointer,
m_Length = length,
m_AllocatorLabel = allocator,
m_MinIndex = 0,
m_MaxIndex = length - 1,
};
return newArray;
}
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;
}
}
}
参考・関連リンク
巻乃もなかさん 及び もなふわすい〜とる〜む関連
紹介/インタビュー記事
配信
- もなふわすい〜とる〜む
-
もなふわちゃんねる
-
【あまあま】こしょこしょ配信【 #もならいぶ 】
- ※今回この記事を書くきっかけとなった講演動画
-
【あまあま】こしょこしょ配信【 #もならいぶ 】
Other
Unity関連
齟齬が出ないよう、ドキュメント各種のバージョンは2019.4で固定してあります。
公式マニュアル
Unityに於けるメモリ領域
- [マネージヒープ] (https://docs.unity3d.com/ja/2019.4/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html)
- 自動メモリ管理
NativeContainer
- NativeContainer
-
Unity Jobs Package →
Custom job types
の項目を参照
JobSystem
UnityCsReferen
参考資料
講演資料
- DOTS
- C#
参考サイト
- DOTS
- C#
- Unity