当将四个 1 字节的变量连接到一个 4 字节的字时,哪种移位和或运算的方法更快? (对比生成的汇编代码)

When joining four 1-byte vars into one 4-byte word, which is a faster way to shift and OR ? (comparing generated assembly code)

所以我目前正在研究按位运算符和位操作,我遇到了两种不同的方法来将四个 1 字节的字组合成一个 4 字节宽的字。

两种方式如下

找到这两种方法后,我比较了两者生成的反汇编代码(使用带有 -O2 标志的 gcc 11 编译),我对反汇编及其生成的代码没有基本的了解,以及什么我只知道代码越短,功能越快(大多数时候我猜......也许有一些例外),现在对于这两种方法,它们似乎具有相同的 numbers/counts 行在生成的反汇编代码中,所以我猜它们具有相同的性能?

我也很好奇指令的顺序,第一种方法似乎交替其他指令sal>or>sal>or>sal>or,而第二种方法更统一sal>sal>sal>or>or>mov>or这对性能举例来说,如果我们正在处理一个更大的词?


两种方法

int method1(unsigned char byte4, unsigned char byte3, unsigned char byte2, unsigned char byte1)
{
    int combine = 0;
    combine = byte4;
    combine <<=8;
    combine |= byte3;
    combine <<=8;
    combine |= byte2;
    combine <<=8;
    combine |= byte1;
    return combine;
}

int method2(unsigned char byte4, unsigned char byte3, unsigned char byte2, unsigned char byte1)
{
    int combine = 0, temp;
    temp = byte4;
    temp <<= 24;
    combine |= temp;
    temp = byte3;
    temp <<= 16;
    combine |= temp;
    temp = byte2;
    temp <<= 8;
    combine |= temp;
    temp = byte1;
    combine |= temp;
    return combine;
}

反汇编

// method1(unsigned char, unsigned char, unsigned char, unsigned char):
        movzx   edi, dil
        movzx   esi, sil
        movzx   edx, dl
        movzx   eax, cl
        sal     edi, 8
        or      esi, edi
        sal     esi, 8
        or      edx, esi
        sal     edx, 8
        or      eax, edx
        ret
// method2(unsigned char, unsigned char, unsigned char, unsigned char):
        movzx   edx, dl
        movzx   ecx, cl
        movzx   esi, sil
        sal     edi, 24
        sal     edx, 8
        sal     esi, 16
        or      edx, ecx
        or      edx, esi
        mov     eax, edx
        or      eax, edi
        ret

这可能是“过早的优化”,但我只想知道是否存在差异。

在没有优化的情况下,方法 2 似乎快了一点点。

这是所提供代码的在线基准测试:https://quick-bench.com/q/eyiiXkxYVyoogefHZZMH_IoeJss

但是,很难从像这样的微小操作中获得准确的指标。 它还取决于给定 CPU 上指令的速度(不同的指令可能需要或多或少的时钟周期)。

此外,按位运算符往往非常快,因为它们是“基本”指令。

所以真正的答案是在目标机器上对其进行基准测试,看看哪个更快。

我完全同意:

So the real answer, would be to benchmark it on your target machine to see which one is faster.

尽管如此,我创建了一个“method3()”,并使用 gcc 10.3.0 版、-O0 和 -O2 反汇编了所有三个,这是 -O2 结果的摘要:

方法三:

int method3(unsigned char byte4, unsigned char byte3, unsigned char byte2, unsigned char byte1)
{
    int combine = (byte4 << 24)|(byte3<<16)|(byte2<<8)|byte1;
    return combine;
}

gcc -O2 -S:

;method1:
    sall    , %eax
    orl %edx, %eax
    sall    , %eax
    movl    %eax, %edx
    movzbl  %r8b, %eax
    orl %edx, %eax
    sall    , %eax
    orl %r9d, %eax
    ...
;method2:
    sall    , %r8d
    sall    , %edx
    orl %r9d, %r8d
    sall    , %eax
    orl %edx, %r8d
    orl %r8d, %eax
    ...
;method3:
    sall    , %r8d
    sall    , %edx
    orl %r9d, %r8d
    sall    , %eax
    orl %edx, %r8d
    orl %r8d, %eax

