第7章 CPU前端优化

接下来讨论如何使用CPU监控特性寻找CPU上运行的代码中可被调优的位置。

标准的算法和数据结构在性能敏感型工作负载并不总能表现的很好。例如,在“扁平化”数据结构的冲击下,链表基本上快被放弃了。传统链表中的每个节点都是动态分配的,除了引入耗时的内存分配操作,还可能让链表中所有元素分散在内存中,导致随机内存访问。

二分搜索在排序数组中查找元素方面是最优的,但是该算法经常会有很多分支预测错误的问题,这就是为何线性搜索在小型(少于20个元素)整型数组上表现得最好。

本章尝试专注于CPU微架构相关的优化,而不是覆盖所有你能想到的优化机会、不过也有必要列出上层的优化点:
        1. 使用开销更低的语言重写程序的性能关键部分;
        2. 分析程序中使用的算法和数据结构;
        3. 调优编译器参数,检查至少使用了-O3(与机器无关的优化功能)、 -march(启用针对特定CPU系列的优化功能)和-flto(启用过程间优化功能);
        4. 如果问题是高度并行化的计算,考虑把程序线程化或者放到GPU上运行;
        5. 当等待IO操作时,使用同步IO以避免阻塞;
        6. 利用更多的RAM来减少必须使用的CPU和IO量(记忆、查找表、数据缓存、压缩等);

数据驱动优化

数据驱动的优化是最重要的调优技术之一,它基于对程序正在处理的数据的洞察,聚焦于数据的分布及其在程序中的转换方式。典型的有SOA和AOS数据布局。如果程序遍历数据结构并且只访问指端b,那么SOA会更好。然而如果程序遍历数据结构并且访问该对象的所有字段都需要进行许多操作,那么AOS会更好。因为该数据的所有成员可能都会保留在相同的缓存行里。

另一个非常重要的数据驱动的优化是“小尺寸优化”,其理念是提前为容器分配一定量的内存,以避免动态内存分配。这对元素数据上限可以预测的中小尺寸容器非常有用。

实现的优化不一定对所有平台都有效果。例如循环阻塞非常依赖系统内存的层次特征,尤其是L2和L3缓存大小。在程序将要运行的平台上测试这些变化是非常重要的。

CPU前端低效指后端在等待指令来执行,但是前端不能给后端提供指令,原因归类为2种:缓存利用率和无法从内存中获取指令。建议只有当TMA显式较高的“前端bound”指标(大于20%)时,才关注CPU前端的代码优化。

7.1 机器码布局

当编译器将源代码翻译为机器码时,它会生成一个串行的字节列。其中指令在内存中放置的偏移位置,也会反过来影响二进制文件的性能。

7.2 基本块

基本块是指只有一个入口和一个出口的指令序列。虽然基本块可以有多个前导和后继,但是在基本块中间没有任何指令可以跳出基本块,保证基本块中的每条代码只会被执行1次,能大大地减少控制流图分析和转化的问题。

7.3 基本块布局

// hot path
if (cond)
    coldFunc();
// hot path again

如果cond通常为真,那么就选默认布局。因为另一个布局通常做2次而不是1次跳转。但是coldFunc是一个错误处理函数,并且不太可能会被经常执行,选择保持热点代码间的直通,并且把选取分支转化为未被选取分支。

选择热点代码间的直通的布局有原因如下:
        1. 未被选取的分支比被选取时耗时更少。一般情况下,Intel CPU每个时钟可以执行2个未被选择的分支,但是每2个时钟周期才能执行一个被选取的分支。
        2. 更充分利用指令和微操作缓存。因为所有热点代码都是连续的,所以没有缓存行碎片化问题。
        3. 被选取的分支对于读取单元来说也更耗时。每个被选取的跳转指令都意味着跳转之后的字节都是都无效的。

可以使用__builtin_expect(cond, 0)注解告诉编译器概率高低。

7.4 基本块对齐

