首页 > 解决方案 > 为什么 p1007r0 std::assume_aligned 不需要结语?

问题描述

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

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

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

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

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

引用提案:

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

标签: c++compiler-optimizationsimdmemory-alignmentauto-vectorization

解决方案


您可以看到使用 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 知道如何做到这一点,那就值得更频繁地调整。


推荐阅读