Variétés de SIMD

Lors du développement de meshoptimizer , la question se pose souvent: "Cet algorithme peut-il utiliser SIMD?"

La bibliothèque est axée sur les performances, mais SIMD n'offre pas toujours des avantages de vitesse importants. Malheureusement, SIMD peut rendre le code moins portable et moins maintenable. Par conséquent, dans chaque cas, il est nécessaire de rechercher un compromis. Lorsque les performances sont primordiales, vous devez développer et maintenir des implémentations SIMD distinctes pour les jeux d'instructions SSE et NEON. Dans d'autres cas, vous devez comprendre quel est l'effet de l'utilisation de SIMD. Aujourd'hui, nous allons essayer d'accélérer le simplificateur de maillage bâclé - un nouvel algorithme récemment ajouté à la bibliothèque - en utilisant les jeux d'instructions SSEn / AVXn.



Pour notre référence, nous simplifions le modèle du Bouddha thaï de 6 millions de triangles à 0,1% de ce nombre. Nous utilisons le compilateur Microsoft Visual Studio 2019 pour l'architecture x64 cible. L'algorithme scalaire peut effectuer une telle rationalisation en environ 210 ms dans un seul thread Intel Core i7-8700K (à ~ 4,4 GHz). Cela correspond à environ 28,5 millions de triangles par seconde. Peut-être que cela suffit dans la pratique, mais j'étais curieux d'explorer les capacités maximales de l'équipement.

Dans certains cas, la procédure peut être parallélisée en divisant la grille en morceaux, mais pour cela, il est nécessaire de procéder à une analyse supplémentaire de la connectivité afin de maintenir les limites, donc pour l'instant nous nous limiterons à des optimisations purement SIMD.

Sept mesures


Pour comprendre les possibilités d'optimisation, nous effectuerons le profilage à l'aide d'Intel VTune. Exécutez la procédure 100 fois pour vous assurer qu'il y a suffisamment de données.



Ici, j'ai activé le mode microarchitecture pour fixer le temps d'exécution de chaque fonction, ainsi que pour trouver des goulots d'étranglement. On voit que la rationalisation se fait à l'aide d'un ensemble de fonctions, chacune nécessitant un certain nombre de cycles. La liste des fonctions est triée par heure. Les voici par ordre d'exécution pour rendre l'algorithme plus facile à comprendre:

  • rescalePositions normalise les positions de tous les sommets dans un seul cube pour préparer la quantification à l'aide de computeVertexIds
  • computeVertexIds calcule un identifiant quantifié de 30 bits pour chaque sommet sur une grille uniforme d'une taille donnée, où chaque axe est quantifié sur une grille (taille de grille 10 bits, donc l'identifiant est 30)
  • countTriangles calcule le nombre approximatif de triangles que l'innovateur créera pour une taille de grille donnée, en supposant l'union de tous les sommets dans une cellule de grille
  • fillVertexCells remplit un tableau qui fillVertexCells tous les sommets aux cellules correspondantes; tous les sommets avec le même ID correspondent à une cellule
  • fillCellQuadrics remplit la structure Quadric (matrice symétrique Quadric ) pour chaque cellule afin de refléter les informations agrégées sur la géométrie correspondante
  • fillCellRemap calcule l'indice de sommet pour chaque cellule, en choisissant l'un des sommets de cette cellule et minimise la distorsion géométrique
  • filterTriangles affiche l'ensemble final de triangles selon les tables sommet-cellule-sommet construites précédemment; la traduction naïve peut produire en moyenne ~ 5% de triangles en double, donc la fonction filtre les doublons.

computeVertexIds et countTriangles sont exécutés plusieurs fois: l'algorithme détermine la taille du maillage pour la fusion des sommets, effectue une recherche binaire accélérée pour atteindre le nombre cible de triangles (dans notre cas 6000) et calcule le nombre de triangles que chaque taille de maillage générera à chaque itération. Les autres fonctions sont lancées une fois. Dans notre fichier, cinq passes de recherche donnent un maillage cible de 40 3 .

VTune signale utilement que la fonction la plus gourmande en ressources est celle qui calcule les quadriques: cela prend presque la moitié du temps d'exécution total de 21 s. Il s'agit du premier objectif d'optimisation de SIMD.

SIMD pièce par pièce


