29 votes

Comment écrire un code simd portable pour la réduction multiplicative complexe ?

Je veux écrire un code simd rapide pour calculer la réduction multiplicative d'un tableau complexe. En C standard c'est :

#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 sera au maximum de 50.

Gcc ne peut pas vectoriser automatiquement les multiplications complexes mais, comme je suis heureux d'assumer le compilateur gcc et si je savais que je voulais cibler sse3 je pourrais suivre Comment activer l'autovectorisation sse3 dans gcc et écrire :

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;
}

Cela produit en effet du code assembleur vectorisé rapide en utilisant gcc. Bien que vous ayez toujours besoin de rembourrer votre entrée à un multiple de 4. L'assemblage que vous obtenez est :

.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

Cependant, il est conçu pour le jeu d'instructions exact de simd et n'est pas optimal pour avx2 ou avx512 par exemple pour lesquels vous devez changer le code.

Comment pouvez-vous écrire du code C ou C++ pour lequel gcc produira du code optimal lorsqu'il est compilé pour l'un des systèmes sse, avx2 ou avx512 ? En d'autres termes, devez-vous toujours écrire des fonctions séparées à la main pour chaque largeur différente de registre SIMD ?

Existe-t-il des bibliothèques open source qui facilitent cette tâche ?

0 votes

La première chose à faire serait de vérifier si certaines macros reflètent les paramètres du compilateur.

2 votes

Je n'ai rien pu obtenir avec GCC, mais Clang autovectorise si vous l'aidez un peu, en utilisant la largeur de vecteur disponible.

2 votes

Si vous recherchez une approche totalement générique qui soit optimale pour toutes les tailles de vecteurs, vous ne l'obtiendrez pas avec un type unique comme float4 . Soit vous faites en sorte que les types de vecteurs soient très grands, soit vous écrivez votre code pour gérer des vecteurs de taille variable.

12voto

chtz Points 6357

Voici un exemple utilisant le Bibliothèque Eigen :

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

Si vous compilez ceci avec clang ou g++ et sse ou avx activé (et -O2), vous devriez obtenir un code machine assez décent. Cela fonctionne également pour d'autres architectures comme Altivec ou NEON. Si vous savez que la première entrée de x est aligné, vous pouvez utiliser MapAligned au lieu de Map .

Vous obtiendrez un code encore meilleur, si vous connaissez la taille de votre vecteur au moment de la compilation :

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

Note : Les fonctions ci-dessus correspondent directement à la fonction f de l'OP. Cependant, comme @PeterCordes l'a souligné, il est généralement mauvais de stocker des nombres complexes entrelacés, car cela nécessitera beaucoup de brassage pour la multiplication. Au lieu de cela, on devrait stocker les parties réelles et imaginaires de manière à ce qu'elles puissent être directement chargées d'un paquet à la fois.

Édition/Addendum : Pour implémenter une structure de tableaux comme la multiplication complexe, vous pouvez en fait écrire quelque chose comme :

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;
}

Ou plus générique (en utilisant 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;
}

Lorsqu'il est compilé avec -mavx -O2 la compilation donne quelque chose comme ceci (avec 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

Pour des raisons qui ne me sont pas évidentes, ceci est en fait caché dans une méthode qui est appelée par la méthode actuelle, qui ne fait que déplacer de la mémoire -- je ne sais pas pourquoi Eigen/gcc ne suppose pas que les arguments sont déjà correctement alignés. Si je compile la même chose avec clang 3.8.0 (et les mêmes arguments), elle est compilée en juste :

    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

Encore une fois, le déplacement de la mémoire au début est bizarre, mais au moins il est vectorisé. Pour gcc et clang, cela est optimisé lorsqu'il est appelé dans une boucle, cependant :

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;
}

La différence ici est que clang va dérouler cette boucle extérieure à 2 multiplications par boucle. D'un autre côté, gcc utilisera des instructions de type fused-multiply-add lorsqu'il est compilé avec le paramètre -mfma .

Le site f8 peut bien sûr aussi être généralisée à des dimensions arbitraires :

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;
}

Et pour réduire la complexX<N> à un seul std::complex la fonction suivante peut être utilisée :

// 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]);
}

