Question Augmentation du nombre de caches en cache lors de la vectorisation du code


J'ai vectorisé le produit scalaire entre 2 vecteurs avec SSE 4.2 et AVX 2, comme vous pouvez le voir ci-dessous. Le code a été compilé avec GCC 4.8.4 avec l'indicateur d'optimisation -O2. Comme prévu, les performances se sont améliorées avec les deux (et AVX 2 plus vite que SSE 4.2), mais lorsque j'ai défini le code avec PAPI, j'ai découvert que le nombre total de ratés (principalement L1 et L2) avait beaucoup augmenté:

Sans vectorisation:

PAPI_L1_TCM: 784,112,091
PAPI_L2_TCM: 195,315,365
PAPI_L3_TCM: 79,362

Avec SSE 4.2:

PAPI_L1_TCM: 1,024,234,171
PAPI_L2_TCM: 311,541,918
PAPI_L3_TCM: 68,842

Avec AVX 2:

PAPI_L1_TCM: 2,719,959,741
PAPI_L2_TCM: 1,459,375,105
PAPI_L3_TCM: 108,140

Y a-t-il un problème avec mon code ou est-ce que ce type de comportement est normal?

Code AVX 2:

double vec_dotProduct(const vec& vecs, const unsigned int& start_a, const unsigned int& start_b, const int& n) {
    double dot = 0;
    register int i = 0;
    const int loopBound = n-3;

    __m256d vsum, vecPi, vecCi, vecQCi;

    vsum = _mm256_set1_pd(0);

    double * const pA = vecs.x+start_a ;
    double * const pB = vecs.x+start_b ;

    for( ; i<loopBound ;i+=4){
        vecPi  = _mm256_loadu_pd(&(pA)[i]);
        vecCi  = _mm256_loadu_pd(&(pB)[i]);
        vecQCi = _mm256_mul_pd(vecPi,vecCi);
        vsum   = _mm256_add_pd(vsum,vecQCi);
    }

    vsum = _mm256_hadd_pd(vsum, vsum);

    dot = ((double*)&vsum)[0] + ((double*)&vsum)[2];

    for( ; i<n; i++)
        dot += pA[i] * pB[i];

    return dot;
}

Code SSE 4.2:

double vec_dotProduct(const vec& vecs, const unsigned int& start_a, const unsigned int& start_b, const int& n) {
    double dot = 0;
    register int i = 0;

    const int loopBound = n-1;

    __m128d vsum, vecPi, vecCi, vecQCi;

    vsum = _mm_set1_pd(0);

    double * const pA = vecs.x+start_a ;
    double * const pB = vecs.x+start_b ;

    for( ; i<loopBound ;i+=2){
        vecPi  = _mm_load_pd(&(pA)[i]);
        vecCi  = _mm_load_pd(&(pB)[i]);
        vecQCi = _mm_mul_pd(vecPi,vecCi);
        vsum   = _mm_add_pd(vsum,vecQCi);
    }

    vsum = _mm_hadd_pd(vsum, vsum);

    _mm_storeh_pd(&dot, vsum);

    for( ; i<n; i++)
        dot += pA[i] * pB[i];

    return dot;
}

Code non vectoriel:

double dotProduct(const vec& vecs, const unsigned int& start_a, const unsigned int& start_b, const int& n) {
    double dot = 0;
    register int i = 0;

    for (i = 0; i < n; ++i)
    {
        dot += vecs.x[start_a+i] * vecs.x[start_b+i];
    }
    return dot;
}

Edit: Assemblage du code non vectorisé:

   0x000000000040f9e0 <+0>:     mov    (%rcx),%r8d
   0x000000000040f9e3 <+3>:     test   %r8d,%r8d
   0x000000000040f9e6 <+6>:     jle    0x40fa1d <dotProduct(vec const&, unsigned int const&, unsigned int const&, int const&)+61>
   0x000000000040f9e8 <+8>:     mov    (%rsi),%eax
   0x000000000040f9ea <+10>:    mov    (%rdi),%rcx
   0x000000000040f9ed <+13>:    mov    (%rdx),%edi
   0x000000000040f9ef <+15>:    vxorpd %xmm0,%xmm0,%xmm0
   0x000000000040f9f3 <+19>:    add    %eax,%r8d
   0x000000000040f9f6 <+22>:    sub    %eax,%edi
   0x000000000040f9f8 <+24>:    nopl   0x0(%rax,%rax,1)
   0x000000000040fa00 <+32>:    mov    %eax,%esi
   0x000000000040fa02 <+34>:    lea    (%rdi,%rax,1),%edx
   0x000000000040fa05 <+37>:    add    $0x1,%eax
   0x000000000040fa08 <+40>:    vmovsd (%rcx,%rsi,8),%xmm1
   0x000000000040fa0d <+45>:    cmp    %r8d,%eax
   0x000000000040fa10 <+48>:    vmulsd (%rcx,%rdx,8),%xmm1,%xmm1
   0x000000000040fa15 <+53>:    vaddsd %xmm1,%xmm0,%xmm0
   0x000000000040fa19 <+57>:    jne    0x40fa00 <dotProduct(vec const&, unsigned int const&, unsigned int const&, int const&)+32>
   0x000000000040fa1b <+59>:    repz retq 
   0x000000000040fa1d <+61>:    vxorpd %xmm0,%xmm0,%xmm0
   0x000000000040fa21 <+65>:    retq   

