为什么在AMD64上对mmap内存的未对齐访问有时会出现段错误?

kas*_*erd 5 c gcc mmap x86-64 auto-vectorization

我有这段代码在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;
}
Run Code Online (Sandbox Code Playgroud)

如果使用分配内存,则仅此段错误mmap.如果我使用malloc,堆栈上的缓冲区,或全局变量,它不会段错误.

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

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

Pet*_*des 13

gcc4.8做了一个尝试到达对齐边界的序言,但它假设uint16_t *p是2字节对齐,即一些标量迭代将使指针16字节对齐.

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


尝试用-O3 -fno-tree-vectorize-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返回值标记为对齐.这将是一个好主意,并且可能会给你提供malloc与之相同的代码.除非它不起作用,因为它会破坏错误检查mmap != (void*)-1,正如@Alcaro在Godbolt上的一个例子所指出的那样:https://gcc.godbolt.org/z/gVrLWT )


在能够访问未对齐的CPU上

SSE2段错误movdqa处于未对齐状态,并且您的元素本身未对齐,因此您有一个不常见的情况,即没有数组元素以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]
Run Code Online (Sandbox Code Playgroud)

它计算出(使用这段代码)在到达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]
Run Code Online (Sandbox Code Playgroud)

注意使用movdqu.movzx混合了一些更多的标量负载:14个迭代中的8个完成SIMD,其余6个用标量完成.这是一个错过优化:它可以很容易地用movq负载做另外4个,特别是因为在用0解包后填充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;
}
Run Code Online (Sandbox Code Playgroud)

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

但是memcpy,与gcc的正常偏好不同,我们得到带有未对齐加载的自动向量化(没有intro/outro来处理对齐):

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

在OP的情况下,简单地安排指针对齐是一个更好的选择.它避免了标量代码的缓存行拆分(或者像gcc那样进行矢量化).它不会花费大量额外的内存或空间,并且内存中的数据布局也不固定.

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

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

uint64_t tmp=0; 然后memcpy通过低3字节编译成实际的副本到内存并重新加载,因此这不是表示奇数大小类型的零扩展的好方法.

  • "我想知道更新的glibc标头是否使用__attribute __((assume_aligned(4096)))来标记mmap" - 不,它们不会,也不应该.mmap返回MAP_FAILED aka(void*) - 1失败,这不是4096对齐,因此GCC将删除您的错误检查.https://gcc.godbolt.org/z/gVrLWT (3认同)
  • 我不确定 gcc 曾经支持超出 C 或 C++ 标准要求的未对齐指针的想法来自哪里。就我所见,它总是将指针代码编译成几乎最简单的代码,就像大多数其他现代编译器一样。当然,由于 x86 支持所有地方的未对齐访问,因此在许多情况下通常可以正常工作,但我认为这不是 gcc“支持”它的任何证据!不支持会是什么样子?主动检查未对齐的指针并中止?当然,没有编译器会这样做(在清理之外)。 (2认同)