本文动机
很多人都知道虚函数对程序会有性能损耗。
然而很多人可能未必知道造成性能损耗的根本原因是什么。
我认为有必要深度挖掘一下这背后的原理
一来是更好的理解虚函数的原理
二者是当我们理解了这根本原因,可能对在其他方面进行性能优化,有更好的指导方向
虚函数的原理
- 虚函数通过类的虚表来维护
- 当子类发生函数调用时,子类对象会现在内存中判断是否为虚函数,如果是虚函数,则根据虚表指针找到虚表,然后调用对应的虚函数
例子分析
虚函数与非虚函数的性能比较
通过分别单独调用虚函数和非虚函数10w次,并对他们的结果进行分析
class A {
public:
virtual void foo() {}
};
class B : public A {
public:
int a;
int c;
virtual void foo() {
a = 10;
}
void boo() {
c = 10;
}
};
调用虚函数10w次
void VirtualFuntionCall(benchmark::State& state) {
for (auto _ : state) {
B b;
for(int i=0; i
调用非虚函数10w次
void NonVirtualFuntionCall(benchmark::State& state) {
for (auto _ : state) {
B b;
for(int i=0; i
他们跑出来的结果如下:
- 虚函数和非虚函数的差别非常小,甚至可以忽略不记
- 这是否可以说明虚函数对性能的损耗并没有那么大?
对象内存乱序带来性能损耗
使用一个子类,通过vector构造10w个内存连续的子类对象,进行调用虚函数10w次;打乱这10w个对象,再次进行10w次的虚函数调用。对他们的性能结果进行分析
class A {
public:
virtual void foo() {}
};
class B : public A {
public:
int a;
int c;
virtual void foo() {
a = 10;
}
};
按内存顺序调用虚函数10w次
void VirtualFuntionSeqCall(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
arr.resize(size);
std::vector target;
for(auto& item: arr){
target.push_back(&item);
}
for(auto item: target){
item->foo();
benchmark::DoNotOptimize(item);
}
}
}
// Register the function as a benchmark
BENCHMARK(VirtualFuntionSeqCall);
乱序调用虚函数10w次
void VirtualFuntionRandomCall(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
arr.resize(size);
std::vector target;
for(auto& item: arr){
target.push_back(&item);
}
std::random_shuffle(target.begin(), target.end());
for(auto item: target){
item->foo();
benchmark::DoNotOptimize(item);
}
}
}
// Register the function as a benchmark
BENCHMARK(VirtualFuntionRandomCall);
他们跑出来的结果如下2.5x faster:
- 虚函数访问慢,是由于对象与对象之间内存不连续,发生大量cache miss,导致性能低
编译优化带来的性能差异
构造短小的虚函数和功能一样的非虚函数,通过一个10w的循环调用,分析编译优化在这两种场景的差异是什么
#include
class A {
public:
int offset;
void set(int o) {
offset = o;
}
int get() {
return offset;
}
virtual int v_get() {
return offset;
}
};
const int size = 100000;
调用虚函数10w次
void virtualFunction(benchmark::State& state) {
// Code inside this loop is measured repeatedly
for (auto _ : state) {
std::vector arr;
arr.resize(size);
for(int i=0; i ret;
ret.resize(size);
for(int i=0; i
调用非虚函数10w次
void normalFunction(benchmark::State& state) {
// Code inside this loop is measured repeatedly
for (auto _ : state) {
std::vector arr;
arr.resize(size);
for(int i=0; i ret;
ret.resize(size);
for(int i=0; i
他们跑出来的结果如下:
- 虚函数是不能编译优化成inline函数的
- 非虚函数可以被优化成inline函数,并做进一步的循环优化
- 因此短小的函数,非虚函数的性能要比虚函数好很多
Jump destination guess与虚函数
虚函数随机子类带来的性能差异
构造多个子类,制造三个场景,顺序的调用子类的虚函数,轮询的调用子类的虚函数和乱序的调用子类的虚函数
对这三个场景的性能结果进行分析
class A {
public:
virtual void foo() {}
};
class B : public A {
public:
int a;
virtual void foo() {
a = 10;
}
};
class C : public A {
public:
int a;
virtual void foo() {
a = 20;
}
};
const int size = 100000;
调用随机子类10w次
void randomVirtualFuntion(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
B b;
C c;
for(int i=0; ifoo();
benchmark::DoNotOptimize(item);
}
}
}
// Register the function as a benchmark
BENCHMARK(randomVirtualFuntion);
轮询调用子类10w次
void roundRobinVirtualFuntion(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
B b;
C c;
for(int i=0; ifoo();
benchmark::DoNotOptimize(item);
}
}
}
// Register the function as a benchmark
BENCHMARK(roundRobinVirtualFuntion);
顺序调用子类10w次
void seqVirtualFuntion(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
B b;
C c;
auto half_size = size/2;
for(int i=0; ifoo();
benchmark::DoNotOptimize(item);
}
}
}
// Register the function as a benchmark
BENCHMARK(seqVirtualFuntion);
他们跑出来的结果如下3.5x faster:
cpu遇到call指令的时候,如果没有明确的地址,而是需要计算的地址,CPU不会等待地址计算完成,就会先去猜测地址是什么,并进行调用,如果发现预测地址错误,则会重新调用正确的地址函数
顺序执行子类和有规律的执行子类的虚函数,CPU都可以正确的预测出来
随机的执行子类虚函数,CPU无法正确预测,所以性能下降
这个特性不是虚函数特有的,我们来看看函数指针的例子
随机指针函数带来的性能差异
构造两个功能相同的指针函数
制造两个场景,乱序执行指针函数,顺序执行指针函数和轮询执行指针函数
并分析他们的性能结果
const int size = 100000;
int b = 0;
void foo1() {
b = 20;
}
void foo2() {
b = 30;
}
随机调用函数指针10w次
void randomFuntion(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
for(int i=0; i
顺序调用函数指针10w次
void seqFuntion(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
auto half_size = size/2;
for(int i=0; i
轮询调用函数指针10w次
void roundRobinFuntion(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
for(int i=0; i
他们跑出来的结果如下3.5x faster:
- 指针函数跑出来的性能结果与虚函数类似
- 这也说明了,凡是需要调用call指令,并且函数地址需要计算的,CPU都会通过目标地址猜测,来进行性能的优化。
- 而乱序带来的性能下降,并不是虚函数所特有的
CPU指令cache带来的性能差异
构造体积大的虚函数
制造两个场景:轮询调用子类虚函数和顺序调用子类虚函数
并分析他们的性能结果
#include
#define VARIABLES \
V(1, a* a* a) \
V(2, a / 3) \
V(3, a / 5) \
V(4, a / (a - 1))
class A {
public:
virtual int long_virtual_function(std::vector& v) {
return 0;
}
};
class B : public A {
public:
virtual int long_virtual_function(std::vector& v) override {
int sum = 20;
for (int i = 0; i < v.size(); i++) {
int a = v[i];
if (a == 0) {
sum += a;
}
#define V(num, expr) \
else if (a == num) { \
sum += (expr) - (expr) / 2; \
}
VARIABLES
#undef V
}
return sum;
}
};
class C : public A {
public:
virtual int long_virtual_function(std::vector& v) override {
int sum = 10;
for (int i = 0; i < v.size(); i++) {
int a = v[i];
if (a == 0) {
sum += a;
}
#define V(num, expr) \
else if (a == num) { \
sum += (expr) - (expr) / 2; \
}
VARIABLES
#undef V
}
return sum;
}
};
const int size = 10000;
轮询调用虚函数10w次
void roundRobinVirtualFuntion(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
B b;
C c;
for(int i=0; i number;
number.resize(1000);
for(int i=0; i<1000; i++){
number.push_back(i);
}
for(auto item: arr) {
auto ret = item->long_virtual_function(number);
benchmark::DoNotOptimize(ret);
}
}
}
// Register the function as a benchmark
BENCHMARK(roundRobinVirtualFuntion);
顺序调用虚函数10w次
void seqVirtualFuntion(benchmark::State& state) {
for (auto _ : state) {
srand (1);
std::vector arr;
B b;
C c;
auto half_size = size/2;
for(int i=0; i number;
number.resize(1000);
for(int i=0; i<1000; i++){
number.push_back(i);
}
for(auto item: arr) {
auto ret = item->long_virtual_function(number);
benchmark::DoNotOptimize(ret);
}
}
}
// Register the function as a benchmark
BENCHMARK(seqVirtualFuntion);
判断分支为4时(小函数)
当分支数为900时(大函数)
- 小函数,在用轮询方式和顺序方式,性能差别并不大
- 大函数,顺序的方式明显比轮询的要好很多
- 这是因为CPU cache有一块专门给指令做的cache。例如在顺序调用虚函数的时候,子类的虚函数一直在cache里面,直到遍历完为止,才会刷出cache。而通过轮询的方式,因为函数体积大,一下子把cache吃满,cache就有肯能被来回的切换,导致性能下降
结论
- 虚函数带来的性能下降,跟函数指针带来的性能下降,原理是相同
- 在批量调用虚函数时,内存最好是cache亲和的,尽量不要乱序的调用
- 虚函数是不能被编译优化,短小的函数最好为非虚函数
- 体积大的虚函数,要避免用round robin的轮询方式调用,最好顺序调用