为什么 ARM 使用两个指令来屏蔽一个值

Why does ARM use two instructions to mask a value?

本文关键字:屏蔽 一个 指令 两个 ARM 为什么      更新时间:2023-10-16

对于以下函数...

uint16_t swap(const uint16_t value)
{
return value << 8 | value >> 8;
}

。为什么带有 -O2 的 ARM gcc 6.3.0 会产生以下程序集?

swap(unsigned short):
lsr r3, r0, #8
orr r0, r3, r0, lsl #8
lsl r0, r0, #16         # shift left
lsr r0, r0, #16         # shift right
bx lr

编译器似乎正在使用两个移位来屏蔽不需要的字节,而不是使用逻辑 AND。 编译器可以使用and r0, r0, #4294901760吗?

较旧的 ARM 程序集无法轻松创建常量。相反,它们被加载到文本池中,然后通过内存加载读入。您建议的这个and只能采用我相信带有移位的 8 位文字。您的0xFFFF0000需要 16 位才能执行 1 条指令。

因此,我们可以从内存加载并执行and(慢), 取 2 条指令来创建值,1 条指令和 (更长), 或者只是便宜地换两次并称其为好。

编译器选择了移位,老实说,它的速度非常快。

现在进行现实检查:

担心一个班次,除非这是100%的瓶颈,否则肯定是浪费时间。 即使编译器不是次优的,你也几乎永远不会感觉到它。 担心代码中的"热"循环,而不是像这样的微操作。 从好奇心来看这个真是太棒了。 担心这个确切的代码来提高你的应用的性能,而不是那么多。


编辑:

这里的其他人已经注意到,较新版本的ARM规范允许更有效地完成这种事情。 这表明,在这个级别上谈论时,指定芯片或至少我们正在处理的确切ARM规范非常重要。 我假设古代 ARM 是因为您的输出中缺乏"更新"指令。 如果我们跟踪编译器错误,那么这个假设可能站不住脚,了解规范更为重要。 对于这样的交换,在以后的版本中确实有更简单的说明来处理这个问题。


编辑 2

可以做的一件事是使其更快。 在这种情况下,编译器可以将这些操作与其他工作交错。 根据 CPU 的不同,这可能会使这里的吞吐量翻倍,因为许多 ARM CPU 都有 2 个整数指令管道。 将说明充分展开,以免有危险,然后就可以走了。 这必须与 I-Cache 使用情况进行权衡,但在重要的情况下,您可以看到更好的东西。

