为什么 p1007r0 std::assume_aligned 消除了对尾声的需要
Why does p1007r0 std::assume_aligned remove the need for epilogue?
我的理解是代码的矢量化是这样的:
对于以下数组中的数据,数组中第一个地址是 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 位。请注意,仅当缓冲区的大小是对齐方式的倍数时,才可以执行此操作。
- 使用std::multimap迭代器创建std::list
- C++中std::resize(n)和std::shrink_to_fit之间的区别
- 来自 std::list 的迭代器 .end() 按预期返回"0xcdcdcdcdcdcdcdcd"但 .begin()
- C++17复制构造函数,在std::unordereded_map上进行深度复制
- 如何导出包含具有"std::unique_ptr"值的"std::map"属性的
- 从持续时间构造std::chrono::system_clock::time_point
- std::具有相同基类的类的变体
- std::向量与传递值的动态数组
- 使用std::vector的OpenCL矩阵乘法
- std::map<struct,struct>::find 找不到匹配项,但是如果我循环通过 begin() 到 end(),我在那里看到匹配项
- std::condition_variable::wait()如何评估给定的谓词
- 如何获取std::result_of函数的返回类型
- std::原子加载和存储都需要吗
- 将对象移动到std::shared_ptr
- POCO::PostgreSQL:如何将std::vector支持添加到`Binder::bind`
- 使用一个考虑到std::map中键值的滚动或换行的键
- 如何从 std::atomic 中提取指针 T<T>?
- 为什么 std::unique 不调用 std::sort?
- 使用std::函数映射对象方法
- 为什么 p1007r0 std::assume_aligned 消除了对尾声的需要