Unity 特有のパフォーマンス劣化の落とし穴 2018年歳末まとめ - part 2

今年遭遇した、気づかないうちに嵌ってしまったUnityのパフォーマンス上の落とし穴を振り返っています。 part 2 です。

今回は主に、Unity 独自の c# 実行環境である IL2CPP と、 Unityエンジン部分の c#API についての経験談を書いてみました。

4. IL2CPPの吐くコードサイズの爆発

IL2CPP の吐くc++コードサイズが非常に大きくなったことによって、困った、ということがありました。 IL2CPP は、monoのAOTなどと比較して 実行時の性能の良さを謳っていたと思いますが、実行時の速度にフォーカスしている代償としてか、現状だととにかく c++コードサイズが肥大化する傾向があると思います。

IL2CPPとはなんぞや

ちなみに、IL2CPP というのは、Unity 社が独自に開発した、 C# を ネイティブバイナリとして事前コンパイルして実行する仕組みの名前です。(sugoi。)

元々、c# という言語は、.NET という共通言語基盤(CLI) 上で動作するプログラミング言語のの代表格の1つでした。
基本的には、c#コンパイラは、この CLIVM上でのみで動作する 中間コード(CIL)を成果物として吐くのが仕事です。

Unity の IL2CPP というやつは、この、VMがなければ走らせられないはずの 中間コード を、力業でネイティブのc++コードに翻訳してしまい、さも最初からネイティブコードだったみたな顔で走らせてしまう技術です。ゲームを実行したい端末に .NETVMがはいってなくてもぜんぜん関係ありません。 独自に内包している VMの機能とともに、元々c#だったはずのコードがc++コードして走ります。

現在の Unity の c# コンパイラは、.NET のオープンソース実装である mono (のフォーク)が使われています。
以前は、ネイティブへの事前コンパイルも、mono がもっているAOTコンパイラ実装に依っていた時代があったらしいのですが、 クロスプラットフォームの保守性やパフォーマンス、完成度などを改善するため、ここをUnity独自のランタイム実装へ踏み切ったようです。 (sugoi……。)

いつのまにか、最近では、VMを載せることが許されない iOSだけでなく、Android でもIL2CPPでゲームを走らせることができるようになりました。 スマホアプリでは だいたい IL2CPPでビルドするのではないでしょうか。

IL2CPPについては、すこし古いですが、Unity Blog の一連のシリーズが詳しいです。 An introduction to IL2CPP internals – Unity Blog

( というか、これの他に実装についてのまとまった情報を見たことがありません )

IL2CPP コードサイズ肥大化によって起こる問題

Il2CPPは速くて良いものだ、となんとなく思っていたのですが、 通常のc# では気にしていなかったことを気にしないといけない場面はやはりあるようです。

ビルドが通らない

IL2CPPの吐く c++ のコードが、c++だけで 1ギガバイトを大きく越えるというすごい量に達してしまった結果、ビルドが通らない、という症状に見舞われたことがありました。 (これはパフォーマンスとは関係ないですが..)

Unity 上で iOSビルドを実行し、 Xcode プロジェクトを生成する、ここまでは問題ないのですが、 Unity と il2cpp が吐いたc++コードを含んだ Xcodeのプロジェクトのビルドがエラーになります。

 Showing Recent Messages
 :-1: b(l) ARM64 branch out of range (155837104 max is +/-128MB): from _main (0x100005B70) to __Unwind_Resume@0x00000000 

どうも、 コンパイルは通るのですが、その後のリンクのフェイズでコケる模様。

同じエラーを踏んでいる人が世の中にそれほど多くなかったのですが、色々検証してみると、問題はビルド対象のc++のシンボルか何かの数が多すぎることのようで、とにかくコードサイズを削減する必要に迫られ、泣きました。

.NET 3.5 → .NET 4.6 でさらにコードサイズが爆発

件のプロジェクトでは、長らく .NET3.5 コンパチでc#を動かしていました。 これを、 .NET4.6 に上げることで、インライン展開だとか、コンパイル時の最適化が積極的に行われるようになる、との情報を得たため、 .NET 4.6 に上げてみることになったのですが、 やってみたところ、成果物の c++ コードのサイズが.NET3.5 の場合と比較してさらに 何割か膨らむ結果になり、 .NET3.5 だとビルドが通るんだけど、4.6 だと通らない、という状況が生まれました。

