Site de Jean-Michel RICHER

Maître de Conférences en Informatique à l'Université d'Angers

Ce site est en cours de reconstruction certains liens peuvent ne pas fonctionner ou certaines images peuvent ne pas s'afficher.


5. TP 5
Produit scalaire



5.1. Corps du programme

Créer un exécutable (dot_product.exe) en architecture 32 bits qui permet de calculer le produit scalaire de deux vecteurs de floats en utilisant les méthodes suivantes :

  • la FPU
  • la partie basse des registres vectoriels SSE
  • la vectorisation avec SSE
  • la vectorisation avec AVX

On rappelle que le produit scalaire de deux vecteurs est calculé par :

$$ ps(x,y) = ∑↙{i=0}↖{n-1} x_i × y_i $$

Le programme sera composé de codes sources en C++ et en assembleur :

  • dot_product.cpp comprendra la méthode main, gérera les arguments en ligne de commande avec getopt, etc...
  • dot_product_nasm.asm comprendra le code assembleur

Le programme devra prendre en entrée 3 paramètres :

 Description   Variable   Option (getopt) 
 nombre de répétitions du calcul   zillions   -z entier 
 taille des vecteurs de réels   vector_size   -s entier 
 méthode de calcul utilisée   method   -m entier 
Variables et paramètres en ligne de commande
  • le nombre de répétitions est le nombre de fois où le produit scalaire est exécuté
  • la taille des vecteurs concerne $x$ et $y$ (variables vector_x et vector_y)
  • la méthode (method) de calcul utilisée est identifiée par son numéro de 1 à $n$

On utilisera deux vecteurs de floats dont la taille sera multiple de 8 (car nous utiliserons la technologie vectorielle AVX) et qui seront alloués dynamiquement en utilisant _mm_malloc ou posix_memalign.

On calculera le temps d'exécution du calcul liés à l'appel de la fonction en utilisant un chronomètre appelé ici Timer :

Afficher le code    assembly/programs/timer.hpp
  1. #include <iostream>
  2. #include <iomanip>
  3. #include <chrono>
  4. using namespace std;
  5.  
  6. class Timer {
  7. private:
  8.     std::chrono::high_resolution_clock::time_point _start, _stop;
  9.  
  10. public:
  11.     Timer() {
  12.         _start = std::chrono::high_resolution_clock::now();
  13.         _stop = _start;
  14.     }
  15.  
  16.     void start() {
  17.         _start = std::chrono::high_resolution_clock::now();
  18.     }
  19.  
  20.     void stop() {
  21.         _stop = std::chrono::high_resolution_clock::now();
  22.     }
  23.  
  24.     friend ostream& operator<<(ostream& out, Timer& obj) {
  25.       auto elapsed_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(obj._stop - obj._start);
  26.         auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(obj._stop - obj._start);
  27.         auto elapsed_s = elapsed_ms.count() / 1000.0;
  28.         out << "time_ns=" << elapsed_ns.count() << endl;
  29.         out << "time_ms=" << elapsed_ns.count() << endl;
  30.         out << "time_s=" << setprecision(3) << elapsed_s << endl;
  31.         return out;
  32.     }
  33. };

Voici le code introductif du programme :

