Niveau :      
Résumé : SSE

Aujourd'hui l'avenir (et un peu le passé aussi). Vous souvenez-vous des cray ? Ces machines surpuissantes qui avaient au moins la puissance de calcul d'une TI92. Comment faisaient-ils pour avoir une telle puissance ? Il faisaient des calculs identiques en parallèle et multipliaient ainsi par n le nombre d'opérations. C'est ce qu'on appelle une architecture vectorielle.

Intel dans sa grande bonté a commencé à mettre en place une telle architecture dans ses processeurs. Tout d'abord avec les instructions MMX. Ces instructions utilisent les registres servant au calcul en virgule flottante (quelques transistors de gagnés), lesquels font 64 bits. On peut ainsi exécuter des instructions identiques simultanément sur 4 fois 1 octets ou sur 2 fois 2 octets. Ce qui est bien, mais pas top.

Le SSE est une amélioration du concept. Tout d'abord on utilise de nouveaux registres (ce qui libère les calculs en virgule flottante) et on leur donne une taille de 128bits. Du coup on peut manipuler deux fois plus d'entiers et cela devient intéressant pour les flottants. On peut caser 4 flottants simple précision et 2 flottants double précision dans 128 bits. Le SSE s'arrête aux 4 flottants et le SSE2 ajoute quasiment toutes les autres combinaisons possibles (flottants et entiers).

Version normale

Comme nous sommes joueurs, nous allons jouer à améliorer notre code de calcul de Mandelbrot. Mais cette fois, nous allons le laisser tourner sous linux. Comme ne sommes plus dans notre monde, mais sur un vrai système, nous allons devoir faire quelques modifications. Nous allons continuer à écrire directement en mémoire, donc on garde nos fonctions d'écriture de pixels, mais cette fois dans une mémoire allouée avec malloc. Ajoutons le support de l'écriture d'un des format d'image les plus simples, le BMP, pour stocker notre image dans un fichier et pouvoir admirer le résultat. Pour cela on prend la première documentation venue.

/* Ecriture de l'image dans un fichier bitmap (on écrit l'en-tête kivabien puis les données) */
void writebitmap(char* filename, char*bitmap, int width, int height);

