Accélérez la multiplication de la matrice flottante 4x4 avec SIMD

De nombreuses années se sont écoulées depuis que je me suis familiarisé avec les instructions MMX, SSE et plus tard AVX sur les processeurs Intel. À un moment donné, ils semblaient être une sorte de magie dans le contexte de l'assembleur x86, qui avait longtemps été quelque chose de banal. Ils m'ont tellement accroché qu'il y a quelques années, j'ai eu l'idée d'écrire mon propre logiciel de rendu pour un jeu célèbre. La chose qui a promis ces instructions m'a promis ceci. À un moment donné, j'ai même pensé à l'écrire. Mais l'écriture de texte s'est avérée beaucoup plus compliquée que le code.

À cette époque, je voulais éviter les problèmes de support sur différents processeurs. Je voulais pouvoir vérifier mon rendu sur le montant maximum disponible. J'ai toujours des amis avec d'anciens processeurs AMD, et leur plafond était SSE3. Par conséquent, à ce moment-là, j'ai décidé de me limiter à un maximum de SSE3. Il y avait donc une bibliothèque mathématique vectorielle, un peu moins complètement implémentée sur SSE, avec une inclusion rare avant SSE3. Cependant, à un moment donné, je me suis demandé quelles performances maximales je pouvais retirer du processeur pour un certain nombre d'opérations mathématiques vectorielles critiques. Une telle opération consiste à multiplier 4 matrices flottantes par 4.


En fait, j'ai décidé de faire cette affaire davantage pour le plaisir. J'ai déjà écrit et j'utilise la multiplication matricielle pour mon rendu logiciel sur SSE et cela me semble suffisant. Mais j'ai alors décidé de voir combien de mesures je pouvais retirer en principe en multipliant 2 matrices float4x4. Sur mon SSE actuel, ce sont 16 cycles d'horloge. Certes, la transition récente vers IACA 3 a commencé à afficher 19, car j'ai commencé à écrire 1 * pour certaines instructions au lieu de 0 *. Apparemment plus tôt, ce n'était qu'un défaut dans l'analyseur.

En bref sur les utilitaires utilisés


Pour l'analyse de code, j'ai utilisé le célèbre utilitaire Intel Architecture Code Analyzer . Pour l'analyse, j'utilise l'architecture Haswell (HSW), au minimum avec le support d'AVX2. Pour écrire du code est également très pratique à utiliser: Intel Intrinsics Guide et Intel optimization manual .

Pour l'assemblage, j'utilise la communauté MSVS 2017 à partir de la console. J'écris le code dans la version avec intrinsèques. Vous écrivez une fois, et généralement cela fonctionne immédiatement sur différentes plateformes. De plus, le compilateur x64 VC ++ ne prend pas en charge l'assembleur en ligne, mais je veux qu'il fonctionne également sous x64.

Étant donné que cet article est déjà un peu au-delà du niveau débutant en programmation SIMD, je ne décrirai pas les registres, les instructions, dessiner (ou raser) de belles images et essayer d'apprendre la programmation en utilisant les instructions SIMD. Le site Web d'Intel regorge d'une documentation excellente, claire et détaillée.

Je voulais tout rendre plus facile ... Mais il s'est avéré comme toujours


C'est là que le moment commence, ce qui complique beaucoup la mise en œuvre et l'article. Je m'y attarderai donc un peu. Ce n'est pas intéressant pour moi d'écrire une multiplication matricielle avec une disposition standard des éléments en ligne. Qui en avait besoin, et donc ils ont étudié dans les universités ou seuls. Notre objectif est la productivité. Tout d'abord, je suis passé à la disposition des colonnes il y a longtemps. Mon logiciel de rendu est basé sur l'API OpenGL et donc, afin d'éviter des transpositions inutiles, j'ai commencé à stocker des éléments dans des colonnes. Ceci est également important car la multiplication matricielle n'est pas si critique. Matrices 2-5-10 bien multipliées. Et c'est tout. Et puis nous multiplions la matrice finie par des milliers ou des millions de sommets. Et cette opération est beaucoup plus critique. Vous pouvez bien sûr transposer à chaque fois. Mais pourquoi, si cela peut être évité.

Mais revenons aux matrices exclusivement. Nous avons déterminé le stockage en colonnes. Cependant, cela peut être encore plus compliqué. Il est plus pratique pour moi de stocker les éléments supérieurs des vecteurs et des lignes de matrice dans les registres SIMD afin que x soit dans le flottant le plus élevé (index 3) et w dans le mineur (index 0). Ici, apparemment, nous devrons retraiter à nouveau sur pourquoi.

Le fait est que dans un logiciel de rendu dans un vecteur, vous devez manipuler le composant w plus souvent ( 1 / z y est stocké), et il est très pratique de le faire via la version _ss de l'opération (opérations exclusivement avec le composant dans le flotteur inférieur du registre xmm ), sans toucher x, y, z . Par conséquent, dans le registre SSE, le vecteur est stocké dans un ordre compréhensible x, y, z, w et en mémoire dans le sens inverse w, z, y, x .

De plus, toutes les options de multiplication sont également implémentées par des fonctions individuelles. Cela se fait parce que je les utilise pour remplacer l'option souhaitée en fonction du type d'instructions prises en charge. Bien décrit ici.

Nous implémentons la fonctionnalité de base


Multiplication avec boucles, rangée ordonnée


Option pour la disposition des éléments en ligne
for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { r[i][j] = 0.f; for (int k = 0; k < 4; ++k) { r[i][j] += m[i][k] * n[k][j]; } } } 