Jetons un coup d'œil au code source de fillCellQuadrics pour mieux comprendre ce qu'il calcule exactement:

 static void fillCellQuadrics(Quadric* cell_quadrics, const unsigned int* indices, size_t index_count, const Vector3* vertex_positions, const unsigned int* vertex_cells) { for (size_t i = 0; i < index_count; i += 3) { unsigned int i0 = indices[i + 0]; unsigned int i1 = indices[i + 1]; unsigned int i2 = indices[i + 2]; unsigned int c0 = vertex_cells[i0]; unsigned int c1 = vertex_cells[i1]; unsigned int c2 = vertex_cells[i2]; bool single_cell = (c0 == c1) & (c0 == c2); float weight = single_cell ? 3.f : 1.f; Quadric Q; quadricFromTriangle(Q, vertex_positions[i0], vertex_positions[i1], vertex_positions[i2], weight); if (single_cell) { quadricAdd(cell_quadrics[c0], Q); } else { quadricAdd(cell_quadrics[c0], Q); quadricAdd(cell_quadrics[c1], Q); quadricAdd(cell_quadrics[c2], Q); } } } 

La fonction parcourt tous les triangles, calcule une quadrique pour chacun d'eux et l'ajoute aux quadriques de chaque cellule. Quadrique - une matrice symétrique 4 × 4, présentée sous la forme de 10 nombres à virgule flottante:

 struct Quadric { float a00; float a10, a11; float a20, a21, a22; float b0, b1, b2, c; }; 

Le calcul d'une quadrique nécessite de résoudre l'équation plane d'un triangle, de construire une matrice quadratique et de la peser en utilisant l'aire du triangle:

 static void quadricFromPlane(Quadric& Q, float a, float b, float c, float d) { Q.a00 = a * a; Q.a10 = b * a; Q.a11 = b * b; Q.a20 = c * a; Q.a21 = c * b; Q.a22 = c * c; Q.b0 = d * a; Q.b1 = d * b; Q.b2 = d * c; Qc = d * d; } static void quadricFromTriangle(Quadric& Q, const Vector3& p0, const Vector3& p1, const Vector3& p2, float weight) { Vector3 p10 = {p1.x - p0.x, p1.y - p0.y, p1.z - p0.z}; Vector3 p20 = {p2.x - p0.x, p2.y - p0.y, p2.z - p0.z}; Vector3 normal = { p10.y * p20.z - p10.z * p20.y, p10.z * p20.x - p10.x * p20.z, p10.x * p20.y - p10.y * p20.x }; float area = normalize(normal); float distance = normal.x*p0.x + normal.y*p0.y + normal.z*p0.z; quadricFromPlane(Q, normal.x, normal.y, normal.z, -distance); quadricMul(Q, area * weight); } 

Il semble qu'il existe de nombreuses opérations en virgule flottante, elles peuvent donc être parallélisées à l'aide de SIMD. Tout d'abord, nous représentons chaque vecteur comme un vecteur SIMD à 4 larges, et modifions également la structure Quadric à 12 nombres à virgule flottante au lieu de 10 afin qu'elle s'intègre exactement dans trois registres SIMD (l'augmentation de la taille n'affecte pas les performances) et changeons l'ordre des champs afin que les calculs quadricFromPlane est devenu plus uniforme:

 struct Quadric { float a00, a11, a22; float pad0; float a10, a21, a20; float pad1; float b0, b1, b2, c; }; 