Afficher le code    assembly/programs/dot_product.cpp
  1. // ==================================================================
  2. // Program: dot_product.cpp
  3. // Date: July 2020
  4. // Author: Jean-Michel Richer
  5. // Email: jean-michel.richer@univ-angers.fr
  6. // ==================================================================
  7. // Description:
  8. //  This program enables to test different implementations of the
  9. // dot product of two vectors of floats
  10. // ==================================================================
  11. #include <iostream>
  12. #include <string>
  13. #include <stdint.h>
  14. #include <getopt.h>
  15. #include <xmmintrin.h>
  16. #include <pmmintrin.h>
  17. #include "timer.hpp"
  18. #include <getopt.h>
  19. using namespace std;
  20.  
  21. // ------------------------------------------------------------------
  22. //
  23. // TYPES
  24. //
  25. // ------------------------------------------------------------------
  26. typedef float f32;
  27. typedef uint32_t u32;
  28. typedef f32 (*DotProductFn)(f32 *x, f32 *y, u32 size);
  29.  
  30. // ------------------------------------------------------------------
  31. //
  32. // GLOBAL VARIABLES
  33. //
  34. // ------------------------------------------------------------------
  35.  
  36. // use 16 for SSE, 32 for AVX, 64 for AVX512
  37. const int ALIGNMENT = 64;
  38.  
  39. // method to test
  40. u32 method = 1;
  41. // number of times the method is called
  42. u32 zillions = 20000;
  43. // size of each vector
  44. u32 vector_size = 64;
  45. // the two vectors used for the dot product
  46. f32 *vector_x, *vector_y;
  47. // print all methods numbers and names
  48. bool list_methods_flag = false;
  49.  
  50.  
  51. // ------------------------------------------------------------------
  52. //
  53. // IMPLEMENTATION OF METHODS
  54. //
  55. // ------------------------------------------------------------------
  56.  
  57.  
  58. /**
  59.  * Method that computes the dot product of two vectors
  60.  * @param x pointer to first vector
  61.  * @param y pointer to second vector
  62.  * @param size size of the vectors
  63.  * @return the dot product of x and y, i.e. x_0 * y_0 + ... + x_(n-1) * y_(n-1)
  64.  */
  65. f32 dp_ref(f32 *x, f32 *y, u32 size) {
  66.   f32 sum = 0.0;
  67.  
  68.   for (u32 i = 0; i < size; ++i) {
  69.     sum += x[i] * y[i];
  70.   }
  71.  
  72.   return sum;
  73. }
  74.  
  75. f32 dp_sse_intrin(f32 *x, f32 *y, u32 size) {
  76.   f32 sum = 0.0;
  77.     u32 i = 0;
  78.  
  79.     __m128 vsum, vx, vy;
  80.  
  81.     vsum = _mm_setzero_ps();
  82.     for ( ; i < (size & ~3); i = i + 4) {
  83.         vx =  _mm_load_ps (&x[i]);
  84.         vy =  _mm_load_ps (&y[i]);
  85.         vx = _mm_mul_ps( vx, vy );
  86.         vsum = _mm_add_ps( vsum, vx );
  87.   }
  88.  
  89.     vsum = _mm_hadd_ps( vsum, vsum);
  90.     vsum = _mm_hadd_ps( vsum, vsum);
  91.     _mm_store_ss(&sum, vsum);
  92.  
  93.     while (i < size) {
  94.         sum += x[i] * y[i];
  95.         ++i;
  96.     }
  97.  
  98.   return sum;
  99. }
  100.  
  101.  
  102. // ------------------------------------------------------------------
  103. //
  104. // METHODS
  105. //
  106. // ------------------------------------------------------------------
  107.  
  108. // register assembly methods here
  109. extern "C" {
  110.  
  111.     f32 dp_fpu(f32 *x, f32 *y, u32 size);
  112.     f32 dp_sse_low(f32 *x, f32 *y, u32 size);
  113.     f32 dp_sse(f32 *x, f32 *y, u32 size);
  114.     f32 dp_avx(f32 *x, f32 *y, u32 size);
  115.   f32 dp_avx_ur2(f32 *x, f32 *y, u32 size);
  116.   f32 dp_avx_ur4(f32 *x, f32 *y, u32 size);
  117.     f32 dp_avx512(f32 *x, f32 *y, u32 size);
  118.   f32 dp_avx512_ur2(f32 *x, f32 *y, u32 size);
  119.   f32 dp_avx512_ur4(f32 *x, f32 *y, u32 size);
  120.   f32 dp_avx512_ur4_fma(f32 *x, f32 *y, u32 size);
  121.    
  122. }
  123.  
  124. typedef struct {
  125.   DotProductFn function;
  126.   string name;
  127. } Method ;
  128.  
  129. #define entry(name) { name, #name }
  130.  
  131. // define methods here
  132.  
  133. Method methods [ ] = {
  134.   { nullptr, "undefined" },
  135.   entry(dp_ref),
  136.   entry(dp_fpu),
  137.     entry(dp_sse_low),
  138.     entry(dp_sse),
  139.    
  140.     entry(dp_sse_intrin),
  141.   entry(dp_avx),
  142.   entry(dp_avx_ur2),
  143.   entry(dp_avx_ur4),
  144.     entry(dp_avx512),
  145.   entry(dp_avx512_ur2),
  146.   entry(dp_avx512_ur4),
  147.   entry(dp_avx512_ur4_fma),
  148.    
  149.   { nullptr, "undefined" }
  150. }; 
  151.  
  152.  
  153. /**
  154.  * main subprogram
  155.  */
  156. int main(int argc, char *argv[]) {
  157.  
  158.   // get command line arguments
  159.   // use getopt
  160.     int opt;
  161.   while ((opt = getopt(argc, argv, "m:s:l")) != -1) {
  162.         switch(opt) {
  163.             case 'm':
  164.                 method = std::stoi(optarg);
  165.                 break;
  166.             case 's':
  167.                 vector_size = std::stoi(optarg);
  168.                 break;
  169.             case 'l':
  170.               list_methods_flag = true;
  171.               break;
  172.             default:
  173.                 cerr << "error !!!!" << endl;
  174.                 exit(EXIT_FAILURE);
  175.         }
  176.     }
  177.  
  178.   if (list_methods_flag == true) {
  179.     int i = 1;
  180.     for ( ; methods[i].function != nullptr; ++i) {
  181.       cout << setw(2) << i << ": " << methods[i].name << endl;
  182.     }
  183.     cout << "nbr_methods=" << (i-1) << endl;
  184.     exit(EXIT_SUCCESS);
  185.   }
  186.   //
  187.   // create vectors
  188.   //
  189.  
  190.   vector_x = (f32 *) _mm_malloc( vector_size * sizeof(f32), ALIGNMENT);
  191.   vector_y = (f32 *) _mm_malloc( vector_size * sizeof(f32), ALIGNMENT);
  192.  
  193.   //
  194.   // initialize vectors
  195.   //
  196.   const u32 modulo = 17; 
  197.  
  198.   for (u32 i = 0; i < vector_size; ++i) {
  199.     vector_x[i] = ((i % 3) == 0) ? -1.0 : 1.0;
  200.     vector_y[i] = (f32) 1 + (i % modulo);
  201.   }
  202.  
  203.   cout << "method=" << method << endl;
  204.   cout << "method_name=" << methods[ method ].name << endl;
  205.  
  206.   // ==============================================================
  207.   // Measure of the time needed to execute the treatment
  208.   // ==============================================================
  209.   Timer timer;
  210.   timer.start();
  211.  
  212.   float total = 0;
  213.   float result = 0;
  214.   u32 z;
  215.  
  216.   result = methods[ method ].function( vector_x, vector_y, vector_size );
  217.   total += result;
  218.   cout << "result=" << std::fixed << result << endl;
  219.    
  220.   for (z = 2; z <= zillions; ++z) {
  221.     result = methods[ method ].function( vector_x, vector_y, vector_size );
  222.     total += result;
  223.   }
  224.      
  225.   timer.stop();
  226.   cout << timer << endl;
  227.  
  228.   cout << "total=" << total << endl;
  229.  
  230.   return EXIT_SUCCESS;
  231. }
  232.  
  233.  

