为啥 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 &lt;&lt; 8 左移是一个问题,但value &gt;&gt; 8 不是(它不必担心将垃圾移到低 16 位)。

(请注意,x86 调用约定不是这样的:返回值允许有高垃圾。(可能是因为调用者可以简单地使用 16 位或 8 位部分寄存器)。输入值也是如此,@ 987654321@:clang 依赖于输入值被符号/零扩展为 32 位。GCC 在调用时提供此功能,但不假定为被调用者。)


ARMv6 具有a rev16 instruction,它可以字节交换寄存器的两个 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 也会编译为相同的代码。

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 resultif (result &gt; 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)?

ARM Cortex A9的流水线介绍

为啥要使用thumb模式,与ARM相比较,Thumb代码的两大优势是啥?

为啥ARM PC寄存器指向下一条要执行的指令之后?

关于ARM PC值