Enseignements en L3
Mention informatique

 

6. Optimisation : au coeur du core

Eduquer c'est chercher à concilier deux mouvements contraires :
celui qui porte à aider chaque enfant à trouver sa propre voie
et celui qui pousse à lui inculquer ce que soi-même on croit juste, beau et vrai

Nicolas Sarkozy, Lettre aux Educateurs
4 septembre 2007

En cours de rédaction.

6.1 Introduction

L'objet de ce chapitre est de familiariser l'utilisateur avec les techniques d'optimisation concernant :

  • alignement
  • l'accès mémoire
  • les unités x87, MMX et SSE

6.2 quelques astuces

Vous trouverez beaucoup d'informations sur le site d'Agner Fog, notamment dans les sections Optimizing subroutines in assembly language: An optimization guide for x86 platforms et The microarchitecture of Intel, AMD and VIA CPUs: An optimization guide for assembly programmers and compiler makers.

Trouver la valeur minimum entre 2 valeurs non signées :

sub eax,  ebx     ; = a-b
sbb edx,  edx     ; = (b > a) ? 0xFFFFFFFF : 0
and edx,  eax     ; = (b > a) ? a-b : 0
add ebx,  edx     ; Result is in ebx

Trouver la valeur minimum entre 2 valeurs signées :

sub eax,  ebx     ; Will not work if overflow here
cdq               ; = (b > a) ? 0xFFFFFFFF : 0
and edx,  eax     ; = (b > a) ? a-b : 0
add ebx,  edx     ; Result is in ebx
if (a < 0) d = b; else d = c;

test eax, eax
mov   edx, ecx
cmovs edx, ebx    ; = (a < 0) ? b : c


6.3 Améliorer l'accès mémoire


6.3.1 Alignement

Nous avons déjà évoqué dans le chapitre 3, les problèmes liés à l'alignement des données en mémoire. Nous savons qu'il est préférable d'aligner les données sur une adresse multiple de 2, 4, 8 ou 16.

