【问题标题】:C++ operator[] access to elements of SIMD (e.g. AVX) variableC++ 运算符 [] 访问 SIMD(例如 AVX)变量的元素
【发布时间】:2021-01-24 16:42:40
【问题描述】:

我正在寻找一种方法来重载 operator[](在更广泛的 SIMD 类中),以方便在 SIMD 字中读取和写入单个元素(例如 __m512i)。几个限制:

  • 符合 C++11(或更高版本)
  • 与其他基于内部函数的代码兼容
  • 不是 OpenCL/SYCL(我可以,但我不能*叹气*)
  • 主要可跨 g++、icpc、clang++ 移植
  • 最好适用于英特尔以外的其他 SIMD(ARM、IBM 等...)
  • (编辑)性能并不是真正的问题(通常不用于性能很重要的地方)

(这排除了诸如通过指针转换的类型双关语和 GCC 向量类型之类的事情。)

主要基于 Scott Meyers 的“更有效的 C++”(第 30 项)和其他代码,我提出了以下 MVC 代码,这些代码似乎“正确”,似乎有效,但也似乎过于复杂。 (“代理”方法旨在处理左/右手 operator[] 的使用,“memcpy”旨在处理类型双关/C++ 标准问题。)

我想知道是否有人有更好的解决方案(并且可以解释它以便我学到一些东西;^))

#include <iostream>
#include <cstring>
#include "immintrin.h"

using T = __m256i;           // SIMD type
using Te = unsigned int;     // SIMD element type

class SIMD {

    class SIMDProxy;

  public :

    const SIMDProxy operator[](int index) const {
      std::cout << "SIMD::operator[] const" << std::endl;
      return SIMDProxy(const_cast<SIMD&>(*this), index);
    }
    SIMDProxy operator[](int index){
      std::cout << "SIMD::operator[]" << std::endl;
      return SIMDProxy(*this, index);
    }
    Te get(int index) {
      std::cout << "SIMD::get" << std::endl;
      alignas(T) Te tmp[8];
      std::memcpy(tmp, &value, sizeof(T));  // _mm256_store_si256(reinterpret_cast<__m256i *>(tmp), c.value);
      return tmp[index];
    }
    void set(int index, Te x) {
      std::cout << "SIMD::set" << std::endl;
      alignas(T) Te tmp[8];
      std::memcpy(tmp, &value, sizeof(T));  // _mm256_store_si256(reinterpret_cast<__m256i *>(tmp), c.value);
      tmp[index] = x;
      std::memcpy(&value, tmp, sizeof(T));  // c.value = _mm256_load_si256(reinterpret_cast<__m256i const *>(tmp));
    }

    void splat(Te x) {
      alignas(T) Te tmp[8];
      std::memcpy(tmp, &value, sizeof(T));
      for (int i=0; i<8; i++) tmp[i] = x;
      std::memcpy(&value, tmp, sizeof(T));
    }
    void print() {
      alignas(T) Te tmp[8];
      std::memcpy(tmp, &value, sizeof(T));
      for (int i=0; i<8; i++) std::cout << tmp[i] << " ";
      std::cout << std::endl;
    }

  protected :

  private :

    T value;

    class SIMDProxy {
      public :
        SIMDProxy(SIMD & c_, int index_) : c(c_), index(index_) {};
        // lvalue access
        SIMDProxy& operator=(const SIMDProxy& rhs) {
          std::cout << "SIMDProxy::=SIMDProxy" << std::endl;
          c.set(rhs.index, rhs.c.get(rhs.index));
          return *this;
        }
        SIMDProxy& operator=(Te x) {
          std::cout << "SIMDProxy::=T" << std::endl;
          c.set(index,x);
          return *this;
        }
        // rvalue access
        operator Te() const {
          std::cout << "SIMDProxy::()" << std::endl;
          return c.get(index);
        }
      private:
        SIMD& c;       // SIMD this proxy refers to
        int index;      // index of element we want
    };
    friend class SIMDProxy;   // give SIMDProxy access into SIMD


};

/** a little main to exercise things **/
int
main(int argc, char *argv[])
{

  SIMD x, y;
  Te a = 3;

  x.splat(1);
  x.print();

  y.splat(2);
  y.print();

  x[0] = a;
  x.print();

  y[1] = a;
  y.print();

  x[1] = y[1]; 
  x.print();
}

