编译器生成昂贵的MOVZX指令

时间:2017-04-19 09:32:44

标签: c++ assembly optimization profiling

我的探查器已将以下功能概要分析识别为热点。

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;  
}

特别是一个汇编指令MOVZXMove 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

我想哄骗编译器生成这条指令,但我想我首先需要理解为什么会产生这条指令。为什么扩展/零扩展,考虑到我使用相同的数据类型?

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

2 个答案:

答案 0 :(得分:9)

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

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

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

答案 1 :(得分:7)

谢谢你提出的好问题!

清除登记和依赖性破坏习语

来自Intel® 64 and IA-32 Architectures Optimization Reference Manual的报价,第3.5.1.8节:

  

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

     

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

movzx vs mov

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

与逻辑相反,使用movzx(填充整个寄存器)的程序实际上比只使用mov的程序工作得更快,而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,现在只是代替“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
  5. 如您所见,指令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
    5. 现在指令4绝不使用指令3所需的任何寄存器,反之亦然,因此指令3和4可以同时执行!

      这就是CPU为我们所做的事情。当将指令转换为将由无序算法执行的微操作(微操作)时,CPU在内部重命名寄存器以消除这些依赖性,因此微操作处理重命名的内部寄存器,而不是而不是我们所知道的真实的。因此,我们不需要自己重命名寄存器,因为我刚刚在上面的示例中重命名 - 在将指令转换为微操作时,CPU将自动为我们重命名所有内容。

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

      让我将代码还原为初始版本。这是:

      1. movzx ecx,bl
      2. shr ebx,8
      3. mov eax,dword ptr [ecx * 4 + edi + 1024 * 3]
      4. movzx ecx,bl
      5. (指令3和4并行运行,因为指令3的ecx不是指令4的ecx,而是一个不同的重命名寄存器 - CPU已自动为指令4微操作分配一个新的,新的寄存器来自内部可用寄存器池。)

        现在让我们回到movxz vs 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
        5. 在这种情况下,指令4通过修改cl修改ecx的0-7位,保持8-32位不变。因此,CPU不能仅重命名指令4的寄存器并分配另一个新的寄存器,因为指令4取决于先前指令留下的位8-32。在执行指令4之前,CPU必须保留8-32位,因此不能只重命名寄存器。它将等到指令3在执行指令4之前完成。指令4不能完全独立 - 它取决于ECX的先前值以前的bl值。所以它一次取决于两个注册表。如果我们使用movzx,那将只依赖于一个寄存器--b1。因此,指令3和4由于它们的相互依赖性而不会并行运行。悲伤却又是真的。

          这就是为什么操作完整寄存器总是更快 - 如果我们只需要修改寄存器的一部分 - 修改完整寄存器总是更快(例如,使用movzx) - 让CPU确切知道寄存器不再取决于其先前的值。修改完整寄存器允许CPU重命名寄存器,让无序执行算法与其他指令一起执行该指令,而不是逐个执行。