方法 1 的指令少于方法 2,方法 2 编译为与方法 3 完全相同的代码。 method1 也有几个“动作”,比“or”或“shift”更“昂贵”。

now for the two methods it seems that they have the same numbers/counts of lines in the generated disassembly code, so I guess they have the same performance?

几十年来情况并非如此。现代 CPU 可以并行执行指令 如果它们彼此独立 。参见

  • How many CPU cycles are needed for each assembly instruction?
  • What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?

在你的情况下,方法 2 显然更好(特别是 GCC11 -O2),因为 3 个班次可以并行发生,只留下一串 or 指令。 (大多数现代 x86 CPU 只有 2 个移位单元,但第 3 个移位可以与第一个 OR 并行发生)。

您的第一个版本有一个 shift/or/shift/or 的长依赖链(在 movzx 零扩展之后),因此它具有相同的吞吐量但延迟更差。如果它不在一些更大计算的关键路径上,性能会相似。

第一个版本也有冗余movzx edi, dil,因为GCC11 -O2没有意识到高位最终会通过3x8 = 24位的移位移出寄存器的顶部。不幸的是,GCC 选择 movzx 到同一个寄存器(RDI 到 RDI),而不是例如 movzx eax, dil .

第二个浪费了 mov eax, edx 因为 GCC 没有意识到它应该对 EAX 执行 movzx 操作之一,而不是将每个 reg 零扩展到自身(击败 mov-消除)。它还可以使用 lea eax, [edx + edi] 合并到不同的 reg 中,因为它可以证明这些值不能有任何重叠的位位置,所以 |+ 是等价的.

浪费 mov 通常只发生在小函数中;显然,当值需要位于特定的硬寄存器中时,GCC 的寄存器分配器会遇到困难。如果它可以选择在哪里产生价值,它就会在 EDX 中结束。

所以在 Intel CPU 上,是的,由于不同的错过优化的巧合,两个版本都有 3 个非消除 movzx 和一个可以从移动消除中受益的指令。在 AMD CPU 上,movzx 永远不会被淘汰,因此 movzx eax, cl 不会受益。


不幸的是,您的编译器选择按顺序执行所有三个 or 操作,而不是依赖关系树。 (a|b) | (c|d) 的最坏情况延迟低于 (((a|b) | c) | d),所有输入的关键路径长度为 2,而 d 为 3,c 为 2,[=33 为 1 =] 或 b。 (用 C 语言以这些不同的方式编写它实际上并没有改变编译器生成 asm 的方式,因为它们知道 OR 是关联的。我正在使用熟悉的语法来表示程序集的依赖模式。)

所以如果所有四个输入同时就绪,组合成对会降低延迟,尽管大多数 CPU 不可能在同一个周期内产生三个移位结果。

我能够使用早期的 GCC (GCC5) 制作这种依赖模式 (Godbolt compiler explorer)。我使用 unsigned 来避免 ISO C 未定义的行为。 (GNU C 确实定义了左移的行为,即使将设置的位移入或移出符号位。)

int method4(unsigned char byte4, unsigned char byte3, unsigned char byte2, unsigned char byte1)
{
    unsigned combine = (unsigned)byte4 << 24;
    combine |= byte3 << 16;
    unsigned combine_low = (byte2 << 8) | byte1;
    combine |= combine_low;
    return combine;
}
# GCC5.5 -O3
method4(unsigned char, unsigned char, unsigned char, unsigned char):
        movzx   eax, sil
        sal     edi, 24
        movzx   ecx, cl
        sal     eax, 16
        or      edi, eax    # (byte4<<24)|(byte3<<16)
        movzx   eax, dl
        sal     eax, 8
        or      eax, ecx    # (byte2<<8) | (byte1)
        or      eax, edi    # combine halves
        ret

但是 GCC11.2 与 method2 相比使用了相同的 asm。如果它是最佳的那会很好,但它不是。


the first method seems to alternate other instructions sal>or>sal>or>sal>or, while the second one is more uniform sal>sal>sal>or>or>mov>or