Ici, tout est simple et clair. Pour chaque élément, nous faisons 4 multiplications et 3 additions. Au total, ce sont 64 multiplications et 48 additions. Et cela sans tenir compte de la lecture des éléments d'enregistrement.

Tout est triste, bref. Pour cette option, pour le cycle interne, l'IACA a émis: 3,65 cycles d'horloge pour l'assemblage x86 et 2,97 horloges pour l'assemblage x64 . Ne demandez pas pourquoi les nombres fractionnaires. Je ne sais pas. IACA 2.1 n'en a pas souffert. Dans tous les cas, ces nombres doivent être multipliés par environ 4 * 4 * 4 = 64. Même si vous prenez x64, le résultat est d'environ 192 mesures. Il est clair qu'il s'agit d'une estimation approximative. Je ne vois pas l'intérêt d'évaluer plus précisément les performances de cette option.

Implémentation en boucle, colonne ordonnée


matrice transposée, réorganiser les indices de ligne et de colonne
 for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { r[j][i] = 0.f; for (int k = 0; k < 4; ++k) { r[j][i] += m[k][i] * n[j][k]; } } } 


Multiplication de cycle, stockage orienté SIMD


le stockage des lignes dans l'ordre inverse en mémoire est ajouté
 for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { r[j][3-i] = 0.f; for (int k = 0; k < 4; ++k) { r[j][3-i] += m[k][3-i] * n[j][3-k]; } } } 


Cette implémentation simplifie quelque peu la compréhension de ce qui se passe à l'intérieur, mais n'est clairement pas suffisante.

Classes d'assistance


Pour faciliter la compréhension et l'écriture de code de référence et de débogage, il est pratique d'implémenter quelques classes auxiliaires. Rien de plus, tout n'est que pour comprendre. Je note que la mise en œuvre de classes vectorielles et matricielles à part entière est une question difficile distincte, et n'est pas incluse dans le sujet de cet article.

