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

0xd*_*eef 0 c x86 assembly bit-manipulation micro-optimization

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

下面给出了两种方式

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

我也对指令的顺序感到好奇,第一种方法似乎交替其他指令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;
}
Run Code Online (Sandbox Code Playgroud)

拆卸

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

这可能是“过早的优化”,但我只是想知道是否有区别。

Pet*_*des 5

现在对于这两种方法来说,它们在生成的反汇编代码中似乎具有相同的行数/行数,所以我猜它们具有相同的性能?

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


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

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

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

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

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

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


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

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

我能够掌握早期的 GCC (GCC5) 来制作这种依赖模式(Godbolt 编译器资源管理器)。我曾经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;
}
Run Code Online (Sandbox Code Playgroud)
# 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
Run Code Online (Sandbox Code Playgroud)

但是 GCC11.2 对此方法与方法 2 做了相同的汇编。如果它是最佳的那就太好了,但事实并非如此。


第一种方法似乎交替其他指令 sal>or>sal>or>sal>or,而第二种方法更统一 sal>sal>sal>or>or>mov>or

依赖链是关键因素。主流 x86 的乱序执行已经有 20 多年了,而且多年来没有任何有序执行的 x86 CPU 被出售。因此,指令调度(独立指令的排序)通常对于像几条指令这样非常小的距离来说并不是什么大问题。当然,在交替的 shl/or 版本中,它们不是独立的,因此您无法在不破坏或重写它的情况下对它们重新排序。


我们能在部分注册恶作剧方面做得更好吗?

仅当您是编译器/JIT 开发人员并试图让编译器更好地处理此类源代码时,此部分才有意义。我不确定这里有明显的胜利,但如果我们不能内联这个函数,那么movzx实际上需要指令,也许是的。

我们当然可以节省指令,但即使是现代 Intel CPU 仍然对 AH 等高 8 位寄存器存在部分寄存器合并惩罚。而且AH合并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
Run Code Online (Sandbox Code Playgroud)

或者也许这样可以避免部分寄存器停顿和对 Intel Haswell 及更高版本的错误依赖。(也适用于完全不重命名部分寄存器的 uarch,如所有 AMD 和 Intel Silvermont 系列,包括 Alder Lake 上的 E 核心。)

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

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

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

当然,如果您最终将此值存储到内存中,那么执行 4 字节存储可能会很好,尤其是在不重新加载它的情况下。(商店转发摊位。)


有关的:

半相关:


归档时间:

查看次数:

488 次

最近记录:

4 年 前