遅い仮想関数呼び出しに関する都市伝説

従来、コンパイラは二重間接アドレッシングを介して仮想関数呼び出しを実装します。クラスに少なくとも1つの仮想関数が含まれる場合、仮想関数テーブルのアドレスはこのクラスの各オブジェクトの先頭に格納されます。 コンパイラーがポインターが指すオブジェクトの特定のタイプを知らない場合、仮想関数を呼び出すには、まずオブジェクトへのポインターを取得し、テーブルの先頭のアドレスを読み取り、次にメソッドの番号で関数の実装が格納されているアドレスを読み取り、関数を呼び出す必要があります。



オブジェクトへのポインターによって特定の関数を見つけるプロセスは遅延バインディングと呼ばれ、プログラムの実行中に実行されます。 遅延バインディングは、呼び出しのオーバーヘッドを増加させるだけでなく、コンパイラによるコードの最適化も妨げます。 このため、仮想機能自体は減速していると見なされます。



上記のテキストでは、キーワードは「if」です。 コンパイラが実際に呼び出す必要がある関数を知っている場合はどうなりますか?



標準(以降、C ++ 03標準と呼ぶ)は、仮想メソッドテーブルについては何も述べていません。 代わりに、5.2.2 / 1([expr.call]、「関数呼び出し」)では、プログラムに仮想関数の呼び出しが含まれている場合、10.3 / 2からの規則に従って選択された対応する関数が呼び出されると言われています([class.virtual ]、「仮想機能」)、およびTL; DR; 関数は、その関数が定義または再定義されている最も派生したクラスから選択する必要があります。



したがって、コンパイラがコードを分析した後、オブジェクトの正確なタイプを見つけることができる場合、遅延バインディングを使用する必要はありません-そして問題ではありません、メソッドは特定のオブジェクトで、参照またはオブジェクトへのポインタによって呼び出されます



意味のない推論から、 gcc.godbolt.orgで試すコードに移りましょう



次の2つのクラスが必要です。



class Base { public: virtual ~Base() {} virtual int Magic() { return 9000; } }; class Derived : public Base { public: virtual int Magic() { return 100500; } };
      
      







開始するには、このコード:



 int main() { Derived derived; return derived.Magic(); }
      
      





-O2を指定したclang 3.4.1は、次のように応答します。

 main: # @main movl $100500, %eax # imm = 0x18894 ret
      
      





マシンコードは、 戻り値100500のみを含むプログラムに対応していることが簡単にわかります。 これは特に興味深いものではありません-ポインターとリンクがないためです。



さて、ゆっくりと攪拌し、ポインターとリンクを追加します。



 int magic( Base& object ) { return object.Magic(); } int main() { Base* base = new Derived(); int result = magic( *base ); delete base; return result; }
      
      





-O2を指定したclang 3.4.1は、次のように応答します。

 magic(Base&): # @magic(Base&) movq (%rdi), %rax jmpq *(%rax) # TAILCALL main: # @main movl $100500, %eax # imm = 0x18894 ret
      
      







コンパイラーのOKCHIエラーいいえ、コンパイラーでは問題ありませんが、最適化の積極性を否定することは無意味です。 再び100500を返します。



比較のために、gcc 4.9.0と-O2を使用:



 main: subq $8, %rsp movl $8, %edi call operator new(unsigned long) movq vtable for Derived+16, (%rax) movq %rax, %rdi call Derived::~Derived() movl $100500, %eax addq $8, %rsp ret
      
      







call Derived ::〜Derived() -仮想デストラクタのため、gccはデストラクタ内にcall :: operator delete()を呼び出します:



 Derived::~Derived(): jmp operator delete(void*)
      
      





彼はそれらをローカルで置き換えることができますが。 このように:

  movq %rax, %rdi call operator delete(void*)
      
      





できましたが、できませんでした。 同時に、 Derived :: Magic()メソッドの本体が呼び出しの代わりに置き換えられ、周囲のコードとともに最適化されます。



小さな余談...原則として、コンパイラーがコードを最適化できる程度について広く話したい場合は、上記の例を参照してください。 Derived :: Magic()の呼び出しとオブジェクトの削除の両方で、コンパイラは同様に正常に最適化できましたが、そのうちの1つを最適化し、2番目は最適化しませんでした。 後退は終わりました。



