编译器生成昂贵的 MOVZX 指令

Compiler generates costly MOVZX instruction

我的探查器已将以下函数探查识别为热点。

typedef unsigned short ushort;

bool isInteriorTo( const std::vector<ushort>& point , const ushort* coord , const ushort dim )
{
    for( unsigned i = 0; i < dim; ++i )
    {
        if( point[i + 1] >= coord[i] ) return false;
    }

    return true;  
}

特别是一条汇编指令 MOVZX (Move with Zero-Extend) 负责大部分运行时间。 if语句编译成

mov     rcx, QWORD PTR [rdi]
lea     r8d, [rax+1]
add     rsi, 2
movzx   r9d, WORD PTR [rsi-2]
mov     rax, r8
cmp     WORD PTR [rcx+r8*2], r9w
jae     .L5

我想劝编译器不要生成这条指令,但我想我首先需要了解为什么会生成这条指令。为什么 widening/zero 扩展,考虑到我使用的是相同的数据类型?

(在 godbolt compiler explorer 上查找整个函数。)

movzx指令零将一个量扩展到一个更大的寄存器中。在您的情况下,一个字(两个字节)零扩展为一个双字(四个字节)。零扩展本身通常是免费的,慢的部分是从 RAM 加载内存操作数 WORD PTR [rsi-2]

为了加快速度,您可以尝试确保要从 RAM 中获取的数据在您需要时位于 L1 缓存中。您可以通过将战略预取内在函数放在适当的位置来实现。例如,假设一个缓存行是 64 字节,您可以添加一个预取内在函数以在每次循环时获取数组条目 i + 32

您还可以考虑改进算法,从而减少需要从内存中获取的数据,但这似乎不太可能。

谢谢你的好问题!

清除寄存器和打破依赖关系的习语

引自 Intel® 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 microarchitecture, a number of instructions can help clear execution dependency when software uses these instructions to clear register content to zero. Break dependences 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 dependences 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.

movzx 对比 mov

编译器知道 movzx 并不昂贵,因此尽可能多地使用它。编码 movzx 可能比编码 mov 需要更多字节,但执行起来并不昂贵。

与逻辑相反,使用 movzx(填充整个寄存器)的程序实际上比仅使用 mov 的程序运行得更快,后者仅设置寄存器的较低部分。

让我用下面的代码片段向您证明这个结论。它是使用 Slicing by-N 算法实现 CRC-32 计算的代码的一部分。在这里:

    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,现在只需将“movzx ecx, bl”改为“mov cl, 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 条指令:

  1.     movzx   ecx, bl
    
  2.     shr     ebx, 8
    
  3.     mov     eax, dword ptr [ecx * 4 + edi + 1024 * 3]
    
  4.     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]

这是我们现在拥有的:

  1.     movzx   ecx, bl
    
  2.     shr     ebx, 8
    
  3.     mov     eax, dword ptr [ecx * 4 + edi + 1024 * 3]
    
  4.     movzx   edx, bl
    

现在指令4绝不会使用指令3所需的任何寄存器,反之亦然,所以指令3和4肯定可以同时执行!

这就是 CPU 为我们所做的。 CPU,在将指令翻译成乱序算法将执行的微操作(微操作)时,在内部重命名寄存器以消除这些依赖性,因此微操作处理重命名的内部寄存器,而不是我们所知道的真实的。因此我们不需要自己重命名寄存器,因为我刚刚在上面的例子中重命名了——CPU 会在将指令翻译成微操作时自动为我们重命名所有内容。

指令 3 和指令 4 的微操作将并行执行,因为指令 4 的微操作将处理与指令 3 的微操作完全不同的内部寄存器(作为 ecx 暴露给外部),所以我们不需要重命名任何东西。

让我将代码恢复到初始版本。在这里:

  1.     movzx   ecx, bl
    
  2.     shr     ebx, 8
    
  3.     mov     eax, dword ptr [ecx * 4 + edi + 1024 * 3]
    
  4.     movzx   ecx, bl
    

(指令 3 和 4 运行 并行,因为指令 3 的 ecx 不是指令 4 的那个 ecx,而是一个不同的、重命名的寄存器——CPU 已经自动分配给指令 4从内部可用寄存器池中微操作一个新的新鲜寄存器)。

现在让我们回到 movxz 与 mov.

Movzx 完全清除寄存器,因此 CPU 肯定知道我们不依赖于保留在寄存器高位的任何先前值。当 CPU 看到 movxz 指令时,它知道它可以在内部安全地重命名寄存器并与前面的指令并行执行该指令。现在从我们的示例 #2 中获取前 4 条指令,其中我们使用 mov 而不是 movzx:

  1.    mov     cl, bl
    
  2.    shr     ebx, 8
    
  3.    mov     eax, dword ptr [ecx * 4 + edi + 1024 * 3]
    
  4.    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 重命名寄存器并让乱序执行算法将这条指令与其他指令一起执行,而不是一条一条地执行。