【问题讨论】:

  • 我考虑过一个(未命名的?)与 std::array 的联合。
  • 我的理解是指针别名和联合方法,虽然它们可能经常工作,并且 are 对 C 有效,但实际上在 C++ 标准下无效。 (因此我的问题)。如果有人想证明我错了,我很高兴。
  • @AndreySemashev “例如,如果没有 reinterpret_cast,就无法加载或存储整数向量,这是另一种类型双关语。” — 不,您可以memcpy,就像 OP 一样。这总是定义明确的,编译器会对其进行优化。此外,UB 和实现定义之间存在(巨大)差异。虽然 SIMD 无法避免后者,但您大多可以避免前者。一个例外是当别名为数组时,这是目前计划在下一个版本中修复的标准缺陷。
  • @KonradRudolph 好吧,那句话的措辞不准确,您确实可以使用memcpy。但是,这样做的预期方法是使用内部函数,例如 _mm_loadu_si128/_mm_storeu_si128,而这些通常需要 reinterpret_cast。允许这样做的原因是编译器允许对向量类型和标量类型进行类型别名。这显然不是纯 C/C++,这就是为什么我说无论如何你都必须依赖一些编译器扩展,我支持这一点。
  • 您可能会发现this GitHub project 是一个有用的参考。

标签: c++ simd intrinsics avx


【解决方案1】:

您的代码效率很低。通常这些 SIMD 类型不存在于内存中的任何位置,它们是硬件寄存器,它们没有地址,您不能将它们传递给 memcpy()。编译器非常努力地假装它们是普通变量,这就是为什么你的代码可以编译并且可能工作的原因,但是它很慢,你一直在从寄存器到内存再返回。

假设 AVX2 和整数通道,我会这样做。

class SimdVector
{
    __m256i val;

    alignas( 64 ) static const std::array<int, 8 + 7> s_blendMaskSource;

public:

    int operator[]( size_t lane ) const
    {
        assert( lane < 8 );
        // Move lane index into lowest lane of vector register
        const __m128i shuff = _mm_cvtsi32_si128( (int)lane );
        // Permute the vector so the lane we need is moved to the lowest lane
        // _mm256_castsi128_si256 says "the upper 128 bits of the result are undefined",
        // and we don't care indeed.
        const __m256i tmp = _mm256_permutevar8x32_epi32( val, _mm256_castsi128_si256( shuff ) );
        // Return the lowest lane of the result
        return _mm_cvtsi128_si32( _mm256_castsi256_si128( tmp ) );
    }

    void setLane( size_t lane, int value )
    {
        assert( lane < 8 );
        // Load the blending mask
        const int* const maskLoadPointer = s_blendMaskSource.data() + 7 - lane;
        const __m256i mask = _mm256_loadu_si256( ( const __m256i* )maskLoadPointer );
        // Broadcast the source value into all lanes.
        // The compiler will do equivalent of _mm_cvtsi32_si128 + _mm256_broadcastd_epi32
        const __m256i broadcasted = _mm256_set1_epi32( value );
        // Use vector blending instruction to set the desired lane
        val = _mm256_blendv_epi8( val, broadcasted, mask );
    }

    template<size_t lane>
    int getLane() const
    {
        static_assert( lane < 8 );
        // That thing is not an instruction;
        // compilers emit different ones based on the index
        return _mm256_extract_epi32( val, (int)lane );
    }

    template<size_t lane>
    void setLane( int value )
    {
        static_assert( lane < 8 );
        val = _mm256_insert_epi32( val, value, (int)lane );
    }
};

// Align by 64 bytes to guarantee it's contained within a cache line
alignas( 64 ) const std::array<int, 8 + 7> SimdVector::s_blendMaskSource
{
    0, 0, 0, 0, 0, 0, 0, -1,  0, 0, 0, 0, 0, 0, 0
};

对于 ARM,情况有所不同。如果通道索引在编译时已知,请参阅 vgetq_lane_s32vsetq_lane_s32 内在函数。

对于在 ARM 上设置通道,您可以使用相同的广播 + 混合技巧。广播是vdupq_n_s32。矢量混合的近似等价物是vbslq_s32,它独立处理每一位,但对于这个用例,它同样适用,因为-1 已设置所有 32 位。

对于提取要么写一个switch,要么将完整的向量存储到内存中,不确定这两个哪个更有效。

