为什么 p1007r0 std::assume_aligned 消除了对尾声的需要

Why does p1007r0 std::assume_aligned remove the need for epilogue?

本文关键字:std p1007r0 assume 为什么 aligned      更新时间:2023-10-16

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

对于以下数组中的数据,数组中第一个地址是 128(或 256 或 SIMD 指令所需的任何)的倍数,逐个元素的处理速度很慢。我们称之为序幕。

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

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

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

提案引述:

如果我们可以使此属性对编译器可见,它可以跳过循环序言和尾声

你可以从使用 GNU C/C++__builtin_assume_aligned中看到对代码生成的影响。

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

考虑这样一种情况:在编译时已知元素总数是向量宽度的倍数,但对齐方式未知。如果你知道对齐方式,你就不需要序幕或尾声。 但如果没有,您两者都需要。最后一个对齐向量之后剩余元素的数量未知。

这个 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 次或每 4 次。

另一个因素是,与智能处理相比,早期 gcc 完全展开的标量序言/尾声是垃圾,在智能处理中,您在开始/结束时执行一个未对齐的潜在重叠向量。 (见这本手写版本的set42的尾声)。 如果海湾合作委员会知道如何做到这一点,那么值得更频繁地对齐。

这在第 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 位。请注意,仅当缓冲区的大小是对齐方式的倍数时,才可以执行此操作。