GCC 中的 `movaps` 与 `movups`:它是如何决定的?

`movaps` vs. `movups` in GCC: how does it decide?

我最近研究了一个用 GCC 8 编译的软件中的段错误。代码如下(这只是一个草图)

struct Point
{
  int64_t x, y;
};

struct Edge
{
  // some other fields
  // ...
  Point p; // <- at offset `0xC0`

  Edge(const Point &p) p(p) {}
};

Edge *create_edge(const Point &p)
{
  void *raw_memory = my_custom_allocator(sizeof(Edge));
  return new (raw_memory) Edge(p);
}

这里的重点是my_custom_allocator() returns指向未对齐内存的指针。代码崩溃是因为为了将原始点 p 复制到新对象的字段 Edge::p 中,编译器在 [inlined] 构造函数中使用了 movdqu/movaps 对代码

movdqu 0x0(%rbp), %xmm1  ; read the original object at `rbp`
...
movaps %xmm1, 0xc0(%rbx) ; store it into the new `Edge` object at `rbx` - crash!

起初,一切似乎都很清楚:内存未正确对齐,movaps 崩溃。我的错。

但是是吗?

试图在 Godbolt 上重现该问题,我观察到 GCC 8 实际上试图相当智能地处理它。当确定内存正确对齐时,它使用 movaps,就像在我的代码中一样。这个

#include <new>
#include <cstdlib>

struct P { unsigned long long x, y; };

unsigned char buffer[sizeof(P) * 100];

void *alloc()
{
  return buffer;
}

void foo(const P& s)
{
  void *raw = alloc();
  new (raw) P(s);
}

结果

foo(P const&):
    movdqu  xmm0, XMMWORD PTR [rsi]
    movaps  XMMWORD PTR buffer[rip], xmm0
    ret

https://godbolt.org/z/a3uSid

但是不确定的时候就用movups。例如。如果我在上面的例子中 "hide" 分配器的定义,它将在相同的代码中选择 movups

foo(P const&):
    push    rbx
    mov     rbx, rdi
    call    alloc()
    movdqu  xmm0, XMMWORD PTR [rbx]
    movups  XMMWORD PTR [rax], xmm0
    pop     rbx
    ret

https://godbolt.org/z/cNKe5A

那么,如果它应该那样做,为什么它在我在本文开头提到的软件中使用 movaps post?在我的例子中,编译器在调用时看不到 my_custom_allocator() 的实现,这就是我希望 GCC 选择 movups 的原因。

这里可能还有哪些其他因素在起作用?它是 GCC 中的错误吗?我如何强制 GCC 使用 movups,最好在任何地方使用?

由于 Edge 结构具有编译器确定的对齐要求,编译器可以自由假设该类型的所有对象都正确对齐。如果您的自定义分配器没有 return 指向正确对齐内存的指针,则您在该地址使用对象会导致未定义行为。

更新:alignof(Edge) 是 16,因为在 x86-64 System V 上是 long double,所以在不太对齐的地址有一个是 UB。这告诉 GCC 使用 movaps.

是安全的

IDK 为什么从 (%rbp) 加载它也没有使用 movaps。我认为隐含的 Edge 不会是 16 字节对齐的,所以这个答案的整个部分都是基于那个猜测(我移到了最后)。


某些类型可能需要 16 字节对齐,特别是 long double

alignof(max_align_t) == 16 on x86-64 System V. malloc 的直接替换需要 return 至少对齐的内存, 对于 16 字节或更大的分配。

(较小的分配当然不能容纳 16 字节的对象,因此不需要 16 字节对齐。您可以要求对象的特定实例与 alignas(16) int foo;,但如果类型本身具有更高的对齐方式,它也具有更大的 sizeof,因此数组仍将遵守正常规则,并且每个元素都满足对齐要求。)

另请参阅 where auto-vectorization with a misaligned uint16_t* leads to a segfault. Also Pascal Cuoq's blog about alignment,对象的对齐程度低于 alignof(T) 是未定义的行为,以及没有 UB 的假设对编译器有何影响。


指令选择

