在编写干净的C代码时,充分利用ARM未对齐的内存访问

Cya*_*yan 18 c arm memory-alignment

过去,ARM处理器无法正确处理未对齐的内存访问(ARMv5及更低版本).喜欢的东西u32 var32 = *(u32*)ptr;也只是失败(引发异常),如果ptr没有正确的4字节对齐.

编写这样的语句对于x86/x64可以正常工作,因为这些CPU总是非常有效地处理这种情况.但根据C标准,这不是一种"正确"的写作方式.u32显然等同于4个字节的结构,必须在4个字节上对齐.

在保持正统正确性确保与任何cpu完全兼容的同时获得相同结果的正确方法是:

u32 read32(const void* ptr) 
{ 
    u32 result; 
    memcpy(&result, ptr, 4); 
    return result; 
}
Run Code Online (Sandbox Code Playgroud)

这个是正确的,将为任何能够或不在未对齐位置读取的CPU生成适当的代码.更好的是,在x86/x64上,它已针对单个读取操作进行了适当优化,因此具有与第一个语句相同的性能.它便携,安全,快速.谁能问更多?

好吧,问题是,在ARM上,我们没有那么幸运.

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

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

试图找到一种使用ARMv6/v7功能的方法,我寻找了几个示例代码的指导.不幸的是,他们似乎选择了第一个声明(直接u32访问),这不应该是正确的.

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

那是什么意思 ?好吧,编译器可以自由地使用这些高级指令,即使它们没有从C代码中特别调用(没有内在的).为了做出这样的决定,它使用a u32* pointer应该在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; 
}
Run Code Online (Sandbox Code Playgroud)

一旦使用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
Run Code Online (Sandbox Code Playgroud)

说得好......

Cya*_*yan 14

好吧,情况比人们想要的更令人困惑.所以,为了澄清,以下是这次旅程的发现:

访问未对齐的内存

  1. 访问未对齐内存的唯一便携式C标准解决方案就是其中memcpy之一.我希望通过这个问题得到另一个,但显然它是迄今为止发现的唯一一个.

示例代码:

u32 read32(const void* ptr)  { 
    u32 value; 
    memcpy(&value, ptr, sizeof(value)); 
    return value;  }
Run Code Online (Sandbox Code Playgroud)

该解决方案在所有情况下都是安全的 它还load register使用GCC 编译为x86目标上的一个简单操作.

但是,在使用GCC的ARM目标上,它转换为一种太大且无用的装配顺序,这会降低性能.

在ARM目标上使用Clang,memcpy工作正常(请参阅下面的@notlikethat评论).将GCC归咎于GCC很容易,但事情并非如此简单:该memcpy解决方案在GCC上运行时可以使用x86/x64,PPC和ARM64目标.最后,尝试另一个编译器icc13,memcpy版本在x86/x64(4个指令,而一个应该足够)上出乎意料地重.这就是我到目前为止可以测试的组合.

我要感谢godbolt的项目,使这些陈述易于观察.

  1. 第二种解决方案是使用__packed结构.此解决方案不是C标准,完全取决于编译器的扩展.因此,编写它的方式取决于编译器,有时取决于其版本.这对于维护便携式代码来说是一团糟.

话虽如此,在大多数情况下,它会导致更好的代码生成memcpy.在大多数情况下只...

例如,对于memcpy解决方案不起作用的上述情况,以下是调查结果:

  • 在x86上使用ICC:__packed解决方案有效
  • 在带有GCC的ARMv7上:__packed解决方案有效
  • 在带有GCC的ARMv6上:不起作用.装配看起来甚至比memcpy.

    1. 最后一种解决方案是使用直接u32访问未对齐的内存位置.此解决方案过去在x86 cpu上工作了几十年,但不建议这样做,因为它违反了一些C标准原则:编译器被授权将此语句视为数据正确对齐的保证,从而导致错误的代码生成.

不幸的是,至少在一种情况下,它是唯一能够从目标中提取性能的解决方案.即用于ARMv6上的GCC.

不要将此解决方案用于ARMv7:GCC可以生成为对齐的内存访问保留的指令,即LDM(加载多个),导致崩溃.

即使在x86/x64上,现在以这种方式编写代码也变得很危险,因为新一代编译器可能会尝试自动向量化一些兼容的循环,基于这些内存位置正确对齐的假设生成SSE/AVX代码,崩溃该程序.

作为回顾,以下是使用约定汇总为表的结果:memcpy> packed> direct.

| 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    |
Run Code Online (Sandbox Code Playgroud)

  • 这个图表很方便,但似乎从 gcc 5 开始,“-march=armv7-a”就可以与“memcpy()”变体配合使用。问题在于旧版 ARM CPU 处理未对齐读/写的方式。因此,任何在 2019 年阅读这篇文章的人都应该意识到“-march”值将对事情产生重大影响。GCC ARM 后端(和基础设施)可能已更新为 grok,较新的 ARM cpu 可以进行未对齐访问。有关该主题的更多信息,请参阅:[Linux 捕获未对齐访问](/sf/ask/1158364161/)。 (2认同)

han*_*rak 5

部分问题可能是您不允许轻松内联和进一步优化。拥有专门的加载函数意味着每次调用时都可能会发出函数调用,这可能会降低性能。

您可以做的一件事是 use 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;
}
Run Code Online (Sandbox Code Playgroud)

这只是使用分配一个新数组malloc(),因为malloc()和朋友为所有内容分配正确对齐的内存:

malloc() 和 calloc() 函数返回指向已分配内存的指针,该指针适合任何类型的变量。

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

这应该相对较快,因为每组数据只需执行一次。此外,在复制它时,memcpy()将能够仅针对初始缺乏对齐进行调整,然后使用可用的最快对齐加载和存储指令,之后您将能够使用正常对齐读取和写入来处理数据表现。