Classes vectorielles et matricielles
 struct alignas(sizeof(__m128)) vec4 { union { struct { float w, z, y, x; }; __m128 fmm; float arr[4]; }; vec4() {} vec4(float a, float b, float c, float d) : w(d), z(c), y(b), x(a) {} static bool equ(float const a, float const b, float t = .00001f) { return fabs(ab) < t; } bool operator == (vec4 const& v) const { return equ(x, vx) && equ(y, vy) && equ(z, vz) && equ(w, vw); } }; struct alignas(sizeof(__m256)) mtx4 { //           union { struct { float _30, _20, _10, _00, _31, _21, _11, _01, _32, _22, _12, _02, _33, _23, _13, _03; }; __m128 r[4]; __m256 s[2]; vec4 v[4]; }; //    mtx4() {} mtx4( float i00, float i01, float i02, float i03, float i10, float i11, float i12, float i13, float i20, float i21, float i22, float i23, float i30, float i31, float i32, float i33) : _00(i00), _01(i01), _02(i02), _03(i03) , _10(i10), _11(i11), _12(i12), _13(i13) , _20(i20), _21(i21), _22(i22), _23(i23) , _30(i30), _31(i31), _32(i32), _33(i33) {} //      operator __m128 const* () const { return r; } operator __m128* () { return r; } //   bool operator == (mtx4 const& m) const { return v[0]==mv[0] && v[1]==mv[1] && v[2]==mv[2] && v[3]==mv[3]; } //  static mtx4 identity() { return mtx4( 1.f, 0.f, 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 0.f, 1.f); } static mtx4 zero() { return mtx4( 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f); } }; 


Fonction de référence pour les tests


Étant donné que l'ordre accepté des éléments dans la matrice complique beaucoup la compréhension, nous ne serons pas non plus dérangés par la fonction de référence claire , qui montrera dans les futures implémentations que tout fonctionne correctement. Nous comparerons avec lui les résultats ultérieurs.

Pour le créer, il suffit de prendre et d'étendre le cycle
 void mul_mtx4_mtx4_unroll(__m128* const _r, __m128 const* const _m, __m128 const* const _n) { mtx4 const& m = **reinterpret_cast<mtx4 const* const*>(&_m); mtx4 const& n = **reinterpret_cast<mtx4 const* const*>(&_n); mtx4& r = **reinterpret_cast<mtx4* const*>(&_r); r._00 = m._00*n._00 + m._01*n._10 + m._02*n._20 + m._03*n._30; r._01 = m._00*n._01 + m._01*n._11 + m._02*n._21 + m._03*n._31; r._02 = m._00*n._02 + m._01*n._12 + m._02*n._22 + m._03*n._32; r._03 = m._00*n._03 + m._01*n._13 + m._02*n._23 + m._03*n._33; r._10 = m._10*n._00 + m._11*n._10 + m._12*n._20 + m._13*n._30; r._11 = m._10*n._01 + m._11*n._11 + m._12*n._21 + m._13*n._31; r._12 = m._10*n._02 + m._11*n._12 + m._12*n._22 + m._13*n._32; r._13 = m._10*n._03 + m._11*n._13 + m._12*n._23 + m._13*n._33; r._20 = m._20*n._00 + m._21*n._10 + m._22*n._20 + m._23*n._30; r._21 = m._20*n._01 + m._21*n._11 + m._22*n._21 + m._23*n._31; r._22 = m._20*n._02 + m._21*n._12 + m._22*n._22 + m._23*n._32; r._23 = m._20*n._03 + m._21*n._13 + m._22*n._23 + m._23*n._33; r._30 = m._30*n._00 + m._31*n._10 + m._32*n._20 + m._33*n._30; r._31 = m._30*n._01 + m._31*n._11 + m._32*n._21 + m._33*n._31; r._32 = m._30*n._02 + m._31*n._12 + m._32*n._22 + m._33*n._32; r._33 = m._30*n._03 + m._31*n._13 + m._32*n._23 + m._33*n._33; } 


L'algorithme classique est clairement peint ici, il est difficile de se tromper (mais vous pouvez :-)). À ce sujet, l'IACA a publié: x86 - 69,95 mesures, x64 - 64 mesures . Voici environ 64 cycles et nous verrons l'accélération de cette opération dans le futur.

Implémentation SSE


Algorithme SSE classique


Pourquoi classique? Parce qu'il fait depuis longtemps partie de l'implémentation de FVec dans le cadre de MSVS. Pour commencer, nous allons écrire comment nous présentons les éléments de matrice dans les registres SSE. Cela semble déjà plus simple ici. Juste une matrice transposée.

 //     00, 10, 20, 30 // m[0] -  SIMD /   01, 11, 21, 31 // m[1] 02, 12, 22, 32 // m[2] 03, 13, 23, 33 // m[3] 

Nous prenons le code de déroulement de la variante ci-dessus. D'une certaine manière, il est hostile à SSE. Le premier groupe de lignes comprend les résultats de la colonne de la matrice résultante: r._00, r._01, r._02, r._03 . Nous avons cette colonne, mais nous avons besoin d'une ligne. Oui, et m , n ne semblent pas pratiques pour les calculs. Par conséquent, nous réorganisons les lignes de l'algorithme de sorte que le résultat r soit rangé.

 //  ,     r[0] r00 = m00*n00 + m01*n10 + m02*n20 + m03*n30; r10 = m10*n00 + m11*n10 + m12*n20 + m13*n30; r20 = m20*n00 + m21*n10 + m22*n20 + m23*n30; r30 = m30*n00 + m31*n10 + m32*n20 + m33*n30; //  ,     r[1] r01 = m00*n01 + m01*n11 + m02*n21 + m03*n31; r11 = m10*n01 + m11*n11 + m12*n21 + m13*n31; r21 = m20*n01 + m21*n11 + m22*n21 + m23*n31; r31 = m30*n01 + m31*n11 + m32*n21 + m33*n31; //  ,     r[2] r02 = m00*n02 + m01*n12 + m02*n22 + m03*n32; r12 = m10*n02 + m11*n12 + m12*n22 + m13*n32; r22 = m20*n02 + m21*n12 + m22*n22 + m23*n32; r32 = m30*n02 + m31*n12 + m32*n22 + m33*n32; //  ,     r[3] r03 = m00*n03 + m01*n13 + m02*n23 + m03*n33; r13 = m10*n03 + m11*n13 + m12*n23 + m13*n33; r23 = m20*n03 + m21*n13 + m22*n23 + m23*n33; r33 = m30*n03 + m31*n13 + m32*n23 + m33*n33; 

Mais c'est déjà beaucoup mieux. Que voit-on en fait? Selon les colonnes de l'algorithme dans chaque groupe, nous avons les lignes de la matrice m impliquées:
 m [0] = {00,10,20,30}, m [1] = {01,11,21,31}, m [2] = {02,12,22,32}, m [3] = {03,13,23,33},
qui sont multipliés par le même élément de la matrice n . Par exemple, pour le premier groupe, c'est: n._00, n._10, n._20, n._30 . Et les éléments de la matrice n pour chaque groupe de lignes de l'algorithme se trouvent à nouveau dans une ligne de la matrice.

Alors tout est simple: on prend simplement les lignes de la matrice m par index, mais comme pour les éléments n , on prend sa ligne et on la mélange à travers les 4 éléments du registre via l'instruction shuffle , pour multiplier par la ligne de la matrice m dans le registre. Par exemple, pour l'élément n._00 (rappelez-vous que son décalage dans le registre a l'index 3), ce sera:
  _mm_shuffle_ps (n [0], n [0], _MM_SHUFFLE (3,3,3,3)) 

Dans une forme simplifiée, l'algorithme ressemble à ceci:

 //   n[0]={00,10,20,30} r[0] = m[0] * n00 + m[1] * n10 + m[2] * n20 + m[3] * n30; //   n[1]={01,11,21,31} r[1] = m[0] * n01 + m[1] * n11 + m[2] * n21 + m[3] * n31; //   n[2]={02,12,22,32} r[2] = m[0] * n02 + m[1] * n12 + m[2] * n22 + m[3] * n32; //   n[3]={03,13,23,33} r[3] = m[0] * n03 + m[1] * n13 + m[2] * n23 + m[3] * n33; 

Implémentation SSE de base
 void mul_mtx4_mtx4_sse_v1(__m128* const r, __m128 const* const m, __m128 const* const n) { r[0] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[0], n[0], _MM_SHUFFLE(0,0,0,0))))); r[1] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[1], n[1], _MM_SHUFFLE(0,0,0,0))))); r[2] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[2], n[2], _MM_SHUFFLE(0,0,0,0))))); r[3] = _mm_add_ps( _mm_add_ps( _mm_mul_ps(m[0], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(3,3,3,3))), _mm_mul_ps(m[1], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(2,2,2,2)))), _mm_add_ps( _mm_mul_ps(m[2], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(1,1,1,1))), _mm_mul_ps(m[3], _mm_shuffle_ps(n[3], n[3], _MM_SHUFFLE(0,0,0,0))))); } 


