7

我有这段代码在兼容 AMD64 的 CPU 上的 Ubuntu 14.04 上运行时会出现段错误:

#include <inttypes.h>
#include <stdlib.h>

#include <sys/mman.h>

int main()
{
  uint32_t sum = 0;
  uint8_t *buffer = mmap(NULL, 1<<18, PROT_READ,
                         MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
  uint16_t *p = (buffer + 1);
  int i;

  for (i=0;i<14;++i) {
    //printf("%d\n", i);
    sum += p[i];
  }

  return sum;
}

如果内存是使用分配的,这只会出现段错误mmap。如果我使用malloc堆栈上的缓冲区或全局变量,它不会出现段错误。

如果我将循环的迭代次数减少到少于 14 次,则不再出现段错误。如果我从循环中打印数组索引,它也不再出现段错误。

为什么未对齐的内存访问在能够访问未对齐地址的 CPU 上会出现段错误,为什么仅在这种特定情况下?

4

1 回答 1

17

相关:Pascal Cuoq 的博客文章显示了 GCC 假定对齐指针(两个int*不部分重叠)的情况:GCC 始终假定对齐指针访问。他还链接到 2016 年的一篇博文(A bug story: data alignment on x86),该博文与这个问题有完全相同的错误:auto-vectorization with a misaligned pointer -> segfault。


gcc4.8 做了一个循环序言,试图到达对齐边界,但它假定它uint16_t *p是 2-byte 对齐的,即一些标量迭代将使指针 16-byte 对齐。

我不认为 gcc 曾经打算在 x86 上支持未对齐的指针,它恰好适用于没有自动矢量化的非原子类型。在 ISO C 中使用uint16_t小于alignof(uint16_t)=2对齐的指针绝对是未定义的行为。当 GCC 在编译时看到您违反规则时,它不会发出警告,并且实际上恰好制作了工作代码(因为malloc它知道返回值最小对齐),但这可能只是 gcc 内部的意外,应该' 不被视为“支持”的指示。


尝试使用-O3 -fno-tree-vectorizeor -O2。如果我的解释是正确的,那将不会出现段错误,因为它只会使用标量负载(正如您在 x86 上所说的那样,它没有任何对齐要求)。


gcc 知道malloc在此目标上返回 16 字节对齐的内存(x86-64 Linux,其中maxalign_t16 字节宽,因为long double在 x86-64 System V ABI 中已填充到 16 字节)。它会看到你在做什么并使用movdqu.

但是 gcc 不被mmap视为内置,因此它不知道它返回页面对齐的内存,并应用其通常的自动矢量化策略,显然假设它uint16_t *p是 2 字节对齐的,因此它可以movdqa在处理错位后使用。您的指针未对齐并违反此假设。

(我想知道较新的 glibc 标头是否用于__attribute__((assume_aligned(4096)))mmap' 的返回值标记为对齐。这将是一个好主意,并且可能会为您提供与 for 相同的代码生成malloc。除非它不起作用,因为它会破坏错误-检查mmap != (void*)-1正如@Alcaro在 Godbolt 上的一个例子所指出的那样:https : //gcc.godbolt.org/z/gVrLWT


在能够访问未对齐的 CPU 上

SSE2movdqa段错误未对齐,并且您的元素本身未对齐,因此您遇到了没有数组元素从 16 字节边界开始的不寻常情况。

SSE2 是 x86-64 的基线,所以 gcc 使用它。


Ubuntu 14.04LTS 使用 gcc4.8.2(题外话:这是旧的和过时的,在许多情况下比 gcc5.4 或 gcc6.4 更糟糕的代码生成,尤其是在自动矢量化时。它甚至不识别-march=haswell。)

14 是 gcc 启发式决定在此函数中自动矢量化循环的最小阈值,有-O3和没有-march-mtune选项。

我把你的代码放在 Godbolt 上,这是相关的部分main

    call    mmap    #
    lea     rdi, [rax+1]      # p,
    mov     rdx, rax  # buffer,
    mov     rax, rdi  # D.2507, p
    and     eax, 15   # D.2507,
    shr     rax        ##### rax>>=1 discards the low byte, assuming it's zero
    neg     rax       # D.2507
    mov     esi, eax  # prolog_loop_niters.7, D.2507
    and     esi, 7    # prolog_loop_niters.7,
    je      .L2
    # .L2 leads directly to a MOVDQA xmm2, [rdx+1]

它计算出(使用此代码块)在到达 MOVDQA 之前要执行多少次标量迭代,但没有任何代码路径导致 MOVDQU 循环。即 gcc 没有代码路径来处理p奇怪的情况。


但是 malloc 的代码生成如下所示:

    call    malloc  #
    movzx   edx, WORD PTR [rax+17]        # D.2497, MEM[(uint16_t *)buffer_5 + 17B]
    movzx   ecx, WORD PTR [rax+27]        # D.2497, MEM[(uint16_t *)buffer_5 + 27B]
    movdqu  xmm2, XMMWORD PTR [rax+1]   # tmp91, MEM[(uint16_t *)buffer_5 + 1B]

注意使用movdqu. 混合了更多的标量movzx负载:14 次迭代中的 8 次使用 SIMD,其余 6 次使用标量。这是一个错过的优化:它可以很容易地在加载的情况下再做 4 个movq,特别是因为在解包后填充 XMM 向量以在添加之前获得 uint32_t 元素。

(还有其他各种错过的优化,比如可能使用pmaddwd乘数1将水平单词对添加到 dword 元素中。)


具有未对齐指针的安全代码:

如果您确实想编写使用未对齐指针的代码,您可以在 ISO C 中使用memcpy. 在具有高效未对齐加载支持的目标(如 x86)上,现代编译器仍将仅使用简单的标量加载到寄存器中,就像取消引用指针一样。但是在自动矢量化时,gcc 不会假定对齐的指针与元素边界对齐,并且会使用未对齐的加载。

memcpy是您在 ISO C/C++ 中表达未对齐加载/存储的方式。

#include <string.h>

int sum(int *p) {
    int sum=0;
    for (int i=0 ; i<10001 ; i++) {
        // sum += p[i];
        int tmp;
#ifdef USE_ALIGNED
        tmp = p[i];     // normal dereference
#else
        memcpy(&tmp, &p[i], sizeof(tmp));  // unaligned load
#endif
        sum += tmp;
    }
    return sum;
}

使用gcc7.2 -O3 -DUSE_ALIGNED,我们得到通常的标量,直到对齐边界,然后是向量循环:(Godbolt 编译器资源管理器

.L4:    # gcc7.2 normal dereference
    add     eax, 1
    paddd   xmm0, XMMWORD PTR [rdx]
    add     rdx, 16
    cmp     ecx, eax
    ja      .L4

但是有了memcpy,我们得到了具有未对齐负载的自动矢量化(没有介绍/结束来处理对齐),这与 gcc 的正常偏好不同:

.L2:   # gcc7.2 memcpy for an unaligned pointer
    movdqu  xmm2, XMMWORD PTR [rdi]
    add     rdi, 16
    cmp     rax, rdi      # end_pointer != pointer
    paddd   xmm0, xmm2
    jne     .L2           # -mtune=generic still doesn't optimize for macro-fusion of cmp/jcc :(

    # hsum into EAX, then the final odd scalar element:
    add     eax, DWORD PTR [rdi+40000]   # this is how memcpy compiles for normal scalar code, too.

在 OP 的情况下,简单地安排要对齐的指针是更好的选择。它避免了标量代码的缓存行拆分(或 gcc 的向量化方式)。它不会花费很多额外的内存或空间,并且内存中的数据布局不固定。

但有时这不是一个选择。 memcpy当您复制原始类型的所有字节时,相当可靠地使用现代 gcc / clang 完全优化。即只是加载或存储,没有函数调用,也没有弹跳到额外的内存位置。即使在-O0,这个简单的内memcpy联没有函数调用,但当然tmp不会优化。

无论如何,如果您担心它在更复杂的情况下或使用不同的编译器可能无法优化,请检查编译器生成的 asm。例如,ICC18 不使用 memcpy 自动矢量化版本。

uint64_t tmp=0;然后将低 3 个字节的 memcpy 编译为实际复制到内存并重新加载,因此这不是表达奇数类型的零扩展的好方法,例如。


GNU C__attribute__((aligned(1)))may_alias

而不是memcpy(当 GCC 不知道指针对齐时,它不会在某些 ISA 上内联,即正是这个用例),您还可以使用带有 GCC 属性的 typedef 来制作类型的欠对齐版本.

typedef int __attribute__((aligned(1), may_alias)) unaligned_aliasing_int;

typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;

相关:为什么glibc的strlen需要这么复杂才能快速运行?展示了如何使用这个来使一个单词一次的 bithack C strlen 安全。

请注意,似乎 ICC 不尊重__attribute__((may_alias)),但 gcc/clang 尊重。我最近正在尝试编写一个可移植且安全的 4 字节 SIMD 负载,例如_mm_loadu_si32(缺少 GCC)。 https://godbolt.org/z/ydMLCK有各种安全组合,但在某些编译器上代码生成效率低下,或者在 ICC 上不安全但到处都很好。

aligned(1)在无法在一条指令中完成未对齐加载的 MIPS 等 ISA 上,可能不如 memcpy 糟糕。

您可以像使用任何其他指针一样使用它。

unaligned_aliasing_int *p = something;
int tmp = *p++;
int tmp2 = *p++;

当然,您可以像平常一样索引它p[i]

于 2017-11-27T13:24:13.753 回答