5.2. Méthode utilisant la FPU

Ecrire la méthode dp_fpu qui réalise le calcul du produit scalaire en utilisant la FPU et l'intégrer au programme. Pour cela on utilisera les instructions :

  • fldz pour mettre la variable sum à 0.
  • fld pour charger les données
  • fmul / fmulp pour réaliser la multiplication
  • fadd / faddp pour réaliser l'addition
  • fst /fstp pour stocker les données de la FPU vers la mémoire

Afin de coder la méthode dp_ref du listing précédent, on utilisera les conventions suivantes :

  • le registre st0 remplacera la variable sum
  • le registre eax stockera l'adresse du vecteur x
  • le registre ebx stockera l'adresse du vecteur y
  • le registre ecx représentera la variable de boucle i
  • le registre edx stockera size

5.3. Automatisation de la compilation

Mettre au point un makefile afin d'automatiser la compilation. On doit notamment prendre en compte lors de l'édition de liens les fichiers objets suivants :

  • dot_product.o
  • dot_product_nasm.o

Comme on va utiliser les instructions SSE dans leur version intrinsics plus tard, il est nécessaire d'utiliser l'option de compilation du compilateur gcc/g++ :

-msse4.2

5.4. Méthode utilisant la partie basse des unités vectorielles SSE

Ecrire la méthode dp_sse_low qui réalise le calcul du produit scalaire en utilisant les 32 premiers bits du vecteur SSE xmm0 qui représentera la variable sum. C'est ce qui est fait en architecture 64 bits, on n'utilise plus la FPU mais les unités vectorielles. Utiliser dans ce cas les instructions :

  • xorps afin de mettre à zéro le vecteur xmm0
  • movss afin de charger x[i] dans la partie basse de xmm1, respectivement y[i] dans xmm2
  • mulss pour multiplier xmm1 par xmm2
  • addss pour additionner xmm1 à xmm0

A la fin du calcul il faut placer la partie basse de xmm0 dans st0 puisque la convention d'appel du C en 32 bits impose que le résultat soit placé dans st0. Pour cela, on réserve de l'espace mémoire dans la pile en abaissant le sommet de pile de 4 octets. On oubliera pas, après le transfert de la donnée, de remonter le sommet de pile de 4 octets.

5.5. Méthode utilisant les unités vectorielles SSE

Ecrire la méthode dp_sse_vec qui réalise le calcul du produit scalaire en utilisant $4 × 32$ bits d'un vecteur SSE. Vous pouvez utiliser :

  • xmm0 pour représenter le tableau v_sum[4]
  • xmm1 pour stocker x[i:i+3] et multiplier xmm1 par y[i:i+3] stocké dans xmm2

Le code équivalent en C doit ressembler à :

