Instructions SIMD sur les réels

Eric SIBERT

- Titre : Instructions SIMD sur les réels

- Auteur : Eric SIBERT

- Parution : 5 novembre 2002

- Mise à jour : 8 décembre 2019

- Licence : Ce document est issu de http://www.developpez.com et reste la propriété exclusive de son auteur. La copie, modification et/ou distribution par quelque moyen que ce soit est soumise à l'obtention préalable de l'autorisation de l'auteur.

Sommaire

- I. Introduction

- II. 3DNow!

- III. SSE

- IV. SSE2

- V. Performances

- VI. Kylix

- Conclusion

I - Introduction

SIMD est l'acronyme de Single Instruction Multiple Datas (une seule instruction, plusieurs données). Ceci recouvre en fait plusieurs jeux d'instructions qui ont été ajoutés aux microprocesseurs Intel et compatibles ces dernières années. Les instructions MMX ont été les premières de la série en permettant d'effectuer simultanément la même instruction sur plusieurs nombres entiers. Elles ont déjà été évoquées dans deux articles, ici et là. Nous allons maintenant étudier les instructions SIMD concernant les nombres réels. Il s'agit des instructions SSE et SSE2 chez Intel ainsi que 3DNow! chez AMD. Ces instructions sont prises en charge par l'assembleur intégré (basm) de Delphi 6 et de Kylix. Donc, il faut abandonner temporairement le Pascal pour l'assembleur afin de les utiliser.

Nous aurons recours à un exemple simple, le calcul du milieu de deux points dans l'espace. Chaque point dispose de trois coordonnées x, y et z et nous devons juste faire la moyenne des coordonnées une à une pour trouver le milieu :

Ce qui se traduit en Pascal par :

type
TZPoint = record
X, Y, Z : Single;
end;

function MidPoint(const P1, P2: TZPoint): TZPoint;
const
Demi : single = 0.5;
begin
Result.X:= (P2.X+P1.X)*Demi;
Result.Y:= (P2.Y+P1.Y)*Demi;
Result.Z:= (P2.Z+P1.Z)*Demi;
end;

Nous avons ici le code le plus optimisé possible en Pascal, en particulier la multiplication par 0,5 plutôt que la division par 2 permet de gagner beaucoup de temps. Le recours à une constante permet aussi d'améliorer le code généré. L'utilisation de l'assembleur classique en supprimant les FWAIT à la fin des lignes de calcul n'apporte pas de gain important. Que reste-t-il pour aller plus vite ? Les instructions SIMD! Nous allons commencer par le 3DNow !

II - 3DNow!

Ces instructions sont disponibles sur les microprocesseurs AMD depuis le K6-2. Il faut aller sur le site web d'AMD pour trouver la description des instructions. Les instructions 3DNow! utilisent les registres MMX. Il s'agit de registres 64 bits qui peuvent ainsi contenir chacun deux nombres réels simple précision. Et les instructions 3DNow! permettent ensuite de réaliser simultanément deux fois la même opération. En pratique, nous devons transférer nos données vers les registres MMX, effectuer les opérations, récupérer les données et désactiver les registres MMX. Comme je ne dispose de machine correspondante, Matthijs Laan a fait le travail de codage pour moi :

function MidPoint_3DNow(const P1, P2: TZPoint): TZPoint;
const
PackedHalf: packed array[0..1] of Single = (0.5, 0.5);
asm
movq    mm0, [PackedHalf]    // chargement de la constante précédente
movq    mm1, [eax]          // chargement de P1.X et P1.Y
pfadd   mm1, [edx]           // addition de P2.X et P2.Y

movd    mm3, [eax + 8]       // chargement de P1.Z
pfadd   mm3, [edx + 8]       // addition de P2.Z

pfmul   mm1, mm0            // multiplication résultat sur X et Y par 0.5
pfmul   mm3, mm0            // multiplication résultat sur Z par 0.5

movq    [ecx], mm1          // déchargement de X et Y
movd    [ecx + 8], mm3      // déchargement de Z

emms                     // désactivation de l'unité MMX
end;

Le codage est aisé grâce à Delphi 6 qui reconnaît les instructions 3DNow ! Ça fonctionne et nous étudierons les performances à la fin de cet article en commun avec les autres jeux d'instructions.

III - SSE