.NET 4.6 にすれば速くなることがわかっているのに、上げることができない、という事態に見舞われてしまったのです。これには涙しました。

メモリ使用量と実行速度

Il2cpp の吐く c++ コードの削減を大胆にやっていくと、結果的に、当然ですが 実行時のコード部分が抱えていたメモリ使用量の削減につながりました。 実行時の メモリを減らしたくて困っている場合、費用対効果が高い場合があるかもしれません。

また、後述の sealed 修飾子を明示的につける、などの、ちょっとした変更によって、c++の実行コードステップ数が大幅に減ったりすることがあったため、通常のc#コンパイラの気持ちになるのではなく、IL2CPPの気持ちになるかならないかで 実行速度が変わってくる場面もありそうです。

対策1. ジェネリック型の 型パラメータには int か enum を使う

ここからは、IL2CPP の吐くコードサイズを減らすための対策として、実際にやってみて効果があったやつをちょっと紹介してみます。

il2cpp は、型パラメータに値型をとる Generics を使うと、型パラメータの数だけ c++表現の実装が出力されるという特徴をもっています。

このことは、 IL2CPP Internals: Generic sharing implementation – Unity Blog にも書かれています。

普段、c# を書いているとき、ヒープアロケーションができるだけ発生しないようなコードを書くように気をつけるのですが、 とにかくコードサイズを減らしたくて困っている場面で、値型をつかっておけばアロケートを減らせるというケースでも、参照型のほうがc++変換後のコードサイズを減るためにトレードオフになってしまい、涙することがありました。

上記のブログによると、型パラメータ(つまり <T> の部分)が参照型、int、enum いずれかである場合には、ジェネリックの実装がシェアされ、いちいち重複して吐かれることはない、と説明されています。

元々の .NETジェネリック実装も、型パラメータが値型の場合はボクシングせず特殊化する実装であるので、その辺の表現をそのまま引き継いでいるのでしょうか。

UniRx.Unit.Default

僕達のプロジェクトでは、UniRx をけっこうヘヴィに使っていたのですが、(いつもお世話になってます)、 ジェネリック実装の重複削減の施策として地味に効果がみられたのは、 UniRx の Unit.Default の実装を置き換えてみる、というけっこうhackyな行為でした。

c# は、型パラメータにvoid が使えないため、 UniRx は、型パラメータがないことを表現するためだけのstruct 、Unit を用意しています。 この Unit は、型パラメータとして使われる頻度がけっこう多いわけですが、 型パラメータに Unit があらわれるたびに、それに対応するc++ジェネリック型の実装が大量に吐かれれてしまっていました。 Unit は、基本的にはただひとつの値、 Unit.Default しか持たないため、これをstruct ではなく、enum の実装に無理矢理書き換えてしまうことで、 il2cpp変換時のジェネリック型のサイズを減らすことができました。

namespace UniRx
{
    enum Unit { Default }
}

これについては、セマンティクス的にはちょっと間違っているのかもしれませんが、
現在のUnity環境下で c++サイズ削減の効果が示せるのであれば、UniRx本家のほうにPRを出してみても良いのかもしれないな、とおもいました。

Dictionary<TKey, TValue>

Dictionary<> も、不用意に使ってしまうと、c++になったときのコードサイズが爆発する筆頭でした。 どうもジェネリック版のDictionary は、実装がそもそも巨大で、1パターン吐かれることによるサイズへの影響が大きいみたいです。

もともと、 Dictionary<> は、直感よりも遅い場合がある (不要意にGetHashCodeが遅いキーをつかえば遅いし、入れた要素数よりも多くの領域を走査するためイテレータが実は遅い) ので、使いどころを気にするのがやはりよさそうです。

対策2. seald 修飾子

il2cpp は 、classのvirtualなメソッド呼び出しを実現するために、c++コードによるvtableのような仕組みを自動生成します。

この件については下に説明があります。

