4

我的理解是代码的矢量化是这样工作的:

对于数组中的数据,数组中的第一个地址是 128(或 256 或任何 SIMD 指令要求的)的倍数,逐个元素地进行缓慢的处理。让我们称之为序幕。

对于数组中第一个地址是 128 的倍数和最后一个地址是 128 的倍数之间的数据,使用 SIMD 指令。

对于最后一个地址为 128 的倍数和数组末尾之间的数据,使用慢元素逐个元素处理。让我们称之为结语。

现在我明白了为什么std::assume_aligned有助于序言,但我不明白为什么它也能让编译器删除结尾。

引用提案:

如果我们可以让这个属性对编译器可见,它可以跳过循环序言和结尾

4

2 回答 2

1

您可以看到使用 GNU C / C++ 对代码生成的影响__builtin_assume_aligned

针对 x86(和 ICC18)的 gcc 7 和更早版本更喜欢使用标量序言来到达对齐边界,然后是对齐的向量循环,然后是标量尾声来清理任何不是完整向量倍数的剩余元素。

考虑在编译时已知元素总数是向量宽度的倍数但不知道对齐方式的情况。 如果您知道对齐方式,则不需要序言或结语。但如果没有,你需要两者。 最后一个对齐向量之后的剩余元素的数量是未知的。

Godbolt 编译器资源管理器链接显示了为使用 ICC18、gcc7.3 和 clang6.0 的 x86-64 编译的这些函数。clang非常积极地展开,但仍然使用未对齐的商店。这似乎是一种将这么多代码大小用于存储的循环的奇怪方式。

// aligned, and size a multiple of vector width
void set42_aligned(int *p) {
    p = (int*)__builtin_assume_aligned(p, 64);
    for (int i=0 ; i<1024 ; i++ ) {
        *p++ = 0x42;
    }
}

 # gcc7.3 -O3   (arch=tune=generic for x86-64 System V: p in RDI)

    lea     rax, [rdi+4096]              # end pointer
    movdqa  xmm0, XMMWORD PTR .LC0[rip]  # set1_epi32(0x42)
.L2:                                     # do {
    add     rdi, 16
    movaps  XMMWORD PTR [rdi-16], xmm0
    cmp     rax, rdi
    jne     .L2                          # }while(p != endp);
    rep ret

这几乎完全是我手动执行的操作,除了可能会展开 2,因此 OoO exec 可以发现循环出口分支未被采用,同时仍在咀嚼商店。

因此未对齐的版本包括序言尾声:

// without any alignment guarantee
void set42(int *p) {
    for (int i=0 ; i<1024 ; i++ ) {
        *p++ = 0x42;
    }
}

~26 instructions of setup, vs. 2 from the aligned version

.L8:            # then a bloated loop with 4 uops instead of 3
    add     eax, 1
    add     rdx, 16
    movaps  XMMWORD PTR [rdx-16], xmm0
    cmp     ecx, eax
    ja      .L8               # end of main vector loop

 # epilogue:
    mov     eax, esi    # then destroy the counter we spent an extra uop on inside the loop.  /facepalm
    and     eax, -4
    mov     edx, eax
    sub     r8d, eax
    cmp     esi, eax
    lea     rdx, [r9+rdx*4]   # recalc a pointer to the last element, maybe to avoid a data dependency on the pointer from the loop.
    je      .L5
    cmp     r8d, 1
    mov     DWORD PTR [rdx], 66      # fully-unrolled final up-to-3 stores
    je      .L5
    cmp     r8d, 2
    mov     DWORD PTR [rdx+4], 66
    je      .L5
    mov     DWORD PTR [rdx+8], 66
.L5:
    rep ret

即使对于一个更复杂的循环,它会从一点点展开中受益,gcc 让主矢量化循环根本不展开,而是在完全展开的标量序言/结尾处花费大量代码大小。uint16_t对于带有元素或其他东西的 AVX2 256 位矢量化来说,这真的很糟糕。(序言/尾声中最多 15 个元素,而不是 3 个)。这不是一个明智的权衡,因此它有助于 gcc7 和更早的版本在指针对齐时告诉它。(执行速度变化不大,但对减少代码膨胀有很大影响。)


顺便说一句,gcc8 倾向于使用未对齐的加载/存储,假设数据通常是对齐的。现代硬件具有廉价的未对齐的 16 和 32 字节加载/存储,因此让硬件处理跨缓存线边界拆分的加载/存储的成本通常是好的。(AVX512 64 字节存储通常值得对齐,因为任何未对齐都意味着每次访问时都会拆分缓存行,而不是每隔一个或每 4 个。)

另一个因素是,与在开始/结束处执行一个未对齐的潜在重叠向量的智能处理相比,较早的 gcc 完全展开的标量序言/尾声是废话。(请参阅此手写版本的尾声set42)。如果 gcc 知道如何做到这一点,那就值得更频繁地调整。

于 2018-05-18T01:25:26.737 回答
1

这在第 5 节的文档本身中进行了讨论:

返回指针 T* 并保证它将指向过度对齐的内存的函数可以返回如下:

T* get_overaligned_ptr()
{
// code...
return std::assume_aligned<N>(_data);
}

这种技术可以用于例如包装过度对齐的数据范围的类的 begin() 和 end() 实现中。只要此类函数是内联的,过度对齐将对调用站点的编译器透明,使其能够执行适当的优化,而无需调用者进行任何额外工作。

begin()end()方法是过度对齐缓冲区的数据访问器_data。也就是说,begin()返回一个指向缓冲区第一个字节的指针,并end()返回一个指向缓冲区最后一个字节后一个字节的指针。

假设它们定义如下:

T* begin()
{
// code...
return std::assume_aligned<N>(_data);
}
T* end()
{
// code...
return _data + size; // No alignment hint!
}

在这种情况下,编译器可能无法消除尾声。但是如果有如下定义:

T* begin()
{
// code...
return std::assume_aligned<N>(_data);
}
T* end()
{
// code...
return std::assume_aligned<N>(_data + size);
}

然后编译器将能够消除尾声。例如,如果 N 是 128 位,则缓冲区的每个 128 位块都保证是 128 位对齐的。请注意,这仅在缓冲区大小是对齐的倍数时才有可能。

于 2018-05-17T23:19:56.803 回答