第9章 Tuning Practice - Script (Unity)

Unityで提供されている機能を何気なく使っていると思わぬ落とし穴にはまることがあります。本章では、Unityの内部実装に関連したパフォーマンスチューニング手法について実例を交えながら紹介します。

9.1 空のUnityイベント関数

AwakeStartUpdateなどUnityが提供しているイベント関数が定義されている場合、実行時にUnity内部のリストにキャッシュされて、リストのイテレーションによって実行されます。

関数内で何も処理を行っていなくとも、定義されているだけでキャッシュ対象となるため、不要なイベント関数を残したままにするとリストが肥大化し、イテレーションのコストが増大します。

たとえば下記サンプルコードのようにUnity上で新規生成したスクリプトにはStartUpdateが最初から定義されていますが、これらの関数が不要であれば必ず削除しておきましょう。

リスト9.1: Unity上で新規生成したスクリプト

 1: public class NewBehaviourScript : MonoBehaviour
 2: {
 3:     // Start is called before the first frame update
 4:     void Start()
 5:     {
 6: 
 7:     }
 8: 
 9:     // Update is called once per frame
10:     void Update()
11:     {
12: 
13:     }
14: }

9.2 tagやnameのアクセス

UnityEngine.Objectを継承したクラスにはtagプロパティとnameプロパティが提供されています。オブジェクトの識別に便利なこれらプロパティですが、実はGC.Allocが発生しています。

それぞれの実装をUnityCsReferenceから引用しました。どちらもネイティブコードで実装された処理を呼び出していることが分かります。

UnityではスクリプトをC#で実装しますが、Unity自体はC++で実装されています。C#メモリ空間とC++メモリ空間は共有できないため、C++側からC#側に文字列情報を受け渡すためにメモリの確保が行われます。これは呼び出すたびに行われるので、複数回アクセスする場合はキャッシュしておきましょう。

Unityの仕組みとC#とC++間のメモリに関する詳細は「Unityランタイム」を参照してください。

リスト9.2: UnityCsReference GameObject.bindings.cs*1から引用

 1: public extern string tag
 2: {
 3:     [FreeFunction("GameObjectBindings::GetTag", HasExplicitThis = true)]
 4:     get;
 5:     [FreeFunction("GameObjectBindings::SetTag", HasExplicitThis = true)]
 6:     set;
 7: }

リスト9.3: UnityCsReference UnityEngineObject.bindings.cs*2から引用

 1: public string name
 2: {
 3:     get { return GetName(this); }
 4:     set { SetName(this, value); }
 5: }
 6: 
 7: [FreeFunction("UnityEngineObjectBindings::GetName")]
 8: extern static string GetName([NotNull("NullExceptionObject")] Object obj);

9.3 コンポーネントの取得

同じGameObjectにアタッチされている他のコンポーネントを取得するGetComponent()も注意が必要な1つです。

前節のtagプロパティやnameプロパティ同様にネイティブコードで実装された処理を呼び出していることもそうですが、指定した型のコンポーネントを「検索する」コストがかかることにも気をつけなければなりません。

下記サンプルコードでは毎フレームRigidbodyコンポーネントを検索するコストがかかることになります。頻繁にアクセスする場合は、あらかじめキャッシュしたものを使い回すようにしましょう。

リスト9.4: 毎フレームGetComponent()するコード

 1: void Update()
 2: {
 3:     Rigidbody rb = GetComponent<Rigidbody>();
 4:     rb.AddForce(Vector3.up * 10f);
 5: }

9.4 transformへのアクセス

Transformコンポーネントは位置や回転、スケール(拡大・縮小)、親子関係の変更など頻繁にアクセスするコンポーネントです。下記サンプルコードのように複数の値を更新することも多いでしょう。

リスト9.5: transformにアクセスする例

 1: void SetTransform(Vector3 position, Quaternion rotation, Vector3 scale)
 2: {
 3:     transform.position = position;
 4:     transform.rotation = rotation;
 5:     transform.localScale = scale;
 6: }

transformを取得するとUnity内部ではGetTransform()という処理が呼び出されます。前節のGetComponent()に比べて最適化されていて高速です。しかしキャッシュした場合よりは遅いので、これも下記サンプルコードのようにキャッシュしてアクセスしましょう。位置と回転の2つはSetPositionAndRotation()を使うことで関数呼び出し回数を減らすこともできます。

リスト9.6: transformをキャッシュする例

 1: void SetTransform(Vector3 position, Quaternion rotation, Vector3 scale)
 2: {
 3:     var transformCache = transform;
 4:     transformCache.SetPositionAndRotation(position, rotation);
 5:     transformCache.localScale = scale;
 6: }

9.5 明示的な破棄が必要なクラス

UnityはC#で開発を行うため、GCによって参照されなくなったオブジェクトは解放されます。しかしUnityのいくつかのクラスは明示的に破棄する必要があります。代表的な例としてはTexture2DSpriteMaterialPlayableGraphなどです。newや専用のCreate関数で生成した場合、必ず明示的に破棄を行いましょう。

