Optimizing software in C++ 笔记

记录一下我从《Optimizing software in C++》中学到的东西。这本书给我的感觉是又新又旧,毕竟始于 2004,最后更新于 2022。

注:还是不怎么全,漏了一些我没看的,或者我本来就会的,还有我觉得用不上的。

  • 复杂指令集 CISC 对于 code cache 比较友好。
  • 64 位:
    • 优点
      • 支持 SSE2 指令集
      • 寄存器多了,函数参数默认先放寄存器里使函数调用变快
      • 64 位整数运算快了
      • 支持 self-relative 寻址,让 PIC(position-independent code) 变快了
      • 大内存的分配和释放快了
    • 缺点
      • 指针变大了,对缓存不友好
      • 大地址访问可能需要额外的指令(虽然不常见)
      • 有些指令在 64 位下长了 1 byte
  • 测试性能时需要注意:
    • 合适的采样频率
    • 去除用户交互带来的无效等待
    • 和系统中的其他进程的相互影响
    • 优化后分辨不出 code address 和函数地址之间的对应关系
    • 在 CPU 核心间反复横跳导致计数器不准
  • bool 运算可能会因为值不一定是 0 或者 1 导致产生过多分支,可以考虑用位运算替代
  • 整数相关:
    • 有符号和无符号整数的转换不需要时间
    • 对于除和模,无符号整数更快
    • 有符号整数往往比无符号整数转浮点数更快
    • 加减 1 cycle,乘法几个 cycle,除法几十个 cycle
  • 浮点数相关:
    • 不要混合精度
    • 减少和整数之间的转换
    • 浮点数可以存在 x87 register 中或者向量寄存器 xmm(ymm, zmm) 中:
      • x87 的精度比较高,始终是 80bit 精度
      • x87 由于历史原因,是一个栈,至多能放 8 个浮点数
      • x87 有一些数学运算的指令,比如一些三角函数,如果想在 xmm 里计算这些的话,只能调用数学库了(但往往更快)
      • 现代编译器默认使用向量寄存器来做浮点数计算
    • 通常不需要考虑双精度浮点数的性能问题(比起单精度浮点数)
    • 把浮点数当整数操作往往会违反 strict aliasing rule 而产生 UB,这种情况下推荐使用 union。
  • dynamic_cast 需要 RTTI,把基类转成子类的时候会做类型检查。static_cast 也可以做到基类转子类,但是如果实际类型不对的话会导致 UB。
  • 循环展开:
    • 优点:
      • 分支测试(要不要跳出循环)的次数变少了
      • 循环次数减少能使 CPU 更容易预测什么时候跳出
    • 缺点:
      • 指令数更多,会占用更多代码缓存或者微指令缓存
      • 如果循环次数不能被整除的话,需要在循环外再执行
      • 对于 loopback buffer 不友好
  • 函数调用的代价:
    • 占用分支跳转缓存
    • 需要建立 stack frame,保存和恢复寄存器,以及可能的异常处理相关信息
    • 如果参数多或者 32 位的话,往栈上放函数参数需要时间
    • 如果函数地址距离当前代码远的话会影响代码缓存
  • 函数相关:
    • 在 32 位中使用 __fastcall 或者在 64 位中对于拥有向量或者浮点数参数的函数使用 __vectorcall 可以更好地利用寄存器来传递函数参数
    • 如果只在当前 cpp 文件用到的话,考虑控制可见性(用 static 或者匿名命名空间或者编译属性)
    • 函数内调用函数的话尽量尾调用或者尾递归
  • stack unwinding 的触发条件:
    • 异常处理
    • 线程被强行终止时(通常从线程函数里 return 会比较好)
    • longjmp 跳出函数时
  • 结构体/类相关:
    • 可以通过调整顺序来减少内存对齐带来的空间浪费
    • 如果把比较大的东西放前面可能会导致访问后面的东西速度变慢,因为 offset 没法存在 8bit 立即数里,导致指令变长
  • 多线程的代价:
    • 启动和终止线程
    • 任务切换
    • 线程间同步
    • 可能的缓存竞争
  • 一些不利于编译器优化的情况:
    • 函数分布于多个编译单元导致无法内联或者编译器常数计算
    • pointer aliasing,编译器不确定两个指针会不会指向同一个地址导致无法优化,可以通过 __restrict__ 来告诉编译器(C99 有 restrict 但 C++ 的这个不在标准内)某个特定参数不会是任何其它指针的别名,也可以通过编译选项来改变这个行为
    • 可以用 [[gnu::const]] 来告诉编译器一个函数是纯函数(没有任何副作用),可以进行一些比如公共子表达式提取的优化
    • 浮点数天然的精度问题导致编译器无法进行一些比如循环归纳或者计算顺序调整的优化
    • 指令上的依赖链会影响乱序执行或者多发射
    • 编译器不知道堆内存的对齐情况,影响向量指令的使用
  • 一些关于编译选项的建议:(虽然我觉得都没必要,甚至有风险)
    • 关闭 RTTI
    • 放松对浮点数计算的要求
    • 自动移除在 link 时没用到的函数
    • 假设不存在 pointer aliasing 的情况
    • 移除 frame pointer
  • 大数据结构内的缓存竞争,如果一个矩阵的一行刚好和缓存中的一行对应(肯定是 2 的幂),那么按列访问时很容易大量浪费缓存空间,耗尽 L2 空间比耗尽 L1 空间会带来更大的损失。一种解决方法是分块计算。注:15213 里就有这个相关的作业。
  • 可以通过指令进行无缓存的写入(否则的话会先把附近的内存先读取到缓存行),但通常不建议,因为从下一级缓存里读取缓存很快。
  • SIMD 相关:
    • 向量寄存器的大小:SSE2 指令集为 128 bits,AVX 为 256 bits,AVX512 为 512 bits
    • AVX 之后对于内存对齐的要求放松了,但是最好 128 bits XMM 按 16 bits 对齐,YMM 按 32 bits 对齐,ZMM 按 64 bits 对齐
    • 有一些指令集扩展,比如 AVX512_FP16 可以做半精度浮点运算
  • 一些对向量化不友好的东西:
    • 较大的元素类型,比如 int64_t,double
    • 未对齐的数据
    • 预测分支带来的效益比两边都执行的向量化要高
    • 编译器不知道指针的同名和对齐情况
    • 指令集不支持
  • 性能测试中的一些建议:
    • 需要 warmup
    • 需要大量数据
    • 考虑旧 CPU,内存紧缺,大量后台进程之类的场景
    • 考虑有反病毒软件在后台扫描所有文件的场景
    • 用随机数据来测试分支预测错误的情况
    • 在真实场景中测试(而不是只测试一个函数)
    • 使用 CPU 内置的性能监控相关的计数器