第8章 CPU后端优化

CPU后端低效:当前端完成取指和译码后,后端发生了过载而不能处理新的指令。TMA将后端bound分为存储和计算bound。

8.1 存储bound

当应用程序执行大量的内存访问并且花费比较长的时间等待内存访问完成时,即被视为存储bound。意味着要改善存储访问情况,减少存储访问次数或者升级存储子系统。

在TMA中,存储bound会统计CPU流水线由于按需加载或者存储指令而阻塞的部分槽位。解决此类性能问题第一步是,定位导致高“存储bound”指标的访存操作。识别出具体访存操作之后,开始调优。

8.1.1 缓存友好的数据类型

关于编写缓存友好算法和数据结构是性能关键要素之一,重点在于时间和空间局部性原则,其目标是从缓存中高效地读取所需的数据。

8.1.1.1 按顺序访问数据

利用缓存空间局部性的最佳方法是按顺序访问内存。

二分搜索的标准实现不会利用空间局部性,解决该问题的著名方法是Eytzinger布局存储数组元素。它的思想是维护一个隐式二叉搜索树,使用类似广度优先搜索的布局把二叉搜索数打包到一个数组中。

8.1.1.2 使用适当容器

几乎在任何语言中都有各种各样的现成容器,了解它们的底层存储机制和性能影响很重要。结合代码如何处理数据,才能选择数据存储方式。

8.1.1.3 打包数据

可通过使数据更紧凑来提高内存层次利用率。打包数据的一个经典例子就是使用位存储。大大减少来回传输的内存数量,同时节省缓存空间。不过b位和a与c共享一个机器字,编译器需要执行移位操作。在额外计算的开销比低效内存转移开销低的情景下,打包数据是有意义的。

struct S {
    unsigned a;
    unsigned b;
    unsigned c;
};

// optimal
struct S {
    unsigned a:4;
    unsigned b:2;
    unsigned c:2;
};

程序员可以通过重新排布结构体或类中字段来减少内存的使用,同时避免由编译器添加结构体填充。

struct S {
    bool a;
    int b;
    short c;
};

// optimal
struct S {
    int b;
    short c;
    bool a;
};

8.1.1.4 对齐与填充

如果变量被存储在能被变量大小整除的内存地址中,那么访问这个地址的效率最高。

alignas(16) int16_t a[N];

对齐可能会导致未使用的字节出现空位,可能会降低内存带宽利用率。

有时需要填充数据结构成员以避免边缘情况,例如缓存争用和伪共享。例如两个线程访问同一结构体的不同字段。缓存一致性问题可能会明显降低程序的运行速度。使用填充方法使得结构体的不同字段处于不同的缓存行。

struct S {
    int a;
    int b;
};

// optimal
struct S {
    int a;
    alignas(64) int b;
};

当通过malloc进行动态分配时,保证返回的内存地址满足平台目标的最小对齐要求。对齐注意事项中最重要的一个是SIMD代码。当使用编译器向量化内建函数时,通常地址要被16、/3或64整除。

8.1.1.5 动态内存分配

有许多malloc替代品,它们更快、更具有可扩展性,能更好地动态解决内存碎片化问题。动态内存分配的一个经典问题是在启动时,线程之间试图同时分配它们的内存区域。

其次,可以使用自定义分配器来加速分配,这类分配器的主要优点是开销低,因为它们不会对每个内存分配执行系统调用。另一个优点是高灵活性。开发者可以基于操作系统提供的内存区域实现自己的分配策略。最简单的策略是维护2个不同的分配器,它们有各自的内存区域:一个用于热数据;一个用于冷数据。将热数据放在一起可以让它们共享高速缓存行,从而提高内存带宽利用率和空间局部性。它还可以提高TLB利用率,因为热数据占用的内存页更少。此外,自定义内存分配器可以使用线程本地存储实现每个线程的分配,从而消除线程之间的同步问题。

8.1.1.6 针对存储器层次调优代码

