这个专栏平时Java / JVM的内容偏多,今天混入一些新血液吧。来聊点C++的话题。
不过说起来还是跟JVM相关的内容。在JVM实现中,要达到高性能的一个重要方面就是要对虚方法调用做优化,要尽其所能将虚方法调用点去虚化(devirtualize),以便支持后续的优化。这是因为Java里非private的成员方法默认就是virtual的,大家愿意也好不愿意也好也很容易会写一大堆虚方法,再加上良好的面向对象风格的代码提倡要尽量写职责单一的小方法,一大堆小的虚方法如果不能好好优化,那性能是上不去的。
而在C++里,虚函数的开销则没Java那么引人关注,毕竟成员函数默认不是virtual的,而且还有CRTP之类的奇技淫巧来向别的方向取舍开销。但始终,对虚函数有优化需求这点跟Java还是很相似的。
很多同学都会在学习了一些C++的基础知识之后就偏执地认为C++的虚函数分派总是通过对vtable(虚函数表)的间接调用来做的,并且虚函数调用无法被内联。事实上编译器优化发展了那么多年,这种事情又怎会没有进一步的优化呢。
今天要说的就是相对新一些的GCC(GCC 4.9或更高)里的一种优化,由-fdevirtualize-speculatively参数控制的“speculative devirtualization”,或者用JVM里更常用的叫法是“guarded devirtualization”。这个优化是Jan Hubicka大大在2013年添加到GCC中的:Speculative call support in the callgraph。正好这几天在调试我们的JVM的一个core dump,留意到我们的JVM的C++代码里有虚函数调用点被GCC应用上了这个优化,就顺手来写写。
让我们先来看个例子:
class Base {
int value_;
public:
virtual int foo() __attribute__ ((noinline)) {
return 42 + this->bar();
}
virtual int bar() {
return value_;
}
};
class Derived : public Base {
public:
int bar() {
return 256;
}
};
int main() {
Base* b = new Derived;
return b->foo();
}
这个例子用GCC 4.9.2在-O2下编译,会发现Base::foo()里对bar()这个虚函数的调用就是普通的通过vtable分派的间接调用。用伪代码来说就是这样:
// this->bar()
bar_ptr = this->_vptr[BAR_VTABLE_INDEX]; // load function entry point from vtable
tmp = bar_ptr(); // indirect call
实际用GCC 4.9.2 -O2在Linux/x86-64上生成的Base::foo()函数的代码是这样的:
Base::foo():
subq $8, %rsp
movq (%rdi), %rax # rax = this->_vptr
call *8(%rax) # call this->_vptr[BAR_VTABLE_INDEX]
addq $8, %rsp
addl $42, %eax
ret
但是如果我们把上面代码例子中Derived::bar()的声明去掉,使得Derived类变成:
class Derived : public Base {
};
再重新编译这个实验代码,就会发现Base::foo()里对bar()的调用变成了这个样子:(继续伪代码)
// this->bar()
bar_ptr = this->_vptr[BAR_VTABLE_INDEX]; // load function entry point from vtable
if (bar_ptr == Base::bar) {
// inlined Base::bar()
tmp = this->value_;
} else {
tmp = bar_ptr(); // normal indirect virtual call
}
实际用GCC 4.9.2 -O2在Linux/x86-64上生成的新版本Base::foo()函数的代码是这样的:
Base::foo():
movq (%rdi), %rax
movq 8(%rax), %rax
cmpq Base::bar(), %rax
jne .L3
movl 8(%rdi), %eax
addl $42, %eax
ret
.L3:
subq $8, %rsp
call *%rax
addq $8, %rsp
addl $42, %eax
ret
这种先做条件检查,然后在检查通过的分支里把虚函数调用变为非虚调用(进而可以被内联)的做法,就叫做“speculative devirtualization”或者“guarded devirtualization”。
GCC具体采用的做法是“function-based guarded devirtualization”,正如上面例子所演示的,它的“guard”其实还是从vtable读出了函数指针,只是读出来之后不马上去调用该函数指针,而是检查一下它是否跟预期的函数指针一致,如果一致则认为检查通过。乍一看这挺傻的,访问vtable的内存访问开销一点都没少,而且还多了个条件分支;如果能内联目标函数的话那可能还值得,否则的话这么做的好处就没多少了。
正因为这个优化并非总是值得的,GCC采用了很保守的策略,只在应该能提升性能的地方采用这种做法。一种情况是通过静态的类层次分析(CHA),发现一个虚函数调用点可能调用的目标函数只有1个可能性,那就生成上面所演示的“speculative devirtualized”代码,这种情况不需要profiling信息的支持。如果在做了该优化后,后续优化没能把目标函数内联进来或者至少从目标函数获取某些有利于优化的信息的话,则会撤销该优化,恢复回到普通的vtable间接调用。
上面演示的例子,之所以最开始的版本bar()还是用普通vtable调用而去掉Derived::bar()之后则变为“speculative devirtualized”调用,就是因为要满足上述保守策略的“只有1个可能调用的目标”的条件。
既然“只有1个可能调用目标”了,为啥不干脆去掉检查变为纯粹的直接调用(进而可能被内联),而要保留一个检查并在检查失败的分支中还去做普通vtable间接调用呢?
这主要是因为对C++程序不一点总是能做真正完备的“全程序分析”——假如碰上共享库/动态链接库,这些库里的类层次状况只能当黑盒子看待,所以总得留下一条退路给类层次分析错误的时候还能正确执行程序。
==================================
GCC选择的guard形式并非唯一的可能性。这种形式的guard在JVM里也有研究和应用,例如说IBM的JVM就有过这种形式的devirtualization。
但HotSpot VM没有使用这种形式的guard。HotSpot VM如果选择做guarded devirtualization的话,只会用type-based guarded devirtualization,也就是说guard检查的是被调用对象的类型,而不是目标方法的地址。
Type-based与function-/method-based的guard各有取舍。前者开销更小,而后者可处理的情况更多。
还是用本文开头的例子,如果是type-based,就可能会生成这样的代码:(还是伪代码)
// this->bar()
if (this->_vptr == vtable_of(Derived)) {
// inlined Derived::bar()
tmp = 256;
} else {
this->_vptr[BAR_VTABLE_INDEX](); // normal indirect virtual call
}
这个guard的形式显然比function-based的更轻一些,只要做一次间接读(读出_vptr字段来),而不像function-based版额外再读出bar的vtable entry出来。
但假如我们要调用foo(),它也是一个虚方法,但在Base与Derived中只有一个版本的实现,用type-based guard就得写成:
if (this->_vptr == vtable_of(Base) || this->_vptr == vtable_of(Derived))
这就未必比function-based版好了。
最后放个传送门:HotSpot VM有没有对invokeinterface指令的方法表搜索进行优化? - RednaxelaFX 的回答
专栏:编程语言与高级语言虚拟机杂谈(仮)
探讨编程语言的设计与实现