这里有一个遗漏的优化,但and不是缺失的部分。 生成 16 位常量并不便宜。 对于循环,是的,在循环外生成常量并在循环内仅使用and将是一个胜利。 (TODO:在数组上循环调用swap,看看我们得到什么样的代码。

对于无序 CPU,还值得在关键路径之外使用多个指令来构建常量,然后关键路径上只有一个AND而不是两个班次。 但这可能很少见,而不是 gcc 选择的。


AFAICT(通过查看简单函数的编译器输出),ARM 调用约定保证输入寄存器中没有高垃圾,并且不允许在返回值中留下高垃圾。 即在输入时,它可以假设r0的高 16 位全部为零,但在返回时必须将它们保留为零。 因此,左移value << 8是一个问题,但value >> 8不是(它不必担心将垃圾向下移动到低 16)。

(请注意,x86 调用约定不是这样的:返回值允许具有高垃圾。 (也许是因为调用方可以简单地使用 16 位或 8 位部分寄存器)。 输入值也是如此,除了作为 x86-64 System V ABI 的未记录部分:clang 取决于将符号/零扩展到 32 位的输入值。 GCC 在呼叫时提供此功能,但不假定为被调用方。


ARMv6 有一个rev16指令,该指令对寄存器的两个 16 位部分进行字节交换。 如果上面的 16 位已经归零,则不需要将它们重新归零,因此gcc -march=armv6应该将函数编译为仅rev16。 但实际上,它发出了一个uxth来提取和零扩展低半字。 (即与and0x0000FFFF完全相同,但不需要大常数)。 我相信这纯粹是错过的优化;据推测,GCC 的旋转习语,或者它以这种方式使用rev16的内部定义,没有包含足够的信息来让它意识到上半部分保持零。

swap:                @@ gcc6.3 -O3 -march=armv6 -marm
rev16   r0, r0
uxth    r0, r0     @ not needed
bx      lr

对于 ARM pre v6,可以使用更短的序列。 只有当我们把它放在我们想要的 asm 上时,GCC 才能找到它:

// better on pre-v6, worse on ARMv6 (defeats rev16 optimization)
uint16_t swap_prev6(const uint16_t value)
{
uint32_t high = value;
high <<= 24;            // knock off the high bits
high >>= 16;            // and place the low8 where we want it
uint8_t low = value >> 8;
return high | low;
//return value << 8 | value >> 8;
}

swap_prev6:            @ gcc6.3 -O3 -marm.   (Or armv7 -mthumb for thumb2)
lsl     r3, r0, #24
lsr     r3, r3, #16
orr     r0, r3, r0, lsr #8
bx      lr

但这破坏了 gcc 的旋转习语识别,因此即使简单版本编译为rev16/uxth时,即使-march=armv6,它也可以编译为相同的代码。

Godbolt 编译器资源管理器上的所有源代码 + asm

ARM是RISC机器(Advanced RISC Machine),因此,所有指令都以相同的大小编码,上限为32位。

指令中的即时值被分配给一定数量的位,而AND指令根本没有足够的位分配给即时值来表示任何 16 位值。

这就是编译器诉诸两个移位指令的原因。

但是,如果您的目标 CPU 是 ARMv6 (ARM11) 或更高版本,编译器会利用新的REV16指令,然后通过UXTH指令屏蔽较低的 16 位,这是不必要和愚蠢的,但根本没有传统的方法来说服编译器不要这样做。

如果你认为海湾合作委员会内在__builtin_bswap16会很好地为你服务,那你就大错特错了。

uint16_t swap(const uint16_t value)
{
return __builtin_bswap16(value);
}

上面的函数生成的机器代码与原始 C 代码完全相同。

即使使用内联程序集也无济于事

uint16_t swap(const uint16_t value)
{
uint16_t result;
__asm__ __volatile__ ("rev16 %[out], %[in]" : [out] "=r" (result) : [in] "r" (value));
return result;
}

同样,完全相同。只要您使用 GCC,就无法摆脱讨厌的UXTH;它根本无法从上下文中读取高 16 位都是零开始,因此UXTH是不必要的。

在汇编中编写整个函数;这是唯一的选择。

这是最佳解决方案,AND 至少需要另外两条指令,可能必须停止并等待要屏蔽的值发生加载。 所以在几个方面更糟。

00000000 <swap>:
0:   e1a03420    lsr r3, r0, #8
4:   e1830400    orr r0, r3, r0, lsl #8
8:   e1a00800    lsl r0, r0, #16
c:   e1a00820    lsr r0, r0, #16
10:   e12fff1e    bx  lr
00000000 <swap>:
0:   ba40        rev16   r0, r0
2:   b280        uxth    r0, r0
4:   4770        bx  lr

后者是armv7,但同时是因为他们添加了支持此类工作的指令。

根据定义,固定长度的RISC指令存在常量问题。 MIPS选择了一条路,ARM选择了另一种方式。 常量在CISC上也是一个问题,只是一个不同的问题。 创建利用 ARMS 桶形移位器并显示 MIPS 解决方案缺点的东西并不困难,反之亦然。

该解决方案实际上具有一点优雅。

其中一部分也是目标的整体设计。

unsigned short fun ( unsigned short x )
{
return(x+1);
}
0000000000000010 <fun>:
10:   8d 47 01                lea    0x1(%rdi),%eax
13:   c3                      retq   

gcc 选择不返回您要求的 16 位变量,它返回 32 位,它没有正确/正确实现我用代码要求的功能。 但是,如果当数据的用户获得该结果或使用它时,掩码发生在那里或使用此架构使用 ax 而不是 eax,那没关系。例如。

unsigned short fun ( unsigned short x )
{
return(x+1);
}
unsigned int fun2 ( unsigned short x )
{
return(fun(x));
}

0000000000000010 <fun>:
10:   8d 47 01                lea    0x1(%rdi),%eax
13:   c3                      retq   
0000000000000020 <fun2>:
20:   8d 47 01                lea    0x1(%rdi),%eax
23:   0f b7 c0                movzwl %ax,%eax
26:   c3                      retq   

编译器设计选择(可能基于体系结构)而不是实现错误。

请注意,对于足够规模的项目,很容易发现错过的优化机会。 没有理由期望优化器是完美的(它不是,也不可能是)。 对于平均而言,他们只需要比人工完成该规模项目的效率更高。

这就是为什么人们常说,对于性能调优,您不会预先优化或立即跳转到asm,您使用高级语言和编译器,以某种方式分析以找到性能问题,然后手动编码,为什么要手动编码它们,因为我们知道我们有时可以执行编译器, 这意味着编译器输出可以改进。

这不是一个错过的优化机会,相反,这是一个非常优雅的指令集解决方案。 屏蔽字节更简单

unsigned char fun ( unsigned char x )
{
return((x<<4)|(x>>4));
}
00000000 <fun>:
0:   e1a03220    lsr r3, r0, #4
4:   e1830200    orr r0, r3, r0, lsl #4
8:   e20000ff    and r0, r0, #255    ; 0xff
c:   e12fff1e    bx  lr
00000000 <fun>:
0:   e1a03220    lsr r3, r0, #4
4:   e1830200    orr r0, r3, r0, lsl #4
8:   e6ef0070    uxtb    r0, r0
c:   e12fff1e    bx  lr

后者是 ARMv7,但使用 ARMv7 他们识别并解决了这些问题,你不能指望程序员总是使用自然大小的变量,有些人觉得需要使用不太优化大小的变量。 有时您仍然需要遮罩到一定大小。