性能会由于指令在内存中的偏移量而发生明显的变化。若循环跨越多条缓存行,可能会导致CPU前端出现性能问题,所以我们可以使用nop指令将循环指令向前移动,以便让整个循环驻留在一条缓存行中。

LLVM使用-mllvm-align-all-blocks对齐基本块,注意它们可能导致性能劣化,插入nop指令,会增加程序的开销,尤其是当它们处于关键路径上。nop指令不需要被执行,但是它们仍然需要从内存中读取、解码和退休,额外地消耗前端数据结构和用于记账的缓冲区空间。

为了细粒度地控制对齐,还可以使用ALIGN汇编指令,针对实验场景,开发人员先生成汇编列表,然后插入ALIGN指令。

7.5 函数拆分

函数拆分的设想是把热点代码和冷代码区分开,该优化对在热路径中具有复杂CFG和大量冷代码的函数是有益的。

void foo(bool cond1, bool cond2) {
    // hot path
    if (cond1) {
        //large amount of cold code cond1
    }
    // hot path
    if (cond2) {
        //large amount of cold code cond2
    }
}

// 优化后
void foo(bool cond1, bool cond2) {
    // hot path
    if (cond1) {
        cold1()
    }
    // hot path
    if (cond2) {
        cold2()
    }
}

void cold1() __attribute_((noinline)) { // cold code (1)};
void cold2() __attribute_((noinline)) { // cold code (2)};

图中我们只保存了热路径的call指令,所以下一个热点代码指令可能会驻留在相同的缓存行,提升CPU前端数据结构(指令缓存和DSB)的利用率。留意其中的另一个重要思想:禁止内联冷函数。最后,创建的新函数要放在.text段之外。如果从不调用该函数,俺么它不会在运行时加载到内存中,所以可能会改善内存占用情况。

7.6 函数分组

热点函数可以被分组在一起以进一步提升CPU前端缓存的利用率,减少需要读取缓存行的数量。

链接器负责程序在最终的二进制输出中所有函数的排列布局。LLVM的LLD链接器使用--symbol-ordering-file优化函数的布局。

HFSort工具基于剖析数据自动生成分区排序文件。

7.7 基于剖析文件的编译优化

大多数编译器都有一组转换功能,可以根据反馈给它们的剖析数据来调整算法,被称为基于剖析文件的编译优化Profile Directed Optimization,PGO。

剖析数据生成方式有二:代码插桩核基于采样的剖析。
        1. 先利用LLVM编译器使用-fprofile-instr-generate告诉编译器生成插桩代码。然后LLVM编译器使用-fprofile-inst-use利用剖析数据重新编译程序,并生成PGO调优的二进制文件。
        2. 基于采样生成编译器所需的剖析数据。然后AutoFDO把linux perf生成的采样数据转换为GCC和LLVM的编译器可以理解的形式。不过编译器会假设所有负载的表现都一样。

7.8 对ITLB的优化

内存地址中虚地址到物理地址的翻译是前端性能调优的另一个重要领域。通过把应用程序的性能关键代码部分地映射到大页上,可以减少ITLB压力。这需要重新链接二进制文件,在合适的页边界对齐代码段。除了使用大页,用于优化指令缓存性能的标准技术也可以提升ITLB性能,即重排函数让热点函数更集中,通过LTO/IPO减小热点区域的大小,使用PGO并避免过度内联。

7.9 总结

转换 如何转换 优点 应用场景 执行者
基本块布局 维护热点代码的直通 未被选取的分支耗时更少;缓存利用率更高 任何代码,尤其是由很多分支的代码 编译器
基本块对齐 使用NOP指令对热点代码进行移位 缓存利用率更高 热点循环 编译器
函数拆分 把冷代码拆分出来并放到单独的函数中 缓存利用率更高 当在热代码间存在大段冷代码的函数时,具有复杂CFG的函数 编译器
函数分组 把热点函数分组到一起 缓存利用率更高 有很多热点小函数 链接器

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