リスト9.7: 生成と明示的な破棄

 1: void Start()
 2: {
 3:     _texture = new Texture2D(8, 8);
 4:     _sprite = Sprite.Create(_texture, new Rect(0, 0, 8, 8), Vector2.zero);
 5:     _material = new Material(shader);
 6:     _graph = PlayableGraph.Create();
 7: }
 8: 
 9: void OnDestroy()
10: {
11:     Destroy(_texture);
12:     Destroy(_sprite);
13:     Destroy(_material);
14: 
15:     if (_graph.IsValid())
16:     {
17:         _graph.Destroy();
18:     }
19: }

9.6 文字列指定

Animatorの再生するステートの指定、Materialの操作するプロパティの指定に文字列を使うのは避けましょう。

リスト9.8: 文字列指定の例

 1: _animator.Play("Wait");
 2: _material.SetFloat("_Prop", 100f);

これらの関数の内部ではAnimator.StringToHash()Shader.PropertyToID()を実行して、文字列から一意な識別値に変換をしています。何回もアクセスする場合に都度変換が行われるのはムダなので、識別値をキャッシュしておいて使い回すようにしましょう。下記サンプルのようにキャッシュした識別値の一覧となるクラスを定義しておくと、取り回しがよいでしょう。

リスト9.9: 識別値のキャッシュの例

 1: public static class ShaderProperty
 2: {
 3:     public static readonly int Color = Shader.PropertyToID("_Color");
 4:     public static readonly int Alpha = Shader.PropertyToID("_Alpha");
 5:     public static readonly int ZWrite = Shader.PropertyToID("_ZWrite");
 6: }
 7: public static class AnimationState
 8: {
 9:     public static readonly int Idle = Animator.StringToHash("idle");
10:     public static readonly int Walk = Animator.StringToHash("walk");
11:     public static readonly int Run = Animator.StringToHash("run");
12: }

9.7 JsonUtilityの落とし穴

UnityではJSONのシリアライズ/デシリアライズのためにJsonUtilityというクラスが提供されています。公式ドキュメント*3にもC#標準のものよりも高速であることが記載されていて、パフォーマンスを意識した実装をするなら利用することも多いでしょう。

JsonUtilityは(機能は .NET JSON より少ないですが)、よく使用されている .NET JSON よりも著しく早いことが、ベンチマークテストで示されています。

しかしパフォーマンスに関わることでひとつ気をつけるべきことがあります。それは「nullの扱い」です。

下記サンプルコードでシリアライズ処理とその結果を示しています。クラスAのメンバーb1を明示的にnullにしているにもかかわらず、クラスBおよびクラスCをデフォルトコンストラクターで生成した状態でシリアライズされているのが分かります。このようにシリアライズ対象となるフィールドにnullがあった場合、JSON化の際にダミーオブジェクトがnewされるので、そのオーバーヘッドは考慮しておいたほうがよいでしょう。

リスト9.10: シリアライズの挙動

 1: [Serializable] public class A { public B b1; }
 2: [Serializable] public class B { public C c1; public C c2; }
 3: [Serializable] public class C { public int n; }
 4: 
 5: void Start()
 6: {
 7:     Debug.Log(JsonUtility.ToJson(new A() { b1 = null, }));
 8:     // {"b1":{"c1":{"n":0},"c2":{"n":0}}}
 9: }

9.8 RenderやMeshFilterの落とし穴

Renderer.materialで取得したマテリアル、MeshFilter.meshで取得したメッシュは複製されたインスタンスなので使い終わったら明示的な破棄が必要です。公式ドキュメント*4*5にもそれぞれ下記のように明記されています。

If the material is used by any other renderers, this will clone the shared material and start using it from now on.

It is your responsibility to destroy the automatically instantiated mesh when the game object is being destroyed.

取得したマテリアルやメッシュはメンバー変数に保持しておき、然るべきタイミングで破棄するようにしましょう。

リスト9.11: 複製されたマテリアルの明示的な破棄

 1: void Start()
 2: {
 3:     _material = GetComponent<Renderer>().material;
 4: }
 5: 
 6: void OnDestroy()
 7: {
 8:     if (_material != null) {
 9:         Destroy(_material);
10:     }
11: }

9.9 ログ出力コードの除去

UnityではDebug.Log()Debug.LogWarning()Debug.LogError()といったログ出力用の関数が提供されています。便利な機能ではありますがいくつかの問題点もあります。

  • ログ出力自体がそこそこ重たい処理
  • リリースビルドでも実行される
  • 文字列の生成や連結によってGC.Allocが発生する

UnityのLogging設定をオフにした場合、スタックトレースは停止しますが、ログは出力されます。UnityEngine.Debug.unityLogger.logEnabledにUnityではfalseを設定すると、ログは出力されませんが、関数内部で分岐しているだけなので、関数の呼び出しコストや不要なはずの文字列の生成や連結は行われてしまいます。#ifディレクティブを使うという手段もありますが、すべてのログ出力処理に手を入れるのは現実的ではありません。

リスト9.12: #ifディレクティブ

 1: #if UNITY_EDITOR
 2:   Debug.LogError($"Error {e}");
 3: #endif

