摘要:本文是《Performance Analysisi and Tuning on Modern CPU》的阅读笔记。该书首先详细描述了现代CPU的硬件架构,一些性能指标和性能优化分析工具。然后介绍了一些从代码上如何修改能够使得代码更加快速的技术细节。最后描述了其他一些技术,并且展望了为了性能优化可能的方向。本文不会赘述硬件架构相关的内容,重点关注第二部分代码优化部分。
关键字:C++,性能优化
1 性能优化概论
原书用了很大的篇幅描述了现代计算机的体系结构,本文不会对该内容进行赘述,我理解这个是一个程序员的基本功。如果感兴趣可以看原文。
1.1 如何优化性能
计算机系统已经发展成非常复杂的设备,几乎不可能预测代码可能运行的速度。实际的运行性能取决于多个因素,比如软件设计方案,软件实现方案,机器设备等多个方面。既然是软件方面的优化,我们就暂且假设设备是符合要求或者在给定的设备上进行优化。基于此性能优化的基本思路一般围绕下面几个点:
算法优化。一个更好的算法能够大幅度降低程序的复杂度降低运算量从而提升性能,比如我们比较熟悉的大数组使用快速排序代替毛冒泡排序。远一点儿的比如傅里叶变换可以替换为快速傅里叶变换FFT,运行时间可以降低一个数量级。
并行化优化。如果我们使用的算法是可以高度并行化的,那可以考虑使用CPU提供的SSE,AVX等并行指令来加速计算,设置于使用GPU进行加速。比如现在比较火的大模型学习就是使用GPU加速卡来加速算法迭代。
减少冗余工作。基本理念是如果能够确定的数据或者操作完全可以提前准备好,而不是在运行时进行计算。比如在游戏引擎中会实现constexpr的三角函数值,避免在运行时计算对应的值,来降低计算开销。
批量处理。简单的理解就是将相似的操作进行打包统一提交给CPU或者GPU进行处理,避免频繁交互带来的内存间通信的延迟。
排序。对已知任务进行相应的排序来提升缓存命中率,比如将频繁调用的API放在相邻的text段来提上指令的解析速度。这一点一般都是使用编译器提供的功能来进行优化,而不需要程序员自己实现类似的功能。
上面的几点总的来说其实就是两个方面,第一,更好的算法,第二,尽可能的利用硬件执行任务降低直接交互次数来降低延迟。当然还有其他比较小的点,比如换语言,毕竟python和C++写性能肯定不一样;换硬件,更好的机器一般就意味着更好的性能;调整编译选项,比如PGO优化等。
1.2 如何找到优化的点
很多时候并不是不知道如何优化,而不清楚复杂系统中那部分性能有问题。这就需要使用相应的工具来分析我们的软件性能有问题的代码块。找到症结,才能对症下药。
此时一般使用厂商或者开源的性能分析工具即可。比如Intel的Vtune,AMD的 uProf,Apple Xcode Instruments, Linux Perf等等。
Intel VTune Profiler是一款功能强大的性能分析工具,专为开发者设计,以深入分析和优化应用程序的性能。它提供了详细的性能指标,包括CPU使用率、内存访问模式和多线程活动,能够帮助用户识别热点和性能瓶颈。VTune 支持多种分析模式,如并行性分析和内存分析,利用硬件性能计数器提供高精度的数据,揭示软件层面难以察觉的问题。尽管它具有直观的图形界面和与多种开发环境的集成支持,使得工具易于上手,但用户仍需面对一定的学习曲线,特别是在理解复杂的性能数据和分析结果时。此外,运行 VTune 进行性能分析可能会消耗较多的系统资源,从而影响被分析程序的性能表现。
AMD uProf 是一款针对AMD处理器的性能分析工具,旨在帮助开发者优化应用程序的性能。它提供了多种分析功能,包括CPU和内存使用情况、功耗监测、热分析以及多线程性能评估,能够识别性能瓶颈和优化点。uProf 的界面友好,支持图形化数据展示,便于用户快速理解性能指标和分析结果。该工具还利用AMD特有的硬件性能计数器,提供精确的分析数据,从而帮助开发者深入挖掘潜在的问题。尽管 uProf 提供了强大的功能和灵活的分析选项,但用户仍需一定的学习时间来掌握其复杂的功能和数据解释。此外,作为专为AMD平台设计的工具,它的适用性可能限制在特定的硬件范围内。
Xcode Instruments 是苹果公司提供的一款强大的性能分析和调试工具,专为macOS和iOS开发者设计。它集成在Xcode中,允许开发者监测和分析应用程序的性能,提供多种分析模板,包括CPU使用率、内存分配、磁盘活动、网络请求和电池使用情况等。Instruments 的图形化界面使得数据可视化直观,用户可以通过实时监控和历史数据回放,深入了解应用的性能瓶颈和资源使用情况。该工具支持多种分析类型,如Time Profiler、Allocations、Leaks和Network等,能够帮助开发者识别内存泄漏、不当的资源管理和潜在的性能问题。此外,Instruments 还允许用户自定义数据采集和分析,以满足特定的需求。尽管它功能强大且易于使用,但新用户可能需要一些时间来熟悉其复杂的界面和各种工具选项。然而,Instruments 的一大优势在于其与Xcode的深度集成,使得开发者能够无缝地在开发和调试过程中使用该工具。总体而言,Xcode Instruments 是一款必不可少的性能分析工具,适合希望优化其macOS和iOS应用程序的开发者,尽管它可能受到特定平台的局限性。
Perf是一个强大的 Linux 性能分析工具,专门用于监控和分析系统性能,帮助开发者和系统管理员识别和解决性能瓶颈。它提供了一系列功能,包括 CPU 性能计数、内存访问模式、上下文切换和 I/O 活动监测,能够深入分析应用程序和系统的行为。Perf 使用 Linux 内核的性能计数器,能够捕捉硬件事件,从而提供高精度的性能数据,帮助用户优化代码和系统配置。Perf 的灵活性体现在其支持多种分析模式,包括记录和回放、实时监控和详细的统计报告。用户可以通过命令行界面轻松执行分析任务,并生成丰富的报告,便于后续分析。尽管 Perf 功能强大且开源,用户在初次使用时可能会面临一定的学习曲线,特别是在理解复杂的性能数据和命令选项时。此外,由于 Perf 是针对 Linux 系统设计的,因此其应用范围主要限于该平台,可能不适用于其他操作系统。总体而言,Perf 是一款极具价值的性能分析工具,适合希望深入了解和优化其 Linux 应用程序和系统性能的开发者和管理员。
当找到症结后就可以分析可以使用那些方案进行优化提升性能。
2 代码优化
2.1 优化内存访问
现代计算机系统运行时延迟比较大的设备就是内存,如果编写的程序对内存友好,降低内存带宽限制带来的瓶颈,则有很大可能性提升性能。我们可以通过以下几种方式降低内存瓶颈。
2.1.1 缓存友好数据结构
缓存友好数据结构的关键便是利用程序运行时访问内存的局部性原理,其基本理论是程序中有20%的代码占据了整个运行周期的20%。局部性原理又分为空间局部性和时间局部性。缓存友好数据结构旨在利用这种局部性降低缓存miss来提升性能。
顺序访问数据
利用缓存空间局部性的最佳方式是顺序内存访问,这样硬件的内存预取器会提前将下一块数据加载。比较典型的例子是矩阵遍历。如果该矩阵是以行优先存储那么下面代码就是缓存友好的,反之不是。
for(auto i = 0;i < row;i ++>){
for(auto j = 0;j < col;j ++>){
matrix[i][j] = i << 16 + j;
}
}
选择适合的容器
不同的编程语言都提供了各自的容器基础库了解他们内存的内存布局根据具体场景选择合理的容器可以加快内存访问。比如C++中vector和arry是线性容器,map是关联容器,unordered_map是关联容器但是内存是线性内存等。
数据压缩
合理的布局内存数据,使得数据更加紧凑能够改善内存的利用率。比如当明确字段宽度时使用bit位限制内存大小来降低整体的内存占用。下面的例子中B使用bit位大大降低了内存占用。
struct A{
char a;
char b;
char c;
};//sizeof(A) = 3 byte
struct B{
char a : 4;
char b : 2;
char c : 1;
};//sizeof(A) = 1 byte
另一个比较实用的例子是合理调整字段的布局来降低内存占用。比如C++语言中结构体内成员的内存会自动对齐,如果布局不合理会显著增加其内存占用。
struct A{
bool a;
int b;
short c;
};//sizeof(A) = 12 byte(假设int 4个字节)
struct B{
int b;
short c;
bool a;
};//sizeof(A) = 8 byte
内存对齐和填充
数据对齐到缓存行虽然增加了内存占用,但是可以降低对象读取时的缓存竞争。这种竞争不只是读取同个对象时不同缓存行的竞争,也有可能是不同线程对不同对象但是存在跨缓存行存储的竞争。比如下面的对象A的实例1,如果对象A的a存储在cacheline0,b存储在cacheline1上,那么如果另一个线程要访问cacheline1上另一个实例2就需要等待实例1同步访问结束,否则可能存在多线程问题。如果内存对齐,上面的内存竞争就不存在。内存对齐在图像领域比较常见的便是RGBA数据存储,通常图像一行的长度并不等于4 * sizeof(R)
,就是因为进行了内存对齐。
struct A{
int a;
int b;
}
动态内存分配
根据场景使用更好的内存分配库能够解决内存碎片等问题来提升性能。比如jmalloc
和mimalloc
等高效的内存分配库。
调整代码来适应内存结构
在某些场景下可以修改代码来适应硬件的缓存结构来提升缓存命中率。比如通过循环嵌套来优化矩阵乘法的性能。
2.1.2 内存
内存预取
内存预取(Memory Prefetching)旨在减少CPU等待数据加载的时间。通过预测程序将要访问的数据,系统可以提前将这些数据从主内存加载到缓存中,从而加快数据的访问速度。内存预取的实现通常依赖于硬件和软件的协同工作。硬件预取器,如CPU内部的预取单元,能够根据访问模式自动识别将要使用的数据,并提前加载。而软件层面的预取,则可以通过编程语言或编译器中的预取指令,明确地告诉系统哪些数据需要提前加载。尽管内存预取可以提高性能,但它也有潜在的缺点。过度预取可能导致缓存污染,浪费带宽,甚至可能使性能下降。因此,设计高效的预取策略需要平衡预取的准确性与资源的使用效率。
内存预取是一项高度依赖于具体平台的技术,优化时需要考虑硬件、软件和应用程序的具体特性,以实现最佳的性能提升。
内存分析
不仅仅可以测量程序中运行耗时的热点也可以测量程序运行的内存热点根据测量情况来分析程序中的内存问题提升性能。一般的工具有valgrind,heap profiler等。
减少TLB未命中
为了减少TLB的内存cache missing,可以使用大页面来提升减少TLB项的数量来提升性能。 一般主要有两种方式EHP和THP。
EHP 主要用于描述程序在堆内存使用上的有效性。它衡量的是实际使用的堆内存与分配的堆内存之间的比率,反映了内存使用的效率。高的 EHP 值表明程序在内存分配和使用上较为高效,避免了内存浪费。监控 EHP 可以帮助开发者识别内存泄漏或不当的内存管理行为,从而优化应用的内存使用,提升性能。
THP 是 Linux 内核的一项特性,旨在简化大页内存的管理。传统的页面大小通常为 4KB,THP 通过使用更大的页面(通常为 2MB)来提高内存管理的效率。大页可以减少页表的大小,降低TLB(Translation Lookaside Buffer)缺失的频率,从而提升内存访问性能。
查看当前THP状态
cat /sys/kernel/mm/transparent_hugepage/enabled
启用THP
echo always > /sys/kernel/mm/transparent_hugepage/enabled
#include <iostream>
#include <sys/mman.h>
#include <unistd.h>
int main() {
size_t pagesize = getpagesize();
size_t hugePageSize = 2 * 1024 * 1024; // 2MB
void* ptr = mmap(NULL, hugePageSize, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
if (ptr == MAP_FAILED) {
std::cerr << "Failed to allocate huge page memory.\n";
return 1;
}
// 使用大页内存
// ...
// 释放大页内存
munmap(ptr, hugePageSize);
return 0;
}
2.2 计算优化
当内存访问不再成为瓶颈时,CPU如何处理内存中的数据可能会成为性能的瓶颈。当应用TMA方法时,低效的计算通常反映在CoreBound类别中,以及在一定程度上反映在Retiring类别中。Core Bound类别代表了CPU乱序执行引擎内所有非内存问题引起的停顿。主要有两个类别:
- 软件指令之间的数据依赖性限制了性能。例如,一系列依赖操作可能导致指令级并行性(ILP)低下,浪费了许多执行槽。下一节将更详细地讨论数据依赖链。
- 硬件计算资源短缺(也称为执行吞吐量不足)。这表明某些执行单元过载(也称为执行端口竞争)。当工作负载频繁执行许多相同类型的指令时,可能会发生这种情况。例如,AI算法通常执行大量的乘法运算,科学应用程序可能运行许多除法和平方根运算。但在任何给定的CPU核心中,乘法器和除法器的数量都是有限的。因此,当端口竞争发生时,指令排队等待执行。这种性能瓶颈非常特定于特定的CPU微架构,通常没有解决办法。
2.2.1 数据依赖
数据依赖就是后续指令的计算依赖前面的指令。如果前后指令相互形成数据依赖,这就意味着只有前序指令执行完成,后序的指令才能正常运行。由于CPU支持超标量和乱序执行,这就会导致数据依赖阻塞CPU的并发能力。
当然写出没有数据依赖的程序是不可能的,但是我们可以分析程序中的数据依赖关系,通过循环展开等其他方式降低数据依赖。
常见的数据依赖有:
- 流依赖,比较常见的就是后续变量的求值依赖前序的变量;
int a = 5;
int b = a + 3; // b 依赖于 a
- 控制依赖。即表达式计算依赖if语句判断结果。
if (condition) {
a = 5; // a 的赋值依赖于 condition 的结果
}
- 循环依赖。在循环结构中,迭代之间的依赖关系。
for (int i = 1; i < 10; ++i) {
sum += i; // sum 的更新依赖于前一次的值
}
2.2.2 内联优化
早期编译器为了优化程序执行会将成本比较低的函数在对应的代码段进行内联展开来避免函数栈变换引入的性能。但是需要注意的时自从C++17标准开始inline
的含义已经发生变化,inline
已经主要表示函数符号在项目中的唯一性。因此函数是否inline
主要看编译器的判断,即便不加inline
编译器也会尝试进行inline
。但是这不意味着inline
失去起作用,虽然标准改变了inline
的定义,但是编译器本身的实现为了兼容性通常也会保留就有的inline
语义。另外如果确切的希望某段代码inline
,可以通过编译器的扩展比如always_inline
等attribute
来强制inline
。
Since inline substitution is unobservable in the standard semantics, compilers are free to use inline substitution for any function that's not marked inline, and are free to generate function calls to any function marked inline. Those optimization choices do not change the rules regarding multiple definitions and shared statics listed above.
Because the meaning of the keyword inline for functions came to mean "multiple definitions are permitted" rather than "inlining is preferred" since C++98, that meaning was extended to variables.
2.2.3 循环优化
循环是几乎所有高性能程序的核心。由于循环代表了执行大量次的代码片段,因此它们是执行时间花费最多的部分。在这样一个关键代码段进行微小的更改可能会对程序的性能产生重大影响。这就是为什么仔细分析程序中热点循环的性能并了解改进它们的可能方法非常重要。虽然很多时候编译器能够自动识别循环优化,但是大部分情况下还是做不到这一点,我们能够认识到如何优化可以在编译器无法优化的情况下手动优化。
2.2.3.1 低级优化
一些低级的优化可以降低循环的代码强度,提升运行效率。虽然这些低级优化通常编译器都会做,但是编译器并不总是能够正常生成对应的代码优化。另外了解这些优化可以让我们了解到具体优化是如何做的,指导我们在编译器无法进行优化的时候进行优化。常见的低级优化有:
- 循环不变代码移动(LICM)。再循环中评估出从不改变的表达式,将不改变的表达式移出循环,避免重复求值。
//优化前
for(int i = 0;i < vec.size();i ++){}
//优化后
const auto len = vec.size();
for(int i = 0;i < len;i ++){}
- 循环展开。循环中容易出现当前的值依赖于上一次计算的一部分值,这种数据依赖会阻塞CPU的并行流水线。可以将循环适当展开,降低数据依赖的程度,提升CPU指令并发执行的能力。
//优化前
const auto len = vec.size();
for(int i = 0;i < len - 1;i ++){
result[i] = 2 * vec[i];
}
//优化后
const auto len = vec.size();
for(int i = 0;i < len - 3;i += 4){
result[i] = 2 * vec[i];
result[i + 1] = 2 * vec[i + 1];
result[i + 2] = 2 * vec[i + 2];
result[i + 3] = 2 * vec[i + 3];
}
- 循环强度降低(LSR)。用更简单的指令替代复杂的指令,比如用加法替换乘法,整数乘法替换浮点计算等等。
//优化前
for(int i = 0;i < len;i ++){
vec[i] = vec[i] * 4;
}
//优化后
for(int i = 0;i < len;i ++){
vec[i] = vec[i] >> 2;
}
- 循环取消开关(Loop Unswitching)。将循环中的条件语句挪出去,避免每次都需要计算if。这种优化一般都会增加代码,但是性能是实打实的。
//优化前
for (int i = 0; i < vec.size(); i++) {
if (i % 2 == 0) {
vec[i] *= 2; // 对偶数位进行处理
} else {
vec[i] *= 3; // 对奇数位进行处理
}
}
//优化后
for (int i = 0; i < vec.size(); i += 2) {
vec[i] *= 2; // 对偶数位进行处理
}
for (int i = 1; i < vec.size(); i += 2) {
vec[i] *= 3; // 对奇数位进行处理
}
2.2.3.2 高级优化
另一类循环优化会影响循环的结构,这些转换是为了优化内存访问,减少缓存失效导致的内存延迟。类似的优化需要有完整的上下文,根据上下文调整循环来,这也就意味着编译器很难进行自动优化。
- 循环交换。循环交换就是根据内存的布局交换内层和外层循环,保证访问的缓存友好性。下面的例子中对于按行存储的矩阵第二种方式比较优,第一种按列存储比较优。
//优化前(按行存储)
for(auto i = 0;i < col;i ++>){
for(auto j = 0;j < row;j ++){
r[j][i] = a[j][i] + b[j][i];
}
}
//优化后
for(auto j = 0;j < col;j ++>){
for(auto i = 0;i < row;i ++){
r[j][i] = a[j][i] + b[j][i];
}
}
- 循环阻塞。循环阻塞的基本思路是通过将矩阵分块,使得在乘法过程中尽可能避免缓存缺失(cache miss)。这样可以确保在处理每个小块时,所需的数据能保持在缓存中,从而提高整体性能。
//优化前(按行存储)
for(auto j = 0;j < col;j ++>){
for(auto i = 0;i < row;i ++){
r[j][i] = a[j][i] + b[j][i];
}
}
//优化后
for(auto j = 0;j < col;j += 8>){
for(auto i = 0;i < row;i += 8){
for(auto jj = j;jj < j + 8; j++){
for(auto ii = i;ii < i + 8; i++){
r[j][i] = a[j][i] + b[j][i];
}
}
}
}
- 循环融合和分裂。循环融合的基本思想是将多个循环合并成一个循环,以减少循环开销和提高数据局部性。循环分裂的基本思想是将一个复杂的循环分解成多个简单的循环,以提高可并行性和优化性能。下面的代码只是为了展示具体的思想实际的代码执行性能并不是优化后更好。
for (int i = 0; i < n; i++) {
c[i] = a[i] + 1;
d[i] = a[i] * 2;
}
// 分裂后的循环
for (int i = 0; i < n; i++) {
c[i] = a[i] + 1;
}
for (int i = 0; i < n; i++) {
d[i] = a[i] * 2;
}
// 第一个循环
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
}
// 第二个循环
for (int i = 0; i < n; i++) {
d[i] = a[i] * b[i];
}
// 融合后的循环
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
d[i] = a[i] * b[i];
}
- 循环展开和融合。循环展开是一种通过增加每次循环迭代中执行的操作数量来减少循环控制开销的优化技术。通过减少迭代次数,可以提高指令流水线的利用率。循环融合是将多个循环合并为一个循环,以减少循环控制开销和提高数据局部性。
//循环展开
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
}
// 循环展开:每次处理两个元素
for (; i <= n - 2; i += 2) {
c[i] = a[i] + b[i];
c[i + 1] = a[i + 1] + b[i + 1];
}
// 处理剩余元素
for (; i < n; i++) {
c[i] = a[i] + b[i];
}
//融合
// 第一个循环
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
}
// 第二个循环
for (int i = 0; i < n; i++) {
d[i] = a[i] * b[i];
}
// 融合后的循环
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
d[i] = a[i] * b[i];
}
2.2.3.3 循环优化
循环优化和其他优化的思路相似。首先,我们使用工具找到对应的热点代码,如果对应的循环代码恰好是性能瓶颈才需要投入优化。其次除了上面常规的循环优化手段,我们还应该借助工具进行优化。比如常规编译器都会提供一些编译选项来进行优化:
- GCC
-O1
,-O2
,-O3
: 这些选项分别表示不同级别的优化。-O2 和 -O3 启用更激进的循环优化,例如循环展开、循环融合等。-funroll-loops
: 这个选项启用循环展开,编译器会尝试将循环展开以减少循环开销。-floop-strip-mine
: 将循环分成多个子循环,以提高缓存利用率。-floop-interchange
: 改变嵌套循环的顺序,以提高数据局部性。-floop-block
: 对循环进行阻塞,以优化内存访问模式。
- Clang
-O1
,-O2
,-O3
: 与GCC类似,控制优化级别。-fno-unroll-loops
: 禁用循环展开。-floop-interchange
: 启用循环交换。-floop-block
: 启用循环阻塞。-fprofile-generate
和-fprofile-use
: 通过收集程序运行时的数据来优化循环,特别适用于动态优化。
-fopt-info
: 这个选项可以用来生成优化信息,帮助开发者理解哪些循环进行了优化,哪种优化被应用。
很多时候编译器因为没有足够的上下文,无法进行优化,此时我们需要主动优化或者给予编译器更多的上下文,让编译器进行优化。
也可以使用相应的框架进行优化,比如Polybench。Polybench 是一个基准测试套件,旨在评估和优化多维数组和循环的性能。它专注于数值计算和高性能计算(HPC)中的典型算法,提供了一系列标准化的测试用例,帮助研究人员和开发者评估编译器优化和硬件性能。
2.2.4 向量化
2.2.4.1 向量化
向量化是编程和编译优化中的一种技术,旨在利用现代处理器的 SIMD(单指令多数据)指令集,同时处理多个数据元素,从而提高程序的执行效率。通过将标量操作转换为向量操作,向量化可以显著加快数据密集型应用的处理速度。向量化是使用现代CPU(如Intel、AMD和ARM)支持的SIMD指令集(如SSE、AVX、NEON)进行优化,该指令允许在单个指令中处理多个数据元素。比如
#include <immintrin.h> // 包含SIMD指令集
void add_simd(const float* a, const float* b, float* c, int n) {
int i;
// 每次处理4个浮点数
for (i = 0; i <= n - 4; i += 4) {
__m128 vec_a = _mm_loadu_ps(&a[i]);
__m128 vec_b = _mm_loadu_ps(&b[i]);
__m128 vec_c = _mm_add_ps(vec_a, vec_b);
_mm_storeu_ps(&c[i], vec_c);
}
// 处理剩余元素
for (; i < n; i++) {
c[i] = a[i] + b[i];
}
}
通常情况下编译器能够识别代码中比较简单的代码进行向量化。
- 合法性检查(Legality Check)。这一阶段,向量化器会评估代码的结构和数据依赖,以确保向量化是合法的。
- 数据依赖性分析:检查循环中的数据依赖,确保没有跨迭代的数据冲突。例如,确定某个循环迭代是否依赖于之前迭代的结果。
- 边界条件验证:确保在向量化时不会超出数组或数据结构的边界,尤其是在处理非整除的数组长度时。
- 内存对齐检查:验证数据在内存中的对齐情况,以确保向量化指令可以在目标架构上有效执行。
- 盈利性检查(Profitability Check)。在这一阶段,向量化器会评估向量化的潜在性能收益。
- 性能预估:分析向量化后代码的执行时间,评估向量化是否能够显著提高性能。
- 计算复杂度分析:检查向量化后的计算复杂度是否低于标量版本,确保向量化的收益大于其开销。
- 资源利用率评估:评估向量化对CPU资源(如寄存器、缓存和执行单元)的利用率,确保不会引入额外的资源消耗。
- 转换本身(Transformation).在这一阶段,向量化器实际执行向量化操作,将标量代码转换为向量化代码。
- 代码转换:将标量循环转换为向量化形式,生成相应的 SIMD 指令。
- 循环重排和优化:根据需要重排循环,以提高数据局部性和并行性。
- 生成向量化代码:输出最终的向量化代码,并确保其在目标硬件上可以有效运行。
通常我们可以通过编译器的相关参数检查来确认是否进行向量化,比如如下的代码:
float func(){
float *ptr = new float[1024];
float sum = 0.0f;
for(auto i = 0;i < 1024 - 1;i++){
sum += ptr[i];
}
return sum;
}
通过命令clang++ -c main.cpp -O3 -march=core-avx2 -Rpass-analysis=.\*
会有以下输出,帮助我们分析编译器的优化状态。当然还有其他命令,这只是命令之一,具体可以参考具体编译器的文档。
main.cpp:8:7: remark: loop not vectorized: cannot prove it is safe to reorder floating-point operations; allow reordering by specifying '#pragma clang loop vectorize(enable)' before the loop or by providing the compiler option '-ffast-math' [-Rpass-analysis=loop-vectorize]
8 | sum += ptr[i];
| ^
main.cpp:4:7: remark: Loop Strength Reduction: IR instruction count changed from 55 to 67; Delta: 12 [-Rpass-analysis=size-info]
4 | float func(){
2.2.4.2 更好的向量化
编译器进行自动向量化时,尽管能够显著提高性能,但生成的向量化代码可能不是最优的,原因如下:
- 局限的分析能力
- 数据依赖性判断:编译器在分析数据依赖时可能无法全面理解程序的上下文,导致某些可向量化的部分被遗漏。
- 复杂控制流:对于复杂的控制流结构(如条件语句和嵌套循环),编译器可能无法正确评估向量化的收益。
- 子optimal的向量化策略
- 固定的向量宽度:编译器通常使用固定的向量宽度,这可能不适合特定的硬件架构,导致性能未达到最佳。
- 缺乏循环重排:编译器可能未能充分利用循环重排等优化技术,导致数据局部性不足。
- 内存访问模式
- 缓存未命中:编译器可能未能优化内存访问模式,导致缓存未命中率高,从而影响性能。
- 不对齐的内存访问:生成的向量化代码可能导致不对齐的内存访问,这会降低性能。
- 硬件特性利用不足
- SIMD指令集的充分利用:编译器可能未能充分利用特定硬件的 SIMD 指令集特性(如 AVX、SSE),未能生成最优指令。
- 寄存器使用效率:在寄存器使用上,编译器可能未能充分利用可用资源,导致寄存器溢出或频繁的内存访问。
这就需要我们根据具体的场景和硬件进行手动优化。或者使用现有的框架比如OpenCL,CUDA,OpenMP等进行并行优化。
2.3 优化分支预测
分支预测旨在减少因分支指令(如条件语句)引起的流水线停顿。通过预测程序执行中的分支方向,处理器可以提前加载指令,从而提高执行效率。分支预测根据预测机制分为
- 静态预测:基于编译时信息进行预测,例如总是预测为“跳转”或“不中断”。
- 动态预测:使用历史信息来预测分支的结果,基于过去的执行行为进行预测。
处理器维护一个分支历史表,记录最近的分支指令及其结果。通过分析历史数据,处理器能够预测未来分支的方向。当预测错误时,处理器必须清空错误路径上的指令并重新加载正确路径的指令,这会导致CPU的指令并行性下降,从而导致性能下降。
分支预测器使用缓存和历史寄存器,因此容易受到与缓存相关的问题的影响,即三个C:
- 强制性缺失(Compulsory misses):当采用静态预测时,如果果在分支的第一次动态出现时没有可用的动态历史信息,可能会发生误预测。
- 容量缺失(Capacity misses):由于程序中分支数量极高或动态模式非常长,导致动态历史丢失,从而产生误预测。
- 冲突缺失(Conflict misses):分支通过它们的虚拟地址和/或牛物理地址的组合被映射到缓存桶(关联集合)中。如果太多活跃的分支被映射到同一个集合,就可能发生历史丢失。冲突缺失的另一个例子是虚伪共享,当两个独立的分支被映射到同一个缓存条目时,它们可能会相互干扰,从而可能降低预测历史的质量。
通常我们可以通过调整代码书写的方式来降低分支预测错误的概率。
用查找表替换分支
在程序中存在大量条件分支的情况下。通过使用查找表,程序可以避免频繁的条件判断,从而减少分支预测失败的代价和流水线停顿。
int compute(int x) {
if (x == 0) return 10;
else if (x == 1) return 20;
else if (x == 2) return 30;
else return -1; // 默认值
}
//优化后
int lookupTable[] = {10, 20, 30, -1}; // 默认值为 -1
int compute(int x) {
if (x < 0 || x >= sizeof(lookupTable)/sizeof(lookupTable[0])) {
return -1; // 边界检查
}
return lookupTable[x]; // 直接查找
}
用算术替换分支
通过将条件分支转换为算术运算,来减少分支指令的使用。这种方法可以提高代码的执行效率,特别是在性能敏感的应用中
int compute(int x) {
if (x > 0) return 1;
else return 0;
}
//优化后
int compute(int x) {
return (x > 0) * 1 + (x <= 0) * 0; // 用算术替换
}
用谓词替换分支
通过使用谓词逻辑来消除条件分支,从而提高程序的性能。这种方法特别适用于可以并行化执行的场景,可以减少分支预测失败的影响和流水线停顿。
void compute(int x, int* result) {
if (x > 0) {
*result = 1;
} else {
*result = 0;
}
}
//优化后
void compute(int x, int* result) {
*result = (x > 0) ? 1 : 0; // 仍然是条件,但可以进一步优化
}
利用编译器的能力进行优化
可以使用编译器提供的一些扩展来帮助优化,比如c++20的likely可以告诉编译器程序大概率进入哪个分支让编译器朝着对应的分支优化。另外还有POG和BOLT:
- PGO:Profile-Guided Optimization (PGO) 是一种编译器优化技术,通过分析程序的运行时行为来优化代码。
- BOLT:BOLT 是一种二进制优化工具,主要用于优化已编译的程序的性能。
2.4 优化机器代码布局
优化机器代码布局是通过合理安排指令和函数在内存中的位置,以提高指令缓存命中率和流水线效率,从而减少执行延迟和提高程序性能的技术。这一过程通常涉及将常用的热函数聚集在一起、重排指令以减少控制依赖、以及利用工具如 PGO 和 BOLT 来分析和调整代码布局,以实现更高效的执行。
- 函数布局优化:
- 热路径优先:通过分析程序的运行时行为,识别最常调用的函数(热函数),将这些函数聚集在一起,减少跨越缓存行的调用,从而提高指令缓存的命中率。
- 局部性原则:将相关函数(例如同一模块或类的函数)放置在相邻的内存地址,以充分利用缓存的局部性,提高数据访问效率。
- 指令重排:
- 减少控制依赖:通过重排序指令,降低分支指令之间的依赖性,从而提高流水线的吞吐量。例如,可以将不依赖于先前计算结果的指令安排在分支指令之前,以减少停顿。
- 软件流水线:通过将指令分散到不同的执行周期中,减少数据依赖带来的停顿,使得处理器可以更高效地利用其资源。
- 分支预测优化:
- 跳转表布局:通过优化分支指令的布局,提高分支预测的准确性,减少预测失败的代价,以提高执行效率。
- 使用延迟槽:在分支指令后填充无条件执行的指令,以充分利用处理器的执行能力,降低分支延迟的影响。
- 工具与技术:
- BOLT:这一工具可以对已编译的二进制文件进行优化,重新排列函数和指令,以提高整体性能,并且不需要源代码。
- 链接器优化:现代链接器(如 GNU ld)提供各种优化选项,能够自动优化代码布局,包括根据函数调用频率重排函数。
- 编译器优化:许多编译器(如 GCC、Clang)具备布局优化选项,可在编译时自动优化代码布局,以实现更高效的执行。
- 评估与测试:
- 性能分析工具:使用 perf、gprof 等性能分析工具监测程序执行,识别热点和瓶颈,以指导布局优化。
- 基准测试:通过对比优化前后的代码性能,量化布局优化的效果,确保所做的改动带来实质性的性能提升。
3 多线程优化
现代处理器一般都是多核处理器,支持并行执行。我们可以合理设计成程序将耗时的任务拆分为多个任务分为多个线程执行,来提高系统资源利用率。但是同时需要注意的是多线程并不是意味着线程数越多性能越好,因为系统的真实硬件线程是又上限的,过多的创建线程会导致过多的上下文切换消耗。
另外,多线程任务也要注意临界资源的访问问题,需要保证线程安全。另外,如果多个线程之间对临界资源的访问冲突过多,严重的情况下多线程程序可能会串行运行。为了降低访问冲突,一方面可以合理划分资源和线程,另一方面可以通过无锁编程,降低对锁的依赖,提升吞吐量。
具体如何使用多线程和无锁,可以搜索thread和memory_order,lock-free相关的论文和文章了解具体的技术细节。
4 其他一些优化技术
本小节描述的优化手段只是具体技术的冰山一角,作为一个引子可以了解到对应的技术,实际使用可以参考更详细的文档。
- 优化IO。
- 批量处理:将多个 I/O 操作合并为一个批量操作,减少系统调用次数,从而降低上下文切换和系统开销。
- 异步 I/O:通过异步 I/O 操作允许程序在等待 I/O 完成时继续执行其他任务,充分利用 CPU 资源。
- 数据缓冲:使用缓冲区将频繁的 I/O 操作存储在内存中,减少对慢速设备的直接访问,提高数据传输效率。
- 合理选择 I/O 设备:选择适合应用需求的 I/O 设备(如 SSD 相对于 HDD),以提高读写速度和响应时间。
- I/O 调度算法:使用高效的 I/O 调度算法(如 CFQ、Deadline)来管理 I/O 请求,优化磁盘访问顺序,减少寻道时间。
- 低延迟优化技术:
- 避免小页错误。小页错误会导致频繁的页面调入和调出,增加延迟。通过合理配置页面大小(例如使用大页)和优化内存访问模式,可以显著减少小页错误的发生。
- 高速缓存预热。高速缓存预热是在程序启动时提前加载必要的数据到 CPU 缓存中,以减少首次访问时的延迟。这可以通过分析程序运行时的数据访问模式来实现。
- 避免TLB驱逐。翻译后备页表 (TLB) 是用来缓存页表条目的。避免 TLB 驱逐可以通过以下方法实现:
- 使用大页内存来减少 TLB 条目数量。
- 优化内存访问模式,使得访问的地址尽可能连续,减少 TLB 缺失。
- 防止意外内核节流。内核节流会导致应用程序的延迟增加。可以通过以下方法减少节流:
- 调整内核参数,确保合适的网络和 I/O 阈值。
- 使用实时调度策略,保证关键任务的 CPU 时间。
- 浮点运算优化。浮点运算是许多科学计算和图形处理应用中的关键部分,且一般浮点运算比较耗费系统资源。同时现实世界中除了少数科学计算大部分场景都对精度要求不高,因此我们可以通过降低精度等方式优化浮点运算。
- 系统调优。通常市场上的系统都是通用系统,为了满足大部分场景的需求而设计,这也就意味着我们可以通过对系统进行配置调优让其在当前场景下性能最优。
- 内核参数调整:根据应用的特性调整内核参数(如网络缓冲区大小、文件句柄限制)以优化资源使用。
- 资源分配:根据应用需求合理分配 CPU、内存和 I/O 资源,使用 cgroups 等工具进行资源限制和分配。
- 进程优先级管理:根据应用的重要性调整进程优先级,确保关键任务获得足够的 CPU 时间。
- 监控和分析:使用性能监控工具(如 top、htop、vmstat)实时监控系统性能,识别瓶颈并进行相应调整。