视频网站上看到的内容,随手记录下来
例子:
#include<bits/stdc++.h>
using namespace std;
class A
{
public:
virtual int foo()
{
return i;
}
int i;
};
class B : public A
{
virtual int foo() override
{
return A::i + 1;
}
};
vector<A*> genData(int n)
{
vector<A*> va;
for (int i=0;i<n;i++)
{
if (rand() & 1)
{
va.push_back(new A());
}
else
{
va.push_back(new B());
}
}
random_shuffle(va.begin(), va.end());
return va;
}
int test(int n, vector<A*>& va)
{
chrono::high_resolution_clock::time_point start =
chrono::high_resolution_clock::now();
int sum = 0;
for (int i=0;i<n;i++)
{
for (int j=0;j<va.size();j++)
{
sum+=va[j]->foo();
}
}
chrono::high_resolution_clock::time_point ed =
chrono::high_resolution_clock::now();
cout<< "epapsed milliseconds: " << chrono::duration_cast<chrono::milliseconds>(ed - start).count() << endl;
return sum;
}
int main()
{
int n = 10000;
vector<A*> va = genData(n);
int m = 10000;
int sum = test(m, va);
cout<<" sum = " << sum <<endl;
return 0;
}
上面代码主要包括
class A,以及继承class A的class B
class A中包括虚函数foo,class B中同样继承了class A中的foo方法
现在将一个基类的A指针的容器,随机的放入类A或者类B的对象
在test函数中通过动态绑定的方式调用foo
在g++ 使用c++11 不开启任何优化的编译选项的情况下,时间大约是1140ms
如果将函数genData中的分支判断条件改为
if (0)
或者
if (1)
可以得到的大约为600ms左右的时间,性能大幅度提升
原因如下:
来自知乎,总结的很好
链接 -> C++性能榨汁机之虚函数的开销
虚函数的实现
虽然C++标准并没有规定编译器实现虚函数的方式,但是大部分编译器均是采用了虚函数表来实现虚函数,即对于每一个包含虚成员函数的类生成一个虚函数表,一个指向虚函数表的指针被放在对象的首地址(不考虑多继承等复杂情况),虚函数表中存储该类所有的虚函数地址。当使用引用或者指针调用虚函数时,首先通过虚函数表指针找到虚函数表,然后通过偏移量找到虚函数地址并调用。关于虚函数表的更多细节,建议阅读《深度探索C++对象模型》这本书。
虚函数表面上的开销
空间开销
首先,由于需要为每一个包含虚函数的类生成一个虚函数表,所以程序的二进制文件大小会相应的增大;其次,对于包含虚函数的类的实例来说,每个实例都包含一个虚函数表指针用于指向对应的虚函数表,所以每个实例的空间占用都增加一个指针大小(32位系统4字节,64位系统8字节)。这些空间开销可能会造成缓存的不友好,在一定程度上影响程序性能。时间开销
虚函数的时间开销主要是增加了一次内存寻址,通过虚函数表指针找到虚函数表,虽对程序性能有一些影响,但是影响并不大。
虚函数背后的开销
上述虚函数表面上的开销其实是微不足道的,真正影响虚函数性能的是隐藏在背后的,不被人轻易察觉的,只有对计算机体系结构有一定理解才能探寻出藏在背后的“性能杀手”。
首先我们先看调用虚函数时,在汇编层生成了什么代码:
...
movq (%rax), %rax
movq (%rax), %rax
movq -24(%rbp), %rdx
movq %rdx, %rdi
call *%rax
...
述汇编代码最重要的就是第6行,在AT&T格式汇编中,这是一个间接调用,意义是从%rax指明的地址处读取跳转的目标位置。这也是虚函数调用与普通成员函数的区别所在,普通函数调用是一个直接调用。直接调用与间接调用的区别就是跳转地址是否确定,直接调用的跳转地址是编译器确定的,而间接调用是运行到该指令时从寄存器中取出地址然后跳转。
有了分支预测器和CPU指令流水线的基本知识,我们可以发现对于直接调用而言,是不存在分支跳转的,因为跳转地址是编译器确定的,CPU直接去跳转地址取后面的指令即可,不存在分支预测,这样可以保证CPU流水线不被打断。而对于间接寻址,由于跳转地址不确定,所以此处会有多个分支可能,这个时候需要分支预测器进行预测,如果分支预测失败,则会导致流水线冲刷,重新进行取指、译码等操作,对程序性能有很大的影响。
网上有部分文章中说对于虚函数这种间接跳转会直接导致流水线冲刷,这种说法明显是自相矛盾的,如果间接跳转必定会导致流水线冲刷,那把这些指令放进流水线的意义何在呢?其实查阅资料就可以知道,Intel和AMD的CPU中存在两级自适应预测器用于预测间接跳转,此预测器可以预测多分支跳转。
解决办法:
针对上面的例子,如何解决性能上的问题呢?
可以把vector<A*>中的对象按照类型排序调用,要用到c++11的语法
#include<bits/stdc++.h>
using namespace std;
class A
{
public:
virtual int foo()
{
return i;
}
int i;
};
class B : public A
{
virtual int foo() override
{
return A::i + 1;
}
};
vector<A*> genData(int n)
{
vector<A*> va;
for (int i=0;i<n;i++)
{
if (rand() & 1)
{
va.push_back(new A());
}
else
{
va.push_back(new B());
}
}
random_shuffle(va.begin(), va.end());
return va;
}
int test(int n, vector<A*>& va)
{
chrono::high_resolution_clock::time_point start =
chrono::high_resolution_clock::now();
int sum = 0;
for (int i=0;i<n;i++)
{
for (int j=0;j<va.size();j++)
{
sum+=va[j]->foo();
}
}
chrono::high_resolution_clock::time_point ed =
chrono::high_resolution_clock::now();
cout<< "epapsed milliseconds: " << chrono::duration_cast<chrono::milliseconds>(ed - start).count() << endl;
return sum;
}
int main()
{
int n = 10000;
vector<A*> va = genData(n);
int m = 10000;
int sum = test(m, va);
cout<<" sum = " << sum <<endl;
sort(va.begin(), va.end(), [](const A* a, const A *b){return typeid(*a).before(typeid(*b));});// 按照类型排序
sum = 0;
sum += test(m,va);
cout<<" sum = " << sum <<endl;
return 0;
}
排序后的时间要比没有排序的时间减少一半