67 votes

Exemples de préemption ?

Quelqu'un peut-il donner un exemple ou un lien vers un exemple qui utilise __builtin_prefetch dans GCC (ou simplement l'instruction asm prefetcht0 en général) pour obtenir un avantage substantiel en termes de performances ? En particulier, j'aimerais que l'exemple réponde aux critères suivants :

  1. Il s'agit d'un exemple simple, petit et autonome.
  2. Retirer le __builtin_prefetch entraîne une dégradation des performances.
  3. Remplacer le __builtin_prefetch avec l'accès mémoire correspondant entraîne une dégradation des performances.

C'est-à-dire que je veux l'exemple le plus court montrant __builtin_prefetch en effectuant une optimisation qui ne pourrait être gérée sans elle.

67voto

Mysticial Points 180300

Voici un morceau de code que j'ai extrait d'un projet plus vaste. (Désolé, c'est le plus court que j'ai pu trouver qui avait une accélération notable grâce au prefetching). Ce code effectue une très grande transposition de données.

Cet exemple utilise les instructions de préextraction SSE, qui peuvent être les mêmes que celles émises par GCC.

Pour exécuter cet exemple, vous devez le compiler pour x64 et disposer de plus de 4 Go de mémoire. Vous pouvez l'exécuter avec une taille de données plus petite, mais il sera trop rapide pour le temps.

#include <iostream>
using std::cout;
using std::endl;

#include <emmintrin.h>
#include <malloc.h>
#include <time.h>
#include <string.h>

#define ENABLE_PREFETCH

#define f_vector    __m128d
#define i_ptr       size_t
inline void swap_block(f_vector *A,f_vector *B,i_ptr L){
    //  To be super-optimized later.

    f_vector *stop = A + L;

    do{
        f_vector tmpA = *A;
        f_vector tmpB = *B;
        *A++ = tmpB;
        *B++ = tmpA;
    }while (A < stop);
}
void transpose_even(f_vector *T,i_ptr block,i_ptr x){
    //  Transposes T.
    //  T contains x columns and x rows.
    //  Each unit is of size (block * sizeof(f_vector)) bytes.

    //Conditions:
    //  - 0 < block
    //  - 1 < x

    i_ptr row_size = block * x;
    i_ptr iter_size = row_size + block;

    //  End of entire matrix.
    f_vector *stop_T = T + row_size * x;
    f_vector *end = stop_T - row_size;

    //  Iterate each row.
    f_vector *y_iter = T;
    do{
        //  Iterate each column.
        f_vector *ptr_x = y_iter + block;
        f_vector *ptr_y = y_iter + row_size;

        do{

#ifdef ENABLE_PREFETCH
            _mm_prefetch((char*)(ptr_y + row_size),_MM_HINT_T0);
#endif

            swap_block(ptr_x,ptr_y,block);

            ptr_x += block;
            ptr_y += row_size;
        }while (ptr_y < stop_T);

        y_iter += iter_size;
    }while (y_iter < end);
}
int main(){

    i_ptr dimension = 4096;
    i_ptr block = 16;

    i_ptr words = block * dimension * dimension;
    i_ptr bytes = words * sizeof(f_vector);

    cout << "bytes = " << bytes << endl;
//    system("pause");

    f_vector *T = (f_vector*)_mm_malloc(bytes,16);
    if (T == NULL){
        cout << "Memory Allocation Failure" << endl;
        system("pause");
        exit(1);
    }
    memset(T,0,bytes);

    //  Perform in-place data transpose
    cout << "Starting Data Transpose...   ";
    clock_t start = clock();
    transpose_even(T,block,dimension);
    clock_t end = clock();

    cout << "Done" << endl;
    cout << "Time: " << (double)(end - start) / CLOCKS_PER_SEC << " seconds" << endl;

    _mm_free(T);
    system("pause");
}

Lorsque je l'exécute avec ENABLE_PREFETCH activé, voici le résultat :

bytes = 4294967296
Starting Data Transpose...   Done
Time: 0.725 seconds
Press any key to continue . . .