Maintenant, nous changeons les éléments n de l'algorithme en shuffle correspondant, multiplication par _mm_mul_ps , la somme par _mm_add_ps , et vous avez terminé. Ça marche. Le code semble cependant bien pire que l'algorithme lui-même. Pour ce code, l'IACA a émis: x86 - 18.89, x64 - 16 cycles . C'est 4 fois plus rapide que le précédent. Dans le registre SSE, le quatrième flotteur. Relation presque linéaire.

Décorer la mise en œuvre de SSE


Pourtant, dans le code, cela a l'air horrible. Nous allons essayer d'améliorer cela en écrivant un peu de sucre syntaxique.

Opérateurs et améliorateurs
 //        ( -    namespace) __m128 operator + (__m128 const a, __m128 const b) { return _mm_add_ps(a, b); } __m128 operator - (__m128 const a, __m128 const b) { return _mm_sub_ps(a, b); } __m128 operator * (__m128 const a, __m128 const b) { return _mm_mul_ps(a, b); } __m128 operator / (__m128 const a, __m128 const b) { return _mm_div_ps(a, b); } //_mm_shuffle_ps(u, v, _MM_SHUFFLE(3,2,1,0))   shuf<3,2,1,0>(u, v) template <int a, int b, int c, int d> __m128 shuf(__m128 const u, __m128 const v) { return _mm_shuffle_ps(u, v, _MM_SHUFFLE(a, b, c, d)); } template <int a, int b, int c, int d> __m128 shuf(__m128 const v) { return _mm_shuffle_ps(v, v, _MM_SHUFFLE(a, b, c, d)); } //    template <int i> __m128 shuf(__m128 const u, __m128 const v) { return _mm_shuffle_ps(u, v, _MM_SHUFFLE(i, i, i, i)); } template <int i> __m128 shuf(__m128 const v) { return _mm_shuffle_ps(v, v, _MM_SHUFFLE(i, i, i, i)); } //  float       , //    ,    template <int a, int b, int c, int d> __m128 shufd(__m128 const v) { return _mm_castsi128_ps(_mm_shuffle_epi32(_mm_castps_si128(v), _MM_SHUFFLE(a, b, c, d))); } template <int i> __m128 shufd(__m128 const v) { return _mm_castsi128_ps(_mm_shuffle_epi32(_mm_castps_si128(v), _MM_SHUFFLE(i, i, i, i))); } 


Le compilateur peut parfaitement intégrer ces fonctions (bien que parfois sans __forceinline en aucune façon).

Donc le code tourne ...
 void mul_mtx4_mtx4_sse_v2(__m128* const r, __m128 const* const m, __m128 const* const n) { r[0] = m[0]*shuf<3>(n[0]) + m[1]*shuf<2>(n[0]) + m[2]*shuf<1>(n[0]) + m[3]*shuf<0>(n[0]); r[1] = m[0]*shuf<3>(n[1]) + m[1]*shuf<2>(n[1]) + m[2]*shuf<1>(n[1]) + m[3]*shuf<0>(n[1]); r[2] = m[0]*shuf<3>(n[2]) + m[1]*shuf<2>(n[2]) + m[2]*shuf<1>(n[2]) + m[3]*shuf<0>(n[2]); r[3] = m[0]*shuf<3>(n[3]) + m[1]*shuf<2>(n[3]) + m[2]*shuf<1>(n[3]) + m[3]*shuf<0>(n[3]); } 


Et donc c'est déjà beaucoup mieux et plus lisible. Pour cela, IACA a produit environ le résultat attendu: x86 - 19 (et pourquoi pas fractionnaire?), X64 - 16 . En fait, les performances n'ont pas changé, mais le code est beaucoup plus beau et compréhensible.

Peu de contribution à l'optimisation future


Introduisons encore une amélioration au niveau d'une fonction apparue récemment dans la version iron. Opération d'ajout multiple (fma) . fma (a, b, c) = a * b + c .

Implémentation d'ajout multiple
 __m128 mad(__m128 const a, __m128 const b, __m128 const c) { return _mm_add_ps(_mm_mul_ps(a, b), c); } 


Pourquoi est-ce nécessaire? Tout d'abord, pour une optimisation future. Par exemple, vous pouvez simplement remplacer mad par fma dans le code fini via les mêmes macros que vous le souhaitez. Mais nous allons jeter les bases de l'optimisation maintenant:

