web-dev-qa-db-ja.com

複雑な乗法削減のためのポータブルsimdコードの書き方

複雑な配列の乗法還元を計算するための高速simdコードを書きたいと思います。標準Cでは、これは次のとおりです。

#include <complex.h>
complex float f(complex float x[], int n ) {
   complex float p = 1.0;
   for (int i = 0; i < n; i++)
      p *= x[i];
   return p;
}

nは最大50になります。

Gccは複素数乗算を自動ベクトル化できませんが、gccコンパイラを想定して満足しているので、sse3をターゲットにしたいと思ったら、次のようにできます gccでsse3自動ベクトル化を有効にする方法 そして次のように記述します。

typedef float v4sf __attribute__ ((vector_size (16)));
typedef union {
  v4sf v;
  float e[4];
} float4
typedef struct {
  float4 x;
  float4 y;
} complex4;
static complex4 complex4_mul(complex4 a, complex4 b) {
  return (complex4){a.x.v*b.x.v -a.y.v*b.y.v, a.y.v*b.x.v + a.x.v*b.y.v};
}
complex4 f4(complex4 x[], int n) {
  v4sf one = {1,1,1,1};
  complex4 p = {one,one};
  for (int i = 0; i < n; i++) p = complex4_mul(p, x[i]);
  return p;
}

これは確かにgccを使用して高速にベクトル化されたアセンブリコードを生成します。入力を4の倍数にパディングする必要がありますが、取得するアセンブリは次のとおりです。

.L3:
    vmovaps xmm0, XMMWORD PTR 16[rsi]
    add     rsi, 32
    vmulps  xmm1, xmm0, xmm2
    vmulps  xmm0, xmm0, xmm3
    vfmsubps        xmm1, xmm3, XMMWORD PTR -32[rsi], xmm1
    vmovaps xmm3, xmm1
    vfmaddps        xmm2, xmm2, XMMWORD PTR -32[rsi], xmm0
    cmp     rdx, rsi
    jne     .L3

ただし、これは正確なsimd命令セット用に設計されており、たとえばコードを変更する必要があるavx2またはavx512には最適ではありません。

Sse、avx2、またはavx512のいずれか用にコンパイルしたときにgccが最適なコードを生成するCまたはC++コードをどのように記述できますか?つまり、SIMDレジスタの幅が異なるごとに、常に手動で個別の関数を作成する必要がありますか?

これを簡単にするオープンソースライブラリはありますか?

27
eleanora

Eigenライブラリ を使用した例を次に示します。

#include <Eigen/Core>
std::complex<float> f(const std::complex<float> *x, int n)
{
    return Eigen::VectorXcf::Map(x, n).prod();
}

これをclangまたはg ++でコンパイルし、sseまたはavxを有効(および-O2)にすると、かなり適切なマシンコードが得られるはずです。また、AltivecやNEONなどの他のアーキテクチャでも機能します。 xの最初のエントリが整列していることがわかっている場合は、MapAlignedの代わりにMapを使用できます。

これを使用してコンパイル時にベクターのサイズがわかっている場合は、さらに優れたコードが得られます。

template<int n>
std::complex<float> f(const std::complex<float> *x)
{
    return Eigen::Matrix<std::complex<float>, n, 1> >::MapAligned(x).prod();
}

注:上記の関数は、OPの関数fに直接対応しています。ただし、@ PeterCordesが指摘しているように、乗算のために多くのシャッフルが必要になるため、インターリーブされた複素数を格納することは一般的に不適切です。代わりに、一度に1つのパケットを直接ロードできるように、実数部と虚数部を格納する必要があります。

編集/補遺:虚数乗法のような配列の構造を実装するには、実際には次のように書くことができます。

typedef Eigen::Array<float, 8, 1> v8sf; // Eigen::Array allows element-wise standard operations
typedef std::complex<v8sf> complex8;
complex8 prod(const complex8& a, const complex8& b)
{
    return a*b;
}

またはより一般的(C++ 11を使用):

template<int size, typename Scalar = float> using complexX = std::complex<Eigen::Array<Scalar, size, 1> >;

template<int size>
complexX<size> prod(const complexX<size>& a, const complexX<size>& b)
{
    return a*b;
}

-mavx -O2でコンパイルすると、次のようにコンパイルされます(g ++-5.4を使用)。

    vmovaps 32(%rsi), %ymm1
    movq    %rdi, %rax
    vmovaps (%rsi), %ymm0
    vmovaps 32(%rdi), %ymm3
    vmovaps (%rdi), %ymm4
    vmulps  %ymm0, %ymm3, %ymm2
    vmulps  %ymm4, %ymm1, %ymm5
    vmulps  %ymm4, %ymm0, %ymm0
    vmulps  %ymm3, %ymm1, %ymm1
    vaddps  %ymm5, %ymm2, %ymm2
    vsubps  %ymm1, %ymm0, %ymm0
    vmovaps %ymm2, 32(%rdi)
    vmovaps %ymm0, (%rdi)
    vzeroupper
    ret

