利用ARM未对齐内存访问的优势,编写干净的C代码。

34

过去,ARM处理器无法正确处理未对齐的内存访问(ARMv5及以下版本)。如果ptr未在4字节上正确对齐,则类似于u32 var32 = *(u32*)ptr;的语句将失败(引发异常)。

对于x86 / x64,编写这样的语句将运行良好,因为这些CPU一直以来都非常有效地处理了这种情况。但根据C标准,这不是“正确”的编写方式。u32显然相当于一个4字节的结构,必须对齐在4字节上。

实现相同结果的正确方法是保持正统的正确性并确保与任何CPU的完全兼容性:

u32 read32(const void* ptr) 
{ 
    u32 result; 
    memcpy(&result, ptr, 4); 
    return result; 
}

这个是正确的,可以生成适用于任何CPU的代码,无论是否能够在不对齐的位置读取。更好的是,在x86 / x64上,它被优化为单个读操作,因此具有与第一个语句相同的性能。它是可移植的,安全的和快速的。还有谁能要求更多呢?

问题是,在ARM上,我们没有那么幸运。

编写memcpy版本确实是安全的,但似乎会导致系统谨慎操作,这对于ARMv6和ARMv7(基本上是任何智能手机)非常缓慢。

在一个重度依赖读操作的性能导向型应用程序中,第一版和第二版之间的差异可以测量:在gcc -O2设置下,它超过了> 5倍。这太大了,不能忽视。

试图找到一种使用ARMv6 / v7功能的方法,我查看了一些示例代码的指导。不幸的是,它们似乎选择了第一个语句(直接u32访问),这是不正确的。

这还不是全部:新的GCC版本现在正在尝试实现自动向量化。在x64上,这意味着SSE/AVX,在ARMv7上,这意味着NEON。ARMv7还支持一些新的“加载多个”(LDM)和“存储多个”(STM)操作码,这些操作码需要指针对齐。

那是什么意思?好吧,即使没有从C代码中特别调用它们(没有内部函数),编译器也可以自由地使用这些高级指令。为了做出这样的决定,它使用了一个应该对齐在4个字节上的事实。如果不是这样,那么所有的赌注都是无效的:未定义的行为,崩溃。

这意味着即使在支持非对齐内存访问的CPU上,直接使用u32访问现在也是危险的,因为它可能会导致在高优化设置(-O3)下生成错误的代码。

所以现在,这是一个两难选择:如何在非对齐内存访问时访问ARMv6/v7的本机性能,而不编写不正确的版本u32访问?

PS:我也尝试过__packed()指令,从性能角度来看,它们似乎与memcpy方法完全相同。

[编辑]:非常感谢迄今为止收到的优秀建议。

查看生成的汇编代码,我可以确认@Notlikethat的发现,即memcpy版本确实生成了正确的ldr操作码(不对齐加载)。但是,我还发现生成的汇编代码无用地调用了str命令。因此,完整的操作现在是一个不对齐的加载,一个对齐的存储,然后是最终的对齐加载。这比必要的工作量要大得多。

回答@haneefmubarak,是的,代码已经被正确地内联了。而且,memcpy远远不能提供最佳速度,因为强制代码接受直接的u32访问会带来巨大的性能提升。因此,一些更好的可能性必须存在。

非常感谢@artless_noise。godbolt服务的链接是无价的。我从未能够如此清晰地看到C源代码及其汇编表示之间的等价关系。这是非常有启发性的。

我完成了@artless的一个示例,它给出了以下结果:

#include <stdlib.h>
#include <memory.h>
typedef unsigned int u32;

u32 reada32(const void* ptr) { return *(const u32*) ptr; }

u32 readu32(const void* ptr) 
{ 
    u32 result; 
    memcpy(&result, ptr, 4); 
    return result; 
}

使用ARM GCC 4.8.2编译时,选择-O3或-O2:

reada32(void const*):
    ldr r0, [r0]
    bx  lr
