仮想関数と最適化について
序
仮想関数って実際どうなるの?
っていう話と、
最適化(インライン展開)はどこまで有効なの?
という話をgccで簡単に検証してみました。
※元は4日ほど前にQiitaに載せた記事ですが、編集不可能になってしまったので(将来的には削除予定)、こちらに引っ越しています。
1. 前提
1-1. 対象読者
- 仮想関数テーブルという言葉を何となく知っている人
- x86_amd64アセンブラを読める人(今回はintelにしてみた。普段はAT&T)
- インライン展開を知っている人
- LinuxとGNUのツールを使ってる人
※なので細かい説明は省略しています
1-2. 検証環境
- Ubuntu 24.04
- gcc 13
2. 検証
簡単なサンプルプログラムを用意し、それをビルドしてバイナリをダンプして確認します。
2-1. サンプル1
検証スクリプト全体
cat >sample1.cpp <<EOF
class cls_base {
public:
virtual int method() = 0;
virtual int method2() = 0;
};
class cls_sub1: public cls_base {
public:
int method() {return 2;}
int method2() {return 5;}
};
class cls_sub2: public cls_base {
public:
int method() {return 3;}
int method2() {return 6;}
};
int main() {
cls_sub2 o;
cls_base* po = &o;
return po->method() + po->method2();
}
EOF
g++ -g -Wall -pedantic sample1.cpp -o sample1
objdump -M intel -StC sample1
class cls_base {
public:
virtual int method() = 0;
virtual int method2() = 0;
};
class cls_sub1: public cls_base {
public:
int method() {return 2;}
int method2() {return 5;}
};
class cls_sub2: public cls_base {
public:
int method() {return 3;}
int method2() {return 6;}
};
int main() {
cls_sub2 o;
cls_base* po = &o;
return po->method() + po->method2();
}
これを最適化せずにコンパイル/リンクし、objdumpした結果のうち、仮想関数呼び出し部分が以下になります。
...
return po->method() + po->method2();
1178: 48 8b 45 e0 mov rax,QWORD PTR [rbp-0x20]
117c: 48 8b 00 mov rax,QWORD PTR [rax]
117f: 48 8b 10 mov rdx,QWORD PTR [rax]
1182: 48 8b 45 e0 mov rax,QWORD PTR [rbp-0x20]
1186: 48 89 c7 mov rdi,rax
1189: ff d2 call rdx
118b: 89 c3 mov ebx,eax
118d: 48 8b 45 e0 mov rax,QWORD PTR [rbp-0x20]
1191: 48 8b 00 mov rax,QWORD PTR [rax]
1194: 48 83 c0 08 add rax,0x8
1198: 48 8b 10 mov rdx,QWORD PTR [rax]
119b: 48 8b 45 e0 mov rax,QWORD PTR [rbp-0x20]
119f: 48 89 c7 mov rdi,rax
11a2: ff d2 call rdx
11a4: 01 d8 add eax,ebx
}
...
rbp-0x20
がpo
です。po
の値を取り出し、最初の8バイトを取ってきていますが、そこが「po
が指すオブジェクト」の仮想関数テーブルになります。このテーブルの最初の8バイトがmethod()
で、次の8バイトがmethod2()
です(この順序はcls_sub1
でもcls_sub2
でも同じです)。最初に呼ぶのはmethod()
なので、取ってきたアドレスをそのまま参照し、rdxレジスタに入れてcallすることで仮想関数呼び出しをしています。
なおpo
の参照先の最初の8バイトのアドレスは、参照先であるo
が生成されたときに書き込まれています。
cls_sub2 o;
1165: 48 8d 05 14 2c 00 00 lea rax,[rip+0x2c14] # 3d80 <vtable for cls_sub2+0x10>
116c: 48 89 45 d8 mov QWORD PTR [rbp-0x28],rax
2-1-1. (余談)仮想関数テーブルの調べ方
2-1-1-1. gccに出力させる
gccに-fdump-lang-class
オプションを付けると[ソースファイル].[クラス識別子].classのような名前のファイルが生成され、そこに仮想関数テーブルに関する情報が出力されます。
$ g++ -fdump-lang-class -g -Wall -pedantic sample1.cpp -o sample1
$ cat sample1.cpp.001l.class
Vtable for cls_base
cls_base::_ZTV8cls_base: 4 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI8cls_base)
16 (int (*)(...))__cxa_pure_virtual
24 (int (*)(...))__cxa_pure_virtual
...
ただし、実際にはシンボル生成されてないものも解析結果として出力されているので注意して下さい。原則インスタンス化されるなど、必要にならないと生成されません。
2-1-1-2. gdbで実行時に調べる
gdbにinfo vtbl 変数名
などしてあげれば出てきます。
Breakpoint 1, main () at sample1.cpp:16
16 int main() {
(gdb) n
17 cls_sub2 o;
(gdb) n
18 cls_base* po = &o;
(gdb) n
19 return po->method() + po->method2();
(gdb) info vtbl po
vtable for 'cls_base' @ 0x555555557d80 (subobject @ 0x7fffffffdd38):
[0]: 0x5555555551c0 <cls_sub2::method()>
[1]: 0x5555555551d4 <cls_sub2::method2()>
(gdb)
詳細は以下。
2-2. サンプル2
サンプル1を-O3
で最適化してみます(個人的な感覚では、よくある最適化は-O2
です)。以下はスクリプトの差分のみです。
--- sample1.sh 2025-02-22 16:58:10.421276062 +0900
@@ -1,4 +1,4 @@
-cat >sample1.cpp <<EOF
+cat >sample2.cpp <<EOF
class cls_base {
public:
virtual int method() = 0;
@@ -20,5 +20,5 @@ int main() {
return po->method() + po->method2();
}
EOF
-g++ -g -Wall -pedantic sample1.cpp -o sample1
-objdump -M intel -StC sample1
+g++ -O3 -g -Wall -pedantic sample2.cpp -o sample2
+objdump -M intel -StC sample2
結果は、main()がこうなります。
...
0000000000001040 <main>:
class cls_sub2: public cls_base {
public:
int method() {return 3;}
int method2() {return 6;}
};
int main() {
1040: f3 0f 1e fa endbr64
cls_sub2 o;
cls_base* po = &o;
return po->method() + po->method2();
}
1044: b8 09 00 00 00 mov eax,0x9
1049: c3 ret
104a: 66 0f 1f 44 00 00 nop WORD PTR [rax+rax*1+0x0]
...
仮想関数呼び出しがあったとしても、インスタンスが分かっていてインライン展開可能であれば展開されて最適化されたような結果(直値)になっています。
2-3. サンプル3
次はサンプル2をモジュール分割してみます。
--- sample2.sh 2025-02-22 17:07:01.199038214 +0900
@@ -1,4 +1,5 @@
-cat >sample2.cpp <<EOF
+cat >sample3.h <<EOF
+#pragma once
class cls_base {
public:
virtual int method() = 0;
@@ -14,11 +15,26 @@ public:
int method() {return 3;}
int method2() {return 6;}
};
+class factory {
+public:
+ cls_base* create();
+};
+EOF
+cat >sample3.cpp <<EOF
+#include "sample3.h"
int main() {
- cls_sub2 o;
- cls_base* po = &o;
+ factory f;
+ cls_base* po = f.create();
return po->method() + po->method2();
}
EOF
-g++ -O3 -g -Wall -pedantic sample2.cpp -o sample2
-objdump -M intel -StC sample2
+cat >sample3_factory.cpp <<EOF
+#include "sample3.h"
+cls_base* factory::create() {
+ static cls_sub2 o;
+ return &o;
+}
+EOF
+g++ -c -O3 -g -Wall -pedantic sample3_factory.cpp
+g++ -O3 -g -Wall -pedantic sample3.cpp sample3_factory.o -o sample3
+objdump -M intel -StC sample3
結果はこうなります。
return po->method() + po->method2();
1087: 48 8b 00 mov rax,QWORD PTR [rax]
108a: 48 89 df mov rdi,rbx
108d: ff 10 call QWORD PTR [rax]
108f: 48 89 df mov rdi,rbx
1092: 89 c5 mov ebp,eax
1094: 48 8b 03 mov rax,QWORD PTR [rbx]
1097: ff 50 08 call QWORD PTR [rax+0x8]
109a: 01 e8 add eax,ebp
}
インスタンスが分からないのだから当たり前ですよね。
2-4. サンプル4
次はモジュール分割しつつもリンク時最適化を入れます(個人的な感覚では、よくある最適化だとリンク時最適化は行われません)。
--- sample3.sh 2025-02-22 17:24:58.867204941 +0900
@@ -1,4 +1,4 @@
-cat >sample3.h <<EOF
+cat >sample4.h <<EOF
#pragma once
class cls_base {
public:
@@ -20,21 +20,21 @@ public:
cls_base* create();
};
EOF
-cat >sample3.cpp <<EOF
-#include "sample3.h"
+cat >sample4.cpp <<EOF
+#include "sample4.h"
int main() {
factory f;
cls_base* po = f.create();
return po->method() + po->method2();
}
EOF
-cat >sample3_factory.cpp <<EOF
-#include "sample3.h"
+cat >sample4_factory.cpp <<EOF
+#include "sample4.h"
cls_base* factory::create() {
static cls_sub2 o;
return &o;
}
EOF
-g++ -c -O3 -g -Wall -pedantic sample3_factory.cpp
-g++ -O3 -g -Wall -pedantic sample3.cpp sample3_factory.o -o sample3
-objdump -M intel -StC sample3
+g++ -c -flto -O3 -g -Wall -pedantic sample4_factory.cpp
+g++ -flto -O3 -g -Wall -pedantic sample4.cpp sample4_factory.o -o sample4
+objdump -M intel -StC sample4
これで元に戻るかと思いきや…
...
return po->method() + po->method2();
1044: 48 8d 3d c5 2f 00 00 lea rdi,[rip+0x2fc5] # 4010 <factory::create()::o>
104b: e8 00 01 00 00 call 1150 <cls_sub2::method()>
1050: 89 c2 mov edx,eax
1052: e8 09 01 00 00 call 1160 <cls_sub2::method2()>
1057: 01 d0 add eax,edx
}
...
中途半端に残ってしまっています。しかし見るからに仮想関数呼び出しにはなっていません。この辺が現状のgccの限界なのかもしれません。
(おまけ)サンプル5
gcc-14にしてみました(apt install g++-14)。
--- sample4.sh 2025-02-22 17:56:11.712872214 +0900
@@ -1,4 +1,4 @@
-cat >sample4.h <<EOF
+cat >sample5.h <<EOF
#pragma once
class cls_base {
public:
@@ -20,21 +20,21 @@ public:
cls_base* create();
};
EOF
-cat >sample4.cpp <<EOF
-#include "sample4.h"
+cat >sample5.cpp <<EOF
+#include "sample5.h"
int main() {
factory f;
cls_base* po = f.create();
return po->method() + po->method2();
}
EOF
-cat >sample4_factory.cpp <<EOF
-#include "sample4.h"
+cat >sample5_factory.cpp <<EOF
+#include "sample5.h"
cls_base* factory::create() {
static cls_sub2 o;
return &o;
}
EOF
-g++ -c -flto -O3 -g -Wall -pedantic sample4_factory.cpp
-g++ -flto -O3 -g -Wall -pedantic sample4.cpp sample4_factory.o -o sample4
-objdump -M intel -StC sample4
+g++-14 -c -flto -O3 -g -Wall -pedantic sample5_factory.cpp
+g++-14 -flto -O3 -g -Wall -pedantic sample5.cpp sample5_factory.o -o sample5
+objdump -M intel -StC sample5
結果は以下のとおり。
...
0000000000001040 <main>:
#include "sample5.h"
int main() {
1040: f3 0f 1e fa endbr64
factory f;
cls_base* po = f.create();
return po->method() + po->method2();
}
1044: b8 09 00 00 00 mov eax,0x9
1049: c3 ret
104a: 66 0f 1f 44 00 00 nop WORD PTR [rax+rax*1+0x0]
...
3. まとめ
- 仮想関数呼び出しは必ずインライン展開とその後の最適化を阻害するわけではない
Discussion