些应用程序的性能取决于特定层缓存的大小,最著名的例子是使用循环分块来改进矩阵乘法。 

8.1.2 显式内存预取

当arr数组足够大时,硬件预取功能将无法捕获它的访存模式,也无法提前预取所需的数据。在计算j和请求元素arrp[j]之间某个时间窗口,可以使用__builtin_prefetch手动显式添加预取指令,如下所示:
 

for (int i = 0; i < N; ++i) {
    int j = calNextIndex();
    // ...
    doSomeExtensiveComputation();
    // ...
    x = arr[j];
}

// optimal
for (int i = 0; i < N; ++i) {
    int j = calNextIndex();
    __builtin_prefetch(arr + j, 0, 1);
    // ...
    doSomeExtensiveComputation();
    // ...
    x = arr[j];
}

要使预取生效,请务必提前插入预取指示,确保被加载的值在被用于计算时已经在缓存中。也不要过早插入预取提示,因为它可能会在预取的那段时间内用不到的数据,污染缓存。

显式内存预取不可移植,即使它在一个平台上实现了性能提升,也不能保证在另一个平台也有类似的提升效果。

最后,显式预取指令会增加代码大小并增加CPU前端的压力。

8.1.3 针对DTLB优化

TBL在L1分为ITLB和DTLB,在L2上是统一TLB。L1 ITLB的未命中有非常小的时延,通常会被乱序执行隐藏。统一TLB的未命中会调用页遍历器,可能会明显损失性能。

Linux中默认页面大小为4KB,当页大小增大时,TLB条目减少,TLB未命中次数减少。Intel 64和AMD 64都支持2MB和1GB巨型页。

使用大页的TLB本身更紧凑,通常需要更少的页遍历,所以在TLB未命中的情况下遍历内核页表的代价会减少。

在Linux系统中,在应用程序中使用大页的方法有2种:显式大页和透明大页。

有一个选项可利用libhugetlbfs库在大页头部动态分配内存,该库重写了现有动态链接二进制可执行文件中使用malloc调用。不需要修改代码,甚至不需要重新链接二进制文件,最终用户只需要修改几个环境变量。

为了更细粒度地通过代码控制对大页的访问,开发者有以下选择:
        1. 带MAP_HUGETLB参数使用mmap;
        2. 对挂载hugetlbfs文件系统中的文件使用mmap;
        3. 对SHM_HUGETLB参数使用shmget。

8.1.3.2 透明大页

linux支持的透明大页(Transparent Huge Page,THP)会自动管理大页。THP功能有2种操作模式:针对系统范围和针对进程范围。当在系统范围内启用THP时,内核会尝试将大页分配给任何可能分配的进程,因此不需要手动保留大页。当在进程范围内启用THP时,内核只会将大页分配给单个进程使用了madvise系统调用的内存区域。使用cat /sys/kernel/mm/transparent_hugepage/enabled查看系统是否启用了THP。

8.1.3.3 显式大页和透明大页对比

显式大页预先保留在虚拟内存,透明大页不会。透明大页分配失败,将默认分配4KB页。

透明大页的后台维护需要管理不可避免的内存碎片化和内存交换问题,从而导致内核产生不确定的延迟开销。而显式大页不受内存碎片化的影响,也无法交换到磁盘。

显式大页可用于应用程序的所有段,包括文本段(DTLB和ITLB都会收益),而透明大页仅可用于动态内存分配的内存区域。

透明大页的优势之一,与显式大页相比所需的操作系统配置更少,可以更快地进行实验。

8.2 计算bound

主要有2种:
        1. 硬件计算资源短缺,表示某些执行单元过载(执行端口争用),在负载频繁执行大量繁重的指令时发生。
        2. 软件指令之间的依赖关系,表示程序数据流或指令流中的依赖关系限制了性能。

本节讨论常见的优化手段,比如函数内联、向量化和循环优化,优化目标是减少执行指令的总量。

8.2.1 函数内联