Lorsque je l'exécute avec ENABLE_PREFETCH désactivé, voici le résultat :

bytes = 4294967296
Starting Data Transpose...   Done
Time: 0.822 seconds
Press any key to continue . . .

Il y a donc un gain de vitesse de 13% grâce au prefetching.

EDITAR:

Voici d'autres résultats :

Operating System: Windows 7 Professional/Ultimate
Compiler: Visual Studio 2010 SP1
Compile Mode: x64 Release

Intel Core i7 860 @ 2.8 GHz, 8 GB DDR3 @ 1333 MHz
Prefetch   : 0.868
No Prefetch: 0.960

Intel Core i7 920 @ 3.5 GHz, 12 GB DDR3 @ 1333 MHz
Prefetch   : 0.725
No Prefetch: 0.822

Intel Core i7 2600K @ 4.6 GHz, 16 GB DDR3 @ 1333 MHz
Prefetch   : 0.718
No Prefetch: 0.796

2 x Intel Xeon X5482 @ 3.2 GHz, 64 GB DDR2 @ 800 MHz
Prefetch   : 2.273
No Prefetch: 2.666

1 votes

Intéressant. Malheureusement, sur les deux machines que j'ai testées (Macbook Pro avec "Core 2 Duo" et une machine Linux avec un "Quad-Core AMD Opteron Processor 2376"), je n'ai pas obtenu de différence significative entre les deux versions. Je pense que cela a à voir avec la taille du cache - il semble que vous ayez une meilleure machine que ces deux-là. Qu'en pensez-vous ?

2 votes

Ma machine est un Core i7 920 @ 3.5 GHz. 8 Mo de cache L3. Cette accélération de 10% est plus ou moins cohérente sur 3 autres machines que j'ai testées : Core i7 2600K @ 4.6 GHz, et 2 x Xeon X5482 @ 3.2 GHz. Mais je dois admettre que je ne l'ai jamais testé sur un ordinateur portable ou une machine AMD.

1 votes

Je viens d'éditer ma réponse avec les benchmarks sur les 4 machines que j'ai testées. Ce sont toutes des ordinateurs de bureau/stations de travail Intel. Cela pourrait donc être la raison. Je n'ai pas testé si votre troisième point est valable. Il se pourrait qu'en le remplaçant par un accès à la mémoire, on obtienne le même résultat.

37voto

James Scriven Points 2027

La recherche binaire est un exemple simple qui pourrait bénéficier de la préextraction explicite. Le modèle d'accès d'une recherche binaire semble assez aléatoire pour le préempteur matériel, il y a donc peu de chances qu'il puisse prédire avec précision ce qu'il doit récupérer.

Dans cet exemple, je préempte les deux emplacements "centraux" possibles de la prochaine itération de boucle dans l'itération actuelle. L'une des prélectures ne sera probablement jamais utilisée, mais l'autre le sera (sauf s'il s'agit de l'itération finale).

 #include <time.h>
 #include <stdio.h>
 #include <stdlib.h>

 int binarySearch(int *array, int number_of_elements, int key) {
         int low = 0, high = number_of_elements-1, mid;
         while(low <= high) {
                 mid = (low + high)/2;
            #ifdef DO_PREFETCH
            // low path
            __builtin_prefetch (&array[(mid + 1 + high)/2], 0, 1);
            // high path
            __builtin_prefetch (&array[(low + mid - 1)/2], 0, 1);
            #endif

                 if(array[mid] < key)
                         low = mid + 1; 
                 else if(array[mid] == key)
                         return mid;
                 else if(array[mid] > key)
                         high = mid-1;
         }
         return -1;
 }
 int main() {
     int SIZE = 1024*1024*512;
     int *array =  malloc(SIZE*sizeof(int));
     for (int i=0;i<SIZE;i++){
       array[i] = i;
     }
     int NUM_LOOKUPS = 1024*1024*8;
     srand(time(NULL));
     int *lookups = malloc(NUM_LOOKUPS * sizeof(int));
     for (int i=0;i<NUM_LOOKUPS;i++){
       lookups[i] = rand() % SIZE;
     }
     for (int i=0;i<NUM_LOOKUPS;i++){
       int result = binarySearch(array, SIZE, lookups[i]);
     }
     free(array);
     free(lookups);
 }

