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)
这可能是“过早的优化”,但我只是想知道是否有区别。
现在对于这两种方法来说,它们在生成的反汇编代码中似乎具有相同的行数/行数,所以我猜它们具有相同的性能?
几十年来情况并非如此。现代 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 字节存储可能会很好,尤其是在不重新加载它的情况下。(商店转发摊位。)
有关的:
mov复制传入的uint8_t. (尽管这说明了为什么即使在将 DIL 与完整 RDI 分开重命名的 CPU 上也没有问题,因为调用者将写入完整的寄存器。)半相关:
| 归档时间: |
|
| 查看次数: |
488 次 |
| 最近记录: |