Les instructions SSE sont apparues chez Intel avec le Pentium III puis ont été étendues au Celeron à partir du 533A. AMD les a portées sur ses microprocesseurs à partir de l'Athlon XP. Avec ces instructions, 8 nouveaux registres 128 bits ont été ajoutés au microprocesseur. Ils sont nommés de xmm0 à xmm7 et peuvent contenir 4 nombres réels simple précision. Nous pouvons ensuite réaliser quatre opérations simultanément. Néanmoins, Intel nous a réservé une mauvaise surprise en ne mettant, sur les PIII, qu'un bus 64 bits pour accéder aux registres xmm. Il faut donc deux instructions pour charger ou décharger un registre xmm :

movlps xmm0,[eax]     //chargement de la partie basse de xmm0 avec P1
movhps xmm0,[eax+8]   //chargement de la partie haute de xmm0 avec P1

Ensuite, certaines instructions comme les opérations (addition, multiplication…) mettant en jeu un registre et une adresse mémoire exigent que cette adresse mémoire soit alignée sur 16 octets (=128 bits). Or le compilateur de Delphi aligne les données sur 4 octets (=32 bits) (Ref : Guide du langage Pascal Objet, Gestion de la mémoire). Première solution, ne pas utiliser les instructions à problème et trouver des méthodes de contournement. En termes de performances, c'est mauvais et mieux vaut ne pas utiliser les instructions SSE. Deuxième méthode, aligner les données sur 16 octets en remplaçant le gestionnaire de mémoire par défaut par le nôtre que nous avons pris soin de programmer dans cette optique. Pour ceux qui ne sont pas très familiers avec les gestionnaires de mémoire :-), Robert Lee a déjà fait le travail pour vous sous Delphi. Prenez son gestionnaire de mémoire et utilisez-le dans votre projet en l'appelant en premier :

program Project1;
uses
MultiMM,      // ici, le nouveau gestionnaire de mémoire
Forms,
Unit1 in 'Unit1.pas' {Form1};

Mais ce n'est pas suffisant, car le nouveau gestionnaire de mémoire n'aligne que les variables que vous allouez dynamiquement. Et pour allouer une variable, rien de tel que new :

T4DPoint = packed record  // définition d'un format de 16 octets
X, Y, Z, T : Single;
end;
const
CPackedHalf: T4DPoint = (x:0.5; y:0.5; z:0.5; t:0.5);  // constante de multiplication

var
Serie1bis, Serie2bis, Rapidebis, PackedHalf : ^T4DPoint;
begin
[...]
new(Serie1bis);  // allocation dynamique des variables
new(Serie2bis);
new(Rapidebis);
new(PackedHalf);  // allocation dynamique d'une variable pour la constante
PackedHalf^:=CPackedHalf;  // transfert de la constante vers une variable alignée
[...]
MidPoint_SSE(Serie1bis^,Serie2bis^,Rapidebis^); // appel de la procédure de calcul
[...]
dispose(Serie1bis);  // désallocation dynamique des variables
dispose(Serie2bis);
dispose(Rapidebis);
dispose(PackedHalf);
end;

Nous pouvons enfin passer au calcul proprement dit :

procedure MidPoint_SSE(const P1, P2 : T4DPoint; var result : T4DPoint);
asm
movlps xmm0,[eax]     // chargement de la partie basse de xmm0 avec P1
movhps xmm0,[eax+8]   // chargement de la partie haute de xmm0 avec P1
mov eax,[PackedHalf]  // récupération de l'adresse de la constante

addps xmm0,[edx]      // addition de P2 à xmm0 (P1)

mulps xmm0,[eax]      // multiplication du résultat par 0.5

movlps [ecx],xmm0     // déchargement de la partie basse de xmm0 avec P1
movhps [ecx+8],xmm0   // déchargement de la partie haute de xmm0 avec P1
end;

Le calcul est simple, fonctionne et permet des gains de temps significatifs.

IV - SSE2

Les instructions SSE2 sont apparues avec le Pentium 4. Elles devraient être portées par AMD sur les Hammer (génération K8) à la mi-2003. Mais mon exemple fonctionne déjà sur les Athlon XP. Une partie des instructions SSE2 semble donc supportée par ce dernier. Outre l'introduction de nouvelles possibilités de calcul, elles correspondent à l'arrivée d'un bus 128 bits pour les registres xmm. Il devient donc possible de charger un registre xmm à l'aide d'une seule instruction, ce qui simplifie le code SSE sans changement par ailleurs :

