为什么引入无用的MOV指令会加速x86_64汇编中的紧密循环?


222

背景:

在使用嵌入式汇编语言优化某些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会提高性能?
  • 为何同一条无用的指令在某些行上会加速,而在另一些行上会减速呢?
  • 这种行为是否可以被编译器预测地利用?

7
各种各样的“无用”指令实际上可以用来打破依赖关系链,将物理寄存器标记为已退休,等等。利用这些操作需要对微体系结构有一定的了解。您的问题应提供简短的说明序列作为最小示例,而不是将人们引导到github。
Brett Hale 2013年

1
@BrettHale好点,谢谢。我添加了一段代码摘录和一些注释。即使稍后使用寄存器中的值,复制寄存器的值以将其标记为已退役也可以吗?
tangentstorm

9
您可以将标准差放在这些平均值上吗?这篇文章中没有任何实际迹象表明存在真正的区别。
2013年

2
您可以尝试使用rdtscp指令对指令进行计时,并检查两个版本的时钟周期吗?
jakobbotsch 2013年

2
也可能是由于内存对齐?我不是自己做数学的(懒惰的:P),但是添加一些虚拟指令可能会使您的代码与内存对齐……
LorenzoDematté2013年

Answers:


144

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

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

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

这个小分支预测教程展示了分支预测缓冲区如何工作。高速缓存缓冲区由分支指令的地址的下部索引。除非两个重要的不相关分支共享相同的低位,否则这将很好地工作。在这种情况下,您最终会产生别名,从而导致许多错误预测的分支(这会使指令流水线停滞并减慢程序速度)。

如果您想了解分支错误预测如何影响性能,请查看以下出色答案:https : //stackoverflow.com/a/11227902/1001643

编译器通常没有足够的信息来知道哪个分支将成为别名以及这些别名是否有意义。但是,可以使用CachegrindVTune之类的工具在运行时确定该信息。


2
嗯 这听起来很有希望。此sha256实现中唯一的条件分支是对FOR循环结束的检查。当时,我将此版本标记为git的古怪之处,并继续进行优化。我接下来的步骤之一是自己在汇编中重写pascal FOR循环,这时这些额外的指令不再具有积极的作用。也许免费的pascal生成的代码比我用它替换的简单计数器更难让处理器预测。
tangentstorm

1
@tangentstorm听起来不错。分支预测表不是很大,因此一个表项可能引用多个分支。这可能会使某些预测无效。如果冲突的分支之一移至表的另一部分,则可以轻松解决此问题。几乎任何微小的变化都可以使这种情况发生:-)
Raymond Hettinger

1
我认为这是我观察到的特定行为的最合理的解释,因此我将其标记为答案。谢谢。:)
tangentstorm

3
关于Bochs的参与者之一的类似问题,有一个绝对出色的讨论,您可能需要将其添加到答案中:emulators.com/docs/nx25_nostradamus.htm
leander

3
INSN对齐的重要性远不止分支目标。解码瓶颈对于Core2和Nehalem来说是一个巨大的问题:它通常很难使执行单元保持忙碌状态。Sandybridge引入了uop缓存,从而极大地提高了前端吞吐量。由于这个问题,已经完成了对齐分支目标的操作,但是它会影响所有代码。
彼得·科德斯

80

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

TL; DR:在程序中随机插入nop指令可以很容易地将性能提高5%或更多,并且不能,编译器无法轻松利用它。它通常是分支预测器和缓存行为的组合,但也可以是例如保留站停顿(即使万一没有任何依赖链中断或明显的资源超额预订)。


1
有趣。但是处理器(或FPC)是否足够聪明,以至于在这种情况下,写入ram是NOP吗?
tangentstorm

8
汇编程序未优化。
Marco van de Voort,

5
编译器可以通过进行难以置信的昂贵优化,例如重复构建和分析,然后使用模拟退火或遗传算法来改变编译器输出,来利用它。我已经阅读了有关该领域的一些工作。但是我们说的是至少要花5到10分钟的时间来编译100%CPU,而最终的优化可能是针对CPU核心模型,甚至是特定于核心或微码版本的。
AdamIerymenko

