使用AVX/AVX2指令集的对齐和非对齐内存访问

15
根据英特尔的软件开发手册(第14.9节),AVX放宽了内存访问的对齐要求。如果数据直接在处理指令中加载,例如:
vaddps ymm0,ymm0,YMMWORD PTR [rax]

负载地址不必对齐。但是,如果使用专用的对齐负载指令,例如

vmovaps ymm0,YMMWORD PTR [rax]

如果负载地址未对齐(必须为32的倍数),则会引发异常。

让我困惑的是来自内置函数的自动代码生成,例如在我的情况下由gcc/g++(4.6.3,Linux)生成,请查看以下测试代码:

#include <x86intrin.h>
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

#define SIZE (1L << 26)
#define OFFSET 1

int main() {
  float *data;
  assert(!posix_memalign((void**)&data, 32, SIZE*sizeof(float)));
  for (unsigned i = 0; i < SIZE; i++) data[i] = drand48();
  float res[8]  __attribute__ ((aligned(32)));
  __m256 sum = _mm256_setzero_ps(), elem;
  for (float *d = data + OFFSET; d < data + SIZE - 8; d += 8) {
    elem = _mm256_load_ps(d);
    // sum = _mm256_add_ps(elem, elem);
    sum = _mm256_add_ps(sum, elem);
  }
  _mm256_store_ps(res, sum);
  for (int i = 0; i < 8; i++) printf("%g ", res[i]); printf("\n");
  return 0;
}

(是的,我知道代码有缺陷,因为我在未对齐的地址上使用了对齐加载,但请谅解...)

我使用以下命令编译代码

g++ -Wall -O3 -march=native -o memtest memtest.C

在拥有AVX的CPU上。如果我使用g++检查生成的代码,则会得到

objdump -S -M intel-mnemonic memtest | more

我看到编译器没有生成对齐的加载指令,而是直接在向量加法指令中加载数据:

vaddps ymm0,ymm0,YMMWORD PTR [rax]

即使内存地址不对齐 (OFFSET 是 1),代码也能正常执行。这是因为 vaddps 可以容忍不对齐的地址。

如果我取消第二个加法 intrinsic 的注释,编译器就无法将加载和加法合并,因为 vaddps 只能有一个内存源操作数,因此会生成:

vmovaps ymm0,YMMWORD PTR [rax]
vaddps ymm1,ymm0,ymm0
vaddps ymm0,ymm1,ymm0

现在程序出现段错误,因为使用了专用的对齐加载指令,但是内存地址没有对齐。(顺便说一下,如果我使用_mm256_loadu_ps,或者将OFFSET设置为0,程序就不会出现段错误。)

这使得程序员受制于编译器,行为部分不可预测,在我看来很令人担忧。

我的问题是:有没有一种方法可以强制C编译器生成一个直接加载处理指令(例如vaddps),或者生成一个专用的加载指令(例如vmovaps)?


1
做这样做的动机是什么?如果您不知道数据是否正确对齐,请使用未对齐的加载。我不会说您完全受编译器支配;如果您告诉它使用对齐的加载,如果指针未对齐,我不会感到惊讶,它可能会导致段错误。在某些情况下,编译器将发出代码以解决您的错误,这只是锦上添花。 - Jason R
2
最近,编译器开始从不生成对齐内存访问。这使得不必区分更容易,并且从 Nehalem 开始的所有处理器上都没有性能惩罚。个人而言,我宁愿让它崩溃,这样就可以让我知道我可能存在性能漏洞。 - Mysticial
@JasonR:我发现这种行为不一致。也许我应该再加入另一个变化:如果我在原始代码中使用_mm256_loadu_ps,gcc会生成一个未对齐的加载vmovups和一个在寄存器操作数上工作的vaddps,而它完全可以只生成一个带有内存操作数的vaddps指令,因为它可以容忍未对齐的地址。 - Ralf
1
@Ralf,Visual Studio在VS2013左右开始这样做。Intel编译器在ICC11和ICC13之间的某个时间开始这样做。但我不确定GCC是否也这样做(如果它真的这样做了)。 - Mysticial
1
我相信当前版本的gcc和clang都会生成对齐的移动指令,无论是在被询问时还是自动生成时。这可能会在某些情况下引起问题,例如如果堆栈没有正确对齐,则将SSE/AVX寄存器类型溢出到堆栈可能会导致分段错误。 - Jason R
显示剩余4条评论
2个回答

7
无法使用内置函数来显式控制负载的折叠。我认为这是内置函数的一个弱点。如果您想显式控制折叠,则必须使用汇编语言。在以前的GCC版本中,我能够使用对齐或未对齐的加载在一定程度上控制折叠。然而,这似乎不再是情况(GCC 4.9.2)。例如,在函数AddDot4x4_vec_block_8widehere中,负载被折叠了。
vmulps  ymm9, ymm0, YMMWORD PTR [rax-256]
vaddps  ymm8, ymm9, ymm8

