为什么要引入无用的MOV指令可以加速x86_64程序集中的紧密循环?

背景:

当用embedded汇编语言优化一些Pascal代码时,我注意到一个不必要的MOV指令,并将其删除。

令我惊讶的是,删除不必要的指令导致我的程序放慢速度

我发现添加任意的,无用的MOV指令可以进一步提高性能

效果是不稳定的,并根据执行顺序进行更改: 相同的垃圾指令由单行上下移动会产生减速

我了解CPU可以进行各种优化和精简,但是,这看起来更像黑魔法。

数据:

我的代码版本有条件地编译运行2**20==1048576次循环中间的三个垃圾操作 。 (周围的程序只是计算SHA-256散列)。

我的老机器(Intel(R)Core(TM)2 CPU 6400 @ 2.13 GHz)上的结果如下:

 avg time (ms) with -dJUNKOPS: 1822.84 ms avg time (ms) without: 1836.44 ms 

程序循环运行25次,每次运行顺序随机更改。

摘抄:

 {$asmmode intel} procedure example_junkop_in_sha256; var s1, t2 : uint32; begin // Here are parts of the SHA-256 algorithm, in Pascal: // s0 {r10d} := ror(a, 2) xor ror(a, 13) xor ror(a, 22) // s1 {r11d} := ror(e, 6) xor ror(e, 11) xor ror(e, 25) // Here is how I translated them (side by side to show symmetry): asm MOV r8d, a ; MOV r9d, e ROR r8d, 2 ; ROR r9d, 6 MOV r10d, r8d ; MOV r11d, r9d ROR r8d, 11 {13 total} ; ROR r9d, 5 {11 total} XOR r10d, r8d ; XOR r11d, r9d ROR r8d, 9 {22 total} ; ROR r9d, 14 {25 total} XOR r10d, r8d ; XOR r11d, r9d // Here is the extraneous operation that I removed, causing a speedup // s1 is the uint32 variable declared at the start of the Pascal code. // // I had cleaned up the code, so I no longer needed this variable, and // could just leave the value sitting in the r11d register until I needed // it again later. // // Since copying to RAM seemed like a waste, I removed the instruction, // only to discover that the code ran slower without it. {$IFDEF JUNKOPS} MOV s1, r11d {$ENDIF} // The next part of the code just moves on to another part of SHA-256, // maj { r12d } := (a and b) xor (a and c) xor (b and c) mov r8d, a mov r9d, b mov r13d, r9d // Set aside a copy of b and r9d, r8d mov r12d, c and r8d, r12d { a and c } xor r9d, r8d and r12d, r13d { c and b } xor r12d, r9d // Copying the calculated value to the same s1 variable is another speedup. // As far as I can tell, it doesn't actually matter what register is copied, // but moving this line up or down makes a huge difference. {$IFDEF JUNKOPS} MOV s1, r9d // after mov r12d, c {$ENDIF} // And here is where the two calculated values above are actually used: // T2 {r12d} := S0 {r10d} + Maj {r12d}; ADD r12d, r10d MOV T2, r12d end end; 

自己尝试一下:

如果你想自己尝试,代码在GitHub上线 。

我的问题:

  • 为什么无用地将寄存器的内容复制到RAM中会提高性能?
  • 为什么同样无用的指令会在某些方面加快速度,而其他方面又会放慢呢?
  • 这种行为是否可以被编译器预测地利用?

速度提高最可能的原因是:

  • 插入一个MOV会将随后的指令转移到不同的存储器地址
  • 其中一条移动的指令是一个重要的条件分支
  • 该分支由于分支预测表中的混叠而被错误地预测
  • 移动分支消除了别名,并允许分支被正确预测

您的Core2不会为每个条件跳转保留一个单独的历史logging。 相反,它保持所有条件跳转的共享历史logging。 全球分支预测的一个缺点是,如果不同的条件跳转是不相关的,则历史被不相关的信息所稀释。

这个小分支预测教程显示了分支预测缓冲区如何工作。 caching缓冲区由分支指令的地址的下部分索引。 除非两个重要的不相关分支共享相同的较低位,否则这种方法运行良好。 在这种情况下,最后会出现锯齿,导致许多预测失误的分支(拖延指令stream水线并放慢您的程序)。

如果你想了解分支预测错误如何影响性能,请看这个优秀的答案: https : //stackoverflow.com/a/11227902/1001643

编译器通常没有足够的信息来知道哪些分支将会被别名,以及这些别名是否是重要的。 但是,可以使用Cachegrind和VTune等工具在运行时确定这些信息。

