在 GCC/CLang 的自动矢量化中强制对齐 load/store 的对齐属性
Alignment attribute to force aligned load/store in auto-vectorization of GCC/CLang
众所周知,GCC/CLang 使用 SIMD 指令可以很好地自动向量化循环。
还已知存在 alignas() 标准 C++ 属性,除其他用途外,它还允许对齐堆栈变量,例如以下代码:
#include <cstdint>
#include <iostream>
int main() {
alignas(1024) int x[3] = {1, 2, 3};
alignas(1024) int (&y)[3] = *(&x);
std::cout << uint64_t(&x) % 1024 << " "
<< uint64_t(&x) % 16384 << std::endl;
std::cout << uint64_t(&y) % 1024 << " "
<< uint64_t(&y) % 16384 << std::endl;
}
输出:
0 9216
0 9216
这意味着 x
和 y
在堆栈上对齐 1024 字节而不是 16384 字节。
现在让我们看另一个代码:
#include <cstdint>
void f(uint64_t * x, uint64_t * y) {
for (int i = 0; i < 16; ++i)
x[i] ^= y[i];
}
如果在 GCC 上使用 -std=c++20 -O3 -mavx512f
属性编译,它会生成以下 asm 代码(提供部分代码):
vmovdqu64 zmm1, ZMMWORD PTR [rdi]
vpxorq zmm0, zmm1, ZMMWORD PTR [rsi]
vmovdqu64 ZMMWORD PTR [rdi], zmm0
vmovdqu64 zmm0, ZMMWORD PTR [rsi+64]
vpxorq zmm0, zmm0, ZMMWORD PTR [rdi+64]
vmovdqu64 ZMMWORD PTR [rdi+64], zmm0
两次 AVX-512 未对齐加载 + xor + 未对齐存储。所以我们可以理解我们的 64 位数组异或运算被 GCC 自动矢量化以使用 AVX-512 寄存器,并且循环也被展开了。
我的问题是如何告诉 GCC 提供给函数指针 x
和 y
都对齐到 64 字节,所以 unaligned load (vmovdqu64
) like in code above, I can force GCC to use aligned load (vmovdqa64
).众所周知,对齐 load/store 可以快得多。
我第一次尝试强制 GCC 对齐 load/store 是通过以下代码:
#include <cstdint>
void g(uint64_t (&x_)[16],
uint64_t const (&y_)[16]) {
alignas(64) uint64_t (&x)[16] = x_;
alignas(64) uint64_t const (&y)[16] = y_;
for (int i = 0; i < 16; ++i)
x[i] ^= y[i];
}
但此代码仍会产生未对齐的负载 (vmovdqu64
),与上面的 asm 代码(之前的代码片段)相同。因此,这个 alignas(64)
提示没有提供任何有用的信息来改进 GCC 汇编代码。
我的问题是如何强制 GCC 进行对齐的自动矢量化,除了为所有操作手动编写 SIMD 内部函数,如 _mm512_load_epi64()
?
如果可能的话,我需要所有 GCC/CLang/MSVC 的解决方案。
刚才@MarcStevens suggested a working solution for my Question, through using __builtin_assume_aligned:
#include <cstdint>
void f(uint64_t * x_, uint64_t * y_) {
uint64_t * x = (uint64_t *)__builtin_assume_aligned(x_, 64);
uint64_t * y = (uint64_t *)__builtin_assume_aligned(y_, 64);
for (int i = 0; i < 16; ++i)
x[i] ^= y[i];
}
它实际上生成了对齐 vmovdqa64
指令的代码。
但只有 GCC 产生对齐指令。 CLang 仍然使用未对齐的 see here,而且 CLang 仅使用超过 16 个元素的 AVX-512 寄存器。
所以仍然欢迎 CLang 和 MSVC 解决方案。
虽然并非所有编译器都可移植,__builtin_assume_aligned
会告诉 GCC 假定指针对齐。
我经常使用一种不同的策略,它使用辅助结构更便于携带:
template<size_t Bits>
struct alignas(Bits/8) uint64_block_t
{
static const size_t bits = Bits;
static const size_t size = bits/64;
std::array<uint64_t,size> v;
uint64_block_t& operator&=(const uint64_block_t& v2) { for (size_t i = 0; i < size; ++i) v[i] &= v2.v[i]; return *this; }
uint64_block_t& operator^=(const uint64_block_t& v2) { for (size_t i = 0; i < size; ++i) v[i] ^= v2.v[i]; return *this; }
uint64_block_t& operator|=(const uint64_block_t& v2) { for (size_t i = 0; i < size; ++i) v[i] |= v2.v[i]; return *this; }
uint64_block_t operator&(const uint64_block_t& v2) const { uint64_block_t tmp(*this); return tmp &= v2; }
uint64_block_t operator^(const uint64_block_t& v2) const { uint64_block_t tmp(*this); return tmp ^= v2; }
uint64_block_t operator|(const uint64_block_t& v2) const { uint64_block_t tmp(*this); return tmp |= v2; }
uint64_block_t operator~() const { uint64_block_t tmp; for (size_t i = 0; i < size; ++i) tmp.v[i] = ~v[i]; return tmp; }
bool operator==(const uint64_block_t& v2) const { for (size_t i = 0; i < size; ++i) if (v[i] != v2.v[i]) return false; return true; }
bool operator!=(const uint64_block_t& v2) const { for (size_t i = 0; i < size; ++i) if (v[i] != v2.v[i]) return true; return false; }
bool get_bit(size_t c) const { return (v[c/64]>>(c%64))&1; }
void set_bit(size_t c) { v[c/64] |= uint64_t(1)<<(c%64); }
void flip_bit(size_t c) { v[c/64] ^= uint64_t(1)<<(c%64); }
void clear_bit(size_t c) { v[c/64] &= ~(uint64_t(1)<<(c%64)); }
void set_bit(size_t c, bool b) { v[c/64] &= ~(uint64_t(1)<<(c%64)); v[c/64] |= uint64_t(b ? 1 : 0)<<(c%64); }
size_t hammingweight() const { size_t w = 0; for (size_t i = 0; i < size; ++i) w += mccl::hammingweight(v[i]); return w; }
bool parity() const { uint64_t x = 0; for (size_t i = 0; i < size; ++i) x ^= v[i]; return mccl::hammingweight(x)%2; }
};
然后使用 reinterpret_cast.
将指向 uint64_t 的指针转换为指向此结构的指针
将 uint64_t 上的循环转换为这些块上的循环通常可以很好地自动矢量化。
正如我从您自己的回答中暗示的那样,您也对 MSVC 解决方案感兴趣。
MSVC 理解 alignas
的正确使用以及它自己的 __declspec(align)
,它也理解 __builtin_assume_aligned
,但它 故意 不想对已知对齐做任何事情。
我的报告因“重复”而关闭:
相关报告已关闭为“不是错误”:
- [MSConnect 3068950] - C++: MOVUPS is generated for alignof(16) data instead of MOVAPS
- Regression (from VS 2015) in SSSE/AVX instructions generation ((V)MOVUPS instead of (V)MOVAPS)
MSVC 仍然利用全局变量的对齐方式,如果它可以观察到指针指向全局变量。即使这样也不是在所有情况下都有效。
众所周知,GCC/CLang 使用 SIMD 指令可以很好地自动向量化循环。
还已知存在 alignas() 标准 C++ 属性,除其他用途外,它还允许对齐堆栈变量,例如以下代码:
#include <cstdint>
#include <iostream>
int main() {
alignas(1024) int x[3] = {1, 2, 3};
alignas(1024) int (&y)[3] = *(&x);
std::cout << uint64_t(&x) % 1024 << " "
<< uint64_t(&x) % 16384 << std::endl;
std::cout << uint64_t(&y) % 1024 << " "
<< uint64_t(&y) % 16384 << std::endl;
}
输出:
0 9216
0 9216
这意味着 x
和 y
在堆栈上对齐 1024 字节而不是 16384 字节。
现在让我们看另一个代码:
#include <cstdint>
void f(uint64_t * x, uint64_t * y) {
for (int i = 0; i < 16; ++i)
x[i] ^= y[i];
}
如果在 GCC 上使用 -std=c++20 -O3 -mavx512f
属性编译,它会生成以下 asm 代码(提供部分代码):
vmovdqu64 zmm1, ZMMWORD PTR [rdi]
vpxorq zmm0, zmm1, ZMMWORD PTR [rsi]
vmovdqu64 ZMMWORD PTR [rdi], zmm0
vmovdqu64 zmm0, ZMMWORD PTR [rsi+64]
vpxorq zmm0, zmm0, ZMMWORD PTR [rdi+64]
vmovdqu64 ZMMWORD PTR [rdi+64], zmm0
两次 AVX-512 未对齐加载 + xor + 未对齐存储。所以我们可以理解我们的 64 位数组异或运算被 GCC 自动矢量化以使用 AVX-512 寄存器,并且循环也被展开了。
我的问题是如何告诉 GCC 提供给函数指针 x
和 y
都对齐到 64 字节,所以 unaligned load (vmovdqu64
) like in code above, I can force GCC to use aligned load (vmovdqa64
).众所周知,对齐 load/store 可以快得多。
我第一次尝试强制 GCC 对齐 load/store 是通过以下代码:
#include <cstdint>
void g(uint64_t (&x_)[16],
uint64_t const (&y_)[16]) {
alignas(64) uint64_t (&x)[16] = x_;
alignas(64) uint64_t const (&y)[16] = y_;
for (int i = 0; i < 16; ++i)
x[i] ^= y[i];
}
但此代码仍会产生未对齐的负载 (vmovdqu64
),与上面的 asm 代码(之前的代码片段)相同。因此,这个 alignas(64)
提示没有提供任何有用的信息来改进 GCC 汇编代码。
我的问题是如何强制 GCC 进行对齐的自动矢量化,除了为所有操作手动编写 SIMD 内部函数,如 _mm512_load_epi64()
?
如果可能的话,我需要所有 GCC/CLang/MSVC 的解决方案。
刚才@MarcStevens suggested a working solution for my Question, through using __builtin_assume_aligned:
#include <cstdint>
void f(uint64_t * x_, uint64_t * y_) {
uint64_t * x = (uint64_t *)__builtin_assume_aligned(x_, 64);
uint64_t * y = (uint64_t *)__builtin_assume_aligned(y_, 64);
for (int i = 0; i < 16; ++i)
x[i] ^= y[i];
}
它实际上生成了对齐 vmovdqa64
指令的代码。
但只有 GCC 产生对齐指令。 CLang 仍然使用未对齐的 see here,而且 CLang 仅使用超过 16 个元素的 AVX-512 寄存器。
所以仍然欢迎 CLang 和 MSVC 解决方案。
虽然并非所有编译器都可移植,__builtin_assume_aligned
会告诉 GCC 假定指针对齐。
我经常使用一种不同的策略,它使用辅助结构更便于携带:
template<size_t Bits>
struct alignas(Bits/8) uint64_block_t
{
static const size_t bits = Bits;
static const size_t size = bits/64;
std::array<uint64_t,size> v;
uint64_block_t& operator&=(const uint64_block_t& v2) { for (size_t i = 0; i < size; ++i) v[i] &= v2.v[i]; return *this; }
uint64_block_t& operator^=(const uint64_block_t& v2) { for (size_t i = 0; i < size; ++i) v[i] ^= v2.v[i]; return *this; }
uint64_block_t& operator|=(const uint64_block_t& v2) { for (size_t i = 0; i < size; ++i) v[i] |= v2.v[i]; return *this; }
uint64_block_t operator&(const uint64_block_t& v2) const { uint64_block_t tmp(*this); return tmp &= v2; }
uint64_block_t operator^(const uint64_block_t& v2) const { uint64_block_t tmp(*this); return tmp ^= v2; }
uint64_block_t operator|(const uint64_block_t& v2) const { uint64_block_t tmp(*this); return tmp |= v2; }
uint64_block_t operator~() const { uint64_block_t tmp; for (size_t i = 0; i < size; ++i) tmp.v[i] = ~v[i]; return tmp; }
bool operator==(const uint64_block_t& v2) const { for (size_t i = 0; i < size; ++i) if (v[i] != v2.v[i]) return false; return true; }
bool operator!=(const uint64_block_t& v2) const { for (size_t i = 0; i < size; ++i) if (v[i] != v2.v[i]) return true; return false; }
bool get_bit(size_t c) const { return (v[c/64]>>(c%64))&1; }
void set_bit(size_t c) { v[c/64] |= uint64_t(1)<<(c%64); }
void flip_bit(size_t c) { v[c/64] ^= uint64_t(1)<<(c%64); }
void clear_bit(size_t c) { v[c/64] &= ~(uint64_t(1)<<(c%64)); }
void set_bit(size_t c, bool b) { v[c/64] &= ~(uint64_t(1)<<(c%64)); v[c/64] |= uint64_t(b ? 1 : 0)<<(c%64); }
size_t hammingweight() const { size_t w = 0; for (size_t i = 0; i < size; ++i) w += mccl::hammingweight(v[i]); return w; }
bool parity() const { uint64_t x = 0; for (size_t i = 0; i < size; ++i) x ^= v[i]; return mccl::hammingweight(x)%2; }
};
然后使用 reinterpret_cast.
将指向 uint64_t 的指针转换为指向此结构的指针将 uint64_t 上的循环转换为这些块上的循环通常可以很好地自动矢量化。
正如我从您自己的回答中暗示的那样,您也对 MSVC 解决方案感兴趣。
MSVC 理解 alignas
的正确使用以及它自己的 __declspec(align)
,它也理解 __builtin_assume_aligned
,但它 故意 不想对已知对齐做任何事情。
我的报告因“重复”而关闭:
相关报告已关闭为“不是错误”:
- [MSConnect 3068950] - C++: MOVUPS is generated for alignof(16) data instead of MOVAPS
- Regression (from VS 2015) in SSSE/AVX instructions generation ((V)MOVUPS instead of (V)MOVAPS)
MSVC 仍然利用全局变量的对齐方式,如果它可以观察到指针指向全局变量。即使这样也不是在所有情况下都有效。