【问题标题】:Alignment attribute to force aligned load/store in auto-vectorization of GCC/CLang在 GCC/CLang 的自动矢量化中强制对齐加载/存储的对齐属性
【发布时间】:2021-12-30 20:57:40
【问题描述】:

众所周知,GCC/CLang 使用 SIMD 指令可以很好地自动矢量化循环。

众所周知,存在alignas()标准C++属性,除其他用途外,它还允许对齐堆栈变量,例如以下代码:

Try it online!

#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

这意味着xy 在堆栈上都在 1024 字节上对齐,但不是 16384 字节。

现在让我们看看另一个代码:

Try it online!

#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 unaligned load + xor + unaligned store 两次。所以我们可以理解,我们的 64 位数组异或操作被 GCC 自动向量化以使用 AVX-512 寄存器,并且循环也展开了。

我的问题是如何告诉 GCC 提供给函数指针 xy 都对齐到 64 个字节,这样我就可以强制 GCC 代替上面代码中的 unaligned load (vmovdqu64)使用aligned load (vmovdqa64)。众所周知,对齐的加载/存储可以相当快。

我第一次尝试强制 GCC 进行对齐加载/存储是通过以下代码:

Try it online!

#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 代码(之前的代码 sn-p)相同。因此,这个alignas(64) 提示对改进 GCC 汇编代码没有任何帮助。

我的问题是如何强制 GCC 进行对齐的自动矢量化,除了为 _mm512_load_epi64() 等所有操作手动编写 SIMD 内在函数?

如果可能,我需要所有 GCC/CLang/MSVC 的解决方案。

【问题讨论】:

  • 对齐加载指令不需要使用对齐加载:如果地址对齐,则加载对齐。参见例如choice between aligned vs. unaligned x86 SIMD instructions
  • @harold 你的意思是如果汇编代码包含未对齐的vmovdqu64 指令,并且如果我的指针是对齐的,那么该指令将在 CPU 内部解码为对齐指令,并且与对齐的速度相同?这是否意味着手动使用对齐的vmovdqa64 根本不会加速任何事情,而不是一点点?如果没有一点加速,为什么还要在 CPU 中引入对齐指令?
  • vmovdqa64 在防止意外错位方面发挥了适度的作用。在过去(Core2 时代和更早的时代)movdqu 使用对齐地址的效率明显低于movdqa,因此在当时它们是单独的指令更有意义。
  • @Arty 似乎引入它是为了让旧处理器更快,但它不再有用了。保留这些说明是为了向后兼容。所以是的,只要您不针对旧架构并且启用 AVX 以便使用 VEX 前缀(在 GCC/Clang/VS 中默认不启用 AVX),就不应该加快速度。只有当您的代码受指令解码的限制时,VEX 前缀的好处才会出现,这对于较新处理器上的良好 SIMD 代码来说不是很频繁(除非循环通过大量加载/存储积极展开)。

标签: c++ performance simd avx512


【解决方案1】:

虽然并非对所有编译器都完全可移植,但__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 上的循环转换为这些块上的循环通常可以很好地自动矢量化。

【讨论】:

  • std::assume_aligned 是访问__builtin_assume_aligned的便携方式
  • reinterpret_casting 就像那是 UB。
  • @Marc 对齐无关紧要。在该指针指向的位置没有uint64_block_t&lt;N&gt; 类型的对象,因此不允许取消引用它。
  • 这里的问题可能是strict aliasing rule,更具体地说,类型可能不是“相似”的事实。规范在这一点上不是很清楚,但似乎这种情况没有被明确接受,因此理论上它会导致 UB。在实践中,我认为std::array 可能 比其内容具有更强的对齐要求(尽管我不知道有任何编译器这样做)。 AFAIK,GCC 使用may_alias 标签来确保 x86/x86-64 SIMD 类型没有问题。
  • @Marc 内存的布局方式与单个大整数数组完全相同的事实并不重要。 C++ 抽象机器指针不是简单地寻址到线性内存中(尽管在实践中它们当然是几乎普遍地实现的),而是一个具有特定行为的独特概念。在某些特定情况下可以增加指针,这不是其中之一。请参阅stackoverflow.com/questions/42420116/…(实际上我在访问第一个块中的整数时是错误的,这也是不允许的)。
【解决方案2】:

刚才@MarcStevens suggested 是我的问题的有效解决方案,通过使用__builtin_assume_aligned

Try it online!

#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 也使用 AVX-512 寄存器,仅具有超过 16 个元素。

所以仍然欢迎 CLang 和 MSVC 解决方案。

【讨论】:

  • Clang 确实“理解”__builtin_assume_aligned;对于-march=icelake-client(现在暗示-mprefer-vector-width=256)它使用vmovapsgodbolt.org/z/shq9fr6GT。你为什么担心 asm 不使用vmovdqa64?您想检测意外错位吗? __builtin_assume_aligned 确保未来的编译器版本不会例如生成在对齐边界之前变为标量的 asm,无论它是否选择不为对齐的情况使用不同的指令。 (因为根本没有性能差异。)
  • @PeterCordes 我之所以费心使用严格对齐的加载/存储,是因为我最初提出的问题。最初我问这个问题只是因为我认为在现代 CPU 上对齐的加载/存储更快。但是正如您和其他人所说,对齐的加载/存储指令的速度与未对齐的速度完全相同,因此它关闭了我最初的问题,因为我想回答它只是因为记住速度的额外可能性,事实并非如此.但只是为了做出干净的问题/答案,即使它很愚蠢,我仍然做了一个答案,现在只是出于好奇。
  • 发布__builtin_assume_aligned 并不愚蠢,这对于 GCC10 及更早版本来说实际上很重要:godbolt.org/z/hTeqaxa8v (assume_aligned(16)) / Why doesn't gcc resolve _mm256_loadu_pd as single vmovupd?。所以是的,编译器知道对齐很有用,只是不是因为你想的确切原因。 vmovdqu64 仅在数据在运行时发生错位时才会减慢速度,而不是出错。
【解决方案3】:

正如我从您自己的回答中所暗示的那样,您也对 MSVC 解决方案感兴趣。

MSVC 理解alignas 的正确用法以及它自己的__declspec(align),它也理解__builtin_assume_aligned,但它有意不想做任何已知对齐的事情。 p>

我的报告以“重复”关闭:

相关报告以“不是错误”关闭:

如果 MSVC 可以观察到指针指向全局变量,那么它仍然会利用全局变量的对齐。 即使这并非在所有情况下都有效。

【讨论】:

  • 感谢 MSVC 信息,已投票。你知道我的问题是否存在 CLang/MSVC 解决方案吗?因为 CLang 也忽略了这个 __builtin_assume_aligned(),正如您在我的回答中的链接所见。
  • @Arty,全局变量适用于 Clang,但仍不适用于 MSVC:godbolt.org/z/8YGjboMYq
  • 局部变量也适用于 CLang,但从 loop-1024 开始,而不是 loop-16,see here 示例。
猜你喜欢
  • 2015-09-12
  • 2017-04-16
  • 1970-01-01
  • 1970-01-01
  • 2015-08-20
  • 1970-01-01
  • 2018-01-24
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多