为啥 ARM 使用两条指令来屏蔽一个值?
Posted
技术标签:
【中文标题】为啥 ARM 使用两条指令来屏蔽一个值?【英文标题】:Why does ARM use two instructions to mask a value?为什么 ARM 使用两条指令来屏蔽一个值? 【发布时间】:2017-12-12 20:07:26 【问题描述】:对于下面的函数...
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
吗?
【问题讨论】:
不是专家,但我猜加载常量会花费更多时间来处理已经在寄存器中的值。就像使用 xor r0, r0 而不是加载 0 存在很多情况,编译器可以有时会做一些不同的更好的事情,但要么它看不到可以应用优化,要么它只是没有实现,或者它在现实生活中实际上并不重要,因此它不会打扰,或者它是速度/空间权衡等。编译器并不神奇。如果您认为它可以做得更好;参与并为您的编译器提交补丁,这对我们所有人都有好处:) 在 ARMv6 或更高版本上,编译器应该为此发出REV16 r0,r0
。
@fuz 你的评论说明你没有理解问题。
旧版 ARM asm 无法轻松创建常量。相反,它们被加载到文字池中,然后通过内存加载读入。您建议的这个and
只能接受我相信带有移位的8 位文字。您的 0xFFFF0000 需要 16 位来执行 1 指令。因此,我们可以从内存中加载 and (慢),使用 2 条指令来创建值和 1 到 and (更长),或者只是便宜地移位两次。
【参考方案1】:
较旧的 ARM 程序集无法轻松创建常量。相反,它们被加载到文字池中,然后通过内存加载读入。您建议的这个and
只能接受我相信带有移位的8 位文字。您的 0xFFFF0000
需要 16 位来执行 1 指令。
所以,我们可以从内存中加载并执行and
(慢),
采取 2 条指令来创造价值,1 条指令来和(更长),
或者只是便宜地换两次并称之为好。
编译器选择了轮班,老实说,它非常快。
现在进行现实检查:
担心一个班次,除非这是 100% 确定的瓶颈是浪费时间。即使编译器是次优的,你也几乎不会感觉到它。担心代码中的“热”循环而不是像这样的微操作。好奇地看这个真是太棒了。担心这个确切的代码会影响您的应用程序的性能,而不是太多。
编辑:
这里的其他人已经注意到,较新版本的 ARM 规范允许更有效地完成此类事情。这表明,在这个级别上讨论时,指定芯片或至少是我们正在处理的确切 ARM 规范是很重要的。我假设古老的 ARM 缺少从您的输出中给出的“更新”指令。如果我们正在跟踪编译器错误,那么这个假设可能不成立,并且了解规范更加重要。对于这样的交换,在以后的版本中确实有更简单的指令来处理它。
编辑 2
可以做的一件事就是让它内联。在这种情况下,编译器可以将这些操作与其他工作交错。根据 CPU 的不同,这可能会使吞吐量翻倍,因为许多 ARM CPU 有 2 个整数指令流水线。将说明展开得足够多,这样就没有危险了,然后就走了。这必须与 I-Cache 的使用情况进行权衡,但在重要的情况下,您可以看到更好的结果。
【讨论】:
担心的原因不是一个特定的应用程序,而是您是否应该报告missed-optimization gcc bug,以便编译器可以为everyone中的everyone生成稍快和/或更小的代码未来。 为什么编译器会费心上半部分(所以我理解)?寄存器的 uint16_t 部分肯定设置正确。如果 sizeof(return val) ARM 没有 16 位寄存器,因此需要清除高位。我很确定在寄存器的上半部分返回“垃圾”将违反规则,指定 16 位宽度或不指定。 内联这样一个微小的函数在大多数情况下可能是一个胜利,尽管 ARM 独特地能够保存/恢复更多保留调用的寄存器而无需额外的代码大小(在 push/pop 中设置更多位)。尽管如此,函数调用也会在每个调用站点接受指令,并且会破坏 R0-R3,因此即使纯粹为了代码大小,它也很接近。如果不需要将高 16 位归零,编译器可能会保存一条指令,例如因为它知道它在结果值上使用strh
。 (@Vroomfondel:是的,看我的回答,用更简单的功能进行测试表明 ABI 需要零扩展输入/输出)【参考方案2】:
这里有一个错过的优化,但and
不是缺失的部分。生成一个 16 位常量并不便宜。对于循环,是的,在循环外生成一个常量并在循环内仅使用 and
将是一个胜利。 (TODO:在数组上循环调用swap
,看看我们得到了什么样的代码。)
对于一个无序的 CPU,可能也值得在关键路径之外使用多条指令来构建一个常量,那么你在关键路径上只有一个 AND
而不是两个班次。但这可能很少见,而不是 gcc 选择的。
AFAICT(通过查看简单函数的编译器输出),ARM 调用约定保证输入寄存器中没有高垃圾,并且不允许在返回值中留下高垃圾。即在输入时,它可以假设r0
的高 16 位全为零,但返回时必须使它们为零。因此,value << 8
左移是一个问题,但value >> 8
不是(它不必担心将垃圾移到低 16 位)。
(请注意,x86 调用约定不是这样的:返回值允许有高垃圾。(可能是因为调用者可以简单地使用 16 位或 8 位部分寄存器)。输入值也是如此,@ 987654321@:clang 依赖于输入值被符号/零扩展为 32 位。GCC 在调用时提供此功能,但不假定为被调用者。)
ARMv6 具有a rev16
instruction,它可以字节交换寄存器的两个 16 位半部分。如果高 16 位已经归零,则不需要重新归零,因此 gcc -march=armv6
应该将函数编译为 rev16
。但实际上它会发出一个uxth
来提取和零扩展低半字。 (即与and
和0x0000FFFF
完全相同,但不需要大常数)。我相信这纯粹是错过的优化;大概 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
也会编译为相同的代码。
All source + asm on the Godbolt compiler explorer
【讨论】:
【参考方案3】:ARM 是一个 RISC 机器(Advanced RISC Machine),因此,所有指令都以相同的大小编码,上限为 32 位。
指令中的立即数分配给一定数量的位,而AND
指令根本没有分配给立即数的足够多的位来表示任何 16 位值。
这就是编译器使用两条移位指令的原因。
但是,如果您的目标 CPU 是 ARMv6 (ARM11) 或更高版本,编译器会利用新的 REV16
指令,然后用 UXTH
指令屏蔽低 16 位,这是不必要且愚蠢的,但简单没有传统的方法可以说服编译器不要这样做。
如果您认为 GCC 内在 __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
是不必要的。
在汇编中编写整个函数;这是唯一的选择。
【讨论】:
您不需要或不希望在该 asm 语句中使用volatile
:它是一个纯函数,如果它已经有输出值,您不需要编译器重新运行 rev16
对于相同的输入。即你想让编译器 CSE 为同一个 x
多个 swap(x)
。如果您使用 uint32_t
参数编写函数,则可以摆脱 uxth
,但在大多数情况下,这可能只是将 uxth
卸载到调用者身上。我尝试使用 uint32_t result
和 if (result > 0xFFFFu) __builtin_unreachable();
向编译器保证高 2 个字节保持为零,但没有运气。 godbolt.org/g/NEidyW
如果您使用的是 ARMv6,最好让 rev16/uxth 内联,而不是在 asm 中实际编写函数。特别是如果可以通过交换进行持续传播。或者可能编写一个 C 包装器,使用 __builtin_constant_p
来决定是调用 asm 版本还是使用纯 C 交换。
我的意思是整个函数,包括调用者,调用者的调用者,调用者的调用者...... :-)
__builtin_bswap16(value)
如果您有最新的 gcc 版本并为其提供适当的编译器选项(例如 -march=armv7-a
),将生成 'rev16'。也许godbolt 或Does ARM have a builtin_rev 是相关的。开始工作很有用,因为代码将能够在 ARM CPU 上更新和/或可以与不同的 CPU 和 GCC 一起使用。
@artlessnoise GCC 花了将近 20 年的时间才最终能够正确编译它。【参考方案4】:
这是最佳解决方案,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,他们认识到并解决了这些问题,你不能指望程序员总是使用自然大小的变量,有些人觉得需要使用不太优化的变量。有时你仍然需要掩码到一定的大小。
【讨论】:
ARM 与 x86 上的调用约定不同。 ARM 显然要求输入和输出是零扩展的,而 x86 允许输入和输出中有大量垃圾。所以lea 0x1(%rdi),%eax
不是“错误的”,也不是做你要求的事情。无论如何,rev16 r0, r0
/ uxth r0,r0
不是错过了优化吗?上半部分保持为零,所以你只需要rev16
。
理解它不是生成 16 位结果才是重点,并且正如所证明的那样,它总体上做了正确的事情(显然,否则 gcc 在 x86 上将无法使用)。没有理由 ARM 或任何其他后端不能做同样的事情来节省整体代码,这不是设计决定。
但这就是汇编语言的问题:它正在生成 16 位结果,因为这就是二进制 / 2 的补码加法的工作原理 (and several other operations where high garbage doesn't affect the low bits)。它还生成 8 位和 32 位结果,而不是将其截断为 16 位这一事实并不重要。 (我从你的评论中知道你的意思,现在我只是在挑剔我猜的措辞。)
但就 ARM 后端跳过截断步骤而言,不,它不能,因为 ARM 调用约定要求将返回值截断为 16 位。 这(调用约定)是相关的设计决策,可能是为了减少整体代码大小。 (我猜大多数函数的调用站点比返回路径要多)。
我理解挑剔...关键是要表明它正在返回一些垃圾,正如你所说,这是一个不正确的结果,因为它可能会溢出 16 位结果。以上是关于为啥 ARM 使用两条指令来屏蔽一个值?的主要内容,如果未能解决你的问题,请参考以下文章
使用Keil开发ARM编程问题,请编程高手指点一下:为啥用了一条#if(1)的预编译指令?1就表示条件为真
为啥 ARM 芯片的指令名称中带有 Javascript(FJCVTZS)?