我不会称其为随机NOP,它们解释了为什么NOP会对性能产生积极影响(tl; dr:stackoverflow.com/a/5901856/357198),而随机插入NOP确实会导致性能下降。本文有趣的是,GCC消除了“战略性” NOP对整体性能没有影响!
PuercoPop 2013年

15

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

现代CPU是RISC / CISC混合体,可将CISC x86指令转换为内部指令,这些指令在行为上更像RISC。另外,还有乱序的执行分析器,分支预测器,英特尔的“微操作融合”,它们试图将指令分组为更大数量的同时工作(有点像VLIW / Itanium titanic)。甚至还有高速缓存边界,这可能会使代码运行得更快,这真是令人难以理解-为什么它更大(也许高速缓存控制器更智能地分配它,或者将其保留更长的时间)。

CISC一直都有一个程序集到微代码的转换层,但关键是现代CPU的处理要复杂得多。利用现代半导体制造工厂中所有多余的晶体管资源,CPU可能可以并行应用多种优化方法,然后在最后选择提供最佳加速的方法。额外的指令可能会使CPU偏向使用比其他优化路径更好的一个优化路径。

额外指令的效果可能取决于CPU型号/版本/制造商,并且不太可能是可预测的。以这种方式优化汇编语言将需要针对许多CPU架构一代执行,也许使用特定于CPU的执行路径,并且仅对于真正重要的代码段才是理想的,尽管如果您正在汇编,您可能已经知道这一点。


6
您的答案有点令人困惑。尽管您所说的大部分内容都是正确的,但在很多地方似乎都在猜测。
alcuadrado

2
也许我应该澄清一下。我感到困惑的是缺乏确定性
alcuadrado

3
合理而合理的猜测是完全有效的。
jturolla

7
除非是英特尔的工程师能够使用特殊的诊断设备,否则没有人真正确定OP为何会观察到这种奇怪的行为。因此,所有其他人只能做猜测。那不是@cowarldlydragon的错。
Alex D

2
下注; 您所说的内容都无法解释OP的行为。您的答案是无用的。
fuz 2015年

0

准备缓存

将操作移动到内存可以准备缓存,并使后续的移动操作更快。一个CPU通常有两个装载单元和一个存储单元。加载单元可以从内存中读取到寄存器中(每个周期读取一次),存储单元可以将寄存器中的内容存储到内存中。也有其他单元在寄存器之间进行操作。所有单元并行工作。因此,在每个周期中,我们可以一次执行多个操作,但最多可以执行两个加载,一个存储和多个寄存器操作。通常,使用普通寄存器最多可以进行4个简单操作,使用XMM / YMM寄存器最多可以进行3个简单操作,使用任何类型的寄存器最多可以进行1-2个复杂操作。您的代码对寄存器有很多操作,因此一个虚拟内存存储操作是免费的(因为无论如何有4个以上的寄存器操作),但是它为随后的存储操作准备了内存缓存。要了解内存存储的工作方式,请参阅英特尔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内部实现的乱序算法以随机顺序并行执行。

引用《英特尔 ®64 和IA-32架构优化参考手册》第3.5.1.8节:

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

汇编/编译器编码规则37.(M影响,MH通用性):通过对32位寄存器(而非部分寄存器)进行操作,可以打破指令之间对寄存器部分的依赖性。对于移动,可以通过32位移动或使用MOVZX来完成。

具有x64的32位操作数的MOVZX和MOV是等效的-它们都打破了依赖链。

这就是为什么您的代码执行速度更快的原因。如果没有依赖性,CPU可以在内部对寄存器进行重命名,即使乍一看似乎第二条指令已修改了第一条指令所使用的寄存器,并且两者无法并行执行。但是由于寄存器重命名,他们可以。

寄存器重命名是CPU内部使用的一种技术,它消除了连续指令对寄存器的重用所引起的错误数据依赖性,这些指令之间没有任何实际的数据依赖性。

我认为您现在看到的太明显了。


都是如此,但与问题中显示的代码无关。
科迪·格雷

@CodyGray-感谢您的反馈。我已经编辑了回复,并增加了有关此情况的章节-由寄存器操作包围的内存移动准备了缓存,并且由于存储单元始终处于空闲状态,因此它是免费的。因此,后续存储操作将更快。
Maxim Masiutin

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.