在编写干净的 C 代码时利用 ARM 未对齐的内存访问
Take advantage of ARM unaligned memory access while writing clean C code
以前是ARM处理器无法正确处理未对齐的内存访问(ARMv5及以下)。如果 ptr
没有在 4 字节上正确对齐,像 u32 var32 = *(u32*)ptr;
这样的东西只会失败(引发异常)。
写这样的声明对于 x86/x64 来说会很好,因为这些 CPU 总是非常有效地处理这种情况。但是按照C标准,这不是"proper"的写法。 u32
显然等同于一个 4 字节的结构, 必须 在 4 字节上对齐。
在保持正统正确性 和 确保与任何 cpu 完全兼容的同时实现相同结果的正确方法是:
u32 read32(const void* ptr)
{
u32 result;
memcpy(&result, ptr, 4);
return result;
}
这个是正确的,将为任何 cpu 能够或不能在未对齐位置读取的代码生成正确的代码。更好的是,在 x86/x64 上,它针对单个读取操作进行了适当优化,因此具有与第一条语句相同的性能。它便携、安全、快速。谁能问更多?
嗯,问题是,在 ARM 上,我们就没那么幸运了。
写memcpy
版本确实是安全的,但是好像是系统性的谨慎操作,对于ARMv6和ARMv7(基本上任何智能手机)都非常慢。
在严重依赖读取操作的以性能为导向的应用程序中,可以测量第 1 版和第 2 版之间的差异:在 > 5x 在 gcc -O2
设置。这太多了,不容忽视。
试图找到一种使用 ARMv6/v7 功能的方法,我在周围的一些示例代码中寻找指导。不幸的是,他们似乎 select 第一个声明(直接 u32
访问),这不应该是正确的。
这还不是全部:新的 GCC 版本现在正在尝试实现自动矢量化。在 x64 上,这意味着 SSE/AVX,在 ARMv7 上,这意味着 NEON。 ARMv7 还支持一些新的 "Load Multiple" (LDM) 和 "Store Multiple" (STM) 操作码,需要 指针对齐。
这是什么意思?好吧,编译器可以自由使用这些高级指令,即使它们不是专门从 C 代码调用的(非内部指令)。为了做出这样的决定,它使用了 u32* pointer
应该在 4 个字节上对齐的事实。如果不是,那么所有赌注都将关闭:未定义的行为,崩溃。
这意味着即使在支持未对齐内存访问的 CPU 上,现在使用直接 u32
访问也是危险的,因为它可能导致在高优化设置下生成错误代码(-O3
).
所以现在,这是一个难题:如何访问 ARMv6/v7 未对齐内存访问的本机性能 而 写入不正确的版本 u32
访问?
PS : 我也试过 __packed()
指令,从性能的角度来看,它们似乎与 memcpy
方法完全一样。
[编辑] : 感谢迄今为止收到的优秀元素。
查看生成的程序集,我可以确认@Notlikethat 发现 memcpy
版本确实生成了正确的 ldr
操作码(未对齐加载)。但是,我还发现生成的程序集无用地调用 str
(命令)。所以完整的操作现在是一个未对齐的加载,一个对齐的存储,然后是一个最终的对齐加载。这比必要的工作要多得多。
回答@haneefmubarak,是的,代码已正确内联。不,memcpy
远未提供最佳速度,因为强制代码接受直接 u32
访问会转化为巨大的性能提升。所以一定存在更好的可能性。
非常感谢@artless_noise。 link 至高无上的服务是无价的。我从来没有能够如此清楚地看到 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
很有说服力....
部分问题可能是您没有考虑到简单的内联性和进一步优化。具有用于加载的专用函数意味着每次调用时都可能发出函数调用,这可能会降低性能。
您可能会做的一件事是使用 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()
和朋友为所有内容分配正确对齐的内存:
The malloc() and calloc() functions return a pointer to the allocated memory that is suitably aligned for any kind of variable.
这应该相对较快,因为您只需为每组数据执行一次。此外,在复制它时,memcpy()
将只能针对初始对齐不足进行调整,然后使用可用的最快对齐加载和存储指令,之后您将能够使用正常对齐处理数据以全性能读取和写入。
好吧,情况比人们想象的还要混乱。因此,为了澄清,这里是这次旅程的发现:
访问未对齐的内存
- 访问未对齐内存的唯一 portable C 标准解决方案是
memcpy
解决方案。我希望通过这个问题得到另一个,但显然这是迄今为止唯一找到的。
示例代码:
u32 read32(const void* ptr) {
u32 value;
memcpy(&value, ptr, sizeof(value));
return value; }
此解决方案在所有情况下都是安全的。它还使用 GCC 在 x86 目标上编译成一个简单的 load register
操作。
但是,在使用 GCC 的 ARM 目标上,它会转化为一个太大且无用的汇编序列,这会降低性能。
在 ARM 目标上使用 Clang,memcpy
工作正常(请参阅下面的@notlikethat 评论)。很容易将整个问题归咎于 GCC,但事情并非如此简单:memcpy
解决方案在具有 x86/x64、PPC 和 ARM64 目标的 GCC 上运行良好。最后,尝试另一个编译器 icc13,memcpy 版本在 x86/x64 上出奇地重(4 条指令,而一条应该足够了)。这只是我到目前为止可以测试的组合。
要感谢godbolt的项目才能做出这样的说法easy to observe.
- 第二种解决方案是使用
__packed
结构。此解决方案不是 C 标准,完全取决于编译器的扩展。因此,编写它的方式取决于编译器,有时还取决于它的版本。这是维护portable代码的一团乱麻。
也就是说,在大多数情况下,它会生成比 memcpy
更好的代码生成。在大多数情况下仅...
例如,对于上述 memcpy
解决方案不起作用的情况,以下是调查结果:
- 在带 ICC 的 x86 上:
__packed
解决方案有效
- 在带有 GCC 的 ARMv7 上:
__packed
解决方案有效
在带有 GCC 的 ARMv6 上:不起作用。组装看起来比 memcpy
.
还要丑
- 最后一个解决方案是使用直接
u32
访问未对齐的内存位置。此解决方案过去在 x86 cpu 上工作了几十年,但不推荐使用,因为它违反了一些 C 标准原则:编译器被授权将此声明视为数据正确对齐的保证,从而导致错误代码生成。
不幸的是,至少在一种情况下,它是唯一能够从目标中提取性能的解决方案。即用于 ARMv6 上的 GCC。
但不要将此解决方案用于 ARMv7:GCC 可以生成为对齐内存访问保留的指令,即 LDM
(加载多个),导致崩溃。
即使在 x86/x64,如今以这种方式编写代码也变得很危险,因为新一代编译器可能会尝试自动向量化一些兼容的循环,生成 SSE/AVX 代码 基于这些内存位置正确对齐的假设,导致程序崩溃
作为回顾,这里是总结为 table 的结果,使用约定: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 |
以前是ARM处理器无法正确处理未对齐的内存访问(ARMv5及以下)。如果 ptr
没有在 4 字节上正确对齐,像 u32 var32 = *(u32*)ptr;
这样的东西只会失败(引发异常)。
写这样的声明对于 x86/x64 来说会很好,因为这些 CPU 总是非常有效地处理这种情况。但是按照C标准,这不是"proper"的写法。 u32
显然等同于一个 4 字节的结构, 必须 在 4 字节上对齐。
在保持正统正确性 和 确保与任何 cpu 完全兼容的同时实现相同结果的正确方法是:
u32 read32(const void* ptr)
{
u32 result;
memcpy(&result, ptr, 4);
return result;
}
这个是正确的,将为任何 cpu 能够或不能在未对齐位置读取的代码生成正确的代码。更好的是,在 x86/x64 上,它针对单个读取操作进行了适当优化,因此具有与第一条语句相同的性能。它便携、安全、快速。谁能问更多?
嗯,问题是,在 ARM 上,我们就没那么幸运了。
写memcpy
版本确实是安全的,但是好像是系统性的谨慎操作,对于ARMv6和ARMv7(基本上任何智能手机)都非常慢。
在严重依赖读取操作的以性能为导向的应用程序中,可以测量第 1 版和第 2 版之间的差异:在 > 5x 在 gcc -O2
设置。这太多了,不容忽视。
试图找到一种使用 ARMv6/v7 功能的方法,我在周围的一些示例代码中寻找指导。不幸的是,他们似乎 select 第一个声明(直接 u32
访问),这不应该是正确的。
这还不是全部:新的 GCC 版本现在正在尝试实现自动矢量化。在 x64 上,这意味着 SSE/AVX,在 ARMv7 上,这意味着 NEON。 ARMv7 还支持一些新的 "Load Multiple" (LDM) 和 "Store Multiple" (STM) 操作码,需要 指针对齐。
这是什么意思?好吧,编译器可以自由使用这些高级指令,即使它们不是专门从 C 代码调用的(非内部指令)。为了做出这样的决定,它使用了 u32* pointer
应该在 4 个字节上对齐的事实。如果不是,那么所有赌注都将关闭:未定义的行为,崩溃。
这意味着即使在支持未对齐内存访问的 CPU 上,现在使用直接 u32
访问也是危险的,因为它可能导致在高优化设置下生成错误代码(-O3
).
所以现在,这是一个难题:如何访问 ARMv6/v7 未对齐内存访问的本机性能 而 写入不正确的版本 u32
访问?
PS : 我也试过 __packed()
指令,从性能的角度来看,它们似乎与 memcpy
方法完全一样。
[编辑] : 感谢迄今为止收到的优秀元素。
查看生成的程序集,我可以确认@Notlikethat 发现 memcpy
版本确实生成了正确的 ldr
操作码(未对齐加载)。但是,我还发现生成的程序集无用地调用 str
(命令)。所以完整的操作现在是一个未对齐的加载,一个对齐的存储,然后是一个最终的对齐加载。这比必要的工作要多得多。
回答@haneefmubarak,是的,代码已正确内联。不,memcpy
远未提供最佳速度,因为强制代码接受直接 u32
访问会转化为巨大的性能提升。所以一定存在更好的可能性。
非常感谢@artless_noise。 link 至高无上的服务是无价的。我从来没有能够如此清楚地看到 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
很有说服力....
部分问题可能是您没有考虑到简单的内联性和进一步优化。具有用于加载的专用函数意味着每次调用时都可能发出函数调用,这可能会降低性能。
您可能会做的一件事是使用 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()
和朋友为所有内容分配正确对齐的内存:
The malloc() and calloc() functions return a pointer to the allocated memory that is suitably aligned for any kind of variable.
这应该相对较快,因为您只需为每组数据执行一次。此外,在复制它时,memcpy()
将只能针对初始对齐不足进行调整,然后使用可用的最快对齐加载和存储指令,之后您将能够使用正常对齐处理数据以全性能读取和写入。
好吧,情况比人们想象的还要混乱。因此,为了澄清,这里是这次旅程的发现:
访问未对齐的内存
- 访问未对齐内存的唯一 portable C 标准解决方案是
memcpy
解决方案。我希望通过这个问题得到另一个,但显然这是迄今为止唯一找到的。
示例代码:
u32 read32(const void* ptr) {
u32 value;
memcpy(&value, ptr, sizeof(value));
return value; }
此解决方案在所有情况下都是安全的。它还使用 GCC 在 x86 目标上编译成一个简单的 load register
操作。
但是,在使用 GCC 的 ARM 目标上,它会转化为一个太大且无用的汇编序列,这会降低性能。
在 ARM 目标上使用 Clang,memcpy
工作正常(请参阅下面的@notlikethat 评论)。很容易将整个问题归咎于 GCC,但事情并非如此简单:memcpy
解决方案在具有 x86/x64、PPC 和 ARM64 目标的 GCC 上运行良好。最后,尝试另一个编译器 icc13,memcpy 版本在 x86/x64 上出奇地重(4 条指令,而一条应该足够了)。这只是我到目前为止可以测试的组合。
要感谢godbolt的项目才能做出这样的说法easy to observe.
- 第二种解决方案是使用
__packed
结构。此解决方案不是 C 标准,完全取决于编译器的扩展。因此,编写它的方式取决于编译器,有时还取决于它的版本。这是维护portable代码的一团乱麻。
也就是说,在大多数情况下,它会生成比 memcpy
更好的代码生成。在大多数情况下仅...
例如,对于上述 memcpy
解决方案不起作用的情况,以下是调查结果:
- 在带 ICC 的 x86 上:
__packed
解决方案有效 - 在带有 GCC 的 ARMv7 上:
__packed
解决方案有效 在带有 GCC 的 ARMv6 上:不起作用。组装看起来比
还要丑memcpy
.- 最后一个解决方案是使用直接
u32
访问未对齐的内存位置。此解决方案过去在 x86 cpu 上工作了几十年,但不推荐使用,因为它违反了一些 C 标准原则:编译器被授权将此声明视为数据正确对齐的保证,从而导致错误代码生成。
- 最后一个解决方案是使用直接
不幸的是,至少在一种情况下,它是唯一能够从目标中提取性能的解决方案。即用于 ARMv6 上的 GCC。
但不要将此解决方案用于 ARMv7:GCC 可以生成为对齐内存访问保留的指令,即 LDM
(加载多个),导致崩溃。
即使在 x86/x64,如今以这种方式编写代码也变得很危险,因为新一代编译器可能会尝试自动向量化一些兼容的循环,生成 SSE/AVX 代码 基于这些内存位置正确对齐的假设,导致程序崩溃
作为回顾,这里是总结为 table 的结果,使用约定: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 |