Lorsque je compile et exécute cet exemple avec DO_PREFETCH activé, je constate une réduction de 20% du temps d'exécution :

 $ gcc c-binarysearch.c -DDO_PREFETCH -o with-prefetch -std=c11 -O3
 $ gcc c-binarysearch.c -o no-prefetch -std=c11 -O3

 $ perf stat -e L1-dcache-load-misses,L1-dcache-loads ./with-prefetch 

  Performance counter stats for './with-prefetch':

    356,675,702      L1-dcache-load-misses     #   41.39% of all L1-dcache hits  
   861,807,382      L1-dcache-loads                                             

   8.787467487 seconds time elapsed

 $ perf stat -e L1-dcache-load-misses,L1-dcache-loads ./no-prefetch 

 Performance counter stats for './no-prefetch':

   382,423,177      L1-dcache-load-misses     #   97.36% of all L1-dcache hits  
   392,799,791      L1-dcache-loads                                             

  11.376439030 seconds time elapsed

Remarquez que nous effectuons deux fois plus de chargements de cache L1 dans la version prefetch. Nous effectuons en fait beaucoup plus de travail, mais le modèle d'accès à la mémoire est plus convivial pour le pipeline. Cela montre également le compromis. Bien que ce bloc de code s'exécute plus rapidement de manière isolée, nous avons chargé beaucoup de déchets dans les caches et cela peut exercer une pression supplémentaire sur d'autres parties de l'application.

0 votes

Peut-on combiner le prefetch intégré avec les instructions AVX si les données sont alignées ? Les résultats d'une préextraction pourraient être chargés dans un registre AVX pour effectuer des comparaisons ?

5voto

ead Points 1051

J'ai beaucoup appris des excellentes réponses fournies par @JamesScriven et @Mystical. Cependant, leurs exemples ne donnent qu'une impulsion modeste - l'objectif de cette réponse est de présenter un exemple (je dois avouer qu'il est quelque peu artificiel), où le prefetching a un impact plus important (environ facteur 4 sur ma machine).

Il existe trois goulets d'étranglement possibles pour les architectures modernes : la vitesse du processeur, la largeur de bande de la mémoire et la latence de la mémoire. Le préemption consiste à réduire la latence des accès à la mémoire.

Dans un scénario parfait, où la latence correspond à X étapes de calcul, nous disposerions d'un oracle qui nous indiquerait à quelle mémoire nous devons accéder en X étapes de calcul, la préextraction de ces données serait lancée et elles arriveraient juste à temps X étapes de calcul plus tard.

Pour beaucoup d'algorithmes, nous sommes (presque) dans ce monde parfait. Pour une simple boucle for, il est facile de prévoir quelles données seront nécessaires X étapes plus tard. L'exécution hors ordre et d'autres astuces matérielles font un très bon travail ici, en dissimulant presque complètement la latence.

C'est la raison pour laquelle l'amélioration est si modeste dans l'exemple de @Mystical : Le préempteur est déjà assez bon - il n'y a tout simplement pas beaucoup de place pour l'amélioration. La tâche est également liée à la mémoire, donc il ne reste probablement pas beaucoup de bande passante - cela pourrait devenir le facteur limitant. Je pourrais voir au mieux environ 8% d'amélioration sur ma machine.

Le point crucial de l'exemple de @JamesScriven : ni nous ni le CPU ne connaissons l'adresse d'accès suivante avant que les données actuelles ne soient extraites de la mémoire - cette dépendance est assez importante, sinon l'exécution hors ordre conduirait à un look-forward et le matériel serait capable de prélever les données. Cependant, comme nous ne pouvons spéculer que sur une seule étape, il n'y a pas beaucoup de potentiel. Je n'ai pas été capable d'obtenir plus de 40% sur ma machine.

