使用CodeQL在C++中寻找10倍以上性能提升
结合动态与静态分析进行性能优化
在上一篇文章中,我提倡构建结合静态和动态分析的系统来进行性能优化。通过这种方式,我们可以构建比单独使用任一分析方法更有用的工具。事实上,对于许多静态分析来说,是否有用很可能取决于它是否与性能分析器结合使用。
类型别名在C/C++中的性能问题
C/C++中的char类型(及其类似类型,如uint8_t)可以别名化任何其他类型。在通过此类类型写入内存时,编译器必须假设它可能修改了任何其他基于堆的内存位置,即使该位置具有完全不同的类型。
这导致编译器无法安全地自动向量化循环,因为它需要在每次写入后重新加载这些值。在循环上下文中,这个问题可能导致编译器生成的代码比原本可能的代码慢10-20倍或更多。
使用CodeQL检测别名写入
CodeQL允许您以声明性面向对象语言编写查询,并将这些查询应用于已转换为数据库中关系表示的代码库。它提供了一种相对易于使用的查询语言,用于查找代码中的有趣模式。
我们构建了CodeQL查询来查找以下模式:
- 通过表达式X从内存加载,其中X表示内存位置
- 通过别名类型(如char)进行写入
- 再次通过X从内存加载,且表达式X的组件自步骤1以来未被修改
实际案例与性能提升
Bitcoin案例:bech32::ExpandHRP函数
在Bitcoin的bech32.cpp文件中,ExpandHRP函数包含一个循环,其中:
- 循环条件调用
std::string的size()函数(从内存读取字符串长度) - 循环体内有对
std::vector<uint8_t>的写入
由于uint8_t本质上是char类型,编译器必须在每次循环迭代时重新加载字符串长度和数据指针。通过将std::vector<uint8_t>替换为std::vector<char8_t>(C++20),我们消除了别名问题,使编译器能够向量化循环。
结果:在32k输入字节时,优化版本比原版本快12倍;在90字节(实际最大输入长度)时,优化版本快2.6倍。
Monero案例:bulletproof_PROVE函数
在Monero的bulletproofs.cc文件中,bulletproof_PROVE函数包含一个循环,其中对char类型的bytes数组进行多次赋值。每次赋值后,编译器必须重新加载多个先前加载的值。
通过将bytes数组的类型改为char8_t,编译器能够一次性执行所有8个赋值。
结果:对于大小为128到8192的输入,速度提升了3-4倍。
分析与结论
通过结合CodeQL静态分析和动态性能分析,我们能够:
- 识别由类型别名引起的性能问题
- 通过简单的类型更改实现显著的性能提升
- 专注于最有可能产生影响的代码区域(热点循环)
这种方法特别适用于:
- 循环中的内存访问模式
- 使用内联函数调用(如
vector.size())的循环条件 - 包含通过字符类型写入的循环体
未来的工作可能包括:
- 探索其他语言级性能问题的静态检测
- 在汇编级别进行此类分析
- 开发特定代码库的重复性能问题模式
结合静态分析和持续性能分析,为性能优化提供了一个强大的工具组合,使开发人员能够专注于最有影响力的改进。