Afficher le code    assembly/programs/dp_sse_vec.cpp
  1. /**
  2.  * Use of SSE vector units
  3.  */
  4. f32 dp_sse_vec(f32 *x, f32 *y, u32 size) {
  5.   // for example use xmm0 to store v_sum
  6.     f32 v_sum[4];
  7.     // i must be defined here because it will be used
  8.     // by the for loop and then the while loop
  9.     u32 i;
  10.    
  11.     // use xorps xmm0, xmm0
  12.     v_sum[0] = v_sum[1] = v_sum[2] = v_sum[3] = 0.0;
  13.    
  14.     // loop unrolling by a factor of 4
  15.     // use xmm1 to store x[i:i+3]
  16.     for (i = 0; i < (size & ~3); i += 4) {
  17.         v_sum[0] += x[i + 0] * y[i + 0];
  18.         v_sum[1] += x[i + 1] * y[i + 1];
  19.         v_sum[2] += x[i + 2] * y[i + 2];
  20.         v_sum[3] += x[i + 3] * y[i + 3];
  21.     }
  22.    
  23.     // sum of partial sums
  24.     float sum = v_sum[0] + v_sum[1] + v_sum[2] + v_sum[3];
  25.    
  26.     // last iterations
  27.     while (i < size) {
  28.       sum += x[i] * y[i];
  29.       ++i;
  30.     }
  31.    
  32.     return sum;
  33. }
  34.  

Les instructions SSE à utiliser sont les suivantes :

  • xorps afin de mettre à zéro le vecteur xmm0
  • movaps pour charger x[i:i+3] dans xmm1 et y[i:i+3] dans xmm2
  • mulps afin de multiplier xmm1 par xmm2
  • addps pour additionner xmm1 à xmm0
  • haddps pour calculer la somme des sommes partielles de xmm0

5.6. Méthode utilisant les unités vectorielles AVX

Ecrire la méthode dp_avx_vec qui réalise le calcul du produit scalaire en utilisant $8 × 32$ bits d'un vecteur AVX. Utiliser dans ce cas les instructions :

Afficher le code    assembly/programs/dp_avx_vec.cpp
  1. /**
  2.  * Use of AVX vector units
  3.  */
  4. f32 dp_avx_vec(f32 *x, f32 *y, u32 size) {
  5.    // use ymm0 to store v_sum
  6.    f32 v_sum[8];
  7.     u32 i;
  8.    
  9.     v_sum[0] = v_sum[1] = v_sum[2] = v_sum[3] = 0.0;
  10.     v_sum[4] = v_sum[5] = v_sum[6] = v_sum[7] = 0.0;
  11.    
  12.     // loop unrolling by a factor of 8
  13.     // use ymm1 to store x[i:i+7]
  14.     for (i = 0; i < (size & ~7); i += 8) {
  15.         v_sum[0] += x[i + 0] * y[i + 0];
  16.         v_sum[1] += x[i + 1] * y[i + 1];
  17.         v_sum[2] += x[i + 2] * y[i + 2];
  18.         v_sum[3] += x[i + 3] * y[i + 3];
  19.         v_sum[4] += x[i + 4] * y[i + 4];
  20.         v_sum[5] += x[i + 5] * y[i + 5];
  21.         v_sum[6] += x[i + 6] * y[i + 6];
  22.         v_sum[7] += x[i + 7] * y[i + 7];
  23.     }
  24.    
  25.     // sum of partial sums
  26.     float sum = v_sum[0] + v_sum[1] + v_sum[2] + v_sum[3] +;
  27.       v_sum[4] + v_sum[5] + v_sum[6] + v_sum[7];
  28.      
  29.     // last iterations 
  30.     while (i < size) {
  31.       sum += x[i] * y[i];
  32.       ++i;
  33.     }
  34.    
  35.     return sum;
  36. }
  37.  

5.7. Méthode utilisant les intrinsics

Ecrire une version intrinsics dp_sse_intrin qui sera traduite par le compilateur C++. On utilisera le site Intel Intrinsics Guide afin d'obtenir une correspondance entre instructions assembleur et méthodes intrinsics.

  • xmm0 = __m128 _mm_setzero_ps() fixe le registre xmm0 à 0
  • xmm1 = __m128 _mm_load_ps (float const* mem_addr) permet de charger les données
  • xmm2 = __m128 _mm_add_ps (__m128 a, __m128 b) réalise l'addition de deux vecteurs
  • __m128 _mm_mul_ps (__m128 a, __m128 b) réalise la multiplication de deux vecteurs
  • __m128 _mm_hadd_ps(__m128 a, __m128 b) réalise la somme dite horizontale
  • void _mm_store_ss(float* mem_addr, __m128 a) stocke la partie basse d'un vecteur dans un espace mémoire de type float en indiquant l'adresse de cet espace

Il faudra définir 3 vecteurs __m128 v_sum, v_x, v_y pour réaliser les calculs.

Notez également que la fonction _mm_hadd_ps est définie dans un autre fichier include (cf. Intrinsics Guide).