比較のため、gcc 4.9.0と-O1



 magic(Base&): subq $8, %rsp movq (%rdi), %rax call *(%rax) addq $8, %rsp ret main: pushq %rbp pushq %rbx subq $8, %rsp movl $8, %edi call operator new(unsigned long) movq %rax, %rbx movq vtable for Derived+16, (%rax) movq %rax, %rdi call magic(Base&) movl %eax, %ebp testq %rbx, %rbx je .L12 movq (%rbx), %rax movq %rbx, %rdi call *16(%rax) .L12: movl %ebp, %eax addq $8, %rsp popq %rbx popq %rbp ret
      
      







まあ、多分結局、あなたがよく尋ねるなら。 このコードでは、「すべてが順番に並んでいます」-多数のメモリアクセスと、間接アドレス指定の呼び出し命令を使用したメソッド呼び出し( call * 16(%rax) )。



ただし、-O2を使用した成功例は非常に手間がかかります。すべてのコードが1つの変換ユニットに含まれているため、最適化が大幅に簡素化されます。



LTOは急いで支援します(または、コンパイラ内のいくつかの翻訳単位の最適化と呼ばれるものは何でも)。



コードをいくつかの翻訳単位に分割し......



 //Classes.h class Base { public: virtual int Magic(); virtual ~Base(); }; class Derived : public Base { public: virtual int Magic(); }; //Classes.cpp #include <Classes.h> #include <stdio.h> Base::~Base() { } int Base::Magic() { return 9000; } int Derived::Magic() { return 100500; } //main.cpp #include <Classes.h> int magic( Base& object ) { return object.Magic(); } int main() { Base* base = new Derived(); int result = magic( *base ); delete base; return result; }
      
      







以下では、gcc 4.9.0でMinGWを使用します



 g++ -flto -g -O3 main.cpp Classes.cpp objdump -d -M intel -S --no-show-raw-insn a.exe >a.txt
      
      







 int main() { 402830: push ebp 402831: mov ebp,esp 402833: and esp,0xfffffff0 402836: sub esp,0x10 402839: call 402050 <___main> Base* base = new Derived(); 40283e: mov DWORD PTR [esp],0x4  ::operator new() 402845: call 4015d8 <__Znwj>    vtable 40284a: mov DWORD PTR [eax],0x404058 int result = magic( *base ); delete base; 402850: mov ecx,eax 402852: call 4015c0 <__ZN7DerivedD0Ev> return result; }       402857: mov eax,0x18894 40285c: leave 40285d: ret
      
      





ここでは、 mov eax、0x18894 (16進表記で100500)の命令に興味があります。ここでも、コンパイラは目的の関数を選択し、呼び出しの代わりに本体を置き換え、周囲のコードを最適化しました。



単純すぎるため、ファクトリを追加します(派生とベースは同じです)...

 //Factory.h #include <Classes.h> class Factory { public: static Base* CreateInstance(); }; //Factory.cpp #include <Factory.h> Base* Factory::CreateInstance() { return new Derived(); } //main.cpp #include <Factory.h> int magic( Base& object ) { return object.Magic(); } int main() { Base* base = Factory::CreateInstance(); int result = magic( *base ); delete base; return result; }
      
      





