【问题标题】:sum of overlapping arrays, auto-vectorization, and restrict重叠数组的总和、自动矢量化和限制
【发布时间】:2014-07-02 07:06:28
【问题描述】:

Arstechnia 最近有一篇文章Why are some programming languages faster than others。它比较了 Fortran 和 C 并提到了求和数组。在 Fortran 中,假设数组不重叠,以便进一步优化。在 C/C++ 中,指向同一类型的指针可能会重叠,因此通常不能使用这种优化。但是,在 C/C++ 中,可以使用 restrict__restrict 关键字告诉编译器不要假设指针重叠。所以我开始研究这个关于自动矢量化的问题。

以下代码在 GCC 和 MSVC 中向量化

void dot_int(int *a, int *b, int *c, int n) {
    for(int i=0; i<n; i++) {
        c[i] = a[i] + b[i];
    }
}

我在有和没有重叠数组的情况下对此进行了测试,它得到了正确的结果。但是,我使用 SSE 手动对该循环进行矢量化的方式无法处理重叠数组。

int i=0;    
for(; i<n-3; i+=4) {
    __m128i a4 = _mm_loadu_si128((__m128i*)&a[i]);
    __m128i b4 = _mm_loadu_si128((__m128i*)&b[i]);
    __m128i c4 = _mm_add_epi32(a4,b4);
    _mm_storeu_si128((__m128i*)c, c4);
}
for(; i<n; i++) {
    c[i] = a[i] + b[i];
}

接下来我尝试使用__restrict。我假设由于编译器可以假设数组不重叠,因此它不会处理重叠数组,但 GCC 和 MSVC 即使使用__restrict 仍然可以得到重叠数组的正确结果。

void dot_int_restrict(int * __restrict a, int * __restrict b, int * __restrict c, int n) {
    for(int i=0; i<n; i++) {
        c[i] = a[i] + b[i];
    }
}

为什么带有和不带有__restrict 的自动矢量化代码会得到重叠数组的正确结果?

这是我用来测试的完整代码:

#include <stdio.h>
#include <immintrin.h>
void dot_int(int *a, int *b, int *c, int n) {
    for(int i=0; i<n; i++) {
        c[i] = a[i] + b[i];
    }
    for(int i=0; i<8; i++) printf("%d ", c[i]); printf("\n"); 
}

void dot_int_restrict(int * __restrict a, int * __restrict b, int * __restrict c, int n) {
    for(int i=0; i<n; i++) {
        c[i] = a[i] + b[i];
    }
    for(int i=0; i<8; i++) printf("%d ", c[i]); printf("\n"); 
}

void dot_int_SSE(int *a, int *b, int *c, int n) {
    int i=0;    
    for(; i<n-3; i+=4) {
        __m128i a4 = _mm_loadu_si128((__m128i*)&a[i]);
        __m128i b4 = _mm_loadu_si128((__m128i*)&b[i]);
        __m128i c4 = _mm_add_epi32(a4,b4);
        _mm_storeu_si128((__m128i*)c, c4);
    }
    for(; i<n; i++) {
        c[i] = a[i] + b[i];
    }
    for(int i=0; i<8; i++) printf("%d ", c[i]); printf("\n"); 
}

int main() {
    const int n = 100;
    int a[] = {1,1,1,1,1,1,1,1};
    int b1[] = {1,1,1,1,1,1,1,1,1};
    int b2[] = {1,1,1,1,1,1,1,1,1};
    int b3[] = {1,1,1,1,1,1,1,1,1};

    int c[8];
    int *c1 = &b1[1];
    int *c2 = &b2[1];
    int *c3 = &b3[1];

    dot_int(a,b1,c, 8);
    dot_int_SSE(a,b1,c,8);

    dot_int(a,b1,c1, 8);
    dot_int_restrict(a,b2,c2,8);
    dot_int_SSE(a,b3,c3,8);

}

输出(来自 MSVC)

2 2 2 2 2 2 2 2 //no overlap default
2 2 2 2 2 2 2 2 //no overlap with manual SSE vector code
2 3 4 5 6 7 8 9 //overlap default
2 3 4 5 6 7 8 9 //overlap with restrict
3 2 2 2 1 1 1 1 //manual SSE vector code