Nous allons donc truquer la compétition et préparer les données de telle sorte que nous sachions quelle adresse est accédée en X étapes, mais qu'il soit impossible pour le matériel de la découvrir en raison de dépendances sur des données non encore accédées (voir le programme complet à la fin de la réponse) :

//making random accesses to memory:
unsigned int next(unsigned int current){
   return (current*10001+328)%SIZE;
}

//the actual work is happening here
void operator()(){

    //set up the oracle - let see it in the future oracle_offset steps
    unsigned int prefetch_index=0;
    for(int i=0;i<oracle_offset;i++)
        prefetch_index=next(prefetch_index);

    unsigned int index=0;
    for(int i=0;i<STEP_CNT;i++){
        //use oracle and prefetch memory block used in a future iteration
        if(prefetch){
            __builtin_prefetch(mem.data()+prefetch_index,0,1);    
        }

        //actual work, the less the better
        result+=mem[index];

        //prepare next iteration
        prefetch_index=next(prefetch_index);  #update oracle
        index=next(mem[index]);               #dependency on `mem[index]` is VERY important to prevent hardware from predicting future
    }
}

Quelques remarques :

  1. Les données sont préparées de manière à ce que l'oracle ait toujours raison.
  2. Il est peut-être surprenant de constater que plus la tâche est limitée par le processeur, plus l'accélération est importante : nous sommes en mesure de masquer presque complètement la latence, ce qui fait que l'accélération est de 1,5 million d'euros. CPU-time+original-latency-time/CPU-time .

Compilation et exécution des pistes :

>>> g++ -std=c++11 prefetch_demo.cpp -O3 -o prefetch_demo
>>> ./prefetch_demo
#preloops   time no prefetch    time prefetch   factor
...
7   1.0711102260000001  0.230566831 4.6455521002498408
8   1.0511602149999999  0.22651144600000001 4.6406494398521474
9   1.049024333 0.22841439299999999 4.5926367389641687
....

à une accélération entre 4 et 5.


Liste des prefetch_demp.cpp :

//prefetch_demo.cpp

#include <vector>
#include <iostream>
#include <iomanip>
#include <chrono>

const int SIZE=1024*1024*1;
const int STEP_CNT=1024*1024*10;

unsigned int next(unsigned int current){
   return (current*10001+328)%SIZE;
}

template<bool prefetch>
struct Worker{
   std::vector<int> mem;

   double result;
   int oracle_offset;

   void operator()(){
        unsigned int prefetch_index=0;
        for(int i=0;i<oracle_offset;i++)
            prefetch_index=next(prefetch_index);

        unsigned int index=0;
        for(int i=0;i<STEP_CNT;i++){
            //prefetch memory block used in a future iteration
            if(prefetch){
                __builtin_prefetch(mem.data()+prefetch_index,0,1);    
            }
            //actual work:
            result+=mem[index];

            //prepare next iteration
            prefetch_index=next(prefetch_index);
            index=next(mem[index]);
        }
   }

   Worker(std::vector<int> &mem_):
       mem(mem_), result(0.0), oracle_offset(0)
   {}
};

template <typename Worker>
    double timeit(Worker &worker){
    auto begin = std::chrono::high_resolution_clock::now();
    worker();
    auto end = std::chrono::high_resolution_clock::now();
    return std::chrono::duration_cast<std::chrono::nanoseconds>(end-begin).count()/1e9;
}

 int main() {
     //set up the data in special way!
     std::vector<int> keys(SIZE);
     for (int i=0;i<SIZE;i++){
       keys[i] = i;
     }

     Worker<false> without_prefetch(keys);
     Worker<true> with_prefetch(keys);

     std::cout<<"#preloops\ttime no prefetch\ttime prefetch\tfactor\n";
     std::cout<<std::setprecision(17);

     for(int i=0;i<20;i++){
         //let oracle see i steps in the future:
         without_prefetch.oracle_offset=i;
         with_prefetch.oracle_offset=i;

         //calculate:
         double time_with_prefetch=timeit(with_prefetch);
         double time_no_prefetch=timeit(without_prefetch);

         std::cout<<i<<"\t"
                  <<time_no_prefetch<<"\t"
                  <<time_with_prefetch<<"\t"
                  <<(time_no_prefetch/time_with_prefetch)<<"\n";
     }

 }