Edit2: Ci-dessous, vous pouvez trouver la comparaison des échecs de cache L1 entre le code vectorisé et le code non vectorisé pour les N plus grands (N sur le x-label et L1 cache sur le y-label). En gros, pour les plus grands N, il y a encore plus de ratés dans la version vectorisée que dans la version non vectorisée.

enter image description here


15
2017-12-03 14:50


origine


Réponses:


Rostislav a raison de dire que le compilateur est auto-vectorisant, et à partir de la documentation de GCC sur -O2:

"-O2 Optimisez encore plus. GCC exécute presque toutes les optimisations prises en charge qui n'impliquent pas de compromis en termes de vitesse spatiale." (d'ici: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html)

GCC avec l'indicateur -O2 tente de générer le code le plus efficace, sans privilégier la taille du code ou la vitesse.

Donc, en termes de cycles de CPU, le code auto-vectorisé -O2 nécessitera le moins de watts à exécuter, mais ne sera pas le code le plus rapide ou le plus petit. C'est le meilleur cas pour le code qui s'exécute sur des appareils mobiles et sur des systèmes multi-utilisateurs, et ceux-ci ont tendance à être l'utilisation préférée de C ++. Si vous souhaitez une vitesse maximale absolue, quel que soit le nombre de watts utilisés, essayez -O3 ou -Ofast si votre version de GCC les prend en charge ou utilisez vos solutions optimisées pour la main.

La cause de ceci est probablement une combinaison de deux facteurs.

Tout d'abord, un code plus rapide génère plus de requêtes dans la mémoire / le cache dans le même laps de temps, ce qui accentue les algorithmes de prédiction de prélecture. Le cache L1 n'est pas très volumineux, généralement de 1 Mo à 3 Mo, et est partagé entre tous les processus en cours sur ce Core CPU, de sorte que le Core CPU ne peut pas être pré-extrait tant que le bloc précédemment récupéré n'est plus utilisé. Si le code s'exécute plus rapidement, il y a moins de temps pour pré-extraire entre les blocs, et dans le code que les pipelines sont efficaces, davantage de caches seront exécutés avant que le Core CPU ne soit complètement arrêté.

Et deuxièmement, les systèmes d'exploitation modernes divisent généralement les processus mono-thread entre plusieurs cœurs en ajustant de manière dynamique l'affinité des threads, afin d'utiliser le cache supplémentaire sur plusieurs cœurs, même s'il ne peut pas exécuter le code en parallèle. remplissez le cache du core 0 avec vos données, puis exécutez-le en remplissant le cache du core 1, puis exécutez le core 1 tout en remplissant le cache du core 0, round-robin jusqu'à la fin. Ce pseudo-parallélisme améliore la vitesse globale des processus à un seul thread et devrait réduire considérablement les erreurs de cache, mais cela ne peut se faire que dans des circonstances très spécifiques ... circonstances spécifiques pour lesquelles de bons compilateurs génèrent du code chaque fois que cela est possible.


0
2018-01-28 05:39



Comme vous pouvez le voir dans certains commentaires, les erreurs de cache proviennent de l’augmentation des performances.

Par exemple, avec les processeurs récents, vous pourrez exécuter 2 add-mx ou AVX2 à chaque cycle, soit 512 bits à chaque cycle. Le temps nécessaire pour charger les données sera plus élevé car plusieurs lignes de cache seront nécessaires.

De plus, en fonction de la configuration de votre système, de l’hyper-threading, des affinités, etc., votre ordonnanceur peut faire d’autres choses en même temps que polluer votre cache avec d’autres threads / processus.

Une dernière chose Les processeurs sont maintenant très efficaces pour reconnaître des modèles simples comme ceux que vous avez avec de très petites boucles, puis utiliser automatiquement la lecture anticipée après quelques itérations. De toute façon, cela ne suffira pas à résoudre le problème de la taille du cache.

Essayez-les avec différentes tailles pour N, vous devriez voir des résultats intéressants. Alignez également vos données en premier et assurez-vous que si vous utilisez 2 variables, elles ne partagent pas la même ligne de cache.


1
2018-01-28 21:52