依赖链是关键因素。主流 x86 乱序 exec 已经有 2 多年了,多年来一直没有出售任何有序 exec x86 CPU。因此,指令调度(独立指令的排序)通常在非常小的距离(如几条指令)上并不是什么大问题。当然,在交替 shl/or 版本中,它们 不是 独立的,因此您不能在不破坏或重写它的情况下重新排序它们。


我们可以通过部分寄存器恶作剧做得更好吗?

只有当您是编译器/JIT 开发人员并试图让编译器更好地处理像这样的源代码时,这部分内容才有意义。我不确定这里是否有明确的胜利,但如果我们不能内联此函数可能是肯定的,因此实际上需要 movzx 指令。

我们当然可以节省指令,但即使是现代英特尔 CPU 仍然对像 AH 这样的高 8 位寄存器有部分寄存器合并惩罚。而且似乎AH-merging uop本身只能在一个周期内发出,所以它实际上至少花费了前端带宽的4条指令。

    movzx eax, dl
    mov   ah, cl
    shl   eax, 16        # partial register merging when reading EAX
    mov   ah, sil        # oops, not encodeable, needs a REX for SIL which means it can't use AH
    mov   al, dil

或者可能是这样,它避免了部分寄存器停顿和错误的依赖性 on Intel Haswell and later。 (还有根本不重命名部分 regs 的 uarches,就像所有 AMD 和 Intel Silvermont 系列,包括 Alder Lake 上的 E-cores。)

# good on Haswell / AMD
# partial-register stalls on Intel P6 family (Nehalem and earlier)
merge4(high=EDI, mid_hi=ESI, mid_lo=EDX, low=ECX):
   mov   eax, edi       # mov-elimination possible on IvB and later, also AMD Zen
                        # zero-extension not needed because more shifts are coming
   shl   eax, 8
   shl   edx, 8
   mov   al, sil        # AX = incoming DIL:SIL
   mov   dl, cl         # DX = incoming DL:CL
   shl   eax, 16        
   mov   ax, dx         # EAX = incoming DIL:SIL:DL:CL
   ret

这是使用 8 位和 16 位 mov 作为 ALU 合并操作,非常类似于 movzx + or,即位域插入低 8 位或低 16 位。我避免移动进入 AH 或其他高 8 位寄存器,因此在 Haswell 或更高版本上没有部分寄存器合并。

总共只有 7 条指令(不计算 ret),全部是单 uop。其中之一只是一个 mov ,在内联时通常可以将其优化掉,因为编译器将选择将值放入哪些寄存器。(除非仍然需要单独的高字节的原始值在寄存器中)。它通常会知道它在内联后已经在寄存器中有一个零扩展值,但这个版本不依赖于此。

当然,如果您最终要将此值存储到内存中,则进行 4 字节存储可能会很好,尤其是在它不打算重新加载的情况下。 (存储转发停顿。)


相关:

  • 其他 CPU 上的部分注册,以及为什么我的“好”版本在硬壳旧 Nehalem 和更早的 CPU 上会很糟糕。
  • 部分寄存器版本的基础
  • - 回复:使用 32 位 mov 复制传入的 uint8_t。 (尽管这就是为什么即使在 do 重命名 DIL 与完整的 RDI 分开的 CPU 上也很好,因为调用者将写入完整的寄存器。)

半相关:

  • 在没有轮班的情况下这样做,只是部分寄存器移动,作为asm练习:有时使用store/reload这将导致存储转发停顿.

a 'c' 仅便携。

unsigned int portable1(unsigned char byte4, unsigned char byte3,
                       unsigned char byte2, unsigned char byte1)
{
union tag1 
  {
  _uint32 result;
  _uint8 a[4];
  } u;
 u.a[0] = byte1;
 u.a[1] = byte2;
 u.a[2] = byte3;
 u.a[3] = byte4;
 return u.result;
}

这应该在大多数环境中生成 4 个 MOV 和一个寄存器加载。如果联合是在模块范围或全局范围内定义的,则最终的 LOAD(或 MOV)将从函数中消失。我们还可以轻松地允许 9 位字节和英特尔与网络字节顺序...