Plusieurs possibilités d'alignement existent :

  • utilisation des options en ligne de commande du compilateur pour aligner le code :
    gcc -falign-functions=n  -falign-jumps=n -falign-labels=n  -falign-loops=n
    
    où n représente une puissance de 2.

  • utilisation des directives du compilateur pour aligner les données :
    • gcc / g++ :
      int j __attribute__((aligned(16));
      
    • icc / icpc / icl :
      int j _declspec(align(16));
      
  • lors de l'allocation mémoire, les données sont alignées mais pas forcément sur la puissance de 2 désirée, notamment pour les données qui seront utilisées par les unités SSE. On peut réaliser un alignement à la main comme suit :
    #include <stdio.h>
    #include <stdlib.h>
    
    #define malloc_align(p,q,T,size,align)     \
      printf("allocate %d bytes\n", size); \
      p = (T *) malloc(size + align-1); \
      q = (T *) (((int)p+align-1) & ~(align-1)); \
      printf("unaligned @ %p\n", p); \
      printf("aligned   @ %p\n", q);
    
    int main() {
      int i;
      int *tab_unaligned, *tab;
    
      malloc_align(tab_unaligned, tab, int, 10*sizeof(int), 16);
      for (i=0; i<10; ++i) tab[i] = i;
      free(tab_unaligned);
    
      return 0;
    }
    
    le résultat en sortie de ce programme est (par exemple) :
    allocate 40 bytes
    unaligned @ 0x804a008
    aligned   @ 0x804a010
    

6.3.2 La Multi-allocation

Cette technique que je qualifie de multi-allocation, consiste à éviter de perdre du temps lors de nombreuses allocation/déallocation de la mémoire. Elle repose sur les principes suivants :

  • les objets sont alloués par blocs et non pas un à un
  • les objets ne sont pas libérés mais placés dans une liste chaînées lorsqu'ils ne sont plus utilisés

Cette technique possède des avantages et des inconvénients :

  • + lors de l'allocation, on réalise une allocation de N objets plutôt que N allocations de 1 objet
  • + lors de la désallocation, on place l'élément au début de la liste, on ne perd pas de temps à faire un appel système
  • - les objets sont toujours présents en mémoire

Voici deux exemples, l'un en C l'autre en C++ :


6.3.3 Cache

Sur un Pentium 4 le cache de données possède une taille de 8 ko et est organisé sous la forme d'un cache 4-way associative. Chaque ligne de cache occupe 64 octets.

Une rapide analyse montre que les 6 bits les moins significatifs (bits 0 à 5) d'une adresse permettent d'adresser l'un des 64 octets de la ligne de cache. En outre :

8 ko = 213 / 4 / 64 = 213 / 28 = 25 = 32

les 5 bits suivants (bits 6 à 10) permettent de sélectionner l'une des 32 lignes parmi les 4 blocs (4-way) possibles.

En conclusion, si les bits 6 à 10 de deux adresses différentes sont égaux, alors il seront placés dans le même espace de cache. Etant donné qu'il y a 4 blocs (4-way), un bloc de cache permet de stocker 8 ko / 4 = 2 ko.


6.3.4 Optimisation des boucles
for (int i = 0; i < n; i++) {
   // (loop body)
}

Dont la traduction basique est :

    mov ecx, n    ; Load n
    xor eax, eax  ;i=0
for_begin:
    cmp eax, ecx  ;i= n
    ; (loop body) ; Loop body goes here
    add eax, 1    ; i++
    jmp for_begin ; Jump back
for_end:

On préfère utiliser add eax,1 plutôt que inc eax,1, car l'instruction inc a un problème pour accèder et mettre à jour une partie des flags, ce qui la rend moins efficace.

L'autre problème est le fait que l'on trouve 2 instructions de saut jge et jmp. On écrira donc :

    mov  edx, n    ; Load n
    test edx, edx ; Test n
    jz   for_end   ; Skip if n <= 0
    xor  ecx, ecx  ; i = 0
for_begin:
    ; (loop body) ; Loop body goes here
    add  ecx, 1    ; i++
    cmp  ecx, edx  ; i < n
    jl   for_begin  ; Loop back if i < n
for_end:

6.3.5 Programmation vectorielle

comment générer des constantes entières pour les registres SSE :

Value 8 bits 16 bits 32 bits 64 bits
0 pxor xmm0,xmm0 pxor xmm0,xmm0 pxor xmm0,xmm0 pxor xmm0,xmm0
1 pcmpeqw xmm0,xmm0
psrlw xmm0,15
packuswb xmm0,xmm0
pcmpeqd xmm0,xmm0
psrlw xmm0,15
pcmpeqw xmm0,xmm0
psrld xmm0,31
pcmpeqw xmm0,xmm0
psrlq xmm0,63
2 pcmpeqw xmm0,xmm0
psrlw xmm0,15
psllw xmm0,1
packuswb xmm0,xmm0
pcmpeqd xmm0,xmm0
psrlw xmm0,15
psllw xmm0,1
pcmpeqw xmm0,xmm0
psrld xmm0,31
pslld xmm0,1
pcmpeqw xmm0,xmm0
psrlq xmm0,63
psllq xmm0,1
-1 pcmpeqw xmm0,xmm0 pcmpeqw xmm0,xmm0 pcmpeqd xmm0,xmm0 pcmpeqw xmm0,xmm0
Cste mov eax, C*01010101h
movd xmm0,eax
pshufd xmm0,xmm0,0
mov eax, C*10001h
movd xmm0,eax
pshufd xmm0,xmm0,0
mov eax, C
movd xmm0,eax
pshufd xmm0,xmm0,0
mov rax, C
movd xmm0,rax
punpcklqdq xmm0,xmm0


6.4 Instructions spécifiques


6.4.1 LEA (Load Effective Address)

L'instruction LEA est très utile car elle permet de regrouper :

  • un décalage (1,2 ou 3 bits)
  • deux additions
  • et un déplacement

Par exemple, la série d'instructions suivantes :

mov  eax,ecx
shl  eax,3
add  eax,ebx
add  eax,200

peut être remplacée par :

lea eax,[ebx+ecx*8+200]

Attention : les symboles [] ne signifient pas qu'il faut charger dans eax la donnée pointée par l'expression entre les crochets, mais calculer ebx+ecx*8+200 et mettre ce résultat dans eax.

De même :

imul eax,5

peut être remplacée par :

lea eax,[eax+eax*4]

6.4.2 inc et dec

Les instructions inc et dec ne modifient pas le drapeau de retenue (carry flag) mais sont susceptibles de modifier d'autres drapeaux, cela correspond à ne modifier qu'une partie du registre des flags et cela se traduit par un coût d'une micro-opération supplémentaire sur Pentium 4 et des délais d'attente sur Penitum II, III et M si une autre instruction tente de lire le registre de flags.


6.4.3 div

La division entière et en flottant est généralement très pénalisante, bien plus que la multiplication. Il existe cependant quelques astuces qui permettent de simplifier la division.


a) division entière par puissance de 2

On sait que diviser X par 2N consiste à décaler la représentation binaire de X de N rangs vers la droite :

mov  eax,X
shr  eax,N

b) division entière par une constante

Dans le cas où on divise par une constante, il est possible de remplacer la division par une multiplication. Pour calculer q = X / d pour une représentation de l'information sur w bits, on calcule la valeur :

f = 2r / d

on multiplie ensuite X par f et on décale à droite de r positions.

