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

8

For the following function...

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 r0, r0, #4294901760


4
虽然我不是专家,但我认为加载常量需要比使用已经在寄存器中的数值更多的时间。就像使用 xor r0, r0 而不是加载 0。 - ZeroUltimax
存在许多情况,编译器有时可以做得更好,但要么它无法看到可以应用优化的地方,要么它尚未实现,或者在实际生活中并不重要,所以它并不会费心去做,或者这是速度/空间的权衡等。编译器并不是魔法。如果您认为它可以做得更好,请参与其中并为您的编译器提交一个补丁,造福我们所有人 :) - Jesper Juhl
在ARMv6或更高版本上,编译器应该只需发出REV16 r0,r0 - fuz
旧版的ARM汇编不能轻松地创建常量。相反,它们被加载到文字池中,然后通过内存加载读取。您建议的这个“and”只能使用带有移位的8位文字。您的0xFFFF0000需要16位才能作为1条指令执行。因此,我们可以从内存中加载和and(慢速),需要2条指令来创建该值并且1条指令进行and(更长),或者便宜地移位两次。 - Michael Dorgan
@fuz:此外,使用-march=armv6,gcc将使用uxth r0, r0(https://godbolt.org/g/P3ASv3)将其截断为16位,以防调用者在`r0`中留下高垃圾。因此,ARMv6也回答了实际的问题。 - Peter Cordes
显示剩余2条评论
4个回答

8
较旧的ARM汇编不能轻松创建常量。相反,它们被加载到文字池中,然后通过内存加载读取。你建议的这个“and”只能使用一个8位的移位文字。你的“0xFFFF0000”需要16位才能完成1个指令。
因此,我们可以从内存加载并执行“and”(较慢),需要2个指令来创建值和1个指令执行“and”(更长),或者只需便宜地移位两次即可。
编译器选择了移位,老实说,它已经足够快了。
现在进行现实检查:
担心单个移位,除非这是100%肯定的瓶颈,否则是浪费时间的。即使编译器不太优化,你几乎永远不会感觉到它。相反,应该关注代码中“热点”循环等微操作。从好奇的角度看待这个问题很棒。但如果为了应用程序性能而担心这个确切的代码,则不太值得。
编辑:
其他人指出,较新版本的ARM规范允许更有效地执行此类操作。这表明,在这个层面上交流时,指定芯片或至少确切的ARM规范是很重要的。我假设是古老的ARM,因为你的输出中没有给出“更新”的指令。如果我们正在跟踪编译器错误,则这个假设可能不成立,了解规范更加重要。对于这样的交换,确实有更简单的指令可以在后续版本中处理。
编辑2:
可能使其更快的一件事是将其内联。在这种情况下,编译器可以将这些操作与其他工作交错执行。根据CPU的不同,这可以将吞吐量提高一倍,因为许多ARM CPU具有2个整数指令管道。将指令分散到足够远的位置,以便没有危险,并且它就可以运行了。这必须权衡I-Cache使用,但在关键情况下,您可以看到更好的效果。

2
值得担心的不是某个具体的应用程序,而是你是否应该报告一个错过优化gcc错误,以便编译器可以为未来的所有人生成稍微更快和/或更小的代码。 - Peter Cordes
编译器为什么要关注寄存器的上半部分(据我所知)?寄存器的uint16_t部分肯定是正确设置的。EABI是否规定返回值必须在整个寄存器中返回,而不仅仅是在sizeof(return val) < sizeof(register)的部分中返回? - Vroomfondel
ARM没有16位寄存器,因此需要清除顶部的位。我非常确定,在寄存器的上半部分返回“垃圾”将违反规则,无论是否指定了16位宽度。 - Michael Dorgan
内联这样一个微小的函数大多数情况下可能是有益的,尽管ARM能够独特地保存/恢复更多的调用保留寄存器而不需要额外的代码大小(在push/pop中有更多的设置位)。但是,每次调用函数都需要指令,并且会破坏R0-R3,因此即使纯粹考虑代码大小也很接近。如果编译器不需要将高16位清零,例如因为它知道正在使用strh来处理结果值,则可以节省一条指令。(@Vroomfondel: 是的,请参见我的答案,使用更简单的函数进行测试显示ABI需要零扩展输入/输出) - Peter Cordes

3
这里存在一个未被优化的问题,但是and不是丢失的部分。生成一个16位常数并不便宜。对于循环来说,生成一个常数并在循环内仅使用and会带来收益。(TODO: 在数组循环中调用swap,看看我们能得到什么样的代码。) 对于乱序CPU来说,值得一提的是,使用多个关键路径之外的指令来构建常数也许是有价值的,这样你只需要一个AND就可以了,而不是两个移位操作。但这可能很罕见,并且不是gcc选择的方式。
根据编译器输出的简单函数,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 v6之前的版本,可以使用更短的指令序列。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的旋转习惯识别,因此即使使用-march=armv6编译器仍会编译成相同的代码,而简单版本则编译为rev16/uxth

在Godbolt编译器浏览器上查看所有源码和汇编代码


2
ARM是一种精简指令集计算机(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。也就是说,你希望让编译器为相同的x CSE多个swap(x)。如果你使用uint32_t参数编写函数,你可以摆脱uxth,但是在大多数情况下,这只是将uxth转嫁给了调用者。我尝试使用uint32_t resultif (result > 0xFFFFu) __builtin_unreachable();来保证编译器上2个字节保持为零,但没有成功。https://godbolt.org/g/NEidyW - Peter Cordes
如果你在ARMv6上,最好让rev16/uxth内联,而不是实际编写汇编函数。特别是如果通过交换进行常量传播的话。或者编写一个C包装器,使用__builtin_constant_p来决定是调用汇编版本还是使用纯C交换。 - Peter Cordes
我的意思是整个函数,包括调用者,调用者的调用者,以及调用者的调用者...... :-) - Jake 'Alquimista' LEE
如果您使用最新的gcc版本并给它适当的编译器选项(例如-march=armv7-a),则__builtin_bswap16(value)将生成'rev16'。也许godboltDoes ARM have a builtin_rev是相关的。这对于在ARM CPU上更新代码或与不同CPU和GCC一起使用非常有用。 - artless noise
@artlessnoise 直到现在,GCC 终于能够正确编译它了,这花费了将近20年的时间。 - Jake 'Alquimista' LEE

0

这是最优解,使用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   

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

请注意,对于足够大的项目,很容易找到错过的优化机会。不要指望优化器完美(它不是也不能)。它们只需要比手动操作更高效,以平均每个大小的项目为基础。

这就是为什么通常说,在性能调整中,您不会预先优化或立即跳转到汇编语言,而是使用高级语言和编译器,通过某种方式进行分析以查找性能问题,然后手动编写代码。为什么手动编码?因为我们知道有时可以超越编译器,意味着编译器输出可以得到改进。

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

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 - Peter Cordes
但是就 ARM 后端跳过截断步骤而言,不行,因为 ARM 调用约定要求返回值被截断为 16 位。这个(调用约定)是相关的设计决策,可能是为了减少总代码大小而采取的。(我猜大多数函数有更多的调用点而不是返回路径)。 - Peter Cordes
我理解你的挑剔……整个意思是要表明它返回了一些垃圾,正如你所说,或者像我所说的那样返回了错误的结果,因为它可能会溢出16位结果。 - old_timer
你被现有编译器的惯例所束缚,而我谈论的是另一件事情。 - old_timer
对于 x86-64 System V, 是的,gcc 的开发者们参与了调用约定的设计。 对于 32 位系统,i386 System V ABI 是从早期系统继承而来的,我认为是在 gcc 被移植到该目标之前。 对于针对 Windows x86-64 调用约定的 gcc,gcc 没有选择(除了没有编译单元外没有调用者的私有“static”函数。当它们不自定义调用约定时,这是一个次要的错过优化)。 - Peter Cordes
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接