IL2CPPの最適化:仮想化

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 26, 2016|5 分
IL2CPPの最適化:仮想化
このウェブページは、お客様の便宜のために機械翻訳されたものです。翻訳されたコンテンツの正確性や信頼性は保証いたしかねます。翻訳されたコンテンツの正確性について疑問をお持ちの場合は、ウェブページの公式な英語版をご覧ください。

Unityのスクリプティング仮想マシンチームは、あなたのコードをより速く実行する方法を常に探しています。これは、IL2CPP AOTコンパイラが実行するいくつかのマイクロ最適化と、その活用方法についての3部構成のミニシリーズの最初の投稿です。ここにあるものは、コードを2倍も3倍も速く実行させるものではないが、これらの小さな最適化は、ゲームの重要な部分で役立つことがある。

仮想化

仮想メソッドの呼び出しは、直接メソッドを呼び出すよりも常に高くつくのだ。我々は、仮想メソッド呼び出しのオーバーヘッドを削減するために、libil2cppランタイム・ライブラリのパフォーマンス改善に取り組んできた(これについては次の記事で詳しく説明する)。コンパイラーは、実行時にどのメソッドが呼び出されるかを知ることはできない。

仮想化とは、仮想メソッド・コールを直接メソッド・コールに変更する、コンパイラの一般的な最適化手法である。コンパイラは、コンパイル時にどのメソッドが実際に呼ばれるかを正確に証明できる場合、この戦術を適用するかもしれない。残念ながら、この事実を証明するのは難しいことが多い。コンパイラーは常にコードベース全体を見るとは限らないからだ。しかし、それが可能であれば、仮想メソッドの呼び出しをより速くすることができる。

典型的な例

若い開発者だった私は、かなり作為的な動物の例で仮想メソッドについて学んだ。このコードはあなたにも馴染みがあるかもしれない:

public abstract class Animal {
  public abstract string Speak();
}

public class Cow : Animal {
   public override string Speak() {
       return "Moo";
   }
}

public class Pig : Animal {
    public override string Speak() {
        return "Oink";
   }
}

そしてUnity(バージョン5.3.5)では、これらのクラスを使って小さな農場を作ることができる:

public class Farm: MonoBehaviour {
   void Start () {
       Animal[] animals = new Animal[] {new Cow(), new Pig()};
       foreach (var animal in animals)
           Debug.LogFormat("Some animal says '{0}'", animal.Speak());

       var cow = new Cow();
       Debug.LogFormat("The cow says '{0}'", cow.Speak());
   }
}

ここでは、スピークの各呼び出しは仮想メソッド呼び出しである。パフォーマンスを向上させるために、IL2CPPにこれらのメソッド・コールのどれかを仮想化しないように説得できるか見てみよう。

生成されるC++コードも悪くない

私が気に入っているIL2CPPの特徴のひとつは、アセンブリ・コードの代わりにC++コードを生成することだ。確かに、このコードは手で書くようなC++のコードには見えないが、アセンブリよりはずっと理解しやすい。そのfor eachループの本体の生成コードを見てみよう:

// Set up a local variable to point to the animal array
AnimalU5BU5D_t2837741914* L_5 = V_2;
int32_t L_6 = V_3;
int32_t L_7 = L_6;

// Get the current animal from the array
V_1 = ((L_5)->GetAt(static_cast<il2cpp_array_size_t>(L_7)));
Animal_t3277885659 * L_9 = V_1;

// Call the Speak method
String_t* L_10 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Animal::Speak() */, L_9);

物事を単純化するために、生成されたコードを少し削除した。Invokeへの醜い呼び出しが見えるか?vtableから適切な仮想メソッドを探し、それを呼び出す。このvtableルックアップは、関数を直接呼び出すよりも遅くなるが、それは理解できる。アニマルは牛でも豚でも、あるいは他の派生型でもいい。

2番目のDebug.LogFormat呼び出しの生成コードを見てみよう:

// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;

// Call the Speak method
String_t* L_17 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Cow::Speak() */, L_16);

この場合でも、仮想メソッドの呼び出しは行っている!IL2CPPは最適化に対してかなり保守的で、ほとんどの場合において正しさを保証することを好む。これは、直接呼び出しが可能かどうかを確認するための十分なプログラム全体解析を行っていないため、より安全な(そしてより遅い)仮想メソッド呼び出しを選択する。

私たちの牧場には他の種類の牛はいないので、Cowから派生する種類はない。この知識をコンパイラに明示すれば、より良い結果が得られるだろう。クラスをこのように定義してみよう:

public sealed class Cow : Animal {
   public override string Speak() {
       return "Moo";
   }
}

sealedキーワードは、誰もCowから派生できないことをコンパイラに伝える(sealedはSpeakメソッドで直接使うこともできる)。これでIL2CPPは、自信を持って直接メソッドを呼び出すことができる:

// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;

// Look ma, no virtual call!
String_t* L_17 = Cow_Speak_m1607867742(L_16, /*hidden argument*/NULL);

ここでのSpeakの呼び出しは、不必要に遅くなることはないだろう。なぜなら、我々はコンパイラに非常に明確に指示し、自信を持って最適化できるようにしているからだ。

このような最適化によってゲームが驚くほど速くなるわけではありませんが、将来そのコードを読む人間のためにも、コンパイラのためにも、コードについて持っている仮定をコードで表現するのは良い習慣です。IL2CPPでコンパイルしているのであれば、生成されたC++コードをプロジェクトで熟読し、他に何が見つかるか見てみることをお勧めする!

次回は、仮想メソッド呼び出しが高価な理由と、高速化のために行っていることについて説明する。