procedure MidPoint_SSE2(const P1, P2 : T4DPoint; var result : T4DPoint);
asm
movaps xmm0,[eax]
mov eax,[PackedHalf]
addps xmm0,[edx]
mulps xmm0,[eax]
movaps [ecx],xmm0
end;

Lorsque vous avez déjà écrit votre code SSE, le passage au SSE2 est très facile et consiste à simplifier le code. Il ne faut donc pas s'en priver.

V - Performances

Venons-en aux performances. J'ai mis dans le tableau suivant le temps moyen en nombre de cycles d'horloge pour calculer le milieu de deux points :

Architecture

Proc / fréquence

Pascal

asm 1 (perso)

asm 2 (Barry Kelly et al .)

3DNow ! (Matthijs Laan et al.)

SSE (perso)

SSE2 (perso)

Puissance effective / MFlops

Intel P5

Pentium 90

38.5

35.5

32.6

X

X

X

17

P 200 MMX

51.2

51.2

63.3

X

X

X

23

Intel P6

Celeron 333@375

18.0

16.8

16.7

X

X

X

138

P III 500@560

18.1

17.0

17.1

X

12.4

X

270

P III 600

18.1

16.8

17.2

X

12.3

X

290

P III 633

18.2

17.0

17.0

X

12.3

X

310

P III 733

18.6

17.2

17.0

X

12.1

X

360

P III 1 GHz

18.6

17.0

17.0

X

12.1

X

500

Intel P7

P4 1.5

19.8

16.0

18.2

X

20.1

12.1

740

AMD K6

K6-2 333

52.3

45.6

60.7

22.3

X

X

90

AMD K7

Duron 800

12.0 12.5

15.0 12.1

13.0 13.0

10.4 10.1

X

X

460 480

Athlon 700

13.3

12.0

12.0

9.3

X

X

450

Athlon 1.0

14.8

12.0

15.0

10.0

X

X

600

Athlon 1.1

12.0

12.0

13.0

10.0

X

X

660

Athlon 1.35

12.1

12.0

13.0

10.0

X

X

810

Athlon XP 1600 +

13.0

12.0

15.0

10.0

10.1

10.1

840

Pour les microprocesseurs de la famille Intel P6 avec instructions SSE, le gain est de 30 % en temps d'exécution, ce qui peut justifier le recours aux instructions SSE. Avec un Pentium 4, les instructions SSE sont pénalisantes et seul le recours au SSE2 permet un gain de 40 %. Vu comme ça, le SSE2 ressemble à une arnaque qui rend obsolète le code SSE écrit pour les PIII et vous oblige à passer au SSE2 pour rattraper les anciennes performances.

Un tour chez AMD montre d'abord que le 3DNow! est très performant avec les K6 (gain 57 %). À l'opposé, avec la nouvelle architecture (Athlon et Duron), les gains sont faibles et l'opportunité d'écrire un code spécial 3DNow! se pose.

Enfin, la comparaison entre Intel et AMD est toute à l'avantage de ce dernier avec un code en Pascal aussi performant que celui optimisé SSE/SSE2 chez Intel. Ce constat est d'ailleurs confirmé par pas mal de tests sur Internet. Il ne faut toutefois pas perdre de vue que nous avons travaillé ici sur un exemple un peu fictif. Celui-ci utilise peu les registres. Or, les instructions SSE disposent de 8 registres 128 bits contre 8 registres 64 bits pour 3DNow ! Avec le SSE, on peut alors envisager de stocker une matrice 4x4, très utile en 3D, et conserver l'autre moitié des registres disponibles pour faire passer les vecteurs à multiplier par la matrice. Ceci est impossible avec le 3DNow! et imposera de nombreux transferts en mémoire. De plus, le 3DNow! bloque l'unité FPU classique contrairement au SSE. Peut-être qu'un jour j'écrirai un article sur l'utilisation en conditions réelles des instructions SIMD :)))

VI - Kylix

Si l'assembleur intégré de Kylix supporte lui aussi les nouveaux jeux d'instructions SIMD, il n'existe par contre pas de gestionnaire de mémoire adapté aux instructions SSE et SSE2. Seules les instructions 3DNow! pourront être utilisées sous Linux.

Conclusion

Nous avons vu dans cet article comment utiliser les instructions 3DNow!, SSE et SSE2. Leur mise en œuvre est simplifiée par l'assembleur de Delphi 6 qui reconnaît les mnémotechniques correspondantes. Les gains en performances sont surtout intéressants pour le SSE et le SSE2.

Code source (13 ko, y compris le gestionnaire de mémoire)