readu32(void const*):
    ldr r0, [r0]    @ unaligned
    sub sp, sp, #8
    str r0, [sp, #4]    @ unaligned
    ldr r0, [sp, #4]
    add sp, sp, #8
    bx  lr

相当有意思....


2
很遗憾,我怀疑你找不到比memcpy更快的东西。 - Adrian
使用u32并不危险。危险的是告诉编译器你比它更了解正在访问的东西(显式转换),而事实并非如此。 - unixsmurf
1
没有可复现的问题。使用Linaro GCC 4.8.3,带有-march=armv6和-O1,上述函数编译为基本上是ldr r0,[r0];str r0,[sp,#4];ldr r0,[sp,#4]。遗憾的是它无法完全省略对局部变量的使用,但这就是您的未对齐字加载;没有多个字节的加载或调用memcpy。 - Notlikethat
1
例如godbolt提供真实的输出和带有main函数的示例 - artless noise
谢谢这些有见地的要素。由于godbolt的帮助,我已经更新了问题的信息。 - Cyan
显示剩余14条评论
2个回答

25

好的,情况比人们想象的更加混乱。因此,为了澄清,以下是这个问题的研究结果:

访问不对齐的内存

  1. 访问不对齐的内存的唯一可移植的C标准解决方案是使用memcpy。我本来希望通过这个问题得到另一个解决方案,但显然目前只发现这一个。

示例代码:

u32 read32(const void* ptr)  { 
    u32 value; 
    memcpy(&value, ptr, sizeof(value)); 
    return value;  }

这个解决方案在所有情况下都是安全的。在x86目标使用GCC编译时,它还会编译成一个微不足道的load register操作。但是,在ARM目标上使用GCC,它会转换成一个过于庞大且无用的汇编序列,从而降低性能。

在ARM目标上使用Clang时,memcpy可以正常工作(请参见下面的@notlikethat评论)。很容易归咎于GCC,但事实并非如此:对于GCC与x86/x64、PPC和ARM64目标,memcpy解决方案表现良好。最后,尝试另一个编译器icc13,在x86/x64上,memcpy版本比预期更加沉重(4条指令,而应该只需要一条)。这只是我到目前为止可以测试的组合。

我要感谢godbolt项目提供了这样的语句,使情况变得易于观察

  1. 第二种解决方案是使用__packed结构。这个解决方案不符合C标准,完全依赖于编译器的扩展。因此,编写它的方式取决于编译器,有时还取决于其版本。这对于可移植代码的维护来说是一场灾难。

尽管如此,在大多数情况下,它比memcpy产生更好的代码生成。只适用于大多数情况...

例如,关于上述memcpy解决方案无法工作的情况,以下是研究结果:

  • 在ICC上的x86中:__packed解决方案有效。
  • 在GCC上的ARMv7中:__packed解决方案有效。
  • 在GCC上的ARMv6中:不起作用。汇编看起来甚至比

    | compiler  | x86/x64 | ARMv7  | ARMv6  | ARM64  |  PPC   |
    |-----------|---------|--------|--------|--------|--------|
    | GCC 4.8   | memcpy  | packed | direct | memcpy | memcpy |
    | clang 3.6 | memcpy  | memcpy | memcpy | memcpy |   ?    |
    | icc 13    | packed  | N/A    | N/A    | N/A    | N/A    |
    

2
这个图表很方便,但似乎自gcc 5以来,-march=armv7-a对于memcpy()变体已经足够了。问题在于旧的ARM CPU处理不对齐的读写方式。因此,阅读此帖子的任何人都应该意识到,在2019年目前,-march值会显着影响事情。可能GCC ARM后端(和基础架构)已经更新以适应新的ARM CPU可以进行未对齐访问。有关更多信息,请参见:Linux trapping un-aligned access - artless noise

5
问题的部分原因可能是你没有考虑易于内联和进一步优化。专门为加载创建一个函数意味着每次调用都可能会发出一个函数调用,从而降低性能。
你可以使用“static inline”,这将允许编译器内联函数“load32()”,从而提高性能。但是,在更高级别的优化下,编译器应该已经为您进行了内联操作。
如果编译器内联4字节的memcpy,则可能会将其转换为最有效的一系列负载或存储,这些存储仍适用于未对齐的边界。因此,即使启用了编译器优化,如果仍然看到性能低下,那么这可能就是您使用的处理器上未对齐读写的最大性能。由于您说“__packed指令”产生与memcpy()相同的性能,这似乎是真实情况。
此时,除了对齐数据外,几乎没有其他办法了。但是,如果您正在处理一组不连续的未对齐的u32,请注意以下内容:
#include <stdint.h>
#include <stdlib.h>

// get array of aligned u32
uint32_t *align32 (const void *p, size_t n) {
    uint32_t *r = malloc (n * sizeof (uint32_t));

    if (r)
        memcpy (r, p, n);

    return r;
}

这只是使用malloc()分配了一个新数组,因为malloc()和相关函数会为所有变量正确对齐分配内存:

malloc() 和 calloc() 函数返回适当对齐的指针,可以用于任何类型的变量。

- malloc(3), Linux 程序员手册

这应该相对快速,因为您应该只需要针对每组数据执行一次此操作。另外,在复制时,memcpy()将能够仅调整初始缺少对齐的部分,然后使用最快的对齐加载和存储指令,之后您将能够使用正常对齐读取和写入以实现全面性能。


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