编辑:

这是另一个生成更简单代码的插入版本

void dot_int(int * __restrict a, int * __restrict b, int * __restrict c, int n) {
    a = (int*)__builtin_assume_aligned (a, 16);
    b = (int*)__builtin_assume_aligned (b, 16);
    c = (int*)__builtin_assume_aligned (c, 16);
    for(int i=0; i<n; i++) {
        c[i] = a[i] + b[i];
    }
}

【问题讨论】:

  • 您的测试用例没有处理别名输入数据会出现问题的情况。您需要输入和输出指针别名为同一个数组并且加载存储顺序很重要的情况。
  • @PaulR,我认为这就是我所做的。我将输出添加到问题的末尾。输出 c 数组与输入 b 数组重叠。手动 SSE 代码失败的原因是加载存储顺序很重要。
  • 好的 - 抱歉 - 也许我看错了你的代码。
  • 你怎么知道编译器正在向量化代码?编译器为这个函数生成什么向量化代码?
  • @Apriori,编译器 GCC 和 MSVC,报告他们向量化循环。我就是这样知道的。该程序集比我预期的要长得多,但乍一看表明编译器生成了一个矢量化和非矢量化版本,然后根据数组是否重叠(内存地址和范围)决定采用哪条路径。但这仍然是一个猜测,我明天有更多时间检查。

标签: c++ c optimization sse auto-vectorization


【解决方案1】:

我想我明白现在发生了什么。事实证明,MSVC 和 GCC 使用 __restrict 给出不同的结果。 MSVC 通过重叠得到正确答案,而 GCC 没有。我认为可以得出这样的结论,即 MSVC 忽略了 __restrict 关键字,而 GCC 正在使用它来进一步优化。

GCC 的输出

2 2 2 2 2 2 2 2 //no overlap default
2 2 2 2 2 2 2 2 //no overlap with manual SSE vector code
2 3 4 5 6 7 8 9 //overlap without __restrict
2 2 2 2 3 2 2 2 //overlap with __restrict
3 2 2 2 1 1 1 1 //manual SSE vector code

我们可以生成一个纯向量化的函数,它提供几乎与 C 一样多的装配线(所有代码都是在 GCC 4.9.0 中使用-O3 生成的)这个:

void dot_int(int * __ restrict a, int * __restrict b, int * __restrict c) {
    a = (int*)__builtin_assume_aligned (a, 16);
    b = (int*)__builtin_assume_aligned (b, 16);
    c = (int*)__builtin_assume_aligned (c, 16);
    for(int i=0; i<1024; i++) {
        c[i] = a[i] + b[i];
    }
}

生产

dot_int(int*, int*, int*):
    xorl    %eax, %eax
.L2:
    movdqa  (%rdi,%rax), %xmm0
    paddd   (%rsi,%rax), %xmm0
    movaps  %xmm0, (%rdx,%rax)
    addq    $16, %rax
    cmpq    $4096, %rax
    jne .L2
    rep ret

但是,如果我们删除允许 a 与 c 重叠的__restrict on a,我通过查看程序集来确定

void dot_int(int * a, int * __restrict b, int * __restrict c) {
        a = (int*)__builtin_assume_aligned (a, 16);
        b = (int*)__builtin_assume_aligned (b, 16);
        c = (int*)__builtin_assume_aligned (c, 16);
        for(int i=0; i<1024; i++) {
            c[i] = a[i] + b[i];
        }
    }

等同于