0voto

wallyk Points 33150

Desde la documentation :

      for (i = 0; i < n; i++)
        {
          a[i] = a[i] + b[i];
          __builtin_prefetch (&a[i+j], 1, 1);
          __builtin_prefetch (&b[i+j], 0, 1);
          /* ... */
        }

17 votes

Je pense que le préprocesseur matériel du CPU l'aurait fait de toute façon. C'est généralement la raison pour laquelle les gens découvrent que "la préextraction ne fait rien" - il faut vraiment que le modèle d'accès soit quelque chose qu'un morceau de logique raisonnablement simple, analysant les modèles d'accès, ne peut pas prédire.

1 votes

@Crowley9 Je ne suis pas d'accord pour dire que c'est une mauvaise réponse. L'OP voulait un exemple simple (probablement pour savoir comment l'utiliser), ceci répond à cela.

0 votes

Les processeurs plus anciens, avec un prefetching matériel moins intelligent, ont bénéficié du prefetching logiciel dans plus de cas. Je pense que même le P4 aurait été assez intelligent pour préempter HW les accès séquentiels aux données contiguës, cependant. C'est un exemple terrible parce que c'est un cas où les instructions de préextraction supplémentaires ne font que ralentir les choses. @a3mlord : L'OP voulait un gain de performance, pas seulement la syntaxe.

0voto

La prélecture des données peut être optimisée en fonction de la taille de la ligne de cache, qui, pour la plupart des processeurs 64 bits modernes, est de 64 octets, afin de précharger, par exemple, un uint32_t[16] avec une seule instruction.

Par exemple, sur ArmV8, j'ai découvert par expérimentation que le fait de couler le pointeur de mémoire vers un vecteur matriciel uint32_t 4x4 (d'une taille de 64 octets) divisait par deux les instructions requises, car avant, je devais incrémenter par 8 car il ne chargeait que la moitié des données, même si j'avais compris qu'il récupérait une ligne de cache complète.

Pré-chargement d'un uint32_t[32] exemple de code original...

int addrindex = &B[0];
    __builtin_prefetch(&V[addrindex]);
    __builtin_prefetch(&V[addrindex + 8]);
    __builtin_prefetch(&V[addrindex + 16]);
    __builtin_prefetch(&V[addrindex + 24]);

Après...

int addrindex = &B[0];
__builtin_prefetch((uint32x4x4_t *) &V[addrindex]);
__builtin_prefetch((uint32x4x4_t *) &V[addrindex + 16]);

Pour une raison quelconque, le type de données int pour l'index/offset de l'adresse donne de meilleures performances. Testé avec GCC 8 sur Cortex-a53. L'utilisation d'un vecteur équivalent de 64 octets sur d'autres architectures pourrait donner la même amélioration de performance si vous trouvez qu'il n'y a pas de pré-recherche de toutes les données comme dans mon cas. Dans mon application avec une boucle d'un million d'itérations, les performances ont été améliorées de 5% juste en faisant cela. Il y avait d'autres conditions pour cette amélioration.

l'allocation de mémoire "V" de 128 mégaoctets devait être alignée sur 64 octets.

uint32_t *V __attribute__((__aligned__(64))) = (uint32_t *)(((uintptr_t)(__builtin_assume_aligned((unsigned char*)aligned_alloc(64,size), 64)) + 63) & ~ (uintptr_t)(63));

De plus, j'ai dû utiliser des opérateurs C au lieu de Neon Intrinsics, car ils nécessitent des pointeurs de type de données ordinaires (dans mon cas, il s'agissait de uint32_t * ), sinon la nouvelle méthode de préemption intégrée a entraîné une régression des performances.

Mon exemple concret se trouve à l'adresse suivante https://github.com/rollmeister/veriumMiner/blob/main/algo/scrypt.c dans le scrypt_core() et sa fonction interne qui sont tous faciles à lire. Le travail difficile est fait par GCC8. L'amélioration globale des performances est de 25%.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X