语句优化一般来说是从执行流中移除指令的过程,这个是针对单一的语句的。
语句优化的问题在于,如果针对的语句不是函数调用而起其他的语句,这种语句的本身消耗的就很少。我们在进行语句优化的时候应该关注以下几种情况:
- 循环
循环所占用的开销是循环次数和循环内的开销的乘积。
- 频繁调用的函数
这种语句的开销是调用次数和函数内开销的乘积。
- 贯穿这个程序的某些用例
这个是针对全局代码而言的。如果我们在一个程序中一直使用std::string 但是做的只是简单的char操作的话,那如果我们把函数内的std::string 统一替换成char,也会有不少的提升。参考《C++性能优化指南》 linux版代码及原理解读 第四章
在嵌入式这种小型处理当中,对语句的优化效果可能会比较明显,但是在桌面级或者更高级的处理器上,有很多中硬件级别的优化,包括指令级别的并发和缓存等,这导致单一的一条语句造成的影响要小于内存操作造成的影响。
在进行语句优化的时候要注意编译器,不同的编译器、不同的版本、不同的优化方式都会造成不同的优化程度甚至反向优化。
.1 优化循环中的代码
原始代码如下 :
- void testFunc_Origin(){
- char s[] = "This string has * many space (0x20) chars. *";
- for (size_t i = 0 ; i < sizeof (s); ++i){
- if (s[i] == ' ')
- s[i] = '*';
- }
- }
复制代码 for(part 1 ; part 2 ; part 3 ) 语句包含了三部分,第一部分是初始化语句,第二部分是从每次循环开始判断是否执行本次循环内的语句。第三部分是每次执行完成之后运行的。
从这里我们可以看到,part 2 是会一直运行的,如果在这部分当中包含了一个运行开销比较大的操作,那这部分就会造成很多的运行消耗。可以看到在debug模式下的运行输出
缓存条件语句
如果我们把part 2 当中的sizeof操作符号放到part 1 当中,代码如下:
- void testFunc_RemoveSizeof(){
- char s[] = "This string has * many space (0x20) chars. *";
- for (size_t i = 0 ,len = sizeof (s); i < len; ++i){
- if (s[i] == ' ')
- s[i] = '*';
- }
- }
复制代码 可以看到在debug模式下的运行结果
testFunc_RemoveSizeof: start
testFunc_RemoveSizeof: stop Total take
407806 ticks and takes 407 mS
但是如果在release模式下,实际上这两种方式运行结果是相同的。
testFunc_Origin: start
testFunc_Origin: stop
Total take 11743 ticks and takes 11 mS
testFunc_RemoveSizeof: start
testFunc_RemoveSizeof: stop
Total take 11708 ticks and takes 11 mS
Process finished with exit code 0
这是因为编译器实际上为我们进行了优化。如果我们把编译器的优化等级调低的话,
set(CMAKE_CXX_FLAGS_RELEASE “-O2”)
那么结果就是
testFunc_Origin: start
testFunc_Origin: stop
Total take 18598 ticks and takes 18 mS
testFunc_RemoveSizeof: start
testFunc_RemoveSizeof: stop
Total take 34442 ticks and takes 34 mS
可以看到,在优化选项不同的时候,效果会不同。
更改循环方式
基本上说,for循环在编译后会编程 if / goto/类似的语句,这种跳转语句可能会降低运行速度,如果我们更换一种循环方式的话
- void testFunc_DoWhile(){
- char s[] = "This string has * many space (0x20) chars. *";
- size_t i = 0, len = sizeof(s); // for循环初始化表达式
- do {
- if (s[i] == ' ')
- s[i] = ' ';
- ++i; // for循环继续表达式
- } while (i < len); // for循环条件
- }
复制代码 结果如下:
testFunc_DoWhile: start
testFunc_DoWhile: stop
Total take 405896 ticks and takes 405 mS
移除不变性代码
像刚才我们把for循环的循环判断部分中的代码移除到初始化部分中,这种不会改变的代码(在这种情况下sizeof的结果是不会改变的)称为不变性代码,将这种代码移动到循环之外是一种常见的优化方式。
从循环中移除隐含的函数调用
C++代码可能会隐式地调用函数,而没有这种很明显的调用语句。以下是几种情况:
• 声明一个类实例(调用构造函数)
• 初始化一个类实例(调用构造函数)
• 赋值给一个类实例(调用赋值运算符)
• 涉及类实例的计算表达式(调用运算符成员函数)
• 退出作用域(调用在作用域中声明的类实例的析构函数)
• 函数参数(每个参数表达式都会被复制构造到它的形参中)
• 函数返回一个类的实例(调用复制构造函数,可能是两次)
• 向标准库容器中插入元素(元素会被移动构造或复制构造)
• 向矢量中插入元素(如果矢量重新分配了内存,那么所有的元素都需要被移动构造或是复制构造)
将循环放入函数以减少调用开销
如果程序需要访问一堆的数据,但是它在访问单个数据的时候需要调用函数进行处理当前的数据,那么在这个流程当中,会不停的触发函数调用的开销 。如果我们将循环中调用函数这种模式改成在调用函数当中运行循环,这样的一种方式称为循环倒置。
看如下代码:
- void replace_nonprinting(char& c) {
- if (!isprint(c))
- c = '.';
- }
- void testFunc_ForLoopCall(std::string &str){
- for (unsigned i = 0, e = str.size(); i < e; ++i)
- replace_nonprinting(str[i]);
- }
复制代码 输出结果:
testFunc_ForLoopCall: start
testFunc_ForLoopCall: stop
Total take 2035282 ticks and takes 2035 mS
将函数replace_nonprinting的调用移除,改成如下代码:
- void testFunc_revertForLoop(std::string &str){
- for (unsigned i = 0, e = str.size(); i < e; ++i){
- if (!isprint(str[i]))
- str[i] = '.';
- }
- }
复制代码 输出结果:
testFunc_revertForLoop: start
testFunc_revertForLoop: stop
Total take 1726703 ticks and takes 1726 mS
可以看到有一部分的优化效果。
优化频繁的检测
假设在一个event loop中,这个事件循环运行在主线程当中,他可以每秒执行1000个事物。如果我们要处理一个终止命令,这个判断需要多长时间判断一次呢?
首先是鼠标按键按下之后,如果我们立即检测事件队列,这时候我们一定会发现那个鼠标事件吗?答案是否定的。
当一个真实的机械按键被按下时会建立一个连接,而这个连接最初是断断续续的。在这种初始的断续状态下,一瞬间去查看这个连接的状态会误认为按键没有被按下。“消除抖动”使得按键被按下的消息被推迟至连接变为连续状态后才发送。50毫秒是一个常用的消除抖动间隔。
那如果像下面这样,我们在每次的事件循环当中都去调用poll_for_exit(),它会花费50ms来检测是否有退出键按下,如果按下就调用exit_program(),这将会花费400~600毫秒来停止程序。
- void main_loop(Event &event){
- if( poll_for_exit() ) // process 50 ms
- exit_program(); // process 400 ms - 600ms
- else if ( event == mouse_event )
- ...
- else if ( event == warn_event )
- ...
- else ...
- }
复制代码 现在我们给程序加一个响应时间。假设我们认为用户对程序终止的响应时间在1s以内是完全可以接受的,程序的终止操作需要消耗400到600ms来处理,那现在我们应该如何处理这个终止事件呢?还需要每次都判断终止事件吗?
做一个简单地数学计算: 1s - 600 ms = 400 ms ;
只要我们在指令传过来之后的400ms之内开始执行exit_program()函数,这样就会在1秒钟之内完全结束掉程序。也就符合用户的预期。
那我们就需要将程序修改一下:
- void main_loop(Event &event){
- static unsigned count = 1 ;
- if( count++ % 350 == 0 )
- exit_program(); // process 400 ms - 600ms
- else if ( event == mouse_event )
- ...
- else if ( event == warn_event )
- ...
- else ...
- }
复制代码 其他优化技巧
对于for循环的优化,网上有很多其他的优化技巧,比如展开循环、openmp或者其他的一些优化方式。感兴趣的可以上网上搜索一下。
.2 从函数中移除代码
函数调用
每次程序调用一个函数时,都会发生类似下面这样的处理(依赖于处理器体系结构和优化器设置)。
(1) 执行代码将一个栈帧推入到调用栈中来保存函数的参数和局部变量。
(2) 计算每个参数表达式并复制到栈帧中。
(3) 执行地址被复制到栈帧中并生成返回地址。
(4) 执行代码将执行地址更新为函数体的第一条语句(而不是函数调用后的下一条语句)。
(5) 执行函数体中的指令。
(6) 返回地址被从栈帧中复制到指令地址中,将控制权交给函数调用后的语句。
(7) 栈帧被从栈中弹出。
从流程上看,函数调用有一定的开销。当频繁的调用某个函数时,单纯调用函数的开销就会变大,参考之前循环倒置的部分。不过从某些情况下,函数调用也可能会有一些好处。比如和那些被内联展开(inline )的大型的函数来说,通常这种函数会更加紧凑一些。同时紧凑的函数有利于提高缓存和虚拟内存的性能。参考前面讲的内存页抖动、以及缓存名中率部分。
《C++性能优化指南》 linux版代码及原理解读 第二章
函数调用的基本开销
除了计算参数表达式的开销外,复制每个参数的值到栈中也会发生开销。
成员函数调用(与函数调用)
每个成员函数都有一个额外的隐藏参数:一个指向this类实例的指针,而成员函数正是通过它被调用的。这个指针必须被写入到调用栈上的内存中或是保存在寄存器中。
函数调用和返回对程序功能的实现没有影响,同样我们可以将函数调用去掉,直接把函数体中的代码替换函数的调用来避免单纯的函数调用的开销。许多编译器对尝试使用这样的方式,即通过内联的方式进行优化。
当调用函数的时候,下一个执行地址EIP(指令指针)会被保存到栈中,函数返回时会将这个数据取出,然后从这个地址的指令继续向下运行。不过这几次都涉及到跨越非连续地址,可能会造成流水线停顿或者高速缓存未命中。
虚函数的开销
C++中,除了构造函数不能被定义为虚函数之外,其他的成员函数都可以被写成虚函数。带有虚函数的实例是通过添加一个指向虚函数表的指针来实现的,这个表中有虚函数的函数地址。虚函数表指针通常都是类实例的第一个字段,这样解引时的开销更小。
调用虚函数的时候有两次解引用操作。首先通过虚函数表的指针获得表的地址,其次通过虚函数表的初始地址加偏移量,解引用获得函数的真实执行地址。这两次操作都是非连续的内存操作,而每次这种非连续的内存操作都会增加流水线停顿和高速缓存未命中的概率。
虚函数的另一个问题是编译器难以内联它们。编译器只有在它能同时访问函数体和构造实例的代码(这样编译器才能决定调用虚函数的哪个函数体)时才能内联它们。
继承中的成员函数调用
如果继承关系最顶端的基类没有虚成员函数,那么代码必须要给this类实例指针加上一个偏移量,来得到继承类的虚函数表,接着会遍历虚函数表来获取函数执行地址。这些代码会包含更多的指令字节,而且这些指令通常都比较慢,因为它们会进行额外的计算。这种开销在小型嵌入式处理器上非常显著,但是在桌面级处理器上,指令级别的并发掩盖了大部分这种额外的开销。
代码必须向this类实例指针中加上一个偏移量来组成指向多重继承类实例的指针。这种开销在小型嵌入式处理器上非常显著,但是在桌面级处理器上,指令级别的并发掩盖了大部分这种额外的开销。
对于继承类中的虚成员函数调用,如果继承关系最顶端的基类没有虚成员函数,那么代码必须要给this类实例指针加上一个偏移量来得到继承类的虚函数表,接着会遍历虚函数表来获取函数执行地址。代码还必须向this类实例指针加上潜在的不同的偏移量来组成继承类的类实例指针。这种开销在小型嵌入式处理器上非常显著,但是在桌面级处理器上,指令级别的并发掩盖了大部分这种额外的开销。
为了组成虚多重继承类的实例的指针,代码必须解引类实例中的表,来确定要得到指向虚多重继承类的实例的指针时需要加在类实例指针上的偏移量。如前所述,当被调用的函数是虚函数时,这里也会产生额外的间接开销。
后续会写博客专门 描述这几中情况的具体实现。
函数指针的开销
- 函数指针(指向非成员函数和静态成员函数的指针)
代码必须解引指针来获取函数的执行地址。编译器也不太可能会内联这些函数。
- 成员函数指针
成员函数指针声明同时指定了函数签名和解释函数调用的上下文中的类。
函数调用开销总结
C风格的不带参数的void函数的调用开销是最小的。如果能够内联它的话,就没有开销;即使不能内联,开销也仅仅是两次内存读取加上两次程序执行的非局部转移3。
如果基类没有虚函数,而虚函数在多重虚拟继承的继承类中,那么这是最坏的情况。不过幸运的是,这种情况非常罕见。在这种情况下,代码必须解引类实例中的函数表来确定加到类实例指针上的偏移量,构成虚拟多重继承函数的实例的指针,接着解引该实例来获取虚函数表,最后索引虚函数表得到函数执行地址。
此时,读者可能会惊讶函数调用的开销居然如此之大,抑或是惊叹C++居然如此高效地实现了这么复杂的特性。这两种看法都是合理的。需要理解的是正是有了函数调用开销,才有优化的机会。坏消息是除非函数会被频繁地调用,否则移除一处非连续内存读取并不足以改善性能;好消息则是分析器会直接指出调用最频繁的函数,让开发人员能够快速地集中精力于最佳优化对象。
善于使用内联函数
移除函数调用开销的一种有效方式就是内联函数。以下几种情况编译器通常会主动将函数进行内联:
在类的定义中同时定义的函数会被隐式内联。比如如下:
[code]class inlineTest{public: inlineTest() = default; void funtTest(){ std::cout |