谢谢你的好问题!
清除寄存器和依赖打破习惯用法
来自Intel® 64 and IA-32 Architectures
Optimization Reference Manual 的引述,第 3.5.1.8 节:
修改部分寄存器的代码序列可能会在其依赖链中遇到一些延迟,但可以通过使用依赖破坏习惯用法来避免。在基于英特尔酷睿微架构的处理器中,当软件使用这些指令将寄存器内容清零时,许多指令可以帮助清除执行依赖性。通过操作 32 位寄存器而不是部分寄存器来打破指令之间对寄存器部分的依赖。为了
移动,这可以通过 32 位移动或使用 MOVZX 来完成。
汇编/编译器编码规则 37。(M 影响,MH 通用性):通过对 32 位寄存器而不是部分寄存器进行操作,打破指令之间对寄存器部分的依赖。对于移动,这可以通过 32 位移动或使用 MOVZX 来完成。
movzx 与 mov
编译器知道 movzx 并不昂贵,并尽可能频繁地使用它。对 movzx 进行编码可能比 mov 需要更多的字节,但执行起来并不昂贵。
与逻辑相反,带有 movzx(填充整个寄存器)的程序实际上比仅使用 mov 的程序运行得更快,后者只设置寄存器的较低部分。
让我在下面的代码片段上向你证明这个结论:
movzx ecx, bl
shr ebx, 8
mov eax, dword ptr [ecx * 4 + edi + 1024 * 3]
movzx ecx, bl
shr ebx, 8
xor eax, dword ptr [ecx * 4 + edi + 1024 * 2]
movzx ecx, bl
shr ebx, 8
xor eax, dword ptr [ecx * 4 + edi + 1024 * 1]
skipped 6 more similar triplets that do movzx, shr, xor.
dec <<<a counter register >>>>
jnz …… <<repeat the whole loop again>>>
这是第二个代码片段。我们已经提前清除了 ecx,现在只使用“mov cl, bl”代替“movzx ecx, bl”:
// ecx is already cleared here to 0
mov cl, bl
shr ebx, 8
mov eax, dword ptr [ecx * 4 + edi + 1024 * 3]
mov cl, bl
shr ebx, 8
xor eax, dword ptr [ecx * 4 + edi + 1024 * 2]
mov cl, bl
shr ebx, 8
xor eax, dword ptr [ecx * 4 + edi + 1024 * 1]
<<< and so on – as in the example #1>>>
现在猜猜以上两个代码片段中哪个运行得更快?之前是不是觉得速度是一样的,还是movzx版本比较慢?事实上,movzx 代码更快,因为自 Pentium Pro 以来的所有 CPU 都进行指令的乱序执行和寄存器重命名。
注册重命名
寄存器重命名是 CPU 内部使用的一种技术,它消除了由连续指令重用寄存器引起的错误数据依赖性,这些指令之间没有任何实际数据依赖关系。
让我从第一个代码片段中提取前 4 条指令:
-
movzx ecx, bl
-
shr ebx, 8
-
mov eax, dword ptr [ecx * 4 + edi + 1024 * 3]
-
movzx ecx, bl
如你所见,指令 4 依赖于指令 2。指令 4 不依赖于指令 3 的结果。
所以 CPU 可以并行(一起)执行指令 3 和 4,但是指令 3 使用指令 4 修改的寄存器(只读),因此指令 4 只能在指令 3 完全完成后开始执行。然后让我们在第一个三元组之后将寄存器 ecx 重命名为 edx 以避免这种依赖性:
movzx ecx, bl
shr ebx, 8
mov eax, dword ptr [ecx * 4 + edi + 1024 * 3]
movzx edx, bl
shr ebx, 8
xor eax, dword ptr [edx * 4 + edi + 1024 * 2]
movzx ecx, bl
shr ebx, 8
xor eax, dword ptr [ecx * 4 + edi + 1024 * 1]
这是我们现在拥有的:
-
movzx ecx, bl
-
shr ebx, 8
-
mov eax, dword ptr [ecx * 4 + edi + 1024 * 3]
-
movzx edx, bl
现在指令 4 绝不会使用指令 3 所需的任何寄存器,反之亦然,因此指令 3 和 4 肯定可以同时执行!
这就是 CPU 为我们做的事情。 CPU在将指令转换为乱序算法将执行的微操作(微操作)时,会在内部重命名寄存器以消除这些依赖性,因此微操作处理重命名的内部寄存器,而不是我们所知道的真实的人。因此我们不需要像我刚刚在上面的示例中重命名的那样自己重命名寄存器 - CPU 会在将指令转换为微操作时自动为我们重命名所有内容。
指令 3 和指令 4 的微操作将并行执行,因为指令 4 的微操作将处理与指令 3 的微操作完全不同的内部寄存器(暴露在外部作为 ecx),所以我们不需要重命名。
让我将代码恢复为初始版本。这里是:
-
movzx ecx, bl
-
shr ebx, 8
-
mov eax, dword ptr [ecx * 4 + edi + 1024 * 3]
-
movzx ecx, bl
(指令 3 和 4 并行运行,因为指令 3 的 ecx 不是指令 4 的 ecx,而是一个不同的重命名寄存器 - CPU 已自动为指令 4 微操作分配一个新的新寄存器,来自内部可用寄存器池)。
现在让我们回到 movxz vs mov。
Movzx 完全清除一个寄存器,因此 CPU 肯定知道我们不依赖任何保留在寄存器高位中的先前值。当 CPU 看到 movxz 指令时,它知道它可以在内部安全地重命名寄存器,并与之前的指令并行执行该指令。现在从我们的示例 #2 中获取前 4 条指令,其中我们使用 mov 而不是 movzx:
-
mov cl, bl
-
shr ebx, 8
-
mov eax, dword ptr [ecx * 4 + edi + 1024 * 3]
-
mov cl, bl
在这种情况下,指令 4 通过修改 cl 修改了 ecx 的 0-7 位,而 8-32 位保持不变。因此,CPU 不能只重命名指令 4 的寄存器并分配另一个新寄存器,因为指令 4 依赖于先前指令留下的位 8-32。 CPU 在执行指令 4 之前必须保留 8-32 位。因此它不能只是重命名寄存器。它将等到指令 3 完成后再执行指令 4。指令 4 没有变得完全独立 - 它取决于 ECX 的先前值 和 bl 的先前值。所以它一次取决于两个寄存器。如果我们使用了 movzx,它将只依赖于一个寄存器 - bl。因此,指令 3 和 4 不会因为相互依赖而并行运行。悲伤但真实。
这就是为什么操作完整的寄存器总是更快的原因。假设我们只需要修改寄存器的一部分。在这种情况下,更改整个寄存器总是更快(例如,使用 movzx)——让 CPU 确定该寄存器不再依赖于其先前的值。修改完整的寄存器可以让CPU重命名寄存器,让乱序执行算法与其他指令一起执行这条指令,而不是一个接一个地执行。