特定场景优化

大多数由-O2-O3启用的编译器优化都可以保证提高性能,或者至少不会严重损害性能。那些没有包含在-O3中的要么不严格符合标准,要么是非常不直接的,需要程序员提供一些额外的输入来帮助决定使用它们是否有益。

让我们讨论一下我们之前在本书中讨论过的最常用的优化方法。

循环展开

默认情况下,循环展开是禁用的,除非循环在编译时进行少量常数的迭代——在这种情况下,它将被完全无跳转的重复指令序列所取代。它可以通过-funroll-loops标志全局启用,该标志将展开迭代次数在编译时或进入循环前可以确定的所有循环。

你也可以使用pragma来标记特定循环:

#pragma GCC unroll 4
for (int i = 0; i < n; i++) {
// ...
}

循环展开会使二进制文件变得更大,所以可能会也可能不会(指令cache miss)使代码运行得更快。不要狂热地使用它。

函数内联

内联最好由编译器决定,但你可以使用inline关键字来提示编译器:

inline int square(int x) {
return x * x;
}

但是,如果编译器认为潜在的性能提升不值得,则可能会忽略该提示。你可以通过添加always_inline属性来强制内联:

#define FORCE_INLINE inline __attribute__((always_inline))

还可以使用-finline-limit=n选项,它允许你(根据指令数量)设置内联函数大小的特定阈值。与之等价的Clang选项是-inline-threshold

分支概率

分支概率可以通过if-s和switch-es中的[[kikely]][[unlikely]]属性来提示:

int factorial(int n) {
if (n > 1) [[likely]]
return n * factorial(n - 1);
else [[unlikely]]
return 1;
}

这是一个C++20中才支持的新功能。在此之前,编译器中又类似的特定内部函数用于包装条件表达式。旧GCC中的相同示例:

int factorial(int n) {
if (__builtin_expect(n > 1, 1))
return n * factorial(n - 1);
else
return 1;
}

关于将编译器指向正确的方向,还有很多其他类似的例子,但我们会在稍后它们变得更相关时再讨论它们。

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%的性能,因此,通常会在性能至关重要的项目构建过程中使用。这是投资可靠的基准代码的一个原因。