绝大部分时候,CPU前端低效可以描述为这样一种情况,后端在等待指令来执行,但是前端不能给后端提供指令。结果就是,没有做任何有意义的工作,CPU时钟周期被浪费了。因为现代处理器是4发射的,所以会有这样一种情况,即4个可用的槽位没有被填满,这也是低效执行的一个原因。实际上,IDQ_UOPS_NOT_DELIVERED性能事件会统计有多少可用的槽位因为前端停顿二没有被利用。TMA使用该性能事件的值来就按它的前端绑定指标。
前端不能被执行单元提供指令的原因有很多,不过通常被归结于缓存利用率和无法从内存中获取指令两类。建议只有当TMA显示较高的 前端绑定 指标时,才开始考虑针对CPU前端的代码优化。
7.1 机器码布局
当编译器将源代码翻译为机器码时,会生成一个串行的字节序列,例如,对于下面的代码。
if (a <= b)
c = 1
编译器会生成如下的汇编代码
cmp rax, rdx
jg .label
汇编指令会被解码,并如下布局在内存中
cmp rax, rdx
jg 40051s
mov rcx, 1
这就是所谓的机器码布局。注意,对同一个程序,可能会以不同的方式布局。给定两个函数foo和bar,我们可以在二进制文件中先放置bar,然后再放foo, 也可以相反的顺序放置。这会影响指令在内存中放置位置的偏移量,也会反击来影响生成的二进制文件的性能。
7.2 基本块
基本块是指只有一个入口和一个出口的指令序列,图40中展示了一个基本块示例。其中MOV指令是入口,JA指令是出口,虽然基本块可以有多个前导和后继,但是在基本块中间没有任何指令可以跳出基本块。
7.3 基本块布局
假设程序中有一个热点路径,
if (cond)
coldFunc()
图41中展示了这段代码的两种可能物理布局,图41a中展示的是在没有任何提示提供的情况下。绝大部分编译器默认产生布局。图41中展示的是如果我们反转判断条件cond并把热点代码放在直通的位置上面产生的布局。
一般情况下,哪个布局更好通常依赖cond是真的还是假的,如果cond通常为真,那么最好的选择默认布局,因为另一个布局需要做两次而不是一次跳转动作。此外,一般情况下,我们会想内联cond判断条件里面的函数。然而,在这个例子中,我们知道coldFUnc 是一个错误处理函数,并且不太可能会被经常执行。通过选择图41b中的布局,我们保持了热点代码间的直通,并且把被选取分支转换为未被选取分支。
对于前面展示的代码,图41中展示的布局表现更好的原因有几个,首先,从本质上将,未被选取的分支比被选取的耗时更少,一般情况下,现代Intel CPU每个时钟周期可以执行两个未被选取的分支,但是每两个时钟周期才能执行一个被选取的分支。
热路径检查条件
是
否 调用coldFunc 可能会被內联
热路径
热路径检查条件
否
是 热路径
调用coldFunc 可能会被內联
其次,图41中的布局可以更充分利用指令和微操作缓存DSB。因为所有热点代码都是连续的,所以没有缓存行碎片化问题;L1指令缓存的所有缓存行都会被热点代码使用,微操作缓存也是一样的,因为它也是基于代码布局进行缓存的。
最后,被选取的分支对于读取单元来说也更耗时。读取单元以16字节=连续块为单位进行读取,所以每个被选取的跳转指令都意味着跳转之后的所有字节都是无用的。这会降低最大的有效读取吞吐量。
为了给编译器提供参考意见以产生优化版本的机器码布局,开发者可以使用
__builtin_expect 提示编译器
if (__builtin_expect(cond, 0))
coldFunc();
开发者通常会使用LIKELY 帮助宏定义来让代码更具有可读性,所以你会发现下面的代码更普遍
if (LIKELY(ptr != nullptr))
7.4 基本块对齐
有时候,性能会由于指令在内存布局中的偏移量而发生明显的变化,我们一起研究下代码清单16中的简单函数。
void benchmark_func(int* a)
{
for (int i = 0; i< 32; ++i)
a[i] += 1;
}
合格的优化编译器可能会为Skylake架构生成如下的机器码
_Z4benchmark_funcPi
mov rax, 0xffffffff80
vpcmpeqd ymm0, ymm0, ymm0
nop DWORD PTR [rax + rax * 1 + 0x0]
vmovdqu ymm1, YMMWORD PTR [rdi + rax * 1 + 0x80]
vpsubd ymm1, ymm1, ymm1
vmovdqu YMMWORD PTR [rdi + rax * 1 + 0x80], ymm1
add rax, 0x20
jne 4046b0
vzeroupper
ret
代码本身对Skylake架构来说是很合理的,但是它的布局并不完美,于循环对应的指令高亮标出。于数据缓存相同,指令缓存行通常也是64字节长的,图42中的粗线框表示缓存行的边界,注意,该循环跨越了多条缓存行;从缓存行0x80 - 0xbf到缓存行0xc0 - 0xff结束。这种情况通常会导致CPU前端出现性能问题,尤其对上面讲到的小循环
。
为解决该问题,我们可以使用NOP指令将循环指令向前移动16个字节,以便让整个循环驻留在一条缓存行中。图42b中用深色阴影区的NOP指令展示这样做的效果,注意,由于基准测试值运行了热点循环,因此可以确定两个缓存行都会驻留在L1指令缓存中,图42b中布局性能更好的原因并不容易解释清楚,涉及相当多的微架构内容,我们本书中不会讨论这些细节
。
尽管CPU架构师努力在设计中隐藏这种瓶颈,但是仍然存在类似的代码布局对性能产生影响的情况。
默认情况下,LLVM编译器识别循环并且按照16字节边界对齐他们。可以使用-mllvm-align-all-blocks编译选项,然而,该选项使用要小心,可能导致性能劣化,插入会被执行到的NOP指令,增加程序开销,尤其当它们在关键路径上时,NOP指令不需要被执行,但是,他们仍然需要被从内存读取,解码和退休。类似所有其他指令,后者会额外消耗前端数据结构和用于记账的缓冲区空间。
为了细粒度的控制对齐,还可以使用ALIGN汇编指令,针对实验场景,开发人员可以先生成汇编列表,然后插入ALIGN指令
.loop
dec rdi
jnz rdi
7.5 函数拆分
思想时把热点代码和冷代码分开,优化对在热点路径中具有复杂CFG和大量冷代码并且相对较大的函数时有益的。代码清单17中展示了该转换可能有收益的样例代码,为了从热路径中移除冷基本块,我们可以把它截取出来并放到一个新的函数中,并调用这个新的函数。