l'algorithme est le suivant :

  • b = (nombre de bits significatifs de d) - 1
  • r = w + b
  • f = 2r / d
  1. si f est en entier alors d est une puissence de 2 :
    result = x shr b
  2. sinon si la partie fractionnaire de f < 0.5 alors
    arrondir f à l'entier k (k < f) le plus proche
    result = ((X+1) × f) shr r
  3. sinon (f > 0.5) alors :
    arrondir f à l'entier k (k > f) le plus proche
    result = (X × f) shr r

example : on désire diviser par 5 sur 32 bits :

  • 510 = 1012
  • b = 3 - 1 = 2
  • r = 32 + 2 = 34
  • f = 234 / 5 = 3435973836.8 = CCCCCCCC,CCCCCCCCD16
  • k = CCCCCCCD16

6.5 Faire confiance au compilateur

L'utilisation des options en ligne de commande des compilateurs permet parfois de simplifier l'optimisation. Par exemple, le compilateur C Intel (icc ou icpc pour le C++), offre différents types d'optimisations :

  • optimisation interprocédurale
  • optimisation guidée par le profilage
  • optimisation des boucles (vectorisation)

6.5.1 Optimisation interprocédurale (-ip, -ipo)

sur les architectures de type IA-32, elle concerne :

  • le passage d'arguments de sous-programmes dans des registres plutôt que dans la pile
  • le déplacement d'invariants en dehors des boucles

6.5.2 Optimisation guidée par le profilage

ce type d'optimsation se déroule en 3 phases :

  1. compiler en utilisant l'option -prof_gen
  2. exécuter le programme, un fichier .dyn est alors généré qui contient des informations qui sont utilisées dans la phase suivante
  3. recompiler avec -prof-use qui prend en compte les informations générées durant l'exécution

6.5.3 Optimisation des boucles

Le compilateur Intel est capable de vectoriser certaines boucles. Par exemple, le code suivant :

i=0;
while (i < n) {
  a[i] = b[i] + c[i];
  ++i;
}

sera vectorisé en :

