编译器生成昂贵的MOVZX指令

Olu*_*ide 7 c++ optimization assembly 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;  
}
Run Code Online (Sandbox Code Playgroud)

特别是一个汇编指令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
Run Code Online (Sandbox Code Playgroud)

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

(在godbolt编译器资源管理器上找到整个函数.)

fuz*_*fuz 10

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

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

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

  • 这是完全正确的.在现代Core i7处理器上,MOVZX reg,mem与MOV reg,mem具有相同的*延迟. (2认同)
  • @CodyGray:零扩展*加载*与movzx的reg-reg形式是分开的.`movzx r32,word [mem]`是一个纯加载,由加载端口处理.它不是微融合的ALU-movzx +负载.根据Agner的表格甚至在P6(奔腾II)上也是如此.它与MOV负载相同(*如果*通过仅在负载后读取r16来避免部分寄存器停止).0F转义字节不计为P6/PM(Core2之前)中简单解码器的1前缀限制的前缀.Silvermont确实计算了0F,而Agner评论说这与其他具有前缀限制的Intel/AMD CPU不同. (2认同)
  • 正确的答案,但错误的建议,IMO。简单地在不展开的情况下添加软件预取到每个缓存行仅预取一次可能会使速度变慢。硬件预取对于简单的顺序访问应该做得很好(并且很容易跟上“字”循环)。也许 OP 正在处理多个短向量,这就是为什么它们会出现缓存未命中的原因。或者在 IvyBridge 之前,我认为预取没有跨越页面边界。或者这可能是 CPU 瓶颈,分析器计数不得不去某个地方。 (2认同)

Max*_*tin 8

谢谢你的好问题!

清除登记和依赖打破习语

从A报价64和IA-32架构优化参考手册,第3.5.1.8:

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

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

movzx vs 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>>>
Run Code Online (Sandbox Code Playgroud)

这是第二个代码片段,我们提前清除了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>>>
Run Code Online (Sandbox Code Playgroud)

现在猜猜上面两个代码片段中的哪一个运行得更快?您之前是否认为速度相同,或者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]
Run Code Online (Sandbox Code Playgroud)

这是我们现在拥有的:

  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的微操作将处理完全不同的内部寄存器(暴露于外部作为ecx)而不是指令3的微操作,因此我们不会需要重命名任何东西.

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

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

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

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

  • 这也是[大多数 x64 指令将 32 位寄存器的上部清零](/sf/ask/782399621/) 的原因,因为修改整个寄存器会破坏依赖链] (2认同)