Ici, certains calculs, en particulier le produit scalaire, ne sont pas très cohérents avec les versions antérieures de SSE. Heureusement, une instruction pour un produit scalaire est apparue dans SSE4.1, ce qui est très utile pour nous.

 static void fillCellQuadrics(Quadric* cell_quadrics, const unsigned int* indices, size_t index_count, const Vector3* vertex_positions, const unsigned int* vertex_cells) { const int yzx = _MM_SHUFFLE(3, 0, 2, 1); const int zxy = _MM_SHUFFLE(3, 1, 0, 2); const int dp_xyz = 0x7f; for (size_t i = 0; i < index_count; i += 3) { unsigned int i0 = indices[i + 0]; unsigned int i1 = indices[i + 1]; unsigned int i2 = indices[i + 2]; unsigned int c0 = vertex_cells[i0]; unsigned int c1 = vertex_cells[i1]; unsigned int c2 = vertex_cells[i2]; bool single_cell = (c0 == c1) & (c0 == c2); __m128 p0 = _mm_loadu_ps(&vertex_positions[i0].x); __m128 p1 = _mm_loadu_ps(&vertex_positions[i1].x); __m128 p2 = _mm_loadu_ps(&vertex_positions[i2].x); __m128 p10 = _mm_sub_ps(p1, p0); __m128 p20 = _mm_sub_ps(p2, p0); __m128 normal = _mm_sub_ps( _mm_mul_ps( _mm_shuffle_ps(p10, p10, yzx), _mm_shuffle_ps(p20, p20, zxy)), _mm_mul_ps( _mm_shuffle_ps(p10, p10, zxy), _mm_shuffle_ps(p20, p20, yzx))); __m128 areasq = _mm_dp_ps(normal, normal, dp_xyz); // SSE4.1 __m128 area = _mm_sqrt_ps(areasq); // masks the result of the division when area==0 // scalar version does this in normalize() normal = _mm_and_ps( _mm_div_ps(normal, area), _mm_cmpneq_ps(area, _mm_setzero_ps())); __m128 distance = _mm_dp_ps(normal, p0, dp_xyz); // SSE4.1 __m128 negdistance = _mm_sub_ps(_mm_setzero_ps(), distance); __m128 normalnegdist = _mm_blend_ps(normal, negdistance, 8); __m128 Qx = _mm_mul_ps(normal, normal); __m128 Qy = _mm_mul_ps( _mm_shuffle_ps(normal, normal, _MM_SHUFFLE(3, 2, 2, 1)), _mm_shuffle_ps(normal, normal, _MM_SHUFFLE(3, 0, 1, 0))); __m128 Qz = _mm_mul_ps(negdistance, normalnegdist); if (single_cell) { area = _mm_mul_ps(area, _mm_set1_ps(3.f)); Qx = _mm_mul_ps(Qx, area); Qy = _mm_mul_ps(Qy, area); Qz = _mm_mul_ps(Qz, area); Quadric& q0 = cell_quadrics[c0]; __m128 q0x = _mm_loadu_ps(&q0.a00); __m128 q0y = _mm_loadu_ps(&q0.a10); __m128 q0z = _mm_loadu_ps(&q0.b0); _mm_storeu_ps(&q0.a00, _mm_add_ps(q0x, Qx)); _mm_storeu_ps(&q0.a10, _mm_add_ps(q0y, Qy)); _mm_storeu_ps(&q0.b0, _mm_add_ps(q0z, Qz)); } else { // omitted for brevity, repeats the if() body // three times for c0/c1/c2 } } } 

Il n'y a rien de particulièrement intéressant dans ce code; nous utilisons abondamment des instructions de chargement / stockage non alignées. Bien que l'entrée de Vector3 puisse être alignée, il ne semble pas y avoir de pénalité notable pour les lectures non alignées. Veuillez noter que dans la première moitié des fonctions, les vecteurs ne sont pas utilisés, ce qui est bien - nos vecteurs ont trois composants, et dans certains cas un seul (voir calcul surfacesq / surface / distance), tandis que le processeur effectue 4 opérations en parallèle. Dans tous les cas, voyons comment la parallélisation a aidé.



Une centaine de démarrages de fillCellQuadrics s'exécute désormais en 5,3 s au lieu de 9,8 s, ce qui permet d'économiser environ 45 ms sur chaque opération - pas mal, mais pas très impressionnant. Dans de nombreuses instructions, nous utilisons trois au lieu de quatre composants, ainsi qu'une multiplication précise, ce qui donne un délai assez important. Si vous avez déjà écrit des instructions pour SIMD, vous savez comment faire correctement le produit scalaire.

Pour ce faire, vous devez faire quatre vecteurs à la fois. Au lieu de stocker un vecteur complet dans un registre SIMD, nous utilisons trois registres - dans l'un, nous stockons quatre composantes de x , dans l'autre nous stockons et dans le troisième z . Ici, quatre vecteurs sont nécessaires pour travailler à la fois: cela signifie que nous traiterons quatre triangles simultanément.

Nous avons de nombreux tableaux avec indexation dynamique. Habituellement, cela aide à transférer des données vers des tableaux préparés de composants x / y / z (ou plutôt, généralement de petits registres SIMD sont utilisés, par exemple, float x[8], y[8], z[8] , pour chacun des 8 sommets en entrée données: cela s'appelle AoSoA (tableaux de structures de tableaux) et donne un bon équilibre entre la localisation du cache et la facilité de chargement dans les registres SIMD), mais ici l'indexation dynamique ne fonctionnera pas très bien, alors chargez les données pour quatre triangles comme d'habitude, et transposez les vecteurs en utilisant un moyen pratique macro _MM_TRANSPOSE .

Théoriquement, vous devez calculer chaque composante de quatre quadriques finies dans son propre registre SIMD (par exemple, nous aurons __m128 Q_a00 avec quatre composantes de a00 quadriques finies). Dans ce cas, les opérations sur les quadriques s'intègrent assez bien dans les instructions SIMD à 4 larges, et la conversion ralentit en fait le code - par conséquent, nous transposons uniquement les vecteurs initiaux, puis transposons les équations planes en arrière et exécutons le même code que nous avons utilisé pour calculer les quadriques, mais répétons-le quatre fois. Voici le code qui calcule ensuite les équations de l'avion (les parties restantes sont omises par souci de concision):

 unsigned int i00 = indices[(i + 0) * 3 + 0]; unsigned int i01 = indices[(i + 0) * 3 + 1]; unsigned int i02 = indices[(i + 0) * 3 + 2]; unsigned int i10 = indices[(i + 1) * 3 + 0]; unsigned int i11 = indices[(i + 1) * 3 + 1]; unsigned int i12 = indices[(i + 1) * 3 + 2]; unsigned int i20 = indices[(i + 2) * 3 + 0]; unsigned int i21 = indices[(i + 2) * 3 + 1]; unsigned int i22 = indices[(i + 2) * 3 + 2]; unsigned int i30 = indices[(i + 3) * 3 + 0]; unsigned int i31 = indices[(i + 3) * 3 + 1]; unsigned int i32 = indices[(i + 3) * 3 + 2]; // load first vertex of each triangle and transpose into vectors with components (pw0 isn't used later) __m128 px0 = _mm_loadu_ps(&vertex_positions[i00].x); __m128 py0 = _mm_loadu_ps(&vertex_positions[i10].x); __m128 pz0 = _mm_loadu_ps(&vertex_positions[i20].x); __m128 pw0 = _mm_loadu_ps(&vertex_positions[i30].x); _MM_TRANSPOSE4_PS(px0, py0, pz0, pw0); // load second vertex of each triangle and transpose into vectors with components __m128 px1 = _mm_loadu_ps(&vertex_positions[i01].x); __m128 py1 = _mm_loadu_ps(&vertex_positions[i11].x); __m128 pz1 = _mm_loadu_ps(&vertex_positions[i21].x); __m128 pw1 = _mm_loadu_ps(&vertex_positions[i31].x); _MM_TRANSPOSE4_PS(px1, py1, pz1, pw1); // load third vertex of each triangle and transpose into vectors with components __m128 px2 = _mm_loadu_ps(&vertex_positions[i02].x); __m128 py2 = _mm_loadu_ps(&vertex_positions[i12].x); __m128 pz2 = _mm_loadu_ps(&vertex_positions[i22].x); __m128 pw2 = _mm_loadu_ps(&vertex_positions[i32].x); _MM_TRANSPOSE4_PS(px2, py2, pz2, pw2); // p1 - p0 __m128 px10 = _mm_sub_ps(px1, px0); __m128 py10 = _mm_sub_ps(py1, py0); __m128 pz10 = _mm_sub_ps(pz1, pz0); // p2 - p0 __m128 px20 = _mm_sub_ps(px2, px0); __m128 py20 = _mm_sub_ps(py2, py0); __m128 pz20 = _mm_sub_ps(pz2, pz0); // cross(p10, p20) __m128 normalx = _mm_sub_ps( _mm_mul_ps(py10, pz20), _mm_mul_ps(pz10, py20)); __m128 normaly = _mm_sub_ps( _mm_mul_ps(pz10, px20), _mm_mul_ps(px10, pz20)); __m128 normalz = _mm_sub_ps( _mm_mul_ps(px10, py20), _mm_mul_ps(py10, px20)); // normalize; note that areasq/area now contain 4 values, not just one __m128 areasq = _mm_add_ps( _mm_mul_ps(normalx, normalx), _mm_add_ps( _mm_mul_ps(normaly, normaly), _mm_mul_ps(normalz, normalz))); __m128 area = _mm_sqrt_ps(areasq); __m128 areanz = _mm_cmpneq_ps(area, _mm_setzero_ps()); normalx = _mm_and_ps(_mm_div_ps(normalx, area), areanz); normaly = _mm_and_ps(_mm_div_ps(normaly, area), areanz); normalz = _mm_and_ps(_mm_div_ps(normalz, area), areanz); __m128 distance = _mm_add_ps( _mm_mul_ps(normalx, px0), _mm_add_ps( _mm_mul_ps(normaly, py0), _mm_mul_ps(normalz, pz0))); __m128 negdistance = _mm_sub_ps(_mm_setzero_ps(), distance); // this computes the plane equations (a, b, c, d) for each of the 4 triangles __m128 plane0 = normalx; __m128 plane1 = normaly; __m128 plane2 = normalz; __m128 plane3 = negdistance; _MM_TRANSPOSE4_PS(plane0, plane1, plane2, plane3); 

Le code est devenu un peu plus long: nous traitons maintenant quatre triangles à chaque itération, et nous n'avons plus besoin d'instructions SSE4.1 pour cela. En théorie, les unités SIMD devraient être utilisées plus efficacement. Voyons comment cela a aidé.



D'accord, ça va. Le code a très légèrement accéléré, bien que la fonction fillCellQuadrics fonctionne désormais presque deux fois plus vite que la fonction d'origine sans SIMD, mais il n'est pas clair si cela justifie une augmentation significative de la complexité. Théoriquement, vous pouvez utiliser AVX2 et traiter 8 triangles par itération, mais ici, vous devrez continuer de tourner la boucle manuellement (idéalement, tout ce code est généré à l'aide d'ISPC, mais mes tentatives naïves pour le faire générer du bon code n'ont pas réussi: au lieu de charger / stocker des séquences il a constamment émis un rassemblement / diffusion, ce qui ralentit considérablement l'exécution). Essayons autre chose.

AVX2 = SSE2 + SSE2


L'AVX2 est un peu un ensemble d'instructions. Il possède des registres à virgule flottante sur 8 larges, et une instruction peut effectuer 8 opérations; mais en substance, une telle instruction ne diffère pas de deux instructions SSE2 exécutées sur deux moitiés du registre (pour autant que je sache, les premiers processeurs avec prise en charge AVX2 ont décodé chaque instruction en deux ou plusieurs micro-opérations, donc le gain de performances a été limité par la phase d'extraction des instructions). Par exemple, _mm_dp_ps effectue un produit scalaire entre deux registres SSE2 et _mm256_dp_ps produit deux produits scalaires entre deux moitiés de deux registres AVX2, il est donc limité à 4 larges pour chaque moitié.

Pour cette raison, le code AVX2 diffère souvent du «SIMD 8-wide» universel, mais ici il fonctionne en notre faveur: au lieu d'essayer d'améliorer la vectorisation en transposant des vecteurs 4-wide, nous revenons à la première version de SIMD et doublons la boucle en utilisant des instructions AVX2 au lieu de SSE2 / SSE4. Nous devons encore charger et stocker des vecteurs de largeur 4, mais en général, nous changeons simplement __m128 en __m256 et _mm_ en _mm256 dans le _mm256 avec plusieurs paramètres:

 unsigned int i00 = indices[(i + 0) * 3 + 0]; unsigned int i01 = indices[(i + 0) * 3 + 1]; unsigned int i02 = indices[(i + 0) * 3 + 2]; unsigned int i10 = indices[(i + 1) * 3 + 0]; unsigned int i11 = indices[(i + 1) * 3 + 1]; unsigned int i12 = indices[(i + 1) * 3 + 2]; __m256 p0 = _mm256_loadu2_m128( &vertex_positions[i10].x, &vertex_positions[i00].x); __m256 p1 = _mm256_loadu2_m128( &vertex_positions[i11].x, &vertex_positions[i01].x); __m256 p2 = _mm256_loadu2_m128( &vertex_positions[i12].x, &vertex_positions[i02].x); __m256 p10 = _mm256_sub_ps(p1, p0); __m256 p20 = _mm256_sub_ps(p2, p0); __m256 normal = _mm256_sub_ps( _mm256_mul_ps( _mm256_shuffle_ps(p10, p10, yzx), _mm256_shuffle_ps(p20, p20, zxy)), _mm256_mul_ps( _mm256_shuffle_ps(p10, p10, zxy), _mm256_shuffle_ps(p20, p20, yzx))); __m256 areasq = _mm256_dp_ps(normal, normal, dp_xyz); __m256 area = _mm256_sqrt_ps(areasq); __m256 areanz = _mm256_cmp_ps(area, _mm256_setzero_ps(), _CMP_NEQ_OQ); normal = _mm256_and_ps(_mm256_div_ps(normal, area), areanz); __m256 distance = _mm256_dp_ps(normal, p0, dp_xyz); __m256 negdistance = _mm256_sub_ps(_mm256_setzero_ps(), distance); __m256 normalnegdist = _mm256_blend_ps(normal, negdistance, 0x88); __m256 Qx = _mm256_mul_ps(normal, normal); __m256 Qy = _mm256_mul_ps( _mm256_shuffle_ps(normal, normal, _MM_SHUFFLE(3, 2, 2, 1)), _mm256_shuffle_ps(normal, normal, _MM_SHUFFLE(3, 0, 1, 0))); __m256 Qz = _mm256_mul_ps(negdistance, normalnegdist); 

Ici, vous pouvez prendre chaque moitié de 128 bits des Qz Qx / Qz / Qz et exécuter le même code que nous avons utilisé pour ajouter les quadriques. Au lieu de cela, nous supposons que si un triangle a trois sommets dans une cellule ( single_cell == true ), il est très probable qu'un autre triangle ait trois sommets dans une autre cellule, et nous effectuons également l'agrégation finale des quadriques en utilisant AVX2:

 unsigned int c00 = vertex_cells[i00]; unsigned int c01 = vertex_cells[i01]; unsigned int c02 = vertex_cells[i02]; unsigned int c10 = vertex_cells[i10]; unsigned int c11 = vertex_cells[i11]; unsigned int c12 = vertex_cells[i12]; bool single_cell = (c00 == c01) & (c00 == c02) & (c10 == c11) & (c10 == c12); if (single_cell) { area = _mm256_mul_ps(area, _mm256_set1_ps(3.f)); Qx = _mm256_mul_ps(Qx, area); Qy = _mm256_mul_ps(Qy, area); Qz = _mm256_mul_ps(Qz, area); Quadric& q00 = cell_quadrics[c00]; Quadric& q10 = cell_quadrics[c10]; __m256 q0x = _mm256_loadu2_m128(&q10.a00, &q00.a00); __m256 q0y = _mm256_loadu2_m128(&q10.a10, &q00.a10); __m256 q0z = _mm256_loadu2_m128(&q10.b0, &q00.b0); _mm256_storeu2_m128(&q10.a00, &q00.a00, _mm256_add_ps(q0x, Qx)); _mm256_storeu2_m128(&q10.a10, &q00.a10, _mm256_add_ps(q0y, Qy)); _mm256_storeu2_m128(&q10.b0, &q00.b0, _mm256_add_ps(q0z, Qz)); } else { // omitted for brevity } 

Le code résultant est plus simple, concis et plus rapide que notre approche SSE2 infructueuse:



Bien sûr, nous n'avons pas atteint une accélération de 8 fois, mais seulement 2,45 fois. Les opérations de chargement et de stockage sont toujours à 4 niveaux, car nous sommes obligés de travailler avec une disposition de mémoire inconfortable en raison de l'indexation dynamique, et les calculs ne sont pas optimaux pour SIMD. Mais maintenant fillCellQuadrics n'est plus le goulot d'étranglement dans notre pipeline de profils, et vous pouvez vous concentrer sur d'autres fonctions.

Rassemblez-vous, les enfants


Nous avons économisé 4,8 secondes lors du test (48 ms à chaque test), et maintenant notre principal intrus est countTriangles . Il semblerait que ce soit une fonction simple, mais elle est exécutée non pas une fois, mais cinq fois:

 static size_t countTriangles(const unsigned int* vertex_ids, const unsigned int* indices, size_t index_count) { size_t result = 0; for (size_t i = 0; i < index_count; i += 3) { unsigned int id0 = vertex_ids[indices[i + 0]]; unsigned int id1 = vertex_ids[indices[i + 1]]; unsigned int id2 = vertex_ids[indices[i + 2]]; result += (id0 != id1) & (id0 != id2) & (id1 != id2); } return result; } 

La fonction énumère tous les triangles source et calcule le nombre de triangles non dégénérés en comparant les identifiants des sommets. Il n'est pas immédiatement clair comment le paralléliser à l'aide de SIMD ... sauf si vous utilisez les instructions de collecte.

Le jeu d'instructions AVX2 a ajouté une famille d'instructions de collecte / diffusion au SIMD x64; chacun d'eux prend un registre vectoriel avec 4 ou 8 valeurs - et effectue simultanément 4 ou 8 opérations de chargement ou de sauvegarde. Si vous utilisez recueillir ici, vous pouvez télécharger trois index, exécuter regrouper pour tous à la fois (ou en groupes de 4 ou 8) et comparer les résultats. Rassembler sur les processeurs Intel a toujours été assez lent, mais essayons. Par souci de simplicité, nous téléchargeons les données de 8 triangles, transposons les vecteurs de la même manière que lors de notre précédente tentative et comparons les éléments correspondants de chaque vecteur:

 for (size_t i = 0; i < (triangle_count & ~7); i += 8) { __m256 tri0 = _mm256_loadu2_m128( (const float*)&indices[(i + 4) * 3 + 0], (const float*)&indices[(i + 0) * 3 + 0]); __m256 tri1 = _mm256_loadu2_m128( (const float*)&indices[(i + 5) * 3 + 0], (const float*)&indices[(i + 1) * 3 + 0]); __m256 tri2 = _mm256_loadu2_m128( (const float*)&indices[(i + 6) * 3 + 0], (const float*)&indices[(i + 2) * 3 + 0]); __m256 tri3 = _mm256_loadu2_m128( (const float*)&indices[(i + 7) * 3 + 0], (const float*)&indices[(i + 3) * 3 + 0]); _MM_TRANSPOSE8_LANE4_PS(tri0, tri1, tri2, tri3); __m256i i0 = _mm256_castps_si256(tri0); __m256i i1 = _mm256_castps_si256(tri1); __m256i i2 = _mm256_castps_si256(tri2); __m256i id0 = _mm256_i32gather_epi32((int*)vertex_ids, i0, 4); __m256i id1 = _mm256_i32gather_epi32((int*)vertex_ids, i1, 4); __m256i id2 = _mm256_i32gather_epi32((int*)vertex_ids, i2, 4); __m256i deg = _mm256_or_si256( _mm256_cmpeq_epi32(id1, id2), _mm256_or_si256( _mm256_cmpeq_epi32(id0, id1), _mm256_cmpeq_epi32(id0, id2))); result += 8 - _mm_popcnt_u32(_mm256_movemask_epi8(deg)) / 4; } 

La macro _MM_TRANSPOSE8_LANE4_PS dans AVX2 est l'équivalent de _MM_TRANSPOSE4_PS , qui ne se trouve pas dans l'en-tête standard, mais est facilement affichée. Il prend quatre vecteurs AVX2 et transpose deux matrices 4 × 4 indépendamment l'une de l'autre:

 #define _MM_TRANSPOSE8_LANE4_PS(row0, row1, row2, row3) \ do { \ __m256 __t0, __t1, __t2, __t3; \ __t0 = _mm256_unpacklo_ps(row0, row1); \ __t1 = _mm256_unpackhi_ps(row0, row1); \ __t2 = _mm256_unpacklo_ps(row2, row3); \ __t3 = _mm256_unpackhi_ps(row2, row3); \ row0 = _mm256_shuffle_ps(__t0, __t2, _MM_SHUFFLE(1, 0, 1, 0)); \ row1 = _mm256_shuffle_ps(__t0, __t2, _MM_SHUFFLE(3, 2, 3, 2)); \ row2 = _mm256_shuffle_ps(__t1, __t3, _MM_SHUFFLE(1, 0, 1, 0)); \ row3 = _mm256_shuffle_ps(__t1, __t3, _MM_SHUFFLE(3, 2, 3, 2)); \ } while (0) 

En raison de certaines fonctionnalités des jeux d'instructions SSE2 / AVX2, nous devons utiliser des opérations de registre à virgule flottante lors de la transposition de vecteurs. Nous chargeons les données un peu négligemment; mais cela n'a pas d'importance, car les performances de collecte nous limitent:



Maintenant, countTriangles est environ 27% plus rapide et remarque le terrible CPI (cycles par instruction): nous envoyons environ quatre fois moins d'instructions, mais rassembler prend beaucoup de temps. C'est génial que cela accélère le travail global, mais, bien sûr, le gain de performances est quelque peu déprimant. Nous avons réussi à dépasser fillCellQuadrics dans le profil, ce qui nous amène à la dernière fonction en haut de la liste, que nous n'avons pas encore examinée.

Chapitre 6, où tout commence à fonctionner comme il se doit


La dernière fonction est computeVertexIds . Dans l'algorithme, il est effectué 6 fois, il représente donc également un excellent objectif d'optimisation. Pour la première fois, nous voyons une fonction qui semble être créée pour une optimisation claire dans SIMD:

 static void computeVertexIds(unsigned int* vertex_ids, const Vector3* vertex_positions, size_t vertex_count, int grid_size) { assert(grid_size >= 1 && grid_size <= 1024); float cell_scale = float(grid_size - 1); for (size_t i = 0; i < vertex_count; ++i) { const Vector3& v = vertex_positions[i]; int xi = int(vx * cell_scale + 0.5f); int yi = int(vy * cell_scale + 0.5f); int zi = int(vz * cell_scale + 0.5f); vertex_ids[i] = (xi << 20) | (yi << 10) | zi; } } 

Après les optimisations précédentes, nous savons quoi faire: dérouler le cycle 4 ou 8 fois, car il ne sert à rien d'accélérer une seule itération, de transposer les composantes vectorielles et d'exécuter les calculs en parallèle. Faisons-le avec AVX2, en traitant 8 sommets à la fois:

 __m256 scale = _mm256_set1_ps(cell_scale); __m256 half = _mm256_set1_ps(0.5f); for (size_t i = 0; i < (vertex_count & ~7); i += 8) { __m256 vx = _mm256_loadu2_m128( &vertex_positions[i + 4].x, &vertex_positions[i + 0].x); __m256 vy = _mm256_loadu2_m128( &vertex_positions[i + 5].x, &vertex_positions[i + 1].x); __m256 vz = _mm256_loadu2_m128( &vertex_positions[i + 6].x, &vertex_positions[i + 2].x); __m256 vw = _mm256_loadu2_m128( &vertex_positions[i + 7].x, &vertex_positions[i + 3].x); _MM_TRANSPOSE8_LANE4_PS(vx, vy, vz, vw); __m256i xi = _mm256_cvttps_epi32( _mm256_add_ps(_mm256_mul_ps(vx, scale), half)); __m256i yi = _mm256_cvttps_epi32( _mm256_add_ps(_mm256_mul_ps(vy, scale), half)); __m256i zi = _mm256_cvttps_epi32( _mm256_add_ps(_mm256_mul_ps(vz, scale), half)); __m256i id = _mm256_or_si256( zi, _mm256_or_si256( _mm256_slli_epi32(xi, 20), _mm256_slli_epi32(yi, 10))); _mm256_storeu_si256((__m256i*)&vertex_ids[i], id); } 

Et regardez les résultats:



nous avons accéléré computeVertexIdsdeux fois. Compte tenu de toutes les optimisations, le temps total d'exécution du programme a été réduit à environ 120 ms, ce qui correspond au calcul de 50 millions de triangles par seconde.

Il peut sembler que nous n'ayons pas encore atteint la croissance de productivité attendue: computeVertexIdsne devrait- elle pas accélérer plus de deux fois après la parallélisation? Pour répondre à cette question, essayons de voir combien de travail cette fonction effectue.

computeVertexIdsIl est exécuté six fois pour un démarrage de programme: cinq fois pendant la recherche binaire et une fois à la fin pour calculer les identifiants finaux qui sont utilisés pour un traitement ultérieur. Chaque fois, cette fonction traite 3 millions de sommets, lisant 12 octets pour chaque sommet et écrivant 4 octets.

Au total, sur plus de 100 exécutions de l'innovateur, cette fonction traite 1,8 milliard de sommets, lisant 21 Go et réécrivant 7 Go. Le traitement de 28 Go en 1,46 seconde nécessite une bande passante de bus de 19 Go / s. Nous pouvons vérifier la bande passante mémoire en exécutant memcmp(block1, block2, 512 MB). Le résultat est de 45 ms, soit environ 22 Go / s sur un seul cœur (bien que la référence AIDA64 montre des vitesses de lecture sur mon système jusqu'à 31 Go / s, mais il utilise plusieurs cœurs). En fait, nous nous sommes approchés de la limite de mémoire maximale réalisable, et une nouvelle augmentation des performances nécessitera un compactage plus étroit de ces sommets afin qu'ils occupent moins de 12 octets.

Conclusion


Nous avons pris un algorithme assez bien optimisé qui simplifie les très grandes grilles à une vitesse de 28 millions de triangles par seconde, et avons utilisé les jeux d'instructions SSE et AVX pour l'accélérer presque deux fois, à 50 millions de triangles par seconde. Au cours de ce voyage, nous avons dû apprendre différentes façons d'utiliser SIMD: registres pour stocker des vecteurs à 3 larges, SoA transpose, instructions AVX2 pour stocker deux vecteurs à 3 larges, rassembler des instructions pour accélérer le chargement des données par rapport aux instructions scalaires, et enfin nous avons directement appliqué AVX2 pour le traitement en streaming.

SIMD n'est souvent pas le meilleur point de départ pour l'optimisation: le rationaliseur de maillage a traversé de nombreuses itérations d'optimisation algorithmique et de microoptimisation sans utiliser d'instructions spécifiques à la plate-forme. Mais à un moment donné, ces possibilités sont épuisées, et si les performances sont critiques, alors SIMD est un outil fantastique qui peut être utilisé si nécessaire.

Je ne sais pas laquelle de ces optimisations tombera dans la branche principale meshoptimizer: au final, ce n'est qu'une expérience pour voir combien le code est overclocké sans changement fondamental dans les algorithmes. J'espère que l'article s'est avéré être informatif et vous donnera des idées pour optimiser le code. Les sources finales de cet article sont ici ; ce travail est basé sur la version de meshoptimizer 99ab49et le modèle Thai Buddha est publié sur Sketchfab .

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


All Articles