i=0;
while (i < (n - (n%4)) {
  a[i:i+3] = b[i:i+3] + c[i:i+3];
  i+=4;
}
while (i < n) {
  a[i] = b[i] + c[i];
  ++i;
}

6.5.4 Autre exemple de vectorisation

Soit le code suivant :

#define N 1024
int __attribute__ ((__aligned__(16))) a[N];
int __attribute__ ((__aligned__(16))) b[N];
int __attribute__ ((__aligned__(16))) c[N];

void sum_vec() {
  for (int i=0;i<N;++i) {
    a[i]=b[i]+c[i];
  }
}

Si on compile avec g++, comme suit :

g++ -S -masm=intel a.cpp -ftree-vectorize -ftree-vectorizer-verbose=4 -O2 -msse2

on obtient le résultat suivant :

a.cpp:11: note: LOOP VECTORIZED.
a.cpp:11: note: vectorized 1 loops in function.

        push    %ebp
        xor     %eax, %eax
        mov     %ebp, %esp
        .p2align 4,,7
.L2:
        movdqa  %xmm0, XMMWORD PTR c[%eax]
        paddd   %xmm0, XMMWORD PTR b[%eax]
        movdqa  XMMWORD PTR a[%eax], %xmm0
        add     %eax, 16
        cmp     %eax, 4096
        jne     .L2
        pop     %ebp
        ret

Il est également possible de déplier la boucle :

g++ -S -masm=intel a.cpp -ftree-vectorize -ftree-vectorizer-verbose=4 -O2 -msse2
-funroll-loops --param max-unroll-times=4
        push    %ebp
        xor     %edx, %edx
        mov     %ebp, %esp

        .p2align 4,,7
.L2:
        lea     %eax, [%edx+16]
        movdqa  %xmm3, XMMWORD PTR c[%edx]
        movdqa  %xmm2, XMMWORD PTR c[%eax]
        paddd   %xmm3, XMMWORD PTR b[%edx]
        paddd   %xmm2, XMMWORD PTR b[%eax]
        lea     %ecx, [%edx+32]
        movdqa  XMMWORD PTR a[%edx], %xmm3
        movdqa  %xmm1, XMMWORD PTR c[%ecx]
        paddd   %xmm1, XMMWORD PTR b[%ecx]
        movdqa  XMMWORD PTR a[%eax], %xmm2
        lea     %eax, [%edx+48]
        add     %edx, 64
        movdqa  %xmm0, XMMWORD PTR c[%eax]
        paddd   %xmm0, XMMWORD PTR b[%eax]
        cmp     %edx, 4096
        movdqa  XMMWORD PTR a[%ecx], %xmm1
        movdqa  XMMWORD PTR a[%eax], %xmm0
        jne     .L2
        pop     %ebp
        ret

mais, le code suivant ne sera pas vectorisé car le compilateur gcc ne peut déterminer si les variables a, b, c sont des tableaux alignés et qu'elle est leur taille. Par contre icc est capable de vectoriser cette boucle.

#define N 1024

void sum_vec(int *a, int *b, int *c) {
  for (int i=0;i<N;++i) {
    a[i]=b[i]+c[i];
  }
}

Avec le compilateur, icc, on obtient :

icc -O3 -S a.c -fsource-asm -use-asm -msse2 -axT
a.c(12): (col. 3) remark: LOOP WAS VECTORIZED.
sum_vec:
..B1.1:                         # Preds ..B1.0
;;; void sum_vec() {
;;;   for (int i=0;i<N;++i) {
        xorl      %eax, %eax                                    #12.3
                                # LOE eax ebx ebp esi edi
..B1.2:                         # Preds ..B1.2 ..B1.1
;;;     a[i]=b[i]+c[i];
        movdqa    b(%eax), %xmm0                                #13.10
        paddd     c(%eax), %xmm0                                #13.15
        movdqa    16+b(%eax), %xmm1                             #13.10
        paddd     16+c(%eax), %xmm1                             #13.15
        movdqa    32+b(%eax), %xmm2                             #13.10
        paddd     32+c(%eax), %xmm2                             #13.15
        movdqa    48+b(%eax), %xmm3                             #13.10
        paddd     48+c(%eax), %xmm3                             #13.15
        movdqa    64+b(%eax), %xmm4                             #13.10
        paddd     64+c(%eax), %xmm4                             #13.15
        movdqa    80+b(%eax), %xmm5                             #13.10
        paddd     80+c(%eax), %xmm5                             #13.15
        movdqa    96+b(%eax), %xmm6                             #13.10
        paddd     96+c(%eax), %xmm6                             #13.15
        movdqa    112+b(%eax), %xmm7                            #13.10
        paddd     112+c(%eax), %xmm7                            #13.15
        movdqa    %xmm0, a(%eax)                                #13.5
        movdqa    %xmm1, 16+a(%eax)                             #13.5
        movdqa    %xmm2, 32+a(%eax)                             #13.5
        movdqa    %xmm3, 48+a(%eax)                             #13.5
        movdqa    %xmm4, 64+a(%eax)                             #13.5
        movdqa    %xmm5, 80+a(%eax)                             #13.5
        movdqa    %xmm6, 96+a(%eax)                             #13.5
        movdqa    %xmm7, 112+a(%eax)                            #13.5
        addl      $128, %eax                                    #12.3
        cmpl      $4096, %eax                                   #12.3
        jb        ..B1.2        # Prob 99%                      #12.3
                                # LOE eax ebx ebp esi edi
..B1.3:                         # Preds ..B1.2

;;;   }
;;; } 

        ret       

6.5.5 Utiliser les intrinsics

Les intrinsics sont des fonctions codées en assembleur qui peuvent être insérées dans un programme C ou C++ et qui permettent de faire appel aux instructions SSE. Sous gcc elles peuvent être utilisées en faisant appel à <xmmintrin.h>.

Les registres SSE ne sont pas directement spécifiables, on utilise donc de nouveaux types de données :

  • __m128 : représentation sous forme de 4 float
  • __m128d : représentation sous forme de 2 double
  • __m128i : représentation sous forme entière (16 bytes ou 8 words ou 4 double words ou 2 quad words)

La plupart des noms de fonctions se table sur le format _mm_<intrin_op>_<suffix> :

  • <intrin_op> : représente l'opération à réaliser
  • <suffix> : définit le type de donnée sur lequel on applique l'opération :
    • s : flottant simple précision
    • d : flottant double précision
    • i128 : entier sur 128 bits signé
    • i64 : entier sur 64 bits signé
    • u64 : entier sur 64 bits non signé
    • i32 : entier sur 32 bits signé
    • u32 : entier sur 32 bits non signé
    • i16 : entier sur 16 bits signé
    • u16 : entier sur 16 bits non signé
    • i8 : entier sur 8 bits signé
    • u8 : entier sur 8 bits non signé

exemple : intrinsics_1.c

guide de référence Intel des Intrinsics


Comparaison assembleur et intrinsics

On désire vectoriser le code C suivant :

void xor(char *t0, char *t1, char*t2) {
   for (int i=0;i<N;++i) {
     t2[i] = t0[i] ^ t1[i]; // partie à vectoriser
  }
}

la traduction assembleur vetorisée qui consiste à réaliser un XOR entre les deux vecteurs t0 et t1 et stocker le résultat dans le troisième vecteur t2 est :

movdqa   xmm0,[esi] ; avec esi stocke l'adresse de t0
movdqa   xmm1,[edi] ; avec edi stocke l'adresse de t1
pxor     xmm0,xmm1
movdqa   [ebx],xmm0 ; avec ebx stocke l'adresse de t2

Son équivalent en intrinsics serait :

__m128i __attribute__((__aligned__(16))) x0, x1;

x0 = _mm_load_si128((const __m128i *) t0);
x1 = _mm_load_si128((const __m128i *) t1);
x0 = _mm_xor_si128(x0,x1);
_mm_store_si128((__m128i *) t2,x0);

qui sera traduit en :

movdqa  xmm0,[t0]
movdqa  [x0],xmm0

movdqa  xmm0,[t1]
movdqa  [x1],xmm0

movdqa  xmm0,[x0]
movdqa  xmm1,[x1]
pxor    xmm0,xmm1
movdqa  [x0],xmm0

mov     xmm0,[x0]
mov     [t2],xmm0

ce qui est sans aucun doute moins efficace, mais il est intéressant de pouvoir utiliser les intrinsics soit lorsqu'on ne dispose pas de programme assembleur ou si on désire assurer une certaine compatibilité entre architectures différentes.


6.6 Fiabiliser son programme

Un aspect souvent négligé en programmation est la mise au point de tests afin de vérifier les fuites mémoires (memory leaks) ou les accès en déhors de zones mémoires autorisées à être accèdées.

6.6.1 Accès non autorisé

Voici un morceau de code qui peut mener à un arrêt brutal de votre programme (la fameuse segmentation fault sous Unix) :

// a.cpp
int main() {
int *tab = new int[10];
for (i=0; i!=11; ++i) tab[i]=0;
return 0;
}

On accède ici à tab[10], ce qui n'est normalement pas autorisé. Votre programme peut alors posséder deux comportements différents :

  • malgrè l'erreur, le programme fonctionne correctement, car la donnée que vous modifiez n'a pas d'influence sur d'autres programmes
  • le programme produit une erreur de segmentation car vous modifiez une donnée en mémoire qui est vitale

Pour déterminer les erreurs de ce type vous pouvez utiliser ElectricFence. ElectricFence est une librairie qui intercepte les allocations mémoires et détermine quand vous procédez à un accès en dehors de zones allouées. Pour utiliser ElectricFence vous pouvez, soit réaliser l'édition de lien avec la version statique de la librairie :

> g++ -o a.exe a.cpp -lefence -ggdb
> gdb a.exe
(gdb) run
Starting program: /home/richer/tmp/a.exe 
[Thread debugging using libthread_db enabled]
[New Thread -1211124016 (LWP 7616)]

  Electric Fence 2.1 Copyright (C) 1987-1998 Bruce Perens.

Program received signal SIGSEGV, Segmentation fault.
[Switching to Thread -1211124016 (LWP 7616)]
0x08048566 in main () at a.cpp:6
4         for (int i=0;i!=11;++i) tab[i]=0;
(gdb) print i
$1 = 10

ou utiliser la librairie dynamique à l'intérieur d'un débogueur, ddd par exemple. Avant de lancer l'exécution du programme, tapez la commande suivante à l'intérieur du debogueur :

set environment LD_PRELOAD=/usr/lib/libefence.so.0.0


6.6.2 Fuite mémoire (memory leak)

Une fuite mémoire correspond à un espace alloué dynamiquement que l'on a oublié de libéré par la suite. Cela peu amener votre programme a utiliser énormément de mémoire et réduite ainsi ses performaces.

Il existe cependant un moyen très simple de détecter les fuites mémoires potentielles en utilisant valgrind :

valgrind --leak-check=full ./a.exe
==7862== Memcheck, a memory error detector.
==7862== Copyright (C) 2002-2006, and GNU GPL'd, by Julian Seward et al.
==7862== Using LibVEX rev 1658, a library for dynamic binary translation.
==7862== Copyright (C) 2004-2006, and GNU GPL'd, by OpenWorks LLP.
==7862== Using valgrind-3.2.1-Debian, a dynamic binary instrumentation framework.
==7862== Copyright (C) 2000-2006, and GNU GPL'd, by Julian Seward et al.
==7862== For more details, rerun with: -v
==7862== 
==7862== 
==7862== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 23 from 1)
==7862== malloc/free: in use at exit: 40 bytes in 1 blocks.
==7862== malloc/free: 1 allocs, 0 frees, 40 bytes allocated.
==7862== For counts of detected errors, rerun with: -v
==7862== searching for pointers to 1 not-freed blocks.
==7862== checked 134,252 bytes.
==7862== 
==7862== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
==7862==    at 0x4021A55: operator new[](unsigned) (vg_replace_malloc.c:195)
==7862==    by 0x8048550: main (a.cpp:4)
==7862== 
==7862== LEAK SUMMARY:
==7862==    definitely lost: 40 bytes in 1 blocks.
==7862==      possibly lost: 0 bytes in 0 blocks.
==7862==    still reachable: 0 bytes in 0 blocks.
==7862==         suppressed: 0 bytes in 0 blocks.
==7862== Reachable blocks (those to which a pointer was found) are not shown.
==7862== To see them, rerun with: --show-reachable=yes

