【干货储备】C++性能优化

举报
Ava tao 发表于 2021/09/30 15:47:21 2021/09/30
【摘要】 做C++,当然不能不关心性能。但是,什么时候开始关心性能优化?2020全球C++及系统软件技术大会中《C++性能调优纵横谈》的演讲,现场座无虚席,好评连连。下面让演讲者,Boolan首席软件咨询师吴咏炜老师为大家揭秘。
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)、软件架构、性能优化、设计模式和代码重用。长期担任资深技术教练,具有丰富技术咨询经验。

https://ucc.alicdn.com/pic/developer-ecology/b187b36637414229b45a08db0ff1c6de.png

引言

先说为什么要用C++?摩尔定律推动计算机的性能不停提高,脚本语言大行其道。但是计算机的性能毕竟有限,到21世纪初,就不得不通过语言层面以及个人写代码的技巧等各方面来提升性能。

https://ucc.alicdn.com/pic/developer-ecology/61fb8305d02a4449b39cad5008881ec7.png

但是我们也无法做到100%优化,因为C++开发效率较低,如果想在整个代码做优化,得不偿失。原因我们看下面这个公式。里面P代表优化的部分所占比例,Sp是对这部分P的性能提升大小。

https://ucc.alicdn.com/pic/developer-ecology/6fe30a16d27c41afaf89b0433d21a817.png

举两个最简单的数据说明:

①如果优化的部分有一个非常重要的函数,这个函数占到系统开销的50%,这时,我们把这个部分的性能提升了50%,这种情况下,结果是提升了20%,这已经是一个非常好的成果。

②反之,如果有一个函数性能提升100%,如果在执行过程中只占了系统开销的1%(不管它占代码总量多少),那即使这部分性能提升了100%,最后结果也只提升了0.5%。

所以,很重要的一件基本的事情,就是要做性能测试。
测不准的问题

性能测试是一件很难的事情,也是一件非常有技巧的事情。以下面的简单代码为例,我们看一下memset和手工清零,性能有没有差异,差异是多少?

https://ucc.alicdn.com/pic/developer-ecology/8099f8f5c6ac4005835c28744b8d8c19.png

下方展示了一个令人惊讶的测试结果

https://ucc.alicdn.com/pic/developer-ecology/f2770b6e85734d1eb3e2ade746b6d75a.pngg

根据示例代码的测试结果我们可以看出,当优化开到 -O2时,memset居然比手工循环慢了10万倍。memset在GCC8之下,开到 -O2不会被优化,仍会做memset,但编译器会完全干掉对buffer的写入。这就是常见的陷阱。

https://ucc.alicdn.com/pic/developer-ecology/8890b8691f74492fb7ec2e8e1f00fb81.png

那怎么绕过测试测不准的问题?volatile可以使测试结果相对合理。

https://ucc.alicdn.com/pic/developer-ecology/dc74c12a8ffa43b798761959f2cd5dc4.png

然而volatile本身会妨碍优化。我们看下方汇编代码,80个单字节的0,去掉volatile,在GCC10下直接做了5次的16字节0写入,而且没有循环。这就是C++编译器的优化魔法。

https://ucc.alicdn.com/pic/developer-ecology/2427afc35fff496e80695726c31a0f5c.png

在前面的示例代码里,两种方式在优化编译下的性能,实际上是完全一致的。

下面列举了一些编译器的优化魔法,在没有同步原语的情况下,编译器可以(通常为了性能)在(当前线程)结果不变的情况下自由地调整执行顺序。比如局部变量可能被全部消除;而全局变量不会被优化没,但是写入的顺序可能会调整,编译器觉得怎么方便怎么写入,只要对外表现行为与程序的设计行为完全一致。

例如:x = a; y = 2; 可以变为 y = 2; x = a

x=a,是从a里面读东西,写到x,做了内存读操作,再做内存写操作。我们看汇编代码,会发现会先做从a读到eax,同时对y写入读,然后对x写入,从而达到最高的并发性。

https://ucc.alicdn.com/pic/developer-ecology/ecd179d2ea5940bb9dcb4712432fd351.png

另外,volatile声明会禁止编译器进行相关优化。

对volatile变量的读,编译器肯定会生成读语句;对volatile变量的写,编译器肯定会生成写语句。这是一种很特殊的场景,所以一般用于驱动程序,内存映射文件等,正常情况下volatile需要谨慎使用。特别需要指出的一点,volatile在C++和Java里面的语义完全不一样,在C++里面没有多线程同步的语义。

以上就是测试可能存在的坑,从防优化的角度我们总结出以下技巧:

https://ucc.alicdn.com/pic/developer-ecology/0a18ce574a3c4c9db62cc1aaa3eb4015.png

性能测试方式

不管是锁,还是额外函数调用,都会有额外开销,尤其锁的性能开销是有点大的,所以我们需要比clock更好的进行性能测试的方式。通过分析测时长相关的函数,我们可以发现rdtsc是x86 和x64系统上的的首选计时方式。

https://ucc.alicdn.com/pic/developer-ecology/87337f23442141f4a561ce152deff13b.png

需要注意,tsc的主屏频率和CPU参考主频不一定一致,需要自己测试,或者从Linux里面使用dmesg查找tsc的频率信息。