Et voilà, cela nous donne une première version du code quasiment identique à la précédente et donc non optimisée pour le SSE (disponible à la fin de l'article).

Version SSE

Commençons à travailler en découvrant les instructions du sse. Celles-ci sont rendues disponibles en C à travers les en-têtes qui vont bien. Ouf, cela nous évitera de coder directement en assembleur :

/* mm=mmx   xmm=sse  emm=sse2   pmm=sse3 (rien que de très logique ...) */
#include <emmintrin.h>

Il est possible de lire ces en-têtes pour savoir quelles sont les instructions et les types disponibles. C'est un peu long et assez peu instructif lorsqu'on ne connait pas la base du sse, mais ils peuvent servir de référence quand on sait ce qu'on cherche. Ils se trouvent dans /usr/lib/gcc/x86_64-linux-gnu/4.1.2/include/ (à adapter à votre cas). Les types et fonctions du sse sont codifiés.

Les opérations sont construites comme suit _mm_XXXX_YY :

  • _mm est un préfixe
  • XXXX est le nom de l'instruction en assembleur (à quelques variations près)
  • YY est le type des opérandes manipulés :
    • sd : double seul (1 double qui n'occupe pas tout le registre)
    • pd : double packé (2 doubles dans un registre)
    • ss : flottant seul (1 flottant qui n'occupe pas tout le registre)
    • ps : flottant packé (4 flottants dans un registre)
    • ...

Les types de données sont aussi définis selon un certain schéma :

  • __m128 : registre de 128 bits
  • __m128i : registre de 128 bits destiné au calcul entier
  • __m128d : registre de 128 bits destiné au calcul flottant
  • __v4sf : vecteur de 4 simple flotants (4 * 32 bits)
  • __v2di : vecteur de 2 entiers doubles (2 * 64 bits)
  • ...

Notez que d'un point de vue binaire, ils sont tous identiques et font 128bits.

Maintenant nous allons modifier le code pour qu'il calcule les points 4 par 4. On va donc changer nos 2 fonctions de calcul en :

/* conversion de coordonnées 4 points par 4 */
void convert_i2d4(int x, int y, __v4sf *ppx, __v4sf *ppy)
/* une itération du calcul pour 4 points */
__v4sf iteration4(__v4sf cx, __v4sf cy, __v4sf *ppx, __v4sf *ppy)

On utilise un des types précédents : __v4sf. C'est un vecteur de 128 bits contenant 4 flottants simple précision. Mais comme nous n'utilisons plus des types standards du C, nous devrons utiliser des fonctions à la place des opérateurs +, -, / et *, ce qui rend le code un peu plus lourd à lire et à écrire, par exemple :

        /* t = p^2 + C */
        tx = _mm_add_ps( _mm_sub_ps(_mm_mul_ps( *ppx, *ppx),  _mm_mul_ps(*ppy, *ppy)) , cx);
        ty = _mm_add_ps( _mm_mul_ps(two, _mm_mul_ps(*ppx, *ppy)), cy);

On y ajoute quelques constantes globales du fait que les constantes n'existent pas pour les type vectoriels :

__v4sf two = _mm_set_ps(2,2,2,2);

Et enfin, on doit adapter un peu notre algorithme pour qu'il ne s'arrête que lorsque les 4 points ont tous dépassés le seuil. Pour que la bonne couleur soit bien enregistrée séparément pour chaque point, on va faire la comparaison sur les 4 points grâce à _mm_cmple_ps qui renvoit 0 ou -1 sous forme de vecteur d'entier. Il nous suffit d'additionner les résultats pour récupérer la couleur de chaque point telle que nous l'avions définie plus tôt.

    /* calcul */
    radius = iteration4(cx, cy, &px, &py);
    /* on compare au rayon max, la valeur de retour est constituées de 4 entiers valant 0 ou -1 */
    jj = (__m128i)_mm_cmple_ps(radius, eight);
    /* et on additionne tous les -1 */
    ii = _mm_add_epi32(ii, jj);
    /* un int fait 32 bits sur les i386 et les x86-64 ... */
    ptr = (int*)&jj;
    /* il y a un moment où ce n'est plus la peine de calculer */
    if(ptr[0] == 0 && ptr[1] == 0 && ptr[2] == 0 && ptr[3] == 0)
        break;
 

Le code complet est disponible à la fin de l'article.

Résultat

Mais cela vaut-il le coup ? Oui, et nous allons le montrer. Tout d'abord sur un processeur 32 bits. Nous allons comparer plusieurs combinaisons. Avec sse, sans sse, avec différents niveaux d'optimisation, avec notre code spécifique, et sans.

exécution sur un i386 (en secondes)
Optimization - -O1 -O2 -O3
Gcc parameter - -msse2 - -msse2 - -msse2 - -msse2
normal code 6.20 6.40 3.4 3.5 2.95 2.65 2.60 2.35
SSE code - 4.75 - 0.91 - 0.55 - 0.50

Les résultats sont assez parlants. Que peut-on en déduire ? Que le sse ne semble pas très performant en l'absence d'optimisation (nosse.c et colonnes sans optimisation ou avec -O1).

Ensuite, que même sans utiliser le sse, gcc est lui-même capable de nous faire gagner beaucoup en performances avec ses optimisations. Mais gardez à l'esprit que cela est surtout valable parce qu'on utilise beaucoup de calculs en virgule flottantes et que nos fonctions prennent de gros arguments.

Enfin, que la modification de notre algorithme pour profiter du sse nous a permis de faire mieux que x4 sur du code déjà optimisé par gcc. En effet, gcc optimise ce qu'il peut, mais il lui est très difficile de changer un algorithme. Il est prévu que de nouvelles versions de gcc sachent vectoriser automatiquement des calculs, mais il faudra tout de même faire attention à la façon dont on code.

exécution sur un x86-64 (en secondes)
Optimization - -O1 -O2 -O3
Gcc parameter - -msse2 - -msse2 - -msse2 - -msse2
normal code 2.10 2.10 1.05 1.05 0.76 0.76 0.72 0.72
SSE code 2.10 2.10 0.30 0.30 0.30 0.30 0.30 0.30

Et enfin regardons le même code sur un processeur 64 bits. Tout d'abord on remarque que ma machine 64 bits est plus puissante que la précédente :-) Ensuite, on constate qu'il n'y a pas de différence entre l'option sse et sont absence. Et heureusement puisque tous les processeurs 64 bits ont le sse par défaut et le compilateur ne se gène pas pour l'ajouter lui-même. On remarque que comme pour le code 32 bits, il y a un facteur 3 entre l'option -O3 et l'absence d'optimisation pour le code normal.

Par contre, les différents niveaux d'optimisation au delà de -O1 n'influent plus sur la vitesse d'exécution. Je n'ai pas d'explications à cela.

Et enfin on constate que le sse a réduit la différence de puissance entre les 2 machines.

Pour ceux qui se demanderaient si on mesure les bonnes choses, un test écrivant tous les pixels en noir a été fait, et il ne prend pas plus de 0.03s sur ces plate-formes (toujours moins de 10%). Ce qui veut dire que nos comparaisons sont pertinentes (et qu'il n'est pas nécessaire d'optimiser cette partie).

Vous voulez tout savoir sur le SSE ? Arstechnica a publié un très bon article sur le sujet. Sinon, vous avez toujours la liste détaillée des instructions.

PS : Intel trouvant que la vectorisation est une bonne chose et voyant que tout cela marche bien a prévu d'ajouter les instructions AVX dans ses prochains processeurs. En gros, c'est la même chose que le SSE mais sur 256 bits cette fois.

PJ :