然而,在先前的GCC版本中,装载操作未被折叠

vmovups ymm9, YMMWORD PTR [rax-256]
vmulps  ymm9, ymm0, ymm9
vaddps  ymm8, ymm8, ymm9

正确的解决方案显然是只在您知道数据对齐时使用对齐加载,如果您真的想要明确控制折叠,请使用汇编语言。

1
没有编译器会将该负载折叠到 vaddps 中,因为它需要来自内存的数据作为 两个 操作数。如果您还没有使用 AVX 进行测试,您可能需要再次测试,因为此示例不是测试编译器是否将负载折叠到后续指令中作为内存操作数的好方法。(顺便说一下,融合是英特尔解码器对 uops 所做的事情。一些具有内存操作数的指令无法进行微型融合,例如 PINSRW。我喜欢使用“折叠”这个术语来描述用内存操作数替换负载的过程。) - Peter Cordes
@PeterCordes,你说得对。我也更喜欢你的术语“折叠”。我正在研究这个问题。我需要修正我的答案。给我一秒钟。 - Z boson
@PeterCordes,我之前在这里重新测试了一下(https://dev59.com/nWEi5IYBdhLWcg3wltIl),并且我没有看到GCC和MSVC之间的任何区别。虽然我没有仔细研究过,但我认为这是因为GCC现在产生的代码与MSVC基本相同。这令人失望。 - Z boson
2
@PeterCordes 我发现ICC15有时会折叠负载,即使这意味着重复它(多个折叠负载到同一地址)。这通常是在寄存器压力较大的情况下。 - Mysticial
1
@Mysticial:很酷。只要它可以使用单寄存器寻址模式,并且代码没有饱和负载端口,已经缓存的东西的微融合负载几乎是免费的。在未融合的领域中,负载uop可以在其他uop之前分派,因此它不会延长它所属的依赖链。(最近的英特尔不能对2个寄存器寻址模式进行微融合: https://dev59.com/jl8e5IYBdhLWcg3wJXrm#31027695。但这可能经常发生在RIP相对常量的负载中。) - Peter Cordes
显示剩余3条评论

4
除了Z玻色子的答案之外,我可以告诉您,问题可能是由于编译器假定内存区域是对齐的(因为__attribute__((aligned(32)))标记了数组)。在运行时,该属性可能不适用于堆栈上的值,因为堆栈仅具有16字节的对齐方式(请参见此处的错误,即使在撰写本文时仍然存在,尽管一些修复已经进入gcc 4.6中)。编译器有权选择实现内置函数的指令,因此它可能会折叠内存加载到计算指令中,也可能不会将其折叠(因为如前所述,内存区域应该对齐),并且它还有权在不进行折叠时使用vmovaps(因为内存区域被认为是对齐的)。
您可以尝试通过指定-mstackrealign-mpreferred-stack-boundary = 5(请参见此处)来强制编译器在main中实现32字节的堆栈重新对齐,但这会产生性能开销。

1
与SSE不同,AVX指令如vaddps ymm0, ymm1, [rsp+16]不需要它们的内存源操作数对齐。(除了vmovaps明确要求对齐检查;这就是编译器在矢量化时如果数组对齐不知道,则使用vmovups,或对于loadu / storeu内联函数等的原因。)GCC更喜欢对齐堆栈内存,如果它将使用AVX指令对其进行操作。 - Peter Cordes
@PeterCordes 是的,但编译器不一定需要生成带有内存操作数的 vaddps。它有权生成 vmovaps,这意味着用户的代码在确保数据正确对齐之前是不安全的。 - Andrey Semashev
是的,使用 _mm256_load_ps 可以做到那样。但你回答中关于为什么它可以折叠负载的解释是错误的。AVX 代码始终可以在无需先证明对齐性的情况下折叠负载,这是相对于 SSE 的一种改进。此外,当前的 GCC 确实知道如何根据需要过度对齐堆栈以支持 alignas(32) float res[8];__attribute__((aligned(32)))。请参见 https://godbolt.org/z/NmBBru 上的函数序言。即使 OP 的 gcc4.6 也有这个功能:https://godbolt.org/z/fZq5tT,但序言更加复杂。 - Peter Cordes
好的,我明白你的意思了,但不幸的是,你的回答表达的方式暗示了保证对齐与负载折叠有关。此外,__attribute__((aligned(32)))和C++11 alignas(32)在GCC上确实适用于本地数组。在Windows上的GCC存在一个不同的错误,即自动向量化可能无法对齐堆栈,但显式的__attribute__仍然可以在那里工作。 - Peter Cordes
就像我所说的那样,你提供的错误链接只是关于-mpreferred-stack-boundary=2破坏了__attribute__((aligned(16)))的情况。该选项通常不安全(违反ABI),而且OP也没有使用它,这是在GCC4.6之前修复的一个错误。 - Peter Cordes
显示剩余4条评论

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