只要 GCC 和 clang 可以证明内存必须充分对齐,就使用 movaps(假设没有 UB)。在 Core2 及更早版本以及 K10 及更早版本上,即使内存恰好在运行时对齐,未对齐的存储指令也会很慢。

Nehalem 和 Bulldozer 改变了这一点,但 GCC 仍然使用 movaps,甚至 -mtune=haswell,甚至 vmovaps-march=haswell,即使它只能在 CPU 上执行便宜 vmovups.

MSVC 和 ICC 从不使用 movaps,这会损害非常旧的 CPU 上的性能,但有时会让您摆脱数据不对齐的情况。它们会将对齐的负载折叠到 SSE 指令的内存操作数中,例如 paddd xmm0, [rdi](这需要对齐,与 AVX1 等价物不同),因此它们仍然会在 有时 上生成错误的代码,但是通常只启用优化。 IMO 这不是特别好。


alignof(Point) 应该仅为 8(继承其最对齐成员的对齐方式,int64_t)。所以 GCC 只能证明任意 Point 的 8 字节对齐,而不是 16.

对于静态存储,GCC 可以知道它选择按 16 对齐数组,因此可以使用 movaps / movdqa 从中加载。 (此外,x86-64 System V ABI 要求 16 字节或更大的静态数组按 16 位对齐,因此 GCC 即使对于在某些其他编译单元中定义的 extern unsigned char buffer[] 全局变量也可以假定这一点。)

您没有显示 Edge 的定义,所以我想知道为什么它具有 16 字节对齐,但可能 alignof(Edge) == 16?否则是的,那可能是编译器错误。

但是它使用 movups 从堆栈加载原始 Edge 对象这一事实似乎表明 alignof(Edge) < 16


可能 raw_memory = __builtin_assume_aligned(raw_memory, 8); 有帮助? IDK 如果这可以告诉 GCC 假设比它已经认为它可以根据其他因素假设的对齐度更低


您可以告诉 GCC Edge(或 int 就此而言)可以通过定义这样的 typedef 始终欠对齐:

typedef long __attribute__((aligned(1), may_alias)) unaligned_aliasing_long;

may_alias 实际上与对齐正交,但值得一提,因为其中一个用例是从 char[] 缓冲区加载以解析字节流。在那种情况下,你会想要两者。这是使用 memcpy(tmp, src, sizeof(tmp)); 执行未对齐的严格别名安全加载的替代方法。

GCC 使用 may_alias 定义 __m128may_alias,aligned(1) 作为定义 _mm_loadu_ps 的一部分(未对齐的 SIMD 加载的内在函数,如 movups).(你不需要 may_aliasfloat 数组加载浮点向量,但你确实需要 may_alias 从某些东西加载它否则。)另见

并参阅 了解我认为对于欠对齐/别名 unsigned long 安全的标量代码,这与 glibc 的后备 C 实现不同。 (必须在没有 -flto 的情况下进行编译,因此它不能内联到其他 glibc 函数中,并且由于违反严格别名而中断。)


分配器和假定对齐方式

(本节是在假设 alignof(Edge) < 16 的情况下编写的。这里不是这种情况,了解函数属性可能会有用,即使它们不是问题的原因。而且可能不是一个可行的解决方法。)

您可以在您的分配器上使用 __attribute__ ((assume_aligned (8))) 来告诉 GCC 关于指针的对齐 returns.

GCC 可能出于某种原因假设你的分配器 returns 内存可用于任何对象(并且 alignof(max_align_t) == 16 在 x86-64 System V 上因为 long double 和其他事情, 以及 Windows x64).

如果不是这种情况,您也许可以告诉它。这个,我们可以看出GCC对"know about"malloc做了特殊的处理。但是,如果您的函数没有 ISO C 或 C++ 定义的名称,或者 GNU C 属性,那就令人惊讶了。 IDK,如果不是编译器错误,这是迄今为止根据您所展示的内容做出的最佳猜测。 (这是可能的。)

来自 the GCC manual:

void* my_alloc1 (size_t) __attribute__((assume_aligned (16)));
void* my_alloc2 (size_t) __attribute__((assume_aligned (32, 8)));