内联不仅能消除调用函数的开销,还支持其他优化手段。当编译器内联某个函数时,编译器的分析范围会扩大到更大的代码块。内联缺点是可能会增加编译结果(二进制文件)大小和编译时间。

大多数编译器基于成本模型来决定函数是否内联。例如LLVM基于计算成本和每个函数调用次数的阈值。一般而言:
        1. 小函数(封装)几乎总是内联;
        2. 具有单个调用点的函数更适合内联;
        3. 大型函数基本不会被内联,因为这会使调用方函数的代码膨胀;
        4. 递归函数不能内联自己;
        5. 通过指针调用的函数可以用内联来代替直接调用,但必须保存在二进制文件中。

除了让编译器根据成本模型来决定是否内联函数,开发者也可以给编译器一些特殊提示(C++ 11 gnu::always_inline)。在程序中寻找潜在的内联对象的一种方法是剖析数据,尤其是分析函数的“传参”和“返回”有多频繁。

8.2.2 循环优化

由于循环代表着一段会被执行很多次的代码,因此大部分的执行时间都耗在循环中。通常循环的性能被内存时延、内存带宽或机器的计算能力的一种或多种限制。屋顶线模型是很好的基于硬件理论最大值评估不同循环的入手点,TMA分析是另一种处理这种瓶颈的方法。

首先,讨论只会在循环内部移动代码的低层优化,目的是使循环内部的计算更高效。然后讨论重构循环的高层优化,通常会影响多个循环,旨在提升内存访问,消除内存带宽和内存时延的问题。已发现的循环转换参考文献Cooper & Torczon 2012。

理解给定循环进行那些转换及编译器的那些优化无法取得相应效果,是性能调优成功的关键。

8.2.2.1 低层优化

转换循环内部的代码,有助于提高算术强度的性能:
        1. 循环不变量外提Loop Invariant Code Motion:循环中永远不会改变的表达式移到循环外;
        2. 循环展开Loop Unrolling:好处是每次迭代都要执行更多的操作,提升指令级并行,同时减少循环开销;不建议开发者手动展开任何循环。编译器非常擅长展开循环,并且通常会以最佳方式来展开。其次,借助乱序执行。处理器具有“内嵌的展开器“。
        3. 循环强度折叠Loop Strength Reduction,LSR, 使用开销更小的指令代替开销高的指令,应用于所有循环变量的表达式,应用在数组索引,编译器通过分析变量的值在循环迭代中的演变方式来实现LSR;
        4. 循环判断外提Loop Unswitching, 如果循环内部有不变的判断条件,则可以将它移到循环外。

8.2.2.2 高层优化

此类优化会改变循环的结构并经常会影响多个嵌套循环,旨在提升内存访问性能,消除内存带宽和时延瓶颈,而编译器很难自动且合法地实现高层优化转换:
        1. 循环交换,交换嵌套循环顺序的过程,目的是对多维数组的元素执行顺序内存访问,消除内存带宽和内存时延瓶颈;
        2. 循环分块, 将多维循环执行范围拆分为若干个循环块,使得每块访问的数据可以与CPU缓存大小适配,优化跨步幅访存算法的内存带宽和内存时延;
        3. 循环合并及拆分,当多个独立循环在相同范围内迭代,并且不互相引用彼此的数据时,它们可以融合在一起。循环合并有助于减少循环开销,还可以改善内存访问的时间局部性。然而循环合并并不总能提高性能,有时将循环拆分为多条路径、预过滤数据、对数据进行排序和重组等可能更好。循环拆分有助于解决在大循环中发生的缓存高度争用的问题,还可以减少寄存器压力,借助编译器对小循环进一步单独优化;

8.2.2.3 发现循环优化的机会

编译优化报告高速我们失败的转换,查看基于应用程序的剖析文件生成的汇编代码的热点部分。

