为什么ARM使用两条指令来掩盖值?

时间:2017-12-12 20:07:26

标签: c++ gcc assembly arm

对于以下功能...

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吗?

4 个答案:

答案 0 :(得分:7)

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

所以,我们可以从内存加载并执行and(慢), 用2条指令创建值,1到和(更长), 或者只是便宜地转移两次并称之为好。

编译器选择了轮班,老实说,这很快。

现在进行现实检查:

担心单一班次,除非这是一个100%肯定的瓶颈是浪费时间。即使编译器是次优的,你几乎也不会感​​觉到它。担心代码中的“热”循环而不是像这样的微操作。从好奇心看这个很棒。担心这个确切的代码可以在您的应用中获得性能,而不是那么多。

编辑:

其他人已经注意到,ARM规范的新版本允许更有效地完成此类事情。这表明,在这个级别进行讨论时,重要的是指定芯片或至少指定我们正在处理的精确ARM规范。我从你的输出中缺少“更新”的指令来假设古老的ARM。如果我们正在跟踪编译器错误,那么这个假设可能不成立,并且知道规范甚至更重要。对于像这样的交换,在更高版本中确实有更简单的指令来处理它。

编辑2

可能做得更快的一件事就是让它内联。在这种情况下,编译器可以将这些操作与其他工作交错。根据CPU的不同,这可能会使吞吐量翻倍,因为许多ARM CPU都有2个整数指令流水线。尽可能地扩展说明,以便没有危险,然后就可以了。这必须权衡I-Cache的使用情况,但在重要的情况下,你可以看到更好的东西。

答案 1 :(得分:3)

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

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

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

(请注意,x86调用约定不是这样的:允许返回值具有高垃圾。(可能因为调用者可以简单地使用16位或8位部分寄存器)。输入值也是如此,except as an undocumented part of the x86-64 System V ABI:clang依赖于输入值为sign / zero扩展为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,可以使用更短的序列。 GCC只有在我们将其手持到我们想要的asm时才会找到它:

// 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,它也会编译为相同的代码。< / p>

All source + asm on the Godbolt compiler explorer

答案 2 :(得分:1)

ARM是RISC机器(高级RISC机器),因此,所有instrutcions都以相同的大小编码,上限为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是不必要的。

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

答案 3 :(得分: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   

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

请注意,对于足够大的项目,很容易找到错过的优化机会。没理由期望优化器是完美的(它不可能)。他们只需要比人类手动为平均大小的项目更有效率。

这就是为什么通常会说为了性能调优你没有预先优化或只是立即跳转到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他们认识并解决了这些问题,你不能指望程序员总是使用自然大小的变量,有些人觉得需要使用不太理想的大小变量。有时你还需要掩饰到一定的尺寸。