【讨论】:

  • 赞成,但 OP 确实提到他们想要缓慢但便携的,大概用于调试打印等用例。当然,在实践中,有人会编写使用每个元素访问的代码,甚至可能在循环中(在这种情况下,存储到内存通常是值得的)。此外,如果元素索引是编译时常量,那么您的方式最终可能会更糟,无法优化到 vpbroadcastd / vpblendd 以进行插入。 (不幸的是,没有vpinsrd ymm, ymm, r/m32, imm,只有一个 xmm 目标版本可以将__m256i 的上车道归零)
  • @PeterCordes Intrinsics 在编译器之间非常可移植。为编译时已知的索引添加了方法。
  • 它们意味着跨 ISA 的可移植性。 最好适用于英特尔以外的其他 SIMD(ARM、IBM 等)。请注意,对于 GNU C++,您也许可以使用 if(__builtin_constant_p(lane) return getlane&lt;p&gt;();。 (arg 可能必须声明为 constexpr,以便您在它是常量时将其用作模板参数。)
  • @PeterCordes 添加了关于 NEON 的注释。不确定他们所说的 IBM,2013 年的 VMX128 是什么意思?反正我没有这方面的经验,只在 Intel/AMD 和 ARM 上编程过 SIMD。
  • 我假设 IBM = PowerPC / POWER SIMD,即 AltiVec (en.wikipedia.org/wiki/AltiVec),它仍然是当前 POWER 架构的一部分。 wiki.raptorcs.com/wiki/Power_ISA/Vector_Operations。好的,是的,2020 年的文档提到了备用名称,显然 VMX 是“最官方”的名称,但仍在使用 Altivec。 cdn.openpowerfoundation.org/wp-content/uploads/resources/…
【解决方案2】:

在原始方法(memcpy、内在加载/存储)和其他建议(用户定义的联合双关语、用户定义的向量类型)中,内在方法似乎有一点优势。这是基于我尝试在 Godbolt (https://godbolt.org/z/5zdbKe) 中编写的一些快速示例。

写入元素的“最佳”方式如下所示。

__m256i foo2(__m256i x, unsigned int a, int index)
{
    alignas(__m256i) unsigned int tmp[8];
    _mm256_store_si256(reinterpret_cast<__m256i *>(tmp), x);
    tmp[index] = a;
    __m256i z = _mm256_load_si256(reinterpret_cast<__m256i const *>(tmp));
    return z;
}

【讨论】:

  • 发布您自己问题的实际答案是好的,并且明确鼓励。但它应该专注于成为一个答案,而不是对其他答案的回复。 (删除编辑/聊天的前几段,或者如果您想保留其中任何一段,请将其移至底部。)如果您实际上包含部分 C++ 和相应的代码块,而不仅仅是 Godbolt 链接。 Godbolt 链接可以包含更多您未包含的代码,但至少包含您认为最好的方式的示例。
  • 是的,使用内部加载/存储是处理内部函数的惯用语。从理论上讲,memcpy 应该同样有效,但如果存在错过优化的情况,也不会感到震惊。 foo1 出人意料地糟糕,但我没想到,-march=haswell 也无济于事(related 回复:拆分未对齐的加载/存储,但你的是对齐的)。可能值得尝试将 4 字节 memcpy 转换为向量内的字节偏移量,以替换一个元素 memcpy,这是别名安全的。
  • bit_cast 只是值的类型双关语(而不是重新解释指针的转换);您可以位转换为unsigned __int128 并右移,否则对元素访问没有帮助。
【解决方案3】:

如果您只关心 g++/clang++/icc 兼容性,您可以使用这些编译器在内部使用的__attribute__ 来定义其内在指令:

typedef int32_t int32x16_t __attribute__((vector_size(16*sizeof(int32_t)))) __attribute__((aligned(16*sizeof(int32_t))));

如果有意义(并且在给定架构上可行),变量将存储在向量寄存器中。此外,编译器为此 typedef 提供了一个可读/可写的 operator[](如果在编译时知道索引,则应该对其进行优化)。

【讨论】:

  • 通常您要定义固定字节宽度的向量,例如 vector_size(64) 用于您定义的 16x 4 字节 AVX-512 向量。此外,aligned() = size 是隐含的,您可以将多个属性放在一个以逗号分隔的列表中。像__attribute__((vector_size(16), aligned(1))) 一样制作一个未对齐的版本,它也可以给任何东西起别名,比如 char* 可以。 (就像 GCC 用于 _mm_loadu_si128
猜你喜欢
  • 2014-12-20
  • 1970-01-01
  • 2012-11-26
  • 2019-06-05
  • 1970-01-01
  • 2014-08-15
  • 2012-03-09
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多