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

Why would introducing useless MOV instructions speed up a tight loop in x86_64 assembly?

背景:

在使用嵌入式汇编语言优化某些Pascal代码时,我注意到一条不必要的MOV指令,并将其删除。

令我惊讶的是,删除了不必要的指令导致我的程序慢下来。

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

这种效果是不稳定的,并且基于执行顺序的更改:相同的垃圾指令被一行上下转置会导致速度减慢。

我知道CPU可以进行各种优化和简化,但这更像是黑色魔法。

数据:

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

我的旧机器(Intel(R)Core(TM)2 CPU [email protected] GHz)上的结果:

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

程序循环运行25次,每次运行顺序随机变化。

Excerpt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
{$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不会为每个条件跳转保留单独的历史记录。相反,它保留了所有条件跳转的共享历史记录。全局分支预测的一个缺点是,如果不同的条件跳跃不相关,则历史会被无关信息稀释。

这个小的分支预测教程展示了分支预测缓冲区是如何工作的。缓存缓冲区由分支指令地址的较低部分索引。除非两个重要的不相关分支共享相同的低位,否则这会很好地工作。在这种情况下,最终会出现别名,这会导致许多预测错误的分支(这会中断指令管道并减慢程序的运行速度)。

如果您想了解分支预测失误如何影响性能,请看下面这个极好的答案:https://stackoverflow.com/a/11227902/1001643

编译器通常没有足够的信息来知道哪些分支将进行别名,以及这些别名是否重要。但是,这些信息可以在运行时使用诸如cachegrind和vtune之类的工具来确定。


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

tl;dr:在程序中随机插入nop指令可以很容易地将性能提高5%或更多,不,编译器不能轻易地利用这一点。它通常是分支预测器和缓存行为的组合,但也可以是预订站暂停(即使没有中断的依赖链或明显的订阅资源)。


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

现代CPU是RISC/CISC的混合体,它将CISC x86指令转换为内部指令,这些指令在行为上更具RISC性。此外,还有无序执行分析器、分支预测器、英特尔的"微操作融合",试图将指令分组成更大的同时工作批次(类似于VLIW/Itanium Titanic)。甚至还有缓存边界可以让代码更快地运行,因为天知道为什么它会更大(可能是缓存控制器更智能地放置它,或者让它保持更长的时间)。

CISC一直都有一个汇编到微码的翻译层,但重点是,对于现代CPU来说,事情要复杂得多。由于现代半导体制造厂的所有额外晶体管房地产,CPU可能会并行应用多种优化方法,然后选择最终提供最佳加速的方法。额外的指令可能会使CPU偏向于使用一个比其他优化路径更好的优化路径。

额外指令的效果可能取决于CPU型号/生成/制造商,并且不太可能是可预测的。以这种方式优化汇编语言需要对许多CPU体系结构代执行,可能使用特定于CPU的执行路径,并且只需要对真正重要的代码部分执行,尽管如果您正在进行汇编,您可能已经知道了这一点。


正在准备缓存

将操作移动到内存可以准备缓存并使后续移动操作更快。一个CPU通常有两个加载单元和一个存储单元。加载单元可以从存储器读取到寄存器(每个周期读取一次),存储单元可以从寄存器存储到存储器。还有其他的单元在寄存器之间进行操作。所有的单元都是并行工作的。因此,在每个循环中,我们可以一次执行几个操作,但不超过两个加载、一个存储和几个寄存器操作。通常情况下,使用普通寄存器最多可执行4个简单操作,使用xmm/ymm寄存器最多可执行3个简单操作,使用任何类型的寄存器最多可执行1-2个复杂操作。您的代码有很多带寄存器的操作,所以一个虚拟内存存储操作是空闲的(因为仍然有4个以上的寄存器操作),但它为随后的存储操作准备内存缓存。要了解内存存储的工作原理,请参阅《英特尔64和IA-32体系结构优化参考手册》。

打破错误的依赖关系

虽然这并不完全适用于您的情况,但有时在64位处理器下使用32位MOV操作(与您的情况一样)用于清除较高的位(32-63)并断开依赖链。

众所周知,在x86-64下,使用32位操作数清除64位寄存器的高位。请阅读英特尔的相关章节-3.4.1.1-?64和IA-32体系结构软件开发人员手册第1卷:

32-bit operands generate a 32-bit result, zero-extended to a 64-bit result in the destination general-purpose register

因此,MOV指令(乍一看似乎没用)清除了相应寄存器的高位。它给了我们什么?它打破了依赖链,允许指令以随机顺序并行执行,这是由1995年Pentium Pro以来的CPU内部实现的无序算法。

英特尔的报价?64和IA-32体系结构优化参考手册,第3.5.1.8节:

Code sequences that modifies partial register can experience some delay in its dependency chain, but can be avoided by using dependency breaking idioms. In processors based on Intel Core micro-architecture, a number of instructions can help clear execution dependency when software uses these instruction to clear register content to zero. Break dependencies on portions of registers between instructions by operating on 32-bit registers instead of partial registers. For
moves, this can be accomplished with 32-bit moves or by using MOVZX.

Assembly/Compiler Coding Rule 37. (M impact, MH generality): Break dependencies on portions of registers between instructions by operating on 32-bit registers instead of partial registers. For moves, this can be accomplished with 32-bit moves or by using MOVZX.

对于X64,带有32位操作数的movzx和mov是等效的-它们都会破坏依赖链。

这就是为什么代码执行得更快。如果没有依赖项,CPU可以在内部重命名寄存器,即使第一眼看到第二条指令似乎修改了第一条指令使用的寄存器,但这两条指令不能并行执行。但由于注册重命名,他们可以。

寄存器重命名是CPU内部使用的一种技术,它通过连续的指令(它们之间没有任何实际的数据依赖关系)重复使用寄存器来消除错误的数据依赖关系。

我想你现在明白这太明显了。