web-dev-qa-db-fra.com

Exemples de prélecture?

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 de performance substantiel? En particulier, j'aimerais que l'exemple réponde aux critères suivants:

  1. C'est un exemple simple, petit et autonome.
  2. La suppression de l'instruction __builtin_prefetch entraîne une dégradation des performances.
  3. Le remplacement de l'instruction __builtin_prefetch par l'accès mémoire correspondant entraîne une dégradation des performances.

C'est-à-dire que je veux que l'exemple le plus court montre que __builtin_prefetch exécute une optimisation qui ne pourrait être gérée sans cela.

58
Shaun Harker

Voici un extrait de code que j'ai extrait d'un projet plus vaste. (Désolé, c'est le plus court problème que je puisse trouver qui ait eu une accélération notable de la prélecture).

Cet exemple utilise les instructions de prélecture SSE, qui peuvent être identiques à 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 ce sera trop rapide.

#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 une accélération de 13% par rapport au préchargement.

MODIFIER:

Voici quelques résultats supplémentaires:

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
61
Mysticial

La recherche binaire est un exemple simple qui pourrait tirer parti de la prélecture explicite. Le modèle d'accès dans une recherche binaire semble assez aléatoire pour le préfetcher matériel, il y a donc peu de chance qu'il prédise avec précision ce qu'il faut extraire.

Dans cet exemple, je pré-prélève les deux emplacements «intermédiaires» possibles de la prochaine itération de la boucle dans l'itération en cours. Un des pré-prélèvements ne sera probablement jamais utilisé, 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% de l'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

Notez que nous effectuons deux fois plus de charges de cache L1 dans la version de prélecture. Nous travaillons actuellement beaucoup plus, mais le modèle d'accès à la mémoire est plus convivial pour le pipeline. Cela montre également le compromis. Tandis que ce bloc de code s'exécute plus rapidement de manière isolée, nous avons chargé beaucoup de courrier indésirable dans les caches et cela peut mettre davantage de pression sur les autres parties de l'application.

29
James Scriven

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

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

Dans un scénario parfait, où la latence correspond à X étapes de calcul, nous aurions un Oracle qui nous indiquerait la mémoire à laquelle nous accéderions dans X étapes de calcul. Le préchargement de ces données serait alors lancé et il arriverait juste in temps X calculs-pas plus tard.

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

C’est la raison pour laquelle il existe une amélioration aussi modeste pour l’exemple de @ Mystical: le préfetcher est déjà très bon - il n’ya tout simplement pas place à l’amélioration. La tâche est également liée à la mémoire, donc il ne reste probablement pas beaucoup de largeur de bande - cela pourrait devenir le facteur limitant. Je pouvais au mieux voir une amélioration d'environ 8% sur ma machine.

L'idée cruciale tirée de l'exemple @JamesScriven: ni le processeur ni nous ne connaissons la prochaine adresse d'accès avant que les données actuelles ne soient extraites de la mémoire - cette dépendance est assez importante, sinon une exécution dans le désordre conduirait à une analyse prospective. et le matériel serait capable de pré-extraire les données. Cependant, comme nous ne pouvons spéculer que sur une seule étape, il n’ya pas beaucoup de potentiel. Je n'ai pas pu obtenir plus de 40% sur ma machine.

Nous allons donc structurer la concurrence et préparer les données de manière à savoir quelle adresse est accessible en X pas à pas, tout en empêchant le matériel de la trouver en raison des dépendances sur des données non encore consulté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. peut-être étonnamment, moins la tâche liée au processeur est importante, plus l’accélération est rapide: nous sommes en mesure de masquer la latence presque complètement, l’accélération est donc de CPU-time+original-latency-time/CPU-time.

Compilation et exécution de 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 de 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";
     }

 }
2
ead

De 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);
          /* ... */
        }
0
wallyk

La pré-extraction des données peut être optimisée en fonction de la taille de la ligne de cache, qui correspond à 64 octets pour les processeurs 64 bits les plus récents. Vous pouvez par exemple pré-charger un uint32_t [16] avec une seule instruction.

Par exemple, sur ArmV8, j'ai découvert expérimentalement en transformant le pointeur de la mémoire en un vecteur matriciel uint32_t 4x4 (taille de 64 octets) divisant par deux le nombre d'instructions requises Je croyais comprendre que cela allait chercher une ligne de cache complète.

Prétraitement d'un exemple de code d'origine uint32_t [32] ...

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, int type de données pour l'index/offset d'adresse a donné de meilleures performances. Testé avec GCC 8 sur Cortex-a53. L'utilisation d'un vecteur équivalent de 64 octets sur d'autres architectures peut apporter les mêmes améliorations de performances si vous constatez qu'il ne s'agit pas d'une pré-extraction 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%. Il y avait d'autres exigences pour l'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, je devais utiliser des opérateurs C au lieu de Neon Intrinsics, car ils requièrent des pointeurs de type de données réguliers (dans mon cas, il s'agissait de uint32_t *), sinon la nouvelle méthode de prélecture intégrée présentait une régression de performance.

Mon exemple du monde réel est disponible à l'adresse suivante: https://github.com/rollmeister/veriumMiner/blob/main/algo/scrypt.c dans scrypt_core () et sa fonction interne, qui sont tous faciles à lire. Le travail acharné est fait par GCC8. L'amélioration globale de la performance était de 25%.

0
Rauli Kumpulainen