合理的优化策略时先尝试容易得优化方案。然后,开发者明确循环中的瓶颈,并基于硬件理论最大值评估性能。可以先使用屋顶线模型指出要分析的瓶颈点,之后尝试各种变换。

本书作者建议尽量依赖编译器做编译优化,仅手动做些必要的转换作为补充。

8.2.2.4 使用循环优化框架

多面体框架检查循环转换的合法性并自动转换循环。Polly是基于 LLVM的高层循环和数据局部性优化器及优化基础设施,它使用基于整数多面体的抽象数学表示来分析和优化程序的内存访问模式。

LLVM基础设施的标准流水线没有启用Polly,需要用户通过显式的编译器选项(-mllvm -polly)来启用它。

8.2.3 向量化

SIMD指令的使用可以大幅提升常规的未向量化代码的运行速度。在性能分析时,最高优先级的工作之一就是确保热点代码被编译器向量化。

本书作者建议让编译器完成向量化工作,仅在需要时根据编译器或者剖析数据获得的反馈进行手动干预。

如果无法让编译器生成所需的汇编指令,则可以使用编译器内建函数重写代码片段。

本书作者观点:使用编译器内建函数的代码类似于内联后的汇编代码,代码很快变得不可读。通常使用编译注解等来调整编译器自动向量化。

编译器会进行3种向量化:内循环自动向量化、外循环向量化和超字向量化。

8.2.3.1 编译器自动向量化

阻碍编译器自动向量化的因素有很多:
        1. 编程语言的固有语义导致,例如编译器必须假设无符号循环索引可能溢出,和C语言中指针可能指向重叠的内存区域。
        2. 处理器向量操作支持,例如大多数处理无法执行预测(掩码控制)的加载和存储操作,和带符号整数到双精度浮点数的向量范围格式支持。

向量化程序通常包含3个阶段:合法性检查、收益检查和转换:
        1. 合法性检查阶段会收集满足循环向量化合法性的需求清单:
                a.循环向量化程序检查循环的迭代是否连续;
                b.循环中的所有内存和算术操作是否都可扩展为连续操作;
                c.所有路径上控制流是否一致;
                d.确保生成的代码不会访问不该访问的内存,并且操作的顺序被保留;
                e.分析可能得指针范围。
        2. 收益检查:
                a. 比较不同的向量化因子识别出让程序运算速度最快的向量化因子。成本包括添加将数据转移到寄存器的指令,预测寄存器压力并估计循环保护成本。
                b. 检查收益的算法很简单,将代码中所有操作的成本相加,比较每个版本的成本,将成本除以预期的执行次数。
        3. 转换:
                a. 在确定转换合法且有收益后,就会转换代码。还会插入启用向量化的保护代码,比如迭代次数除不断。

8.2.3.2 探索向量化的机会

首先分析程序中热点循环,检查编译器已经做了那些优化。最简单的方法是检查编译器向量化标记。当无法向量化循环时,编译器会给出失败原因。其他办法是检查程序的汇编输出,最好是分析剖析工具的输出。经过查看汇编费时,但是该技能是高回报的,因为从汇编代码中可以发现次优代码、如缺乏向量化、次优向量化因子,执行不必要的计算等。

向量化标记可以很清晰地解释出了什么问题以及为什么编译器不能对代码进行向量化。

gcc 10.2输出优化报告(使用参数-fopt-info启用)。

开发者应该意识到向量化代码的隐藏成本,尤其是AVX512会导致大幅度地降频。

对于小循环次数的循环而言,强制向量化程序使用较小的向量化因子或展开计数以减少循环处理的元素数量。

8.3 本章小结

        1. 缓存友好型数据结构、内存预取和利用大内存来提高DTLB性能的常见优化方法。
        2. 现代编译器很擅长通过执行许多不同的代码转换来消除不必要的计算开销。
        3. 讨论了函数内联、循环有啊胡和向量化等常见编译器转换优化。

你可能感兴趣的:(现代CPU性能分析与优化,Bakhvalov,性能优化,计算机体系结构)