【问题标题】:Compiler generates costly MOVZX instruction编译器生成昂贵的 MOVZX 指令
【发布时间】:2017-09-15 10:44:27
【问题描述】:

我的分析器已将以下函数分析识别为热点。

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

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

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

【问题讨论】:

  • 看看 gcc 7 的输出。我会冒险让 cmets 猜测 x64 ISA 不再支持移动到 16 位寄存器(例如) mov dx, 1 所以它必须将值符号扩展到更大的寄存器中。在您的情况下,这是一个 64 位寄存器,但在 gcc7 中,它是一个 32 位寄存器。然后它可以将寄存器的低 16 位部分与内存的 16 位进行比较。
  • @djgandy 您仍然可以移动到 16 位寄存器(例如使用 mov r9w, word ptr [rsi-2]),但这样做会导致代价高昂的部分寄存器更新,这是应该避免的。 movzx 覆盖整个寄存器,提高性能。
  • @fuz 很高兴知道,毫无疑问这就是编译器会避免使用该方法的原因。
  • Movzx reg32,[mem16]mov reg16,[mem16] 快很多。你应该感谢编译器。
  • 昂贵的不是指令,而是内存访问。它没有很好地缓存。当向量很大时,这是不可避免的,除了“让它变小”的按钮之外,没有什么简单的按钮可以按下。访问内存通常是处理器必须做的最昂贵的事情之一,你在这上面花多少钱很重要。 DDR4 的降价速度出奇的快。

标签: c++ assembly optimization profiling x86-64


【解决方案1】:
推荐的答案 Intel

谢谢你的好问题!

清除寄存器和依赖打破习惯用法

来自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 条指令:

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

在这种情况下,指令 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重命名寄存器,让乱序执行算法与其他指令一起执行这条指令,而不是一个接一个地执行。

【讨论】:

  • 这也是most x64 instructions zero the upper part of a 32 bit register的原因,因为修改整个寄存器会破坏依赖链]
  • 如果// ecx is already cleared here to 0xor ecx,ecx 一起完成,两个版本将以相同的性能运行。 :P xor-zeroing 设置内部“高字节零”标志。很难做一个简单的例子来说明这个问题。也许尝试使用字节movandor 设置寄存器的低字节。比如mov cl, 0x12and ecx, 0xffffff00 / or ecx, 0x12
  • (抱歉,我现在正在挑选您所有的字节寄存器答案。在您最近的答案中看到英特尔手册中的KNL quote 后,我一直在寻找this recent comment thread。)
【解决方案2】:

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

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

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

【讨论】:

  • 这完全正确。在现代 Core i7 处理器上,MOVZX reg, mem 与 MOV reg, mem 有相同延迟。
  • 在现代英特尔处理器上确实如此,但在历史上不是。然而,回到 Pentium Pro 的过程中,您受到了部分寄存器停顿的严重惩罚,这意味着 MOVZX 仍然是净性能上的胜利。例外情况是您可以编写代码以首先清除整个寄存器(XOR reg、reg),然后仅加载低 16 位或 8 位别名。这并没有破坏对 PPro 的依赖(它并没有真正破坏依赖),但它在后来的处理器上确实如此,并且通常比 MOVZX 稍快,因为该指令的历史延迟很高。
  • 不只是延迟,MOVZX 还有一个额外的指令前缀,从历史上看,前缀越多,解码指令所需的时间越长,这意味着吞吐量也会降低。无论如何,要吹毛求疵,说“现代 Core i7 处理器”并没有多大意义。 i7 与 i5 或 i3(甚至是奔腾或赛扬)的微架构没有什么不同。真正重要的是微架构,甚至在省略(重命名)reg-reg 移动的 Skylake 上,MOVZX没有被淘汰,所以仍然有一些成本,只是没有明显的延迟。
  • @CodyGray:零扩展 loads 与 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 不同。
  • 正确的答案,但错误的建议,IMO。简单地添加软件预取而不展开每个高速缓存行仅预取一次可能会使其变慢。硬件预取应该适用于简单的顺序访问(并且很容易跟上word 循环)。也许 OP 正在使用多个短向量,这就是为什么他们会出现缓存未命中的原因。或者在 IvyBridge 之前,我认为预取并没有跨越页面边界。或者这可能是 CPU 瓶颈,分析器计数必须去某个地方。
猜你喜欢
  • 2022-12-06
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-03-15
  • 1970-01-01
  • 2016-10-26
  • 1970-01-01
  • 2012-04-03
相关资源
最近更新 更多