IL2CPP Internals: Method calls – Unity Blog

吐かれたc++コードを覗いてみると、たとえば、classのメソッド呼び出し一種類につき、いちいち以下のようなc++が吐かれていることが確認できます。

struct VirtActionInvoker0
{
    typedef void (*Action)(void*, const RuntimeMethod*);

    static inline void Invoke (Il2CppMethodSlot slot, RuntimeObject* obj)
    {
        const VirtualInvokeData& invokeData = il2cpp_codegen_get_virtual_invoke_data(slot, obj);
        ((Action)invokeData.methodPtr)(obj, invokeData.method);
    }
};

これは、コンパイル時に継承をしていないclass 、つまり実質はvirtualじゃないメソッドであっても同様の挙動をしています。 (実行時にサブクラスが生成されないことが保証されていないからなんでしょうか、あるいは、今後改善されるのかな)

ところが、 class宣言に sealed をつけると、上記のような vtableを表現するための型の生成が抑制され、メソッド呼び出しがほとんどただの関数コールになります。

継承しないclassにいちいちsealedをつけるのはスタイルとしてちょっと冗長かなという気がしますが、sealedをつけるだけで、c++コードサイズの削減と、c++世界での実行パスの削減による性能向上の2つのメリットがあるため、 il2cpp環境ではいちいちsealedをつけまくることを検討しても見合うかもしれません。

5. 無害に見える UnityのAPIが実はアロケートを発生させてる件

続いて、とくに何も悪いことをしていないつもりなのに、UnityのAPIをふつうにつかっていたつもりなのに予想外のヒープアロケートが発生しているシリーズです。

なかでも厄介だったのが、 gameObject.name を呼び出すたびに 文字列が生成されているらしい、という件です。(これは同僚に教えてもらいました。来年もよろしくおねがいします)

たとえば transform プロパティなんかは、エディタ上で実行したときのみアロケートが発生するものの、実機で実行した際にはそういったことがない、という挙動をするようなのですが、どうも gameObject.name のほうは、実機上でもアロケートが発生しているようで、ちょっと参りました。

このプロパティを、イテレータの中で使って、オブジェクト同士を比較するコードなどがかなり頻出していたため、改善が非常にむずいポイントとなってしまいました。

ただのプロパティアクセスなのに、評価のたびに参照が変わるというのはかなり直感に反するし、こういうのは測ってみたいと知らないとわからない、落とし穴と言えるのではないでしょうか。

だいたいどの処理系でも、GCアロケートが多いと、GCの回収フェイズが発生せずとも、とりあえず遅い、といった傾向があると思います。 よく使う低レイヤの道具については、こういうのを把握しておきたいものです。 ( でも Unity のバージョンが上がると挙動かわったりしそう)

6. Demo/Example/Sampleはじめ、ビルド時に必要ないやつが含まれやすい

もうひとつ思い出したので書いておきます。 Unity のアセットストア経由でダウンロードしたアセットには、必ずサンプルのシーンなどがついてくると思います。 動かせるサンプルがあふということは当然、スクリプトもくっついてきます。

Unity は、とくに指定しなければ、全てのc#スクリプトがひとつのアセンブリとしてビルドされるので、全く必要のないスクリプトが本番ビルドに紛れ込みやすいです。

というか、かなり積極的に隔離したり省いていかなければ、普通にやると無駄なものが一緒にコンパイルされるはずです。

サンプルのタグの他にも、エディタだけの機能のつもりが、大量の Editor アセンブリじゃないスクリプトがついてくることもよくあります。(例: メモリプロファイラ)

対策としては、

  • Unityの提供しているAssembly Definitionでアセンブリを定義すると、どのビルドに含むか設定できる
  • ビルド前にに不用なアセットのファイルを削除するフローをつくる

このあたりでしょうか。

まとめ

ちょっと駆け足になってしまいましたが、Unity のc# まわりで踏んだ罠についてできる限り紹介してみました。 年内に今年はまった落とし穴をふりかえり、一回 供養することが目的だったので、一旦ここで終わりたいと思います。

思ったより反応をもらえたり、間違いの指摘などをもらえて大変有意義でした。 m(_ _)m