__attribute__((optimize("no-tree-vectorize")))
inline void dot_SSE(int * __restrict a, int * __restrict b, int * __restrict c) {
    for(int i=0; i<1024; i+=4) {    
        __m128i a4 = _mm_load_si128((__m128i*)&a[i]);
        __m128i b4 = _mm_load_si128((__m128i*)&a[i]);
        __m128i c4 = _mm_add_epi32(a4,b4);
        _mm_store_si128((__m128i*)&c[i],c4);
    }
}
__attribute__((optimize("no-tree-vectorize")))
void dot_int(int * __restrict a, int * __restrict b, int * __restrict c) {
    a = (int*)__builtin_assume_aligned (a, 16);
    b = (int*)__builtin_assume_aligned (b, 16);
    c = (int*)__builtin_assume_aligned (c, 16);
    int pass = 1;
    if((c+4)<a || (a+4)<c) pass = 0;
    if(pass) {
        for(int i=0; i<1024; i++) {
            c[i] = a[i] + b[i];
        }   
    }
    else {
        dot_SSE(a,b,c);
    }
}

换句话说,如果 a 和 c 指针在 16 个字节内(|ac| 这证实了我的猜测,即自动矢量化代码包括处理重叠的矢量化和非矢量化版本。

在重叠数组上,这会得到正确的结果:2 3 4 5 6 7 8 9

【讨论】:

  • 第一个区间i1 = a-b我觉得没必要,因为输入重叠也没关系。
【解决方案2】:

我不明白问题出在哪里。在 Linux/64 位、GCC 4.6、-O3、-mtune=native、-msse4.1(即非常旧的编译器/系统)上测试,此代码

void dot_int(int *a, int *b, int *c, int n) {
    for(int i=0; i<n; ++i) {
        c[i] = a[i] + b[i];
    }
}

编译到这个内部循环:

.L4:
    movdqu  (%rdi,%rax), %xmm1
    addl    $1, %r8d
    movdqu  (%rsi,%rax), %xmm0
    paddd   %xmm1, %xmm0
    movdqu  %xmm0, (%rdx,%rax)
    addq    $16, %rax
    cmpl    %r8d, %r10d
    ja      .L4
    cmpl    %r9d, %ecx
    je      .L1

这段代码

void dot_int_restrict(int * __restrict a, int * __restrict b, int * __restrict c, int n) {
    for(int i=0; i<n; ++i) {
        c[i] = a[i] + b[i];
    }
}

编译成这样:

.L15:
    movdqu  (%rbx,%rax), %xmm0
    addl    $1, %r8d
    paddd   0(%rbp,%rax), %xmm0
    movdqu  %xmm0, (%r11,%rax)
    addq    $16, %rax
    cmpl    %r10d, %r8d
    jb      .L15
    addl    %r12d, %r9d
    cmpl    %r12d, %r13d
    je      .L10

您可以清楚地看到负载少了一个。我猜它正确估计了在执行求和之前不需要显式加载内存,因为结果不会覆盖任何东西。

还有更多优化的空间——GCC 不知道参数是 f.i。 128 位对齐,因此它必须生成一个巨大的前导码来检查是否没有对齐问题 (YMMV),并生成一个 postable 来处理额外的未对齐部分(或宽度小于 128 位)。这实际上发生在上述两个版本中。这是为dot_int生成的完整代码:

dot_int:
.LFB626:
        .cfi_startproc
        testl   %ecx, %ecx
        pushq   %rbx
        .cfi_def_cfa_offset 16
        .cfi_offset 3, -16
        jle     .L1
        leaq    16(%rdx), %r11
        movl    %ecx, %r10d
        shrl    $2, %r10d
        leal    0(,%r10,4), %r9d
        testl   %r9d, %r9d
        je      .L6
        leaq    16(%rdi), %rax
        cmpl    $6, %ecx
        seta    %r8b
        cmpq    %rax, %rdx
        seta    %al
        cmpq    %r11, %rdi
        seta    %bl
        orl     %ebx, %eax
        andl    %eax, %r8d
        leaq    16(%rsi), %rax
        cmpq    %rax, %rdx
        seta    %al
        cmpq    %r11, %rsi
        seta    %r11b
        orl     %r11d, %eax
        testb   %al, %r8b
        je      .L6
        xorl    %eax, %eax
        xorl    %r8d, %r8d
        .p2align 4,,10
        .p2align 3
.L4:
        movdqu  (%rdi,%rax), %xmm1
        addl    $1, %r8d
        movdqu  (%rsi,%rax), %xmm0
        paddd   %xmm1, %xmm0
        movdqu  %xmm0, (%rdx,%rax)
        addq    $16, %rax
        cmpl    %r8d, %r10d
        ja      .L4
        cmpl    %r9d, %ecx
        je      .L1
.L3:
        movslq  %r9d, %r8
        xorl    %eax, %eax
        salq    $2, %r8
        addq    %r8, %rdx
        addq    %r8, %rdi
        addq    %r8, %rsi
        .p2align 4,,10
        .p2align 3
.L5:
        movl    (%rdi,%rax,4), %r8d
        addl    (%rsi,%rax,4), %r8d
        movl    %r8d, (%rdx,%rax,4)
        addq    $1, %rax
        leal    (%r9,%rax), %r8d
        cmpl    %r8d, %ecx
        jg      .L5
.L1:
        popq    %rbx
        .cfi_remember_state
        .cfi_def_cfa_offset 8
        ret
.L6:
        .cfi_restore_state
        xorl    %r9d, %r9d
        jmp     .L3
        .cfi_endproc

现在在您的情况下,整数实际上对齐(因为它们在堆栈上),但是如果您可以使它们对齐并告诉 GCC,那么您可以改进代码生成:

typedef int intvec __attribute__((vector_size(16)));

void dot_int_restrict_alig(intvec * restrict a, 
                           intvec * restrict b, 
                           intvec * restrict c, 
                           unsigned int n) {
    for(unsigned int i=0; i<n; ++i) {
        c[i] = a[i] + b[i];
    }
}

这会生成这段代码,没有前导码:

dot_int_restrict_alig:
.LFB628:
        .cfi_startproc
        testl   %ecx, %ecx
        je      .L23
        subl    $1, %ecx
        xorl    %eax, %eax
        addq    $1, %rcx
        salq    $4, %rcx
        .p2align 4,,10
        .p2align 3
.L25:
        movdqa  (%rdi,%rax), %xmm0
        paddd   (%rsi,%rax), %xmm0
        movdqa  %xmm0, (%rdx,%rax)
        addq    $16, %rax
        cmpq    %rcx, %rax
        jne     .L25
.L23:
        rep
        ret
        .cfi_endproc

注意对齐的 128 位加载指令的用法(movdqaa 对齐,而 movdqu,未对齐)。

【讨论】:

  • 我认为代码也有非矢量化版本。至少在gcc.godbolt.org 上是这样。我的猜测是编译器会生成两个版本,然后当它进入函数时,它会决定数组是否重叠,然后选择矢量化或非矢量化版本。无论如何,我明天会更仔细地检查您的答案。
  • 您的函数dot_int_restrict_alig 生成的代码几乎与我的手动 SSE 函数所做的代码相同,只是它使用对齐的加载/存储并且它在重叠数组上进行核心转储。您发布的程序集无法处理重叠数组。必须有更多代码(您没有发布)来处理重叠数组的情况。
  • 你的意思是,更多的代码是从dot_int_restrict_alig 生成的?不,这是整个生成的函数。它根本不处理重叠数组——参数是restrict
  • 不,我的意思是其他功能。
  • 是的,其他函数有我在矢量化循环周围发布的巨大序言。在矢量化循环之后,它们也有我没有粘贴的小代码。我会修改我的帖子并添加它。
【解决方案3】:

如果您对重叠数组使用“限制”,您将获得未定义的行为。这就是你在“重叠限制”情况下得到的。未定义的行为意味着任何事情都可能发生。它确实做到了。巧合的是,这种行为与没有“限制”的行为相同。完全正确。它直接属于“任何事情都可能发生”的定义。没什么好抱怨的。

【讨论】:

  • 我不同意。当阵列不重叠时,最佳解决方案是我手动完成的解决方案。如果编译器假设数组不重叠,它应该产生不能得到相同结果的数组。我认为编译器更有可能忽略了restrict关键字。
  • 还有一点是为什么这个函数在没有限制的情况下完全矢量化了?当您不能假设数组不重叠时,您如何有效地对其进行矢量化?我不知道如何有效地做到这一点。
猜你喜欢
  • 2017-06-10
  • 1970-01-01
  • 2016-09-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-02-20
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多