declares that my_alloc1 returns 16-byte aligned pointers and that my_alloc2 returns a pointer whose value modulo 32 is equal to 8.

我不知道为什么它会假设一个 void* return 由一个函数编辑并转换为另一种类型会比正在构造的对象的类型具有更多的对齐方式,尽管. 我们可以知道它使用 movups 从某处加载一个 Edge。这似乎表明 alignof(Edge) < 16.

同样相关的是 __attribute__((alloc_size(1))) 告诉 GCC 函数的第一个参数是一个大小。如果您的函数将显式对齐作为参数,请使用 alloc_align (position) 表示,否则不要使用。

正如其他参与者在已经发布的答案中正确指出的那样,触发因素是我的数据类型的对齐要求。具体的罪魁祸首原来是 long double 数据字段,它也出现在我的 struct 中,这最初引起了我的注意。这个long double数据字段强制整个结构的对齐要求变成16。

再次,正式地,这里没有争论的余地:违反此对齐要求会导致未定义的行为。故事结局。

但实际上(指 GCC 的特定实现行为),这似乎并不明确。 GCC 的行为在这里仍然有一个奇怪的特点。

在上面,在我的原始问题中,您可以看到一个对齐要求为 8 的结构示例(假设它没有 long double 字段)。使用此数据类型,GCC 的行为与我在上面描述的一样:

  1. raw_pointer 的对齐对编译器来说是显而易见的并且已知为 16 或更大时,GCC 生成 movaps 指令。
  2. raw_pointer 的对齐方式对编译器来说是显而易见的并且已知它小于 16 时,GCC 会生成 movups 指令。
  3. raw_pointer 的对齐方式对编译器来说不明显时,它会生成 movups 条指令。

因此,在这种情况下,GCC 会谨慎行事,它的行为 permissively/defensively。即使数据未对齐,实际上代码也会运行 "as expected"。 (也许我遗漏了一些东西,它可以很好地使它具有 8 对齐数据的 GPF,但对于它的价值,我还没有遇到它。)

但是一旦我们跳转到 16 对齐结构(比如,通过添加一个 long double 字段),GCC 逻辑将变为以下内容:

  1. raw_pointer 的对齐对编译器来说是显而易见的并且已知为 16 或更大时,GCC 生成 movaps 指令。
  2. raw_pointer 的对齐方式对编译器来说是显而易见的并且已知它小于 16 时,GCC 会生成 movups 指令。
  3. raw_pointer 的对齐对编译器来说不明显时,它会生成 movaps 指令(是的,movaps!)

注意第三点:这个小细节就是导致上述项目中GPF的原因。这是同一崩溃的一个小例子: http://coliru.stacked-crooked.com/a/c5cd2be91ebba41e 。 (顺便说一句,Clang 在这方面似乎更加严格。16 位对齐的数据?使用 movaps,即使指针是 "obviously" 未对齐。)

查看情况 1 和 2,GCC 似乎也有点 打算 表现 permissively/defensively,就像它对 8 对齐数据一样数据。但是出于某种原因,对于情况 3,它选择使用 movaps 而不是 movups。为什么与 8 对齐决策过程不一致?

同样,显然,"the behavior is undefined, it is your fault"。但是上面为 8 对齐和 16 对齐数据所做的决定之间的不一致让我觉得有点奇怪。如果这是有意的,那么至少有一个选项可以让 GCC 以与处理 8 对齐数据相同的方式处理 16 对齐数据,即当事情不完全透明时使用 movups

转念一想,这里真的没有"inconsistency"。逻辑是可靠的:对于 8 对齐数据,GCC 不能假设 movaps 的普遍适用性,因此即使数据完全是 8 对齐,它 也必须 采取防御措施。使用 16 对齐的数据,GCC 可以正式推断出 movaps 在所有情况下的适用性,因此它 没有 采取防御措施。


对于那些出于某种原因(内存节省、遗留项目等)不能或不想对其结构进行 16 对齐的人来说,这是一种快速解决方法:将 long double 字段声明为 packed "kills" 他们的对齐要求。如果通过这样做,您成功地将结构的对齐要求降低到 8 或更少,那么良好的旧的宽容 GCC 行为将 return.