私には明らかではない理由で、これは実際のメソッドによって呼び出されるメソッドに隠されています。このメソッドはメモリ内を移動するだけです。Eigen/ gccが引数がすでに適切に配置されていると想定しない理由はわかりません。同じものをclang3.8.0(および同じ引数)でコンパイルすると、次のようにコンパイルされます。

    vmovaps (%rsi), %ymm0
    vmovaps %ymm0, (%rdi)
    vmovaps 32(%rsi), %ymm0
    vmovaps %ymm0, 32(%rdi)
    vmovaps (%rdi), %ymm1
    vmovaps (%rdx), %ymm2
    vmovaps 32(%rdx), %ymm3
    vmulps  %ymm2, %ymm1, %ymm4
    vmulps  %ymm3, %ymm0, %ymm5
    vsubps  %ymm5, %ymm4, %ymm4
    vmulps  %ymm3, %ymm1, %ymm1
    vmulps  %ymm0, %ymm2, %ymm0
    vaddps  %ymm1, %ymm0, %ymm0
    vmovaps %ymm0, 32(%rdi)
    vmovaps %ymm4, (%rdi)
    movq    %rdi, %rax
    vzeroupper
    retq

繰り返しますが、最初の記憶の動きは奇妙ですが、少なくともそれはベクトル化されています。ただし、gccとclangの両方で、ループで呼び出されると、これは最適化されます。

complex8 f8(complex8 x[], int n) {
    if(n==0)
        return complex8(v8sf::Ones(),v8sf::Zero()); // I guess you want p = 1 + 0*i at the beginning?

    complex8 p = x[0];
    for (int i = 1; i < n; i++) p = prod(p, x[i]);
    return p;
}

ここでの違いは、clangがその外側のループをループごとに2回の乗算に展開することです。一方、gccは、-mfmaでコンパイルすると、fused-multiply-add命令を使用します。

もちろん、f8関数は任意の次元に一般化することもできます。

template<int size>
complexX<size> fX(complexX<size> x[], int n) {
    using S= typename complexX<size>::value_type;
    if(n==0)
        return complexX<size>(S::Ones(),S::Zero());

    complexX<size> p = x[0];
    for (int i = 1; i < n; i++) p *=x[i];
    return p;
}

また、complexX<N>を単一のstd::complexに減らすには、次の関数を使用できます。

// only works for powers of two
template<int size> EIGEN_ALWAYS_INLINE
std::complex<float> redux(const complexX<size>& var) {
    complexX<size/2> a(var.real().template head<size/2>(), var.imag().template head<size/2>());
    complexX<size/2> b(var.real().template tail<size/2>(), var.imag().template tail<size/2>());
    return redux(a*b);
}
template<> EIGEN_ALWAYS_INLINE
std::complex<float> redux(const complexX<1>& var) {
    return std::complex<float>(var.real()[0], var.imag()[0]);
}

ただし、clangとg ++のどちらを使用するかによって、アセンブラーの出力はまったく異なります。全体として、g ++は入力引数のインラインロードに失敗する傾向があり、clangはFMA操作の使用に失敗します(YMMV ...)基本的に、生成されたアセンブラーコードを検査する必要があります。さらに重要なことに、コードのベンチマークを行う必要があります(このルーチンが全体的な問題にどの程度の影響を与えるかはわかりません)。

また、Eigenは実際には線形代数ライブラリであることに注意したいと思います。純粋なポータブルSIMDコード生成のためにそれを利用することは、実際には設計されたものではありません。

12
chtz

移植性が主な関心事である場合、独自の構文でSIMD命令を提供する多くのライブラリ ここ があります。それらのほとんどは、組み込み関数よりも単純で移植性の高い明示的なベクトル化を行います。 このライブラリ(UME :: SIMD) は最近公開され、優れたパフォーマンスを発揮します

この論文(UME :: SIMD)Vc に基づくインターフェースが確立されており、UME :: SIMDという名前が付けられています。これにより、プログラマーはSIMDISAの広範な知識を必要とせずにSIMD機能にアクセスできます。 UME :: SIMDは、組み込み関数と比較してパフォーマンスを低下させることなく、明示的なベクトル化のためのシンプルで柔軟かつポータブルな抽象化を提供します

3
Martin

私はあなたがこれに対する完全に一般的な解決策を持っているとは思わない。 「vector_size」を32に増やすことができます。

typedef float v4sf __attribute__ ((vector_size (32)));

また、すべての配列を増やして8つの要素にします。

typedef float v8sf __attribute__ ((vector_size (32)));

typedef union {
  v8sf v;
  float e[8];
} float8;
typedef struct {
  float8 x;
  float8 y;
} complex8;
static complex8 complex8_mul(complex8 a, complex8 b) {
  return (complex8){a.x.v*b.x.v -a.y.v*b.y.v, a.y.v*b.x.v + a.x.v*b.y.v};
}

これにより、コンパイラはAVX512コードを生成できるようになります(-mavx512fを追加することを忘れないでください)が、メモリ転送を最適ではなくすることにより、SSE)でコードを少し悪化させます。ただし、SSEベクトル化を無効にすることはありません。

両方のバージョン(4つと8つの配列要素)を保持し、フラグによってそれらを切り替えることもできますが、面倒すぎてほとんどメリットがない可能性があります。

1
anatolyg