您可能需要阅读http://research.google.com/pubs/pub37077.html

TL; DR:在程序中随机插入nop指令可以轻松地将性能提高5%甚至更多,编译器不会轻易利用这一点。 它通常是分支预测器和caching行为的组合,但也可以是例如保留站停顿(即使没有依赖链被破坏或明显的资源超额订阅)。

我相信在现代CPU中,汇编指令是程序员向CPU提供执行指令的最后一个可见层,实际上是CPU实际执行的几个层。

现代CPU是RISC / CISC混合体系,可将CISC x86指令转换为更具RISC性能的内部指令。 此外,还有乱序执行分析器,分支预测器,英特尔的“微操作器融合”(micro-ops fusion),试图将指令分成大批同时工作(类似于VLIW / Itanium泰坦尼克号)。 甚至有caching边界可以让代码运行得更快 – 为什么如果它更大(也许caching控制器更聪明地插入它,或者保持更长的时间)。

CISC一直有一个汇编到微码的翻译层,但问题是现代的CPU要复杂得多。 随着现代半导体制造工厂中所有额外的晶体pipe房地产,CPU可能可以并行地应用几种优化方法,然后select提供最佳加速的那一种。 额外的指令可能会偏向于使用一个优于其他优化path的CPU。

额外指令的效果可能取决于CPU型号/代号/制造商,不太可能是可预测的。 以这种方式优化汇编语言将需要在许多CPU架构的代中执行,也许使用特定于CPU的执行path,并且只对真正非常重要的代码段是可取的,尽pipe如果你正在进行汇编,你可能已经知道了。

准备caching

将操作移动到内存可以准备caching并使后续的移动操作更快。 一个CPU通常有两个负载单元和一个存储单元。 一个加载单元可以从存储器读入一个寄存器(每个周期读一次),一个存储单元从寄存器存储到存储器。 还有其他单位在登记册之间进行操作。 所有单位并行工作。 所以,在每个周期,我们可以同时进行多个操作,但是不能多于两个负载,一个存储和多个寄存器操作。 通常最多4个简单操作,包括简单寄存器,最多3个XMM / YMM寄存器的简单操作,以及1-2个与任何寄存器的复杂操作。 你的代码有很多操作寄存器,所以一个虚拟内存存储操作是空闲的(因为总共有4个以上的寄存器操作),但是它为后续的存储操作准备了内存高速caching。 要了解内存存储如何工作,请参阅“ Intel 64和IA-32体系结构优化参考手册” 。

打破错误的依赖关系

虽然这并不完全指向你的情况,但有时在64位处理器下使用32位mov操作(就像你的情况一样)是用来清除更高位(32-63)并打破依赖关系链。

众所周知,在x86-64下,使用32位操作数清除64位寄存器的较高位。 请阅读“ 英特尔®64和IA-32架构软件开发人员手册”卷1的相关部分 – 3.4.1.1:

32位操作数产生一个32位结果,在目标通用寄存器中零扩展为一个64位结果

所以,看起来毫无用处的mov指令清除了相应寄存器的较高位。 它给了我们什么? 它打破了依赖链,允许指令以自由顺序并行执行,自1995年以来由Pentium Pro在CPU内部实现的乱序( Out-of-Order)algorithm 。

英特尔 ®64 和IA-32架构优化参考手册的报价,第3.5.1.8节:

修改部分寄存器的代码序列可以在其依赖链中经历一些延迟,但是可以通过使用依赖关系破译习语来避免。 在基于英特尔酷睿微架构的处理器中,当软件使用这些指令将寄存器内容清零时,许多指令可以帮助清除执行相关性。 通过操作32位寄存器而不是部分寄存器来中断指令间寄存器部分的依赖关系。 对于移动,这可以通过32位移动或使用MOVZX来完成。

汇编/编译器编码规则37.(M影响,MH通用性) :通过操作32位寄存器而不是部分寄存器来中断指令间寄存器部分的依赖关系。 对于移动,这可以通过32位移动或使用MOVZX来完成。

带有32位操作数的MOVZX和MOV是相同的 – 它们都会打断依赖链。

这就是为什么你的代码执行更快。 如果没有依赖关系,CPU可以在内部对寄存器进行重命名,即使乍一看看第二条指令可能会修改第一条指令所使用的寄存器,但二条指令并不能并行执行。 但由于寄存器重命名,他们可以。

寄存器重命名是CPU内部使用的一种技术,消除了由于在它们之间没有任何实际数据相关性的连续指令而重复使用寄存器而引起的错误数据依赖性。

我想你现在看到这太明显了。