6.7 Les librairies dédiées

Un grand nombre d'algorithmes ont été étudiés et améliorés de manière à obtenir des temps d'exécution minimaux.

  • BLAS (Basic Linear Algebra) est une bibliothèque de sous-programmes liés au calcul vectoriel et matriciel.
  • LAPACK (Linear Algebra PACKage) écrit en Fortran 77, concerne la résolution de systèmes d'équations linéaires, recherche de valeurs propres, factorisation de matrices,... LAPACK utilise une partie des sous-programmes de BLAS

Différentes implémentations de BLAS/LAPACK existent et dépendent du processeur utilisé :

  • AMD : ACML
  • HP : MLIB
  • Intel : MKL
  • SUN : SPL

Voici un petit exemple de produit matriciel qui utilise le sous-programme SGEMM (Single floating-point GEneral Matrix matrix Multiplication) de la librairie Intel MKL :

int size; // size of square matrix
float *A; // first square matrix
float *B; // second square matrix
float *C; // result

mkl_set_num_threads(2);

// compute C = alpha * A * B + beta * C

cblas_sgemm(CblasRowMajor, CblasNoTrans, CblasNoTrans,
	    size, size, size,
	    1.0, /* alpha */
	    A, size,
	    B, size,
	    0.0, /* beta */
	    C, size);