コンパイル、逆アセンブル...初期結果は非常に明確ではありません-積極的な最適化、マシンコードとソースコードは最も読みやすい方法で比較されなかったため、マシンコードはそのまま残され、ソースコード行の一部は対応するマシンコードに可能な限り近くに配置されます

 int main() { 402830: push ebp 402831: mov ebp,esp 402833: push esi 402834: push ebx 402835: and esp,0xfffffff0 402838: sub esp,0x10 40283b: call 402050 <___main> return new Derived(); 402840: mov DWORD PTR [esp],0x4  ::operator new() 402847: call 4015d8 <__Znwj> 40284c: mov ebx,eax int magic( Base& object ) { return object.Magic(); 40284e: mov ecx,eax    vtable 402850: mov DWORD PTR [eax],0x404058   Derived::Magic() 402856: call 401580 <__ZN7Derived5MagicEv> int main() { delete base; 40285b: mov ecx,ebx 40285d: mov esi,eax 40285f: call 4015b0 <__ZN7DerivedD0Ev> return result; 402864: lea esp,[ebp-0x8] 402867: mov eax,esi 402869: pop ebx 40286a: pop esi 40286b: pop ebp 40286c: ret ( )
      
      







ここでは、ラインに興味があります

  402856: call 401580 <__ZN7Derived5MagicEv>
      
      







これは、 Derived :: Magic()への直接呼び出しです。

  00401580 <__ZN7Derived5MagicEv>: int Derived::Magic() { return 100500; } 401580: mov eax,0x18894 401585: ret
      
      







コンパイラーは、どの関数を呼び出すかを正しく決定しましたが、呼び出しの代わりに関数本体を代用しませんでした。



ファクトリーをパラメーター化します(BaseとDerivedは同じです)...

 //Factory.h #include <Classes.h> enum ClassType { BaseType, DerivedType }; class Factory { public: static Base* CreateInstance(ClassType classType); }; //Factory.cpp #include <Factory.h> Base* Factory::CreateInstance(ClassType classType) { switch( classType ) { case BaseType: return new Base(); case DerivedType: return new Derived(); } } //main.cpp #include <Factory.h> int magic( Base& object ) { return object.Magic(); } int main() { Base* base = Factory::CreateInstance(DerivedType); int result = magic( *base ); delete base; return result; }
      
      





...前の試行と同じコードを取得します。



プログラムが実行されているときに工場パラメーターを計算します...

 #include <Factory.h> #include <cstdlib> int magic( Base& object ) { return object.Magic(); } int main() { Base* base = Factory::CreateInstance(rand() ? BaseType : DerivedType); int result = magic( *base ); delete base; return result; }
      
      





取得します...(結果は再び非常に明確に見えません)

 int main() { 402830: push ebp 402831: mov ebp,esp 402833: push esi 402834: push ebx 402835: and esp,0xfffffff0 402838: sub esp,0x10 40283b: call 402050 <___main> Base* base = Factory::CreateInstance(rand() ? BaseType : DerivedType);  rand() 402840: call 4027c8 <_rand> Base* Factory::CreateInstance(ClassType classType) { switch( classType ) {        switch 402845: test eax,eax 402847: mov DWORD PTR [esp],0x4  40284e: jne 402875 <_main+0x45>  rand()   ,      402875  rand()  ,    ... case DerivedType: return new Derived();  ::operator new() 402850: call 4015d8 <__Znwj>    vtable  Derived 402855: mov DWORD PTR [eax],0x404070 40285b: mov ebx,eax int magic( Base& object ) { return object.Magic();      -   "" ,     ,    402875 (rand() != 0) 40285d: mov eax,DWORD PTR [ebx] 40285f: mov ecx,ebx   Magic() 402861: call DWORD PTR [eax] 402863: mov esi,eax int main() { delete base; 402865: mov eax,DWORD PTR [ebx] 402867: mov ecx,ebx     402869: call DWORD PTR [eax+0x8] return result; } 40286c: lea esp,[ebp-0x8] 40286f: mov eax,esi 402871: pop ebx 402872: pop esi 402873: pop ebp 402874: ret Base* Factory::CreateInstance(ClassType classType) { switch( classType ) { case BaseType: return new Base();        40284e  rand() != 0  ::operator new() 402875: call 4015d8 <__Znwj>    vtable  Base 40287a: mov DWORD PTR [eax],0x404058 402880: mov ebx,eax         402882: jmp 40285d <_main+0x2d>
      
      







かなり興味深い結果。 ファクトリメソッドコードは完全に配置されています。 main()内で直接rand()関数を呼び出した結果に応じて、対応するクラスがフォークされ、インスタンス化されます。 コンパイラーはさらに各ブランチに直接呼び出しを行うことができましたが、最適化に対処できず、2つの間接呼び出しに陥りました。1つは遅延バインディングでMagic()メソッドを呼び出し、もう1つは遅延バインディングでオブジェクトを削除します。



ご覧のとおり、仮想関数の呼び出しは遅延バインディングを使用することを強制するものではなく、実際に何が起こるかはコンパイラー、その設定、および特定のコードに依存します。



ドミトリー・メッシェリャコフ、

開発者製品部門



All Articles