Cependant, selon que j'utilise clang ou g++, j'obtiens une sortie assembleur très différente. En général, g++ a tendance à ne pas charger en ligne les arguments d'entrée, et clang n'utilise pas les opérations FMA (YMMV ...). Essentiellement, vous devez inspecter le code assembleur généré de toute façon. Et plus important encore, vous devriez évaluer le code (je ne suis pas sûr de l'impact de cette routine sur votre problème global).

Aussi, je voulais noter que Eigen est en fait une bibliothèque d'algèbre linéaire. L'exploiter pour la génération de code SIMD purement portable n'est pas vraiment ce pour quoi elle a été conçue.

0 votes

Suggestion : compiler avec -march=haswell pour activer FMA et AVX, si vous voulez vraiment fonctionner sur Haswell ou plus. Cela permet également -mtune=haswell ce qui affecte les décisions de génération de code pour des choses comme la façon de faire des chargements de vecteurs 256b éventuellement non alignés ( -mtune=generic charge les deux moitiés séparément, -mtune=haswell utilise vmovups ymm )

1 votes

Votre premier exemple vectorise, mais comme il stocke des choses avec des réels et des complexes entrelacés, il doit mélanger. Malheureusement, il ne parvient même pas à utiliser vfmaddsubps au lieu de faire un vmulps et ensuite vaddsubps de sorte qu'il n'utilise pas la FMA, même lorsqu'il est compilé avec l'option -march=haswell -ffast-math (J'ai essayé gcc7.1 et clang4.0. Clang utilise des vfmaddss y vfmsubss en dehors de la boucle intérieure).

0 votes

@PeterCordes Oui, les deux premiers exemples doivent s'imbriquer, mais ce sont en fait les seuls qui ressemblent à ceux du PO. f fonction. L'OP f4 nécessite non seulement de rembourrer les données d'entrée, mais aussi de les entrelacer "manuellement" avant de les appeler ou de les stocker sous forme de SOA (ou "tableau de structure de paquet") en premier lieu (ce qui peut être ou non une bonne idée de toute façon, selon la façon dont les données sont générées).

3voto

Martin Points 1290

Si la portabilité est votre principale préoccupation, il existe de nombreuses bibliothèques ici qui fournissent des instructions SIMD dans leur propre syntaxe. La plupart d'entre eux font la vectorisation explicite plus simple et portable que les intrinsèques. Cette bibliothèque (UME::SIMD) a été publié récemment et est très performant

Sur ce document(UME::SIMD) une interface basée sur Vc a été établi qui s'appelle UME::SIMD. Il permet au programmeur d'accéder aux capacités SIMD sans avoir besoin d'une connaissance approfondie des ISA SIMD. UME::SIMD fournit une abstraction simple, flexible et portable pour la vectorisation explicite. vectorisation explicite sans perte de performance par rapport aux intrinsèques

0 votes

Merci. Si la portabilité signifie être vraiment rapide si le processeur n'a que le sse, l'avx ou l'avx512, alors c'est effectivement mon objectif. Seriez-vous en mesure de montrer le code pour mon problème spécifique en utilisant cette bibliothèque ? Je ne suis pas encore sûr à 100% de ce que cela donnerait d'être rapide pour les trois,

0 votes

En ce qui concerne UME:SIMD, je ne comprends pas encore quel problème il résout pour ma question pour être honnête. Ne devez-vous pas encore spécifier le nombre d'éléments emballés dans un vecteur, ce qui laisse le même problème que j'avais auparavant, n'est-ce pas ?

0 votes

Les bibliothèques sont la voie à suivre. Sur le terrain des grands équipements embarqués en temps réel (radars, etc.), la bibliothèque la plus courante était/est VSIPL. Elle était assez bizarre à utiliser, mais assez efficace. Ces personnes mrcy.com/produits/logiciels/multicore_mathpack sont assez bons, très utiles si vous avez une base de code établie sur leur matériel depuis des décennies. Quoi qu'il en soit, ces écosystèmes disposent d'un vieux code qui est toujours utilisé, simplement recompilé à nouveau, ce qui permet d'économiser une fortune dans la maintenance des capacités à long terme, grâce à la longévité des bibliothèques utilisées dans ce domaine.

1voto

anatolyg Points 8076

Je ne pense pas que vous ayez une solution générale pour cela. Vous pouvez augmenter votre "vector_size" à 32 :

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

Augmentez également tous les tableaux pour qu'ils aient 8 éléments :

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};
}

Cela rendra le compilateur capable de générer du code AVX512 (n'oubliez pas d'ajouter -mavx512f ), mais rendra votre code légèrement plus mauvais en SSE en rendant les transferts de mémoire sous-optimaux. Cependant, il ne désactivera certainement pas la vectorisation SSE.

Vous pourriez conserver les deux versions (avec 4 et 8 éléments de tableau), en passant de l'une à l'autre par un drapeau, mais cela pourrait être trop fastidieux pour un bénéfice limité.

0 votes

En soi, cela ne fera pas grand-chose. Vous devez également réécrire le code pour effectuer la multiplication vectorielle.

1 votes

J'étais trop paresseux pour l'écrire en entier. J'ai mis à jour ma réponse maintenant.

0 votes

-mavx2 n'est pas le bon drapeau pour avx512. En pratique le tableau x est d'une longueur entre 30 et 50 pour moi si cela fait une différence.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X