🐡

仮想関数と最適化について

2025/02/26に公開

仮想関数って実際どうなるの?
っていう話と、

最適化(インライン展開)はどこまで有効なの?
という話をgccで簡単に検証してみました。

※元は4日ほど前にQiitaに載せた記事ですが、編集不可能になってしまったので(将来的には削除予定)、こちらに引っ越しています。

1. 前提

1-1. 対象読者

  • 仮想関数テーブルという言葉を何となく知っている人
  • x86_amd64アセンブラを読める人(今回はintelにしてみた。普段はAT&T)
  • インライン展開を知っている人
  • LinuxとGNUのツールを使ってる人

※なので細かい説明は省略しています

1-2. 検証環境

  • Ubuntu 24.04
  • gcc 13

2. 検証

簡単なサンプルプログラムを用意し、それをビルドしてバイナリをダンプして確認します。

2-1. サンプル1

検証スクリプト全体
sample1.sh
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
sample1.cpp
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-0x20poです。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
...

https://gcc.gnu.org/onlinedocs/gcc/Developer-Options.html

ただし、実際にはシンボル生成されてないものも解析結果として出力されているので注意して下さい。原則インスタンス化されるなど、必要にならないと生成されません。

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) 

詳細は以下。

https://sourceware.org/gdb/current/onlinedocs/gdb.html/Debugging-C-Plus-Plus.html

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. まとめ

  • 仮想関数呼び出しは必ずインライン展開とその後の最適化を阻害するわけではない
GitHubで編集を提案

Discussion