このような場合に活用できるのがConditional属性です。Conditional属性が付いた関数は、指定したシンボルが定義されていない場合、コンパイラによって呼び出し部分が除去されます。リスト9.13のサンプルのように、自作のログ出力クラスを通してUnity側のログ機能を呼び出すのをルールとして、自作クラス側の各関数にConditional属性を付加することで、必要に応じて関数の呼び出しごと除去できるようにするとよいでしょう。

リスト9.13: Conditional属性の例

 1: public static class Debug
 2: {
 3:     private const string MConditionalDefine = "DEBUG_LOG_ON";
 4: 
 5:     [System.Diagnostics.Conditional(MConditionalDefine)]
 6:     public static void Log(object message)
 7:         => UnityEngine.Debug.Log(message);
 8: }

注意点として、指定したシンボルが関数の呼び出し側から参照できる必要があるということです。#defineで定義されたシンボルのスコープは、記述したファイル内に限定されてしまいます。Conditional属性が付いた関数を呼び出しているすべてのファイルにシンボルを定義していくのは現実的ではありません。UnityにはScripting Define Symbolsというプロジェクト全体に対してシンボルを定義する機能があるので活用しましょう。「Project Settings -> Player -> Other Settings」で設定ができます。

Scripting Define Symbols

図9.1: Scripting Define Symbols

9.10 Burstを用いたコードの高速化

Burst*6はUnity公式が開発する、ハイパフォーマンスなC#スクリプティング行うためのコンパイラです。

BurstではC#のサブセット言語を用いてコードを記述します。BurstがC#コードをLLVMというコンパイラ基盤*7の中間構文であるIR(Intermediate Representation)に変換し、IRを最適化をした上で機械語に変換されます。

このときにコードを可能な限りベクトル化し、SIMDという命令を積極的に使った処理に置き換えます。これによって、より高速なプログラムの出力が期待できます。

SIMDはSingle Instruction/Multiple Dataの略で、単一の命令を同時に複数のデータに適用するような命令を指します。つまりSIMD命令を積極的に利用することで、1命令でデータがまとめて処理されるため、通常の命令と比べて高速に動作します。

9.10.1 Burstを用いたコードの高速化

BurstではHigh Performance C#(HPC#)*8と呼ばれるC#のサブセット言語を用いてコードを記述します。

HPC#の特徴の1つとしてC#の参照型、つまりクラスや配列などが利用できません。そのため原則として構造体を用いてデータ構造を記述します。

配列のようなコレクションは代わりにNativeArray<T>などのNativeContainer*9を利用します。HPC#の詳細については脚注記載のドキュメントを参考にしてください。

BurstはC# Job Systemと組み合わせて利用します。そのため自身の処理をIJobを実装したジョブのExecuteメソッド内に記述します。定義したジョブにBurstCompile属性を付与することで、そのジョブがBurstによって最適化されます。

リスト9.14に、与えられた配列の各要素を二乗してOutput配列に格納する例を示します。

リスト9.14: 簡単な検証用のJob実装

 1: [BurstCompile]
 2: private struct MyJob : IJob
 3: {
 4:     [ReadOnly]
 5:     public NativeArray<float> Input;
 6: 
 7:     [WriteOnly]
 8:     public NativeArray<float> Output;
 9: 
10:     public void Execute()
11:     {
12:         for (int i = 0; i < Input.Length; i++)
13:         {
14:             Output[i] = Input[i] * Input[i];
15:         }
16:     }
17: }

リスト9.14の14行目の各要素はそれぞれ独立して計算でき(計算に順序依存がない)、かつ出力配列のメモリアライメントは連続しているためSIMD命令を用いてまとめて計算が可能です。

コードがどのようなアセンブリに変換されるかは、図9.2のようにBurst Inspectorを用いて確認できます。

Burst Inspectorを用いることで、コードがどのようなアセンブリに変換されるか確認できる

図9.2: Burst Inspectorを用いることで、コードがどのようなアセンブリに変換されるか確認できる

リスト9.14の14行目の処理は、ARMV8A_AARCH64向けのアセンブリでリスト9.15に変換されます。

リスト9.15: リスト9.14の14行目のARMV8A_AARCH64向けのアセンブリ

 1:         fmul        v0.4s, v0.4s, v0.4s
 2:         fmul        v1.4s, v1.4s, v1.4s

アセンブリのオペランドに、.4sというサフィックスがついていることから、SIMD命令が利用されていることが確認できます。

ピュアなC#により実装されたコードとBurstにより最適化されたコードのパフォーマンスを実機で比較します。

実機にはAndroid Pixel 4a、IL2CPPをスクリプトバックエンドとしてビルドを行い比較しています。また配列のサイズは2^20 = 1,048,576としています。計測は同じ処理を10回繰り返し、処理時間の平均をとりました。

表9.1にパフォーマンス比較の計測結果を示します。

表9.1: ピュアなC#実装とBurstによる最適化されたコードの処理時間の比較

手法処理時間(非表示)
ピュアなC#実装5.73ms
Burstによる実装0.98ms

ピュアなC#実装と比べて約5.8倍ほど高速化を確認できました。