On compile le programme avec les librairies suivantes :

ALL=C icpc -o main.exe obj/*.o -O3 -axT -msse2 
-Wl,--start-group 
/opt/intel/mkl/10.0.011/lib/32/libmkl_core.a
/opt/intel/mkl/10.0.011/lib/32/libmkl_intel.a 
/opt/intel/mkl/10.0.011/lib/32/libmkl_blacs.a 
/opt/intel/mkl/10.0.011/lib/32/libmkl_lapack.a 
/opt/intel/mkl/10.0.011/lib/32/libmkl_intel_thread.a
/opt/intel/mkl/10.0.011/lib/32/libmkl_scalapack.a 
-Wl,--end-group -lguide -lpthread

Voici quelques résultats :

Processor L2 Cache basic BLAS
(1 CPU)
BLAS
(2 CPU)
BLAS
(4 CPU)
C2D E6420 @ 2.13 Ghz
FSB 1066 Mhz
4 Mo 3m23s 5s24 3s08 -
C2D E8400 @ 3.00 Ghz
FSB 1333 Mhz
6 Mo 3m03s 3s40 ? -
C2Q Q6600 @ 2.40 Ghz
FSB 1066 Mhz
8 Mo 3m01s 4s59 2s48 1s27
C2Q Q9300 @ 2.50 Ghz
FSB 1333 Mhz
6 Mo 3m03s 4s21 2s26 1s27
Résultats sur calcul de deux matrices carrées de float de dimension 3000
(DDR2-SDRAM PC6400)

Processor L2 Cache basic BLAS
(1 CPU)
BLAS
(2 CPU)
BLAS
(4 CPU)
C2D E6420 @ 2.13 Ghz
FSB 1066 Mhz
4 Mo 8m50s 12s26 6s9 -
C2D E8400 @ 3.00 Ghz
FSB 1333 Mhz
6 Mo 8m01s 11s32 4s3 -
C2Q Q6600 @ 2.40 Ghz
FSB 1066 Mhz
8 Mo 7m27s 10s74 5s54 2s92
C2Q Q9300 @ 2.50 Ghz
FSB 1333 Mhz
6 Mo 7m22s 10s07 5s11 2s71
Résultats sur calcul de deux matrices carrées de float de dimension 4000
(DDR2-SDRAM PC6400)

On remarquera que :

  • bien que le Core 2 Duo E8400 fonctionne à une fréquence de 3 Ghz, il obtient des temps de calcul plus importants que le Core 2 Quad Q6600 qui ne fonctionne qu'à 2,4 Ghz. Cela est du principalement à la taille du cache L2 qui est de 8 Mo sur le Quad Core alors qu'il n'est que de 6 Mo sur le Dual Core
  • la fréquence du FSB ne semble pas influencer les résultats puisque le Q6600 et le Q9300 obtienent sensiblement les mêmes résultats

6.8 Programmation GPU (GPU Programming/Computing)

Le GPU computing est destiné à accélérer les algorithmes massivement parallèles en profitant des nombreuses unités d'exécution présentes au seun d'un GPU. Ces algorithmes doivent être conçus de manière à se décompose en une multitude de petits threads qui seront exécutés en parallèle, par groupes, sur le GPU... Une façon de penser et de programmer totalement différente donc qui a demandé à NVidia de concevoir un langage adapté : C pour CUDA. Celui-ci a été écrit par Ian Buck, qui était à l'origine de Brook GPU, un langage destiné à exploiter le pipeline de rendu 3D, et non directement le coeur de calcul du GPU, ce qui le rendait plus rigide et moins performant.
(Citation: Hardware.fr, Damien Triolet)

L'idée de la programmation GPU repose sur l'utilisation des circuits GPU (Graphical Processing Unit) des cartes graphiques. En effet, ces circuits se révèlent très performants pour réaliser du calcul vectoriel ou matriciel.

Par exemple, dans le cas de la multiplication matricielle, la société GPU-Tech annonce en novembre 2007 des temps 10 foix supérieurs à un CPU pour des flottants en simple précision. Sur un AMD Athlon 64 3800+, on obtient les résultats suivants :


Image du site GPU-Tech

La difficulté de la programmation GPU est de faire cohabiter un CPU avec GPU.

quelques transparents :


6.8.1 Application Folding@Home

Le projet Folding@Home (2000) vise à étudier le repliement des protéines. Lorsque des protéines ne se replient pas correctement ou ne sont pas correctement formées, elles peuvent engendrer des maladies (Alzheimer, Encéphalopathie spongiforme bovine (Mad Cow Disease), Parkinson, Myopathie, ...).

Le problème lié au repliement des protéines est qu'il demande énormément de temps de calcul. L'idée du projet Folding@Home a été de demander à des particuliers de télécharger sur leur ordinateur un programme qui s'exécute lorsque la machine est en veille, et effectue des calculs. On dispose ainsi qu'une importante source de calculateurs.

Historique des améliorations du programme de calcul :

  • 2000 : Tinker engine
  • 2003 : Gromacs, utilisation de code assembleur (à la main) + instructions SSE (amélioration d'un facteur de 20 à 30 par rapport à Tinker)
  • 2006 : utilisation de la carte graphique ATI X1900 (nouvelle amélioration d'un facteur de 20 à 30)

6.8.2 CUDA de NVidia

CUDA est un outil de développement pour les GPU supportant la technologie CUDA (Compute Unified Device Architecture) disponible sous Linux et Windows. CUDA comporte un certain nombre d'algorithmes optimisés, parmi lesquels on trouve :

  • multiplication et transposition de matrices
  • transformation de Fourier
  • algorithmes de traitement d'images

Pour utiliser CUDA il suffit de disposer d'une carte graphique basée sur le chipset GeForce 8800. Il existe également des cartes graphiques dédiées au calcul comme la C870 dotée de 1,5 Mo de mémoire et d'une puissance de 500 Gflops (Tesla).


GPU 8600 GTS 8800 GTS 8800 GT
Fréquence 675 Mhz 500 Mhz 600 Mhz
Largeur Bus Mémoire 128 bits 320 bits 256 bits
Taille Mémoire 256 Mo 320 Mo 256/512 Mo
Pipelines (Pixels/Vertex) 8 24 28
Unités de Texture 16 48 56
BP Mémoire 32 Go/s 64 Go/s 57,6 Go/s
Transistors (millions) 289 681 754
Process 0,08u TSMC 0.09u TSMC 0.065u TSMC
Aire du die (mm2) 169 484 324
Caractéristiques de quelques GPU NVidia

GPUs
Caractéristiques GPU (Source 59Hardware)


6.8.3 Tesla et Fermi de NVidia

Au mois de septembre 2009, NVidia présente sa future architecture Fermi (GT300, GF100), l'ancienne architecture était le Tesla (GT200).

  • 3 Milliards de transistors sur une puce gravée en 40 nm
  • 16 multiprocesseurs (512 cores organisés par groupes de 32)
  • jusqu'à 6 Go de mémoire avec la GDDR5
  • mémoire cache de niveau 1 et 2
  • support langage C / C++
Core Fermi
Core Fermi

6.9 Profiling and tuning

Le profiling et le tuning de programmes sont deux aspects complémentaires de l'optimisation :

  • le profilage consiste à déterminer les hot spots, c'est à dire les endroits du code qui consomment le plus de ressources
  • le réglage (tuning) consiste à modifier les hot spots de manière à les rendre plus cool (=frais, donc moins chauds)

Dans la suite de cette section, nous étudions le comportement du programme suivant : prog.c


6.9.1 Utilisation de prof/gprof sous Unix

On utilse les options de compilation classiques avec gprof :

> gcc -o prog.exe prog.c -pg
> ./prog.exe
> gprof prog.exe >prog_gprof.txt

Le résultat en sortie peut être visualisé ici.

profil à plat

Le flat profile est le suivant :

Flat profile:

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls   s/call   s/call  name    
 99.55     21.94    21.94  2000000     0.00     0.00  proc3
  0.27     22.00     0.06        2     0.03     0.03  fill_array
  0.09     22.02     0.02     2000     0.00     0.01  proc21
  0.09     22.04     0.02     2000     0.00     0.01  proc22
  0.00     22.04     0.00        2     0.00     0.00  allocate_array
  0.00     22.04     0.00        1     0.00    21.98  proc1

On peut donc lire que :

  • le sous-programme proc3 a été appelé 2.000.000 de fois et a consommé 99.55% du temps CPU (soit 21.94s sur les 22.04s du temps d'exécution total), c'est donc cette fonction qu'il faudra optimiser
  • proc21 a été appelé 2000 fois, ainsi que proc22 : bien que proc21 et proc22 appellent proc3, ici n'est reporté que le temps d'exécution hors appels aux autres sous-programmes.
granularité

Le second type d'information reporté par gprof concerne la granularité, c'est à dire le temps total passé dans chaque sous-programme ainsi que le nombre d'appels aux autres sous-programmes :

index % time    self  children    called     name
                                                 
[1]    100.0    0.00   22.04                 main [1]
                0.00   21.98       1/1           proc1 [2]
                0.06    0.00       2/2           fill_array [6]
                0.00    0.00       2/2           allocate_array [7]
-----------------------------------------------
                0.00   21.98       1/1           main [1]
[2]     99.7    0.00   21.98       1         proc1 [2]
                0.02   10.97    2000/2000        proc21 [4]
                0.02   10.97    2000/2000        proc22 [5]
-----------------------------------------------
               10.97    0.00 1000000/2000000     proc21 [4]
               10.97    0.00 1000000/2000000     proc22 [5]
[3]     99.5   21.94    0.00 2000000         proc3 [3]
-----------------------------------------------
                0.02   10.97    2000/2000        proc1 [2]
[4]     49.9    0.02   10.97    2000         proc21 [4]
               10.97    0.00 1000000/2000000     proc3 [3]
-----------------------------------------------
                0.02   10.97    2000/2000        proc1 [2]
[5]     49.9    0.02   10.97    2000         proc22 [5]
               10.97    0.00 1000000/2000000     proc3 [3]
-----------------------------------------------
                0.06    0.00       2/2           main [1]
[6]      0.3    0.06    0.00       2         fill_array [6]
-----------------------------------------------
                0.00    0.00       2/2           main [1]
[7]      0.0    0.00    0.00       2         allocate_array [7]
-----------------------------------------------

On voit donc que :

  • [1] la fonction main consomme 100% du temps d'exécution, ce qui est normal puisque c'est elle qui se trouve au sommet de l'arbre d'appel des sous-programmes.
  • [2] c'est ensuite au tour de proc1 d'appeler :
    • proc21 : 2000 fois, soit 10.97s
    • proc22 : 2000 fois, soit 10.97s
  • [3] proc3 est appelé 2.000.000 de fois :
    • 1.000.000 de fois par proc21
    • 1.000.000 de fois par proc22

6.9.2 Utilisation de VTune Analyzer (Intel)

VTune est un outil complet de profiling et de tuning qui permet de déterminer les hot spots et d'éclairer sur leur comportement : instruction peu performante, accès mémoire non aligné, nombre important de défauts de cache dus à des données de trop grande taille.

cas de prog.c


cas de popcount

Pour rappel, cette fonction compilée avec des options de compilation différentes donnait des résultats de 8s ou 23s.

  push   ebp
  xor    ecx,ecx
  mov    ebp,esp
  mov    edx,DWORD PTR [ebp+8]
L1:
  movzx  eax,dl
  movsx  eax,BYTE PTR [eax+0x8048bd0]
  add    ecx,eax
  shr    edx,0x8
  test   edx,edx ; sans cette instruction 23s, avec 8s
  jne    L1
  pop    ebp
  mov    eax,ecx
  ret    

Un rapide analyse sous VTune, nous donne les résultats suivants pour les compteurs choisis :

compteur sans avec
RAT_STALL.ANY % 97 41
RAT_STALL.FLAGS % 98 38
RAT_STALL_FLAGS events 38.000 106 35 106

  • RAT_STALL.FLAGS : compte le nombre de cycles durant lesquels l'exécution est suspendue en raison d'un accès partiel au registre des FLAGS

Précédent Sommaire  

marqueur eStat\'Perso