现代硬件算法[4.3]: 特定场景优化
特定场景优化
大多数由-O2
和-O3
启用的编译器优化都可以保证提高性能,或者至少不会严重损害性能。那些没有包含在-O3
中的要么不严格符合标准,要么是非常不直接的,需要程序员提供一些额外的输入来帮助决定使用它们是否有益。
让我们讨论一下我们之前在本书中讨论过的最常用的优化方法。
循环展开
默认情况下,循环展开是禁用的,除非循环在编译时进行少量常数的迭代——在这种情况下,它将被完全无跳转的重复指令序列所取代。它可以通过-funroll-loops
标志全局启用,该标志将展开迭代次数在编译时或进入循环前可以确定的所有循环。
你也可以使用pragma来标记特定循环:
|
循环展开会使二进制文件变得更大,所以可能会也可能不会(指令cache miss)使代码运行得更快。不要狂热地使用它。
函数内联
内联最好由编译器决定,但你可以使用inline
关键字来提示编译器:
inline int square(int x) { |
但是,如果编译器认为潜在的性能提升不值得,则可能会忽略该提示。你可以通过添加always_inline
属性来强制内联:
还可以使用-finline-limit=n
选项,它允许你(根据指令数量)设置内联函数大小的特定阈值。与之等价的Clang选项是-inline-threshold
。
分支概率
分支概率可以通过if
-s和switch
-es中的[[kikely]]
和[[unlikely]]
属性来提示:
int factorial(int n) { |
这是一个C++20中才支持的新功能。在此之前,编译器中又类似的特定内部函数用于包装条件表达式。旧GCC中的相同示例:
int factorial(int n) { |
关于将编译器指向正确的方向,还有很多其他类似的例子,但我们会在稍后它们变得更相关时再讨论它们。
profile导向优化
将所有这些元数据添加到源代码中是乏味的。即便没有这些,C++已经不受人待见了。
某些优化是否有益并不总是显而易见的。要决定是否进行分支重排、函数内联或循环展开,我们需要回答以下问题:
- 这个分支多久执行一次?
- 这个函数多久调用一次?
- 这个循环中的平均迭代次数是多少?
幸运的是,有一种方法可以自动提供这些真实世界的信息。
profile导向优化(Profile-guided optimization,PGO,也被称为“pogo”,因为发音更容易且有趣)是一种使用profiling data来提升性能的技术,而不仅仅是静态分析。简而言之,它首先向程序中的感兴趣点添加计时器和计数器,编译并在实际数据上运行它,然后利用测试运行中得到额外信息再次编译。
整个过程通过现代编译器实现了自动化。例如,-fprofile-generate
标志将允许GCC使用profiling代码对程序进行检测:
g++ -fprofile-generate [other flags] source.cc -o binary |
在我们运行程序后(最好是使用尽可能代表真实的输入用例),它将生成一组*.gcda
文件,其中包含测试运行的日志数据,之后我们可以重新构建程序,但这次添加-fprofile-use
标志:
g++ -fprofile-use [other flags] source.cc -o binary |
对于大型代码库,这样做通常会提升10-20%的性能,因此,通常会在性能至关重要的项目构建过程中使用。这是投资可靠的基准代码的一个原因。