We should forget about small efficiencies, say about 97percent of the
time:premature optimization is the root of all evil.
我们应该在97%的时间忘记优化:过早优化是万恶之源。
做C++,当然不能不关心性能。但是,什么时候开始关心性能优化?2020全球C++及系统软件技术大会中《C++性能调优纵横谈》的演讲,现场座无虚席,好评连连。下面让演讲者,Boolan首席软件咨询师吴咏炜老师为大家揭秘。
国内知名 C++专家。曾任英特尔亚太研发中心资深系统架构师,近 30 年 C/C++系统级软件开发和架构经验。专注于 C/C++ 语言(包括 C++98/C++11/14/17/20)、软件架构、性能优化、设计模式和代码重用。长期担任资深技术教练,具有丰富技术咨询经验。
引言
先说为什么要用C++?摩尔定律推动计算机的性能不停提高,脚本语言大行其道。但是计算机的性能毕竟有限,到21世纪初,就不得不通过语言层面以及个人写代码的技巧等各方面来提升性能。
但是我们也无法做到100%优化,因为C++开发效率较低,如果想在整个代码做优化,得不偿失。原因我们看下面这个公式。里面P代表优化的部分所占比例,Sp是对这部分P的性能提升大小。
举两个最简单的数据说明:
①如果优化的部分有一个非常重要的函数,这个函数占到系统开销的50%,这时,我们把这个部分的性能提升了50%,这种情况下,结果是提升了20%,这已经是一个非常好的成果。
②反之,如果有一个函数性能提升100%,如果在执行过程中只占了系统开销的1%(不管它占代码总量多少),那即使这部分性能提升了100%,最后结果也只提升了0.5%。
所以,很重要的一件基本的事情,就是要做性能测试。
测不准的问题
性能测试是一件很难的事情,也是一件非常有技巧的事情。以下面的简单代码为例,我们看一下memset和手工清零,性能有没有差异,差异是多少?
下方展示了一个令人惊讶的测试结果
▼
根据示例代码的测试结果我们可以看出,当优化开到 -O2时,memset居然比手工循环慢了10万倍。memset在GCC8之下,开到 -O2不会被优化,仍会做memset,但编译器会完全干掉对buffer的写入。这就是常见的陷阱。
那怎么绕过测试测不准的问题?volatile可以使测试结果相对合理。
然而volatile本身会妨碍优化。我们看下方汇编代码,80个单字节的0,去掉volatile,在GCC10下直接做了5次的16字节0写入,而且没有循环。这就是C++编译器的优化魔法。
在前面的示例代码里,两种方式在优化编译下的性能,实际上是完全一致的。
下面列举了一些编译器的优化魔法,在没有同步原语的情况下,编译器可以(通常为了性能)在(当前线程)结果不变的情况下自由地调整执行顺序。比如局部变量可能被全部消除;而全局变量不会被优化没,但是写入的顺序可能会调整,编译器觉得怎么方便怎么写入,只要对外表现行为与程序的设计行为完全一致。
例如:x = a; y = 2; 可以变为 y = 2; x = a
x=a,是从a里面读东西,写到x,做了内存读操作,再做内存写操作。我们看汇编代码,会发现会先做从a读到eax,同时对y写入读,然后对x写入,从而达到最高的并发性。
另外,volatile声明会禁止编译器进行相关优化。
对volatile变量的读,编译器肯定会生成读语句;对volatile变量的写,编译器肯定会生成写语句。这是一种很特殊的场景,所以一般用于驱动程序,内存映射文件等,正常情况下volatile需要谨慎使用。特别需要指出的一点,volatile在C++和Java里面的语义完全不一样,在C++里面没有多线程同步的语义。
以上就是测试可能存在的坑,从防优化的角度我们总结出以下技巧:
性能测试方式
不管是锁,还是额外函数调用,都会有额外开销,尤其锁的性能开销是有点大的,所以我们需要比clock更好的进行性能测试的方式。通过分析测时长相关的函数,我们可以发现rdtsc是x86 和x64系统上的的首选计时方式。
需要注意,tsc的主屏频率和CPU参考主频不一定一致,需要自己测试,或者从Linux里面使用dmesg查找tsc的频率信息。
以rdtsc为计时方式,我们可实现一个性能分析器profiler,测量出函数调用和虚函数调用的额外开销(不同的软硬件会影响测试数据),可以发现开销是很低的。
我们前面说的测试方式属于插桩测试。插桩测试的开销随测试范围而变,虽然函数调用开销较低,但依然存在开销,而且测量出的时钟周期都可能带来问题,所以插桩本身可能影响测试结果,但是结果相对较为精确、稳定,适合对单个函数进行性能调优。
另外一种测试方式是采样测试。采样测试需要依赖于一个外部的东西,在程序的执行过程中,它会定期中断程序,然后检查调用栈,知道程序当前执行到哪里,最后看百分比的分布,从而知道函数的大概比例。采样测试比较优势的地方,是总体开销可控,而且适合用来寻找程序的热点。
总结:整体找程序的热点与问题在哪里,用采样测试;已经找到热点,需要进行精细优化,用插桩测试。
关于采样测试常用的一些工具。一个是GCC自带的工具gprof,它是采样结合了部分插桩,可以很快上手尝试,但是因为总体效果不太好,所以并不推荐。比较推荐的Google的gperftools.
编译的时候不需要做特殊处理,用普通的 -g和 -o参数就可以。执行的时候,可以在命令行上指定预加载profiler库,再指定CPUPROFILE输出到哪个文件,然后执行代码,这样就可以生成test.prof文件,最后再用google-pprof工具把test.prof生成输出文件,可以是svg、jpg、png之类的格式。
其中,SVG的效果会比较好一些,有层次关系、树形结构,字体的大小代表了耗时百分比的高低,可以很清晰的看到整体执行的性能,进行分析。
性能优化
1、循环优化
循环会放大代码中的低效率,所以不必要的反复执行的代码要提到循环外面,否则会有额外的开销。以下面这个糟糕代码为例:
首先,strlen这个函数会被反复调用,其次,strlen是个很糟糕的函数,它的执行时间与你的字符串长度成正比。所以如果给了一个长的字符串,即使不考虑strlen本身的函数调用开销的问题,也需要考虑是不是应该把这个长度随时随地带在API里,而不是调strlen来获得它的长度。那这种问题如何优化?
长度不变的情况,在for循环的开头初始化一下,然后后面就是循环写入。这个优化也是GCC可能自动做的,当GCC能够判定你肯定没有在修改这个字符串的时候,它甚至可以帮你直接做到这一点。但是当你把s,一个char*,传到另外一个函数去,GCC判定不了那个函数背后做了什么,就无法优化。所以还是需要手工将优化写出来,这是一种非常基本的优化方式。
如果长度可变的情况,原理是一样的。可以先把长度保存下来,然后在长度进行变化的时候,直接调整长度值。但是,如果字符串太长的话,我们仍需要做一些其他的优化操作,但是概念是一样的,就是尽量避免重复做不必要的操作,这是最基本的优化思路。
2、多线程优化
在某些内存管理器里,每次调用时会有个加解锁的问题,而加解锁是绝对的性能杀手。所以,
①能使用 atomic 就不用 mutex;
②如果读比写多很多,考虑使用读写锁(shared_mutex)而不是独占锁(mutex);
③使用线程本地(thread_local)变量
3、算术表达式优化
下面这个等式,从代码角度看是否成立?
这个地方的关键是是否使用了浮点数类型。浮点数的精度有限,这就意味着一个操作先做还是后做,可能会影响结果,编译器就会保守处理,不敢轻易做优化。所以除非你开了 -Ofast,告诉编译器可以不管 IEEE 浮点数据运算规则,在碰到浮点数的时候,要做一下手工处理。
不需要做的优化
1、移位和乘法,不需要开优化, -O0就会做。
查看下方骚操作
▼
2、提取公共表达式
3、略去本地变量的初始化
无用的初始化,编译器会自动消掉。
以上就是吴咏炜老师在2020全球C++及系统软件技术大会中分享的内容,这里只讨论了部分性能优化点,性能调优的手段还有很多,欢迎大家有问题交流讨论。2021全球C++及系统软件技术大会将于11月25-26日上海举办,吴咏炜老师会再次出席为大家带来现代C++新特性技能分享,感兴趣的小伙伴不要错过哦!