Variante avec ajout multiple
 void mul_mtx4_mtx4_sse_v3(__m128* const r, __m128 const* const m, __m128 const* const n) { r[0] = mad(m[0], shuf<3>(n[0]), m[1]*shuf<2>(n[0])) + mad(m[2], shuf<1>(n[0]), m[3]*shuf<0>(n[0])); r[1] = mad(m[0], shuf<3>(n[1]), m[1]*shuf<2>(n[1]) + mad(m[2], shuf<1>(n[1]), m[3]*shuf<0>(n[1])); r[2] = mad(m[0], shuf<3>(n[2]), m[1]*shuf<2>(n[2])) + mad(m[2], shuf<1>(n[2]), m[3]*shuf<0>(n[2])); r[3] = mad(m[0], shuf<3>(n[3]), m[1]*shuf<2>(n[3])) + mad(m[2], shuf<1>(n[3]), m[3]*shuf<0>(n[3])); } 


IACA: x86 - 18,89, x64 - 16 . Encore une fois fractionnaire. Pourtant, IACA produit parfois des résultats étranges. Le code n'a pas tellement changé. Probablement même un peu pire. Mais l'optimisation nécessite parfois de tels sacrifices.

Nous passons à l'épargne via _mm_stream


Divers guides d'optimisation recommandent à nouveau de ne pas extraire le cache pour les opérations de sauvegarde en bloc. Cela est généralement justifié lorsque vous traitez des sommets qui sont des milliers ou plus. Mais pour les matrices, ce n'est peut-être pas si important. Cependant, je vais l'ajouter quand même.

Option d'économie de flux
 void mul_mtx4_mtx4_sse_v4(__m128* const r, __m128 const* const m, __m128 const* const n) { _mm_stream_ps(&r[0].m128_f32[0], mad(m[0], shuf<3>(n[0]), m[1]*shuf<2>(n[0])) + mad(m[2], shuf<1>(n[0]), m[3]*shuf<0>(n[0]))); _mm_stream_ps(&r[1].m128_f32[0], mad(m[0], shuf<3>(n[1]), m[1]*shuf<2>(n[1])) + mad(m[2], shuf<1>(n[1]), m[3]*shuf<0>(n[1]))); _mm_stream_ps(&r[2].m128_f32[0], mad(m[0], shuf<3>(n[2]), m[1]*shuf<2>(n[2])) + mad(m[2], shuf<1>(n[2]), m[3]*shuf<0>(n[2]))); _mm_stream_ps(&r[3].m128_f32[0], mad(m[0], shuf<3>(n[3]), m[1]*shuf<2>(n[3])) + mad(m[2], shuf<1>(n[3]), m[3]*shuf<0>(n[3]))); } 


Rien n'a changé dans le temps ici, du mot du tout. Mais, selon les recommandations, nous ne touchons plus au cache.

Implémentation AVX


Option AVX de base



Ensuite, nous passons à la prochaine étape d'optimisation. Le 4ème flotteur est inclus dans le registre SSE, et dans l'AVX il est déjà 8. Autrement dit, il y a une chance théorique de réduire le nombre d'opérations effectuées et d'augmenter la productivité sinon de moitié, puis au moins 1,5 fois. Mais quelque chose me dit que tout ne sera pas aussi simple avec la transition vers AVX. Pouvons-nous obtenir les données nécessaires à partir de registres doubles?

Essayons de le comprendre. Encore une fois, nous écrivons notre algorithme de multiplication utilisé ci-dessus. Vous ne pouvez pas faire cela, mais il est plus pratique de traiter le code lorsque tout est à proximité et que vous n'avez pas à faire défiler une demi-page.

 //    : 00, 10, 20, 30, 01, 11, 21, 31, 02, 12, 22, 32, 03, 13, 23, 33 //   SSE: r0 = m0*n00 + m1*n10 + m2*n20 + m3*n30 r1 = m0*n01 + m1*n11 + m2*n21 + m3*n31 r2 = m0*n02 + m1*n12 + m2*n22 + m3*n32 r3 = m0*n03 + m1*n13 + m2*n23 + m3*n33 

En sortie, nous nous attendons à obtenir le résultat en ymm = {r0: r1} et ymm = {r2: r3} . Si dans la version SSE notre algorithme était généralisé aux colonnes, nous devons maintenant le généraliser aux lignes. Donc, agir comme dans le cas de l'option SSE ne fonctionnera pas.

Si l'on considère la matrice m dans les registres ymm , on obtient respectivement ymm = {m0: m1} et ymm = {m2: m3} . Auparavant, nous n'avions que des colonnes matricielles dans le registre, et maintenant des colonnes et des lignes.

Si vous essayez d'agir comme auparavant, vous devez multiplier ymm = {m0: m1} par le registre ymm = {n00, n00, n00, n00}: {n10, n10, n10, n10} . Étant donné que n00 et n01 sont dans la même ligne de la matrice n , à en juger par l'ensemble d'instructions AVX disponibles, les disperser par ymm sera coûteux. La lecture aléatoire et la permutation fonctionnent séparément pour chacun des deux fours d'un flotteur (haut et bas xmm ) à l'intérieur des registres ymm .

Si nous prenons ymm de la matrice n , alors nous obtenons les deux éléments n00 et n10 au plus haut de 2 xmm à l'intérieur du registre ymm . {n00, n10, n20, n30}: {n01, n11, n21, n31} . Habituellement, l'index pour les instructions existantes est de 0 à 3. Et il ne flotte que dans un registre xmm sur deux dans le registre ymm . Il n'est pas possible de transférer le n10 de l'ancien xmm vers le plus jeune à moindre coût . Et puis cet objectif doit être répété plusieurs fois. Nous ne pouvons pas supporter une telle perte de mesures. Il faut trouver autre chose.

Nous généralisions les colonnes, mais maintenant les lignes. Par conséquent, nous essaierons d'aller d'une manière un peu différente. Nous devons obtenir le résultat dans {r0: r1} . Cela signifie que l'algorithme doit être amélioré non pas sur des lignes distinctes de l'algorithme, mais sur deux à la fois. Et ici, ce qui était un inconvénient dans le travail de mélange et de permutation , sera un plus pour nous. Nous regardons ce que nous aurons dans les registres ymm lorsque nous considérons la matrice n .

 n0n1 = {00, 10, 20, 30} : {01, 11, 21, 31} n2n3 = {02, 12, 22, 32} : {03, 13, 23, 33} 

Oui, nous remarquons que dans différentes parties xmm du registre ymm, nous avons les éléments 00 et 01 . Ils peuvent être multipliés en casse par registre via la commande permute dans {_00, _00, _00, _00}: {_ 01, _01, _01, _01} , indiquant un seul index 3 pour les deux parties xmm . C'est exactement ce dont nous avons besoin. En effet, les coefficients sont également utilisés sur différentes lignes. Seulement maintenant dans le registre ymm correspondant pour la multiplication, il sera nécessaire de conserver {m0: m0} , c'est-à-dire la première ligne dupliquée de la matrice m .

Donc, nous peignons l'algorithme plus en détail. Nous lisons les doubles rangées de la matrice m dans les registres ymm :

 mm[0] = {m0:m0} mm[1] = {m1:m1} mm[2] = {m2:m2} mm[3] = {m3:m3} 

Et puis nous calculerons la multiplication comme:

 r0r1 = mm[0] * {n00,n00,n00,n00:n01,n01,n01,n01} + // permute<3,3,3,3>(n0n1) mm[1] * {n10,n10,n10,n10:n11,n11,n11,n11} + // permute<2,2,2,2>(n0n1) mm[2] * {n20,n20,n20,n20:n21,n21,n21,n21} + // permute<1,1,1,1>(n0n1) mm[3] * {n30,n30,n30,n30:n31,n31,n31,n31} // permute<0,0,0,0>(n0n1) r2r3 = mm[0] * {n02,n02,n02,n02:n03,n03,n03,n03} + // permute<3,3,3,3>(n2n3) mm[1] * {n12,n12,n12,n12:n13,n13,n13,n13} + // permute<2,2,2,2>(n2n3) mm[2] * {n22,n22,n22,n22:n23,n23,n23,n23} + // permute<1,1,1,1>(n2n3) mm[3] * {n32,n32,n32,n32:n33,n33,n33,n33} // permute<0,0,0,0>(n2n3) 

Nous réécrivons plus clairement:

 r0r1 = mm[0]*n0n1<3,3,3,3>+mm[1]*n0n1<2,2,2,2>+mm[2]*n0n1<1,1,1,1>+mm[3]*n0n1<0,0,0,0> r2r3 = mm[0]*n2n3<3,3,3,3>+mm[1]*n2n3<2,2,2,2>+mm[2]*n2n3<1,1,1,1>+mm[3]*n2n3<0,0,0,0> 

Ou sous une forme simplifiée:

 r0r1 = mm[0]*n0n1<3> + mm[1]*n0n1<2> + mm[2]*n0n1<1> + mm[3]*n0n1<0> r2r3 = mm[0]*n2n3<3> + mm[1]*n2n3<2> + mm[2]*n2n3<1> + mm[3]*n2n3<0> 

Tout semble clair.

Il ne reste plus qu'à écrire une implémentation
 void mul_mtx4_mtx4_avx_v1(__m128* const r, __m128 const* const m, __m128 const* const n) { __m256 mm0 = _mm256_set_m128(m[0], m[0]); __m256 mm1 = _mm256_set_m128(m[1], m[1]); __m256 mm2 = _mm256_set_m128(m[2], m[2]); __m256 mm3 = _mm256_set_m128(m[3], m[3]); __m256 n0n1 = _mm256_load_ps(&n[0].m128_f32[0]); __m256 y1 = _mm256_permute_ps(n0n1, 0xFF);//3,3,3,3 __m256 y2 = _mm256_permute_ps(n0n1, 0xAA);//2,2,2,2 __m256 y3 = _mm256_permute_ps(n0n1, 0x55);//1,1,1,1 __m256 y4 = _mm256_permute_ps(n0n1, 0x00);//0,0,0,0 y1 = _mm256_mul_ps(y1, mm0); y2 = _mm256_mul_ps(y2, mm1); y3 = _mm256_mul_ps(y3, mm2); y4 = _mm256_mul_ps(y4, mm3); y1 = _mm256_add_ps(y1, y2); y3 = _mm256_add_ps(y3, y4); y1 = _mm256_add_ps(y1, y3); __m256 n2n3 = _mm256_load_ps(&n[2].m128_f32[0]); __m256 y5 = _mm256_permute_ps(n2n3, 0xFF); __m256 y6 = _mm256_permute_ps(n2n3, 0xAA); __m256 y7 = _mm256_permute_ps(n2n3, 0x55); __m256 y8 = _mm256_permute_ps(n2n3, 0x00); y5 = _mm256_mul_ps(y5, mm0); y6 = _mm256_mul_ps(y6, mm1); y7 = _mm256_mul_ps(y7, mm2); y8 = _mm256_mul_ps(y8, mm3); y5 = _mm256_add_ps(y5, y6); y7 = _mm256_add_ps(y7, y8); y5 = _mm256_add_ps(y5, y7); _mm256_stream_ps(&r[0].m128_f32[0], y1); _mm256_stream_ps(&r[2].m128_f32[0], y5); } 


Voici les chiffres intéressants de l'IACA: x86 - 12,53, x64 - 12 . Bien sûr, je voulais mieux. J'ai raté quelque chose.

Optimisation AVX plus sucre syntaxique


Il semble que dans le code ci-dessus, AVX n'ait pas été utilisé à son plein potentiel. Nous constatons qu'au lieu de définir deux lignes identiques dans le registre ymm , nous pouvons utiliser la diffusion , qui peut remplir le registre ymm avec deux valeurs xmm identiques. En cours de route, ajoutez également du «sucre syntaxique» pour les fonctions AVX.

Implémentation AVX améliorée
 __m256 operator + (__m256 const a, __m256 const b) { return _mm256_add_ps(a, b); } __m256 operator - (__m256 const a, __m256 const b) { return _mm256_sub_ps(a, b); } __m256 operator * (__m256 const a, __m256 const b) { return _mm256_mul_ps(a, b); } __m256 operator / (__m256 const a, __m256 const b) { return _mm256_div_ps(a, b); } template <int i> __m256 perm(__m256 const v) { return _mm256_permute_ps(v, _MM_SHUFFLE(i, i, i, i)); } template <int a, int b, int c, int d> __m256 perm(__m256 const v) { return _mm256_permute_ps(v, _MM_SHUFFLE(a, b, c, d)); } template <int i, int j> __m256 perm(__m256 const v) { return _mm256_permutevar_ps(v, _mm256_set_epi32(i, i, i, i, j, j, j, j)); } template <int a, int b, int c, int d, int e, int f, int g, int h> __m256 perm(__m256 const v) { return _mm256_permutevar_ps(v, _mm256_set_epi32(a, b, c, d, e, f, g, h)); } __m256 mad(__m256 const a, __m256 const b, __m256 const c) { return _mm256_add_ps(_mm256_mul_ps(a, b), c); } void mul_mtx4_mtx4_avx_v2(__m128* const r, __m128 const* const m, __m128 const* const n) { __m256 const mm[] { _mm256_broadcast_ps(m+0), _mm256_broadcast_ps(m+1), _mm256_broadcast_ps(m+2), _mm256_broadcast_ps(m+3) }; __m256 const n0n1 = _mm256_load_ps(&n[0].m128_f32[0]); _mm256_stream_ps(&r[0].m128_f32[0], mad(perm<3>(n0n1), mm[0], perm<2>(n0n1)*mm[1])+ mad(perm<1>(n0n1), mm[2], perm<0>(n0n1)*mm[3])); __m256 const n2n3 = _mm256_load_ps(&n[2].m128_f32[0]); _mm256_stream_ps(&r[2].m128_f32[0], mad(perm<3>(n2n3), mm[0], perm<2>(n2n3)*mm[1])+ mad(perm<1>(n2n3), mm[2], perm<0>(n2n3)*mm[3])); } 


Et ici, les résultats sont déjà plus intéressants. IACA produit des nombres: x86 - 10, x64 - 8,58 , ce qui semble beaucoup mieux, mais toujours pas 2 fois.

Option AVX + FMA (finale)


Faisons une autre tentative. Il serait maintenant logique de rappeler à nouveau le jeu d'instructions FMA, car il a été ajouté aux processeurs après AVX. Il suffit de changer individuellement mul + add pour une seule opération. Bien que nous utilisions toujours l'instruction de multiplication pour donner au compilateur plus de possibilités d'optimisation et le processeur pour l'exécution parallèle de multiplications. Habituellement, je regarde le code généré dans l'assembleur pour m'assurer quelle option est meilleure.

Dans ce cas, nous devons calculer a * b + c * d + e * f + g * h . Vous pouvez faire ce front: fma (a, b, fma (c, d, fma (e, f, g * h))) . Mais, comme nous le voyons, il est impossible d'effectuer une opération ici sans terminer la précédente. Et cela signifie que nous ne pourrons pas utiliser la possibilité de faire des multiplications par paires, comme le permet le pipeline SIMD. Si nous transformons légèrement les calculs fma (a, b, c * d) + fma (e, f, g * h) , nous verrons que nous pouvons paralléliser les calculs. Faites d'abord deux multiplications indépendantes, puis deux opérations fma indépendantes.

Implémentation AVX + FMA
 __m256 fma(__m256 const a, __m256 const b, __m256 const c) { return _mm256_fmadd_ps(a, b, c); } void mul_mtx4_mtx4_avx_fma(__m128* const r, __m128 const* const m, __m128 const* const n) { __m256 const mm[]{ _mm256_broadcast_ps(m + 0), _mm256_broadcast_ps(m + 1), _mm256_broadcast_ps(m + 2), _mm256_broadcast_ps(m + 3) }; __m256 const n0n1 = _mm256_load_ps(&n[0].m128_f32[0]); _mm256_stream_ps(&r[0].m128_f32[0], fma(perm<3>(n0n1), mm[0], perm<2>(n0n1)*mm[1])+ fma(perm<1>(n0n1), mm[2], perm<0>(n0n1)*mm[3])); __m256 const n2n3 = _mm256_load_ps(&n[2].m128_f32[0]); _mm256_stream_ps(&r[2].m128_f32[0], fma(perm<3>(n2n3), mm[0], perm<2>(n2n3)*mm[1])+ fma(perm<1>(n2n3), mm[2], perm<0>(n2n3)*mm[3])); } 


IACA: x86 - 9.21, x64 - 8 . Maintenant c'est très bien. Quelqu'un dira probablement ce qui peut être fait encore mieux, mais je ne sais pas comment.

Repères


Je constate tout de suite que ces chiffres ne doivent pas être considérés comme la vérité ultime. Même avec un test fixe, ils nagent dans certaines limites.Et plus encore, ils se comportent différemment sur différentes plateformes. Avec toute optimisation, prenez des mesures spécifiquement pour votre cas.

Table des matières


  • Fonction: nom de la fonction. La fin sur s - fonctionne avec un streaming, mov autrement normal (sans streaming). Ajouté pour plus de clarté, car c'est assez important.
  • Cycles IACA: nombre de ticks par fonction calculé par IACA
  • Cycles mesurés: nombre mesuré de mesures (moins c'est plus)
  • Accélération IACA: nombre de mesures sur une ligne zéro / nombre de mesures sur une ligne
  • Accélération mesurée: nombre de mesures dans la ligne zéro / nombre de mesures dans la ligne (plus c'est mieux)


Pour loop_m, les graduations de l'article ont été multipliées par 64. Autrement dit, il s'agit d'une valeur très approximative. En fait, cela s'est avéré ainsi.

i3-3770:


x86
FonctionCycles IACACycles mesurésAccélération IACAAccélération mesurée
dérouler_m70,0050,751,001,00
loop_m233,60119,210,300,43
sse_v1m18,8927.513.701.84
sse_v2m19.0027.613.681.84
sse_v3m18.8927.223.701.86
sse_v4s18.8927.183.701.87
avx_v1m13.0019.215.382.64
avx_v1s13.0020.035.382.53
avx_v2m10.0012.916.993.93
avx_v2s10.0017.346.992.93

x64
FunctionIACA cyclesMeasured cyclesIACA speedupMeasured speedup
unroll_m7068.601.001.00
loop_m233.60119.370.300.57
sse_v1m18.8921.983.703.12
sse_v2m19.0021.093.683.25
sse_v3m18.8922.193.703.09
sse_v4s18.8922.393.703.06
avx_v1m13.009.615.387.13
avx_v1s13.0016.905.384.06
avx_v2m10.009.206.997.45
avx_v2s10.0014.646.994.68

i7-8700K:


x86
FunctionIACA cyclesMeasured cyclesIACA speedupMeasured speedup
unroll_m69.9540.251.001.00
loop_m233.6079.490.300.51
sse_v1m18.8919.313.702.09
sse_v2m19.0019.983.682.01
sse_v3m18.8919.693.702.04
sse_v4s18.8919.673.702.05
avx_v1m13.0014.225.382.83
avx_v1s13.0014.135.382.85
avx_v2m10.0011.736.993.43
avx_v2s10.0011.816.993.41
AVX+FMAm9.2110.387.603.88
AVX+FMAs9.2110.327.603.90

x64
FunctionIACA cyclesMeasured cyclesIACA speedupMeasured speedup
unroll_m69.9557.111.001.00
loop_m233.6075.730.300.75
sse_v1m18.8915.833.703.61
sse_v2m19.0017.223.683.32
sse_v3m18.8915.923.703.59
sse_v4s18.8916.183.703.53
avx_v1m13.007.035.388.12
avx_v1s13.0012.985.384.40
avx_v2m10.005.406.9910.57
avx_v2s10.0011.396.995.01
AVX+FMAm9.219.737.605.87
AVX+FMAs9.219.817.605.82

Testez le code dans la source. S'il existe des suggestions raisonnables sur la façon de les améliorer, écrivez dans les commentaires.

BONUS du royaume de la fiction


En fait, c'est du domaine de la fiction parce que si je voyais des processeurs prenant en charge AVX512, alors peut-être sur les photos. Cependant, j'ai essayé d'implémenter l'algorithme. Ici, je ne vais rien expliquer, une analogie complète avec AVX + FMA. L'algorithme est le même, seulement moins d'opérations.

Comme on dit, je vais le laisser ici
 __m512 operator + (__m512 const a, __m512 const b) { return _mm512_add_ps(a, b); } __m512 operator - (__m512 const a, __m512 const b) { return _mm512_sub_ps(a, b); } __m512 operator * (__m512 const a, __m512 const b) { return _mm512_mul_ps(a, b); } __m512 operator / (__m512 const a, __m512 const b) { return _mm512_div_ps(a, b); } template <int i> __m512 perm(__m512 const v) { return _mm512_permute_ps(v, _MM_SHUFFLE(i, i, i, i)); } template <int a, int b, int c, int d> __m512 perm(__m512 const v) { return _mm512_permute_ps(v, _MM_SHUFFLE(a, b, c, d)); } __m512 fma(__m512 const a, __m512 const b, __m512 const c) { return _mm512_fmadd_ps(a, b, c); } void mul_mtx4_mtx4_avx512(__m128* const r, __m128 const* const m, __m128 const* const _n) { __m512 const mm[]{ _mm512_broadcast_f32x4(m[0]), _mm512_broadcast_f32x4(m[1]), _mm512_broadcast_f32x4(m[2]), _mm512_broadcast_f32x4(m[3]) }; __m512 const n = _mm512_load_ps(&_n[0].m128_f32[0]); _mm512_stream_ps(&r[0].m128_f32[0], fma(perm<3>(n), mm[0], perm<2>(n)*mm[1])+ fma(perm<1>(n), mm[2], perm<0>(n)*mm[3])); } 


Les chiffres sont fantastiques: x86 - 4,79, x64 - 5,42 (IACA avec architecture SKX). Ceci malgré le fait que l'algorithme a 64 multiplications et 48 additions.

Code PS de l'article




Ceci est ma première expérience en écrivant un article. Merci à tous pour vos commentaires. Ils aident à améliorer le code et l'article.

Source: https://habr.com/ru/post/fr418247/


All Articles