C++ 性能优化概览

C++ 性能优化概览

地址:The Most Important Optimizations to Apply in Your C++ Programs - Jan Bielak - CppCon 2022

提升程序性能从这5个方面入手

|>> 避免做无意义的工作

比如说减少不必要的数据拷贝、减少不必要的内存分配

|>> 尽可能利用计算资源

比如说充分利用多核并行执行的优势,采用多线程或者并行算法来执行程序,执行程序时使用多个CPU核心;使用 SIMD 指令集 (?)

|>> 避免程序中的等待和阻塞行为

比如在程序中由于数据的依赖,导致数据获取需要有先后顺序,那么需要加锁等待。使用过多的锁结构就会增加等待的时间。这时可以通过减少锁、采用异步的一些API来处理,避免主线程阻塞。

|>> 针对硬件层面的优化

比如多用 vector 这种线性访问的数据结构以提升 CPU 缓存命中率;又比如可以尽可能避免分支预测失败来提升CPU的流水线效率

|>> 针对操作系统层面的优化

利用好操作系统中的线程优先级,让主要程序跑在优先级高的进程上;或者针对内存映射等下功夫,从操作系统层面上提升代码执行速度。

具体的实现这些优化要怎么做呢

|>> 充分发挥 C++ 语言的性能

C++ 语言在性能上有巨大优势,我们可以从多个方面通过编写高效的C++代码来提升程序性能,这其中可以是选用合适的数据结构、利用语言的一些特性——内联函数、模板优化、RAII 资源管理、移动语义、引用,还可以使用现在 C++ 的特性,如C++17/20 的并行算法库

|>> 在 pipeline 上下功夫

因为程序是高级语言➡编译➡链接➡执行这一套的逻辑,所以可以在编译与构建流程中“下刀儿”,比如采用一些编译器优化选项啊,如(如 -O2, -O3, -flto)、启用预编译头文件啊、优化像CMake、Ninja这样的构建系统啊、构建过程采用分模块构建和并行构建,等等。

|>> 最底层的,面向硬件的手动优化

手写汇编或利用 SIMD 指令(如 SSE/AVX)进行加速、控制缓存行为,提高缓存命中率、利用线程亲和性(Affinity)优化 CPU 使用、 利用 GPU 或专用硬件进行计算任务分担,等等。我也不懂他们是怎么做的……(此处挖坑)

具体方案

一、 在 pipeline 上下功夫的一些方案

  1. 开启编译优化:包括一些 O2,O3的编译器参数
  2. 设置目标架构:告诉编译器我要生成哪个架构的代码,可以让编译器在特定架构上做优化
  3. 让数值计算更快:在一些对浮点要求精度不高的场景中,可以通过使用较低精度浮点数来让计算更快
  4. 关闭异常机制(Exceptions)和运行时类型信息(RTTI):异常机制是编译器用来生成额外代码以实现异常抛出/捕获,RTTI 用于支持dynamic_cast和typeid,用来做动态类型识别。
  5. 开启链接时优化(Link Time Optimization,LTO),它是在链接阶段做的工作。因为这时候编译器可以看到全局代码,这里可以跨文件内联,减少代码冗余
  6. 使用联合构建(Unity Build),Unity Build 是一种将多个 .cpp 源文件合并成一个大文件(也叫 Uber file)进行编译的方法。原本每个源文件各自编译,Unity Build 会把它们集中编译成一个或少数几个“翻译单元”。因为当cpp文件太多时,由于每个 .cpp 文件编译时都要启动编译器、解析头文件等,所以很耗时,现在通过这个技术合并后只需编译一次,速度就快了。
  7. 使用静态链接 (Link statically) 而不是动态库。不过在使用一些开源代码时,如果静态构建,则需要他们的源代码,然后才能编译构建。使用静态链接,编译器可以多做一些优化,不像动态库就是一堆二进制很难做优化。除此之外也节省了动态时需要加载外部库耗费的时间,所有代码在一个统一的地址空间中加载,内存管理更整齐,CPU 缓存友好度,等等。
  8. 使用性能分析指导优化 (Profile-Guided Optimization,PGO),PGO 是一种利用程序实际运行时的数据(profile)来指导编译器优化代码的技术。就是首先,收集到一些性能数据,然后记录各个函数的调用频率、分支走向等信息,最后利用数据重新编译生成优化后的最终执行文件。
  9. 尝试不同的编译器,GCC, Clang, MSVC,嗯……
  10. 尝试不同的标准库
  11. 采用最新的工具,因为一般新工具会对编译器做一些更优的优化
  12. 替换系统默认的 lib 库,用更高效的内存分配库替换原有的 malloc 实现

二、C++ 语言上的优化

  1. 尽可能使用 constexpr,首先是 constexpr 函数:让常量表达式在编译期间就求值。
constexpr int square(int x) {
    return x * x;
}

int main() {
    int result = square(5); // 编译期已计算为 25
    return result;
}

当这个 square 传入变量作为参数时,是不能在编译期间就确定值的。当 constexpr 修饰的函数传入变量时,结果不会在编译期间确定,也不会报错。如果想某个函数只能在编译期间求值,传入变量就会报错,那么可以使用 consteval 这个关键字。

总结:如果你希望函数既能在编译期使用,又能在运行期工作,用 constexpr;如果你要求只能编译期计算,用 consteval

关于 constexpr 变量:如果要创建一个不可变、始终在编译期就求值的变量,可以用 constexpr 修饰变量,如

constexpr std::array<int, 5> primes{ 2, 3, 5, 7, 11 };  // 创建时即为常量,运行时值始终不可变

如果想让变量的初始化在编译期完成、但运行期可修改,可以选择 constinit。这时变量初始化必须是常量表达式,运行时也可以修改这个变量的值。

constinit std::array<int, 5> primes{ 2, 3, 5, 7, 11 }; // 需要常量表达式初始化
primes[0] = 13; // 运行期可修改