以rdtsc为计时方式,我们可实现一个性能分析器profiler,测量出函数调用和虚函数调用的额外开销(不同的软硬件会影响测试数据),可以发现开销是很低的。

https://ucc.alicdn.com/pic/developer-ecology/28154f52d9ed4fe9be9e6600a019e078.png

我们前面说的测试方式属于插桩测试。插桩测试的开销随测试范围而变,虽然函数调用开销较低,但依然存在开销,而且测量出的时钟周期都可能带来问题,所以插桩本身可能影响测试结果,但是结果相对较为精确、稳定,适合对单个函数进行性能调优。

另外一种测试方式是采样测试。采样测试需要依赖于一个外部的东西,在程序的执行过程中,它会定期中断程序,然后检查调用栈,知道程序当前执行到哪里,最后看百分比的分布,从而知道函数的大概比例。采样测试比较优势的地方,是总体开销可控,而且适合用来寻找程序的热点。

总结:整体找程序的热点与问题在哪里,用采样测试;已经找到热点,需要进行精细优化,用插桩测试。

关于采样测试常用的一些工具。一个是GCC自带的工具gprof,它是采样结合了部分插桩,可以很快上手尝试,但是因为总体效果不太好,所以并不推荐。比较推荐的Google的gperftools.

https://ucc.alicdn.com/pic/developer-ecology/236ea43004b54506920cb2b5ade1f8b5.png

编译的时候不需要做特殊处理,用普通的 -g和 -o参数就可以。执行的时候,可以在命令行上指定预加载profiler库,再指定CPUPROFILE输出到哪个文件,然后执行代码,这样就可以生成test.prof文件,最后再用google-pprof工具把test.prof生成输出文件,可以是svg、jpg、png之类的格式。

其中,SVG的效果会比较好一些,有层次关系、树形结构,字体的大小代表了耗时百分比的高低,可以很清晰的看到整体执行的性能,进行分析。

性能优化

1、循环优化

循环会放大代码中的低效率,所以不必要的反复执行的代码要提到循环外面,否则会有额外的开销。以下面这个糟糕代码为例:

https://ucc.alicdn.com/pic/developer-ecology/9bbe1c4869bc4e8baed8c50109fb51ae.png

首先,strlen这个函数会被反复调用,其次,strlen是个很糟糕的函数,它的执行时间与你的字符串长度成正比。所以如果给了一个长的字符串,即使不考虑strlen本身的函数调用开销的问题,也需要考虑是不是应该把这个长度随时随地带在API里,而不是调strlen来获得它的长度。那这种问题如何优化?

长度不变的情况,在for循环的开头初始化一下,然后后面就是循环写入。这个优化也是GCC可能自动做的,当GCC能够判定你肯定没有在修改这个字符串的时候,它甚至可以帮你直接做到这一点。但是当你把s,一个char*,传到另外一个函数去,GCC判定不了那个函数背后做了什么,就无法优化。所以还是需要手工将优化写出来,这是一种非常基本的优化方式。

https://ucc.alicdn.com/pic/developer-ecology/4debf5c5c6754d95b41d255ec2110b8f.png

如果长度可变的情况,原理是一样的。可以先把长度保存下来,然后在长度进行变化的时候,直接调整长度值。但是,如果字符串太长的话,我们仍需要做一些其他的优化操作,但是概念是一样的,就是尽量避免重复做不必要的操作,这是最基本的优化思路。

2、多线程优化

在某些内存管理器里,每次调用时会有个加解锁的问题,而加解锁是绝对的性能杀手。所以,

①能使用 atomic 就不用 mutex;

②如果读比写多很多,考虑使用读写锁(shared_mutex)而不是独占锁(mutex);

③使用线程本地(thread_local)变量

3、算术表达式优化

下面这个等式,从代码角度看是否成立?

https://ucc.alicdn.com/pic/developer-ecology/f16c4a808de94af28ea7ca28f196e296.png

这个地方的关键是是否使用了浮点数类型。浮点数的精度有限,这就意味着一个操作先做还是后做,可能会影响结果,编译器就会保守处理,不敢轻易做优化。所以除非你开了 -Ofast,告诉编译器可以不管 IEEE 浮点数据运算规则,在碰到浮点数的时候,要做一下手工处理。

01ae7a81131c4f8f9293a8be0e343346[1].png

不需要做的优化

1、移位和乘法,不需要开优化, -O0就会做。

https://ucc.alicdn.com/pic/developer-ecology/596e9b70f18243b1bddb03adb4174e5d.png

查看下方骚操作

https://ucc.alicdn.com/pic/developer-ecology/05c91544e4a941e1b1c727dc21677e81.png

2、提取公共表达式

https://ucc.alicdn.com/pic/developer-ecology/6cb54d47800d42e48e7298a84ae9ca61.png

3、略去本地变量的初始化

无用的初始化,编译器会自动消掉。

https://ucc.alicdn.com/pic/developer-ecology/0ccf8d6dbf584691a57992037d4bb842.png

以上就是吴咏炜老师在2020全球C++及系统软件技术大会中分享的内容,这里只讨论了部分性能优化点,性能调优的手段还有很多,欢迎大家有问题交流讨论。2021全球C++及系统软件技术大会将于11月25-26日上海举办,吴咏炜老师会再次出席为大家带来现代C++新特性技能分享,感兴趣的小伙伴不要错过哦!

800.417-01.jpg

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。