やらなイカ?

たぶん、iOS/Androidアプリの開発・テスト関係。

Unityにおけるインライン化

テスタビリティ*1など内部品質の文脈で、クラスやメソッドの責務を分けるべし、メソッド分割すべし、という話をよくします。

メソッド分割には、メソッド呼び出しが増えることによる実行時オーバーヘッド増加という問題があります。 これに対しては、

という方針できましたが、Unityにおいて根拠が乏しかったため、簡単に検証してみました。

インライン化

メソッド分割に起因するオーバーヘッドの増加は、コンパイラによる「インライン化(inlining)」または「インライン展開(inline expansion)」と呼ばれる最適化によって緩和できます。

インライン化とは、文字通り呼び出し先のメソッドを呼び出し元メソッド内に直接展開する最適化手法です。 インライン化にはメソッドのバイト数などの条件があるため必ず行われるとは限らず、またロジックのコピーが作られるためバイナリサイズが大きくなるデメリットがあります。

C#で書かれたコードは中間言語(IL)に翻訳されます。 .NETの場合はILを.NETランタイムで実行するときにJITコンパイラにより最適化されますが、UnityではILがIL2CPPによってC++コードに変換された後、AOTのC++コンパイラによってネイティブコードとなる際に最適化が行われます。

インライン化について、Unityマニュアルの 特殊な最適化 ページには次のように書かれています。

ここで主な問題は、 Unity がメソッドのインライン化をほとんど行わないことです。 IL2CPP 下であっても、多くのメソッドは現在、適切なインライン化を行いません。これは特にプロパティに関して言えることです。さらに、 virtual および interface メソッドのインライン化は一切行えません。

「ほとんど」とありますが、テスタビリティや可読性向上を目的としたメソッド分割の範囲で最適化されるのかを確認してみます。

検証

検証に使用した環境は

  • Unity 2020.3.21f1
  • プラットフォーム: macOS/x64

です。

次の2通りのコードを実行してみました。

public int Calculate(int count)
{
    var sum = 0;
    for (var i = 0; i < count; i++)
    {
        sum += i;
    }

    return sum;
}
private static int DividedMethod(int i)
{
    return i;
}

public int Calculate(int count)
{
    var sum = 0;
    for (var i = 0; i < count; i++)
    {
        sum += DividedMethod(i);
    }

    return sum;
}

count に100,000,000を与えたときの実行時間は次の通りです。なお、IL2CPPだけは精度が出なかったため少し呼び出し負荷を増しています。

Unityエディタ Mono IL2CPP
メソッド分割なし (No Divide) 約 270 [ms] 約 49 [ms] 約 3.5 [ms]
メソッド分割あり (Divided) 約 660 [ms] 約 65 [ms] 約 23 [ms]

メソッド分割有無であきらかな性能差が出ています。

続いて、別のアセンブリに定義されたメソッドを呼ぶケースも検証するため*3、3パターン追加します。

  • アセンブリにあるメソッドを呼ぶ (Another Assembly)
  • アセンブリにあるメソッドに MethodImpl(MethodImplOptions.AggressiveInlining) 属性を付ける (Aggressive Inlining)
  • アセンブリにあるメソッドに MethodImpl(MethodImplOptions.NoInlining) 属性を付ける (No Inlining)

AggressiveInlining は、その名の通り積極的にインライン化を行なうように指示するものです。 .NETのJITコンパイラが解釈する属性ですが、UnityのIL2CPPもこれを解釈してインライン化されやすいC++コードを出力してくれるようです。

結果は次のようになりました。

Unityエディタ

f:id:nowsprinting:20211215074846j:plain:w400

Mono

f:id:nowsprinting:20211215050856p:plain:w400

IL2CPP

f:id:nowsprinting:20211216062846p:plain:w400

No Inliningのケースではアセンブリを超えてメソッドコールが行われるため、IL2CPPでも時間がかかっています。

Another Assemblyのケースは AggressiveInlining を指定していないのですが、指定したときと同様に呼び出し元のcppファイルにメソッドのコピーが作られており、コンパイル時にインライン化(もしくは類する最適化)されているようです。

Dividedのケースでは同一cppファイルにメソッドはあるものの、最適化はされていないか、限定的であることがうかがえます。

まとめ

  • 実行性能が気になる場合はプロパティでなくメソッドを使用する
  • インライン化してほしいメソッドには MethodImpl(MethodImplOptions.AggressiveInlining) 属性を付ける

検証コードは下記リポジトリに置いてあります。

github.com

参考

ufcpp.net

notnullvariable.com

lpha-z.hatenablog.com

learning.unity3d.jp

*1:ISTQB GLOSSARY Ver3.2(日本語版)では「試験性」と称しています

*2:ボトルネックに当たったときに対処するという方針。「早すぎる最適化は諸悪の根源(Premature optimization is the root of all evil)」という言葉は有名です

*3:IL2CPPにおいては、アセンブリ単位にcppファイルが生成されます。通常、別のcppファイルに定義されたメソッドはインライン化されません