CPU性能优化--前端优化

发布于:2024-12-22 ⋅ 阅读:(16) ⋅ 点赞:(0)

绝大部分时候,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

这就是所谓的机器码布局注意同一个程序可能会不同方式布局给定两个函数foobar我们可以二进制文件中放置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中展示了该转换可能有收益的样例代码,为了从热路径中移除冷基本块,我们可以把它截取出来并放到一个新的函数中,并调用这个新的函数。