web-dev-qa-db-fra.com

Pourquoi std :: fill (0) est-il plus lent que std :: fill (1)?

J'ai observé sur un système que std::fill Sur un grand std::vector<int> Était significativement et systématiquement plus lent lors de la définition d'une valeur constante 0 Par rapport à une valeur constante 1 Ou une valeur dynamique:

5,8 Gio/s vs 7,5 Gio/s

Cependant, les résultats sont différents pour des tailles de données plus petites, où fill(0) est plus rapide:

performance for single thread at different data sizes

Avec plus d'un thread, à 4 GiB taille des données, fill(1) montre une pente plus élevée, mais atteint un pic beaucoup plus bas que fill(0) (51 GiB/s contre 90 Gio/s):

performance for various thread counts at large data size

Cela soulève la question secondaire, pourquoi la bande passante maximale de fill(1) est tellement inférieure.

Le système de test était un processeur Intel Xeon E5-2680 v3 à deux sockets réglé à 2,5 GHz (via /sys/cpufreq) Avec 8x16 GiB DDR4-2133. J'ai testé avec GCC 6.1 .0 (-O3) Et le compilateur Intel 17.0.1 (-fast), Les deux obtiennent des résultats identiques. GOMP_CPU_AFFINITY=0,12,1,13,2,14,3,15,4,16,5,17,6,18,7,19,8,20,9,21,10,22,11,23 A été défini. Strem/add/24 threads obtient 85 Gio/s sur le système.

J'ai pu reproduire cet effet sur un autre système de serveur à double socket Haswell, mais pas sur aucune autre architecture. Par exemple, sur Sandy Bridge EP, les performances de la mémoire sont identiques, tandis que dans le cache fill(0) est beaucoup plus rapide.

Voici le code à reproduire:

#include <algorithm>
#include <cstdlib>
#include <iostream>
#include <omp.h>
#include <vector>

using value = int;
using vector = std::vector<value>;

constexpr size_t write_size = 8ll * 1024 * 1024 * 1024;
constexpr size_t max_data_size = 4ll * 1024 * 1024 * 1024;

void __attribute__((noinline)) fill0(vector& v) {
    std::fill(v.begin(), v.end(), 0);
}

void __attribute__((noinline)) fill1(vector& v) {
    std::fill(v.begin(), v.end(), 1);
}

void bench(size_t data_size, int nthreads) {
#pragma omp parallel num_threads(nthreads)
    {
        vector v(data_size / (sizeof(value) * nthreads));
        auto repeat = write_size / data_size;
#pragma omp barrier
        auto t0 = omp_get_wtime();
        for (auto r = 0; r < repeat; r++)
            fill0(v);
#pragma omp barrier
        auto t1 = omp_get_wtime();
        for (auto r = 0; r < repeat; r++)
            fill1(v);
#pragma omp barrier
        auto t2 = omp_get_wtime();
#pragma omp master
        std::cout << data_size << ", " << nthreads << ", " << write_size / (t1 - t0) << ", "
                  << write_size / (t2 - t1) << "\n";
    }
}

int main(int argc, const char* argv[]) {
    std::cout << "size,nthreads,fill0,fill1\n";
    for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
        bench(bytes, 1);
    }
    for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
        bench(bytes, omp_get_max_threads());
    }
    for (int nthreads = 1; nthreads <= omp_get_max_threads(); nthreads++) {
        bench(max_data_size, nthreads);
    }
}

Résultats présentés compilés avec g++ fillbench.cpp -O3 -o fillbench_gcc -fopenmp.

65
Zulan

À partir de votre question + l'asm généré par le compilateur à partir de votre réponse:

  • fill(0) est un ERMSB rep stosb qui utilisera 256b magasins dans une boucle microcodée optimisée. (Fonctionne mieux si le tampon est aligné, probablement à au moins 32B ou peut-être 64B).
  • fill(1) est une simple boucle de stockage vectoriel movaps de 128 bits. Un seul magasin peut exécuter par cycle d'horloge de base quelle que soit la largeur, jusqu'à 256b AVX. Les magasins 128b ne peuvent donc remplir que la moitié de la bande passante d'écriture du cache L1D de Haswell. C'est pourquoi fill(0) est environ 2x plus rapide pour les tampons jusqu'à ~ 32 ko. Compilez avec -march=haswell Ou -march=native Pour corriger cela .

    Haswell peut à peine suivre la surcharge de la boucle, mais il peut toujours exécuter 1 magasin par horloge même s'il n'est pas déroulé du tout. Mais avec 4 uops de domaine fusionné par horloge, c'est beaucoup de remplissage qui prend de la place dans la fenêtre hors service. Un certain déroulement pourrait peut-être permettre aux échecs TLB de commencer à résoudre plus en avance sur les emplacements des magasins, car il y a plus de débit pour les uops d'adresse de magasin que pour les données de magasin. Le déroulement pourrait aider à compenser le reste de la différence entre ERMSB et cette boucle vectorielle pour les tampons qui tiennent dans L1D. (Un commentaire sur la question indique que -march=native N'a aidé que fill(1) pour L1.)

Notez que rep movsd (Qui pourrait être utilisé pour implémenter fill(1) pour int éléments) aura probablement les mêmes performances que rep stosb Sur Haswell. Bien que seule la documentation officielle garantisse uniquement que ERMSB donne rapidement rep stosb (Mais pas rep stosd), les CPU réels qui prennent en charge ERMSB utilisent un microcode similaire pour rep stosd =. Il y a un doute sur IvyBridge, où peut-être que b est rapide. Voir l'excellent @ BeeOnRope réponse ERMSB pour les mises à jour à ce sujet.

gcc a quelques options de réglage x86 pour les opérations de chaîne ( comme -mstringop-strategy= alg et -mmemset-strategy=strategy ), mais IDK si l'un d'entre eux le fera obtenir qu'il émette réellement rep movsd pour fill(1). Probablement pas, car je suppose que le code commence comme une boucle, plutôt que comme un memset.


Avec plus d'un thread, à 4 GiB taille des données, fill (1) affiche une pente plus élevée, mais atteint un pic beaucoup plus faible que fill (0) (51 Gio/s vs 90 Gio/s):

Un magasin movaps normal sur une ligne de cache froid déclenche un Read For Ownership (RFO). Une grande partie de la bande passante DRAM réelle est consacrée à la lecture des lignes de cache de la mémoire lorsque movaps écrit les 16 premiers octets. Les magasins ERMSB utilisent un protocole sans RFO pour ses magasins, de sorte que les contrôleurs de mémoire n'écrivent que. (Sauf pour les lectures diverses, comme les tables de pages si des sauts de page manquent même dans le cache L3, et peut-être quelques manquements de charge dans les gestionnaires d'interruption ou autre).

@BeeOnRope explique dans les commentaires que la différence entre les magasins RFO réguliers et le protocole d'évitement RFO utilisé par ERMSB a des inconvénients pour certaines plages de tailles de tampon sur les processeurs du serveur où il y a une latence élevée dans le cache uncore/L3. Voir également la réponse ERMSB liée pour en savoir plus sur RFO vs non-RFO, et la latence élevée de l'uncore (L3/mémoire) dans les processeurs Intel à plusieurs cœurs étant un problème pour la bande passante à cœur unique .


movntps (_mm_stream_ps()) les magasins sont faiblement ordonnés, donc ils peuvent contourner le cache et aller directement en mémoire une ligne de cache entière à la fois sans jamais lire la ligne de cache dans L1D. movntps évite les RFO, comme le fait rep stos. (Les magasins rep stos Peuvent se réorganiser les uns avec les autres, mais pas en dehors des limites de l'instruction.)

Vos résultats movntps dans votre réponse mise à jour sont surprenants.
Pour un thread unique avec de gros tampons, vos résultats sont movnt >> RFO régulier> ERMSB . C'est donc vraiment bizarre que les deux méthodes non-RFO soient sur les côtés opposés des anciens magasins ordinaires, et que ERMSB est loin d'être optimal. Je n'ai actuellement aucune explication à cela. (modifications bienvenues avec explication + bonne preuve).

Comme nous nous y attendions, movnt permet à plusieurs threads d'atteindre une bande passante de stockage globale élevée, comme ERMSB. movnt va toujours directement dans les tampons de remplissage de ligne, puis dans la mémoire, il est donc beaucoup plus lent pour les tailles de tampon qui tiennent dans le cache. Un vecteur 128b par horloge suffit pour saturer facilement la bande passante sans RFO d'un seul cœur en DRAM. vmovntps ymm (256b) n'est probablement qu'un avantage mesurable par rapport à vmovntps xmm (128b) lors du stockage des résultats d'un calcul vectorisé AVX 256b lié au processeur (c'est-à-dire uniquement lorsqu'il évite la tâche de décompresser 128b).

La bande passante movnti est faible car le stockage dans des blocs 4B goulots d'étranglement sur 1 magasin uop par horloge ajoute des données aux tampons de remplissage de ligne, pas sur l'envoi de ces tampons de ligne complète à la DRAM (jusqu'à ce que vous ayez suffisamment de threads pour saturer la bande passante mémoire) .


@osgx a posté quelques liens intéressants dans les commentaires :

Voir aussi d'autres choses dans le wiki de la balise x86 .

39
Peter Cordes

Je partagerai mes conclusions préliminaires , dans l'espoir d'encourager des réponses plus détaillées . Je pensais simplement que cela ferait trop partie de la question elle-même.

Le compilateur optimisefill(0) vers un memset interne. Il ne peut pas faire de même pour fill(1), puisque memset ne fonctionne que sur les octets.

Plus précisément, les deux glibcs ​​__memset_avx2 Et __intel_avx_rep_memset Sont implémentés avec une seule instruction à chaud:

rep    stos %al,%es:(%rdi)

Où la boucle manuelle se compile en une instruction réelle de 128 bits:

add    $0x1,%rax                                                                                                       
add    $0x10,%rdx                                                                                                      
movaps %xmm0,-0x10(%rdx)                                                                                               
cmp    %rax,%r8                                                                                                        
ja     400f41

Fait intéressant, il existe une optimisation de modèle/en-tête pour implémenter std::fill Via memset pour les types d'octets, mais dans ce cas, il s'agit d'une optimisation du compilateur pour transformer la boucle réelle. Étrangement, pour un std::vector<char>, Gcc commence également à optimiser fill(1). Le compilateur Intel ne fonctionne pas, malgré la spécification du modèle memset.

Comme cela ne se produit que lorsque le code fonctionne réellement en mémoire plutôt qu'en cache, il semble que l'architecture Haswell-EP ne parvienne pas à consolider efficacement les écritures à un octet.

J'apprécierais tout autre aperçu du problème et des détails de la micro-architecture associés. En particulier, il n'est pas clair pour moi pourquoi cela se comporte si différemment pour quatre threads ou plus et pourquoi memset est tellement plus rapide dans le cache.

Mise à jour:

Voici un résultat en comparaison avec

  • fill (1) qui utilise -march=native (avx2 vmovdq %ymm0) - cela fonctionne mieux en L1, mais similaire à la version movaps %xmm0 pour les autres niveaux de mémoire.
  • Variantes de mémoires non temporelles 32, 128 et 256 bits. Ils fonctionnent de manière cohérente avec les mêmes performances quelle que soit la taille des données. Tous surpassent les autres variantes en mémoire, en particulier pour un petit nombre de threads. 128 bits et 256 bits fonctionnent exactement de la même manière, pour un faible nombre de threads, 32 bits sont nettement moins performants.

Pour <= 6 thread, vmovnt a un avantage 2x sur rep stos lors de l'utilisation en mémoire.

Bande passante à thread unique:

single threaded performance by data size

Bande passante agrégée en mémoire:

memory performance by thread count

Voici le code utilisé pour les tests supplémentaires avec leurs hot-boucles respectives:

void __attribute__ ((noinline)) fill1(vector& v) {
    std::fill(v.begin(), v.end(), 1);
}
┌─→add    $0x1,%rax
│  vmovdq %ymm0,(%rdx)
│  add    $0x20,%rdx
│  cmp    %rdi,%rax
└──jb     e0


void __attribute__ ((noinline)) fill1_nt_si32(vector& v) {
    for (auto& elem : v) {
       _mm_stream_si32(&elem, 1);
    }
}
┌─→movnti %ecx,(%rax)
│  add    $0x4,%rax
│  cmp    %rdx,%rax
└──jne    18


void __attribute__ ((noinline)) fill1_nt_si128(vector& v) {
    assert((long)v.data() % 32 == 0); // alignment
    const __m128i buf = _mm_set1_epi32(1);
    size_t i;
    int* data;
    int* end4 = &v[v.size() - (v.size() % 4)];
    int* end = &v[v.size()];
    for (data = v.data(); data < end4; data += 4) {
        _mm_stream_si128((__m128i*)data, buf);
    }
    for (; data < end; data++) {
        *data = 1;
    }
}
┌─→vmovnt %xmm0,(%rdx)
│  add    $0x10,%rdx
│  cmp    %rcx,%rdx
└──jb     40


void __attribute__ ((noinline)) fill1_nt_si256(vector& v) {
    assert((long)v.data() % 32 == 0); // alignment
    const __m256i buf = _mm256_set1_epi32(1);
    size_t i;
    int* data;
    int* end8 = &v[v.size() - (v.size() % 8)];
    int* end = &v[v.size()];
    for (data = v.data(); data < end8; data += 8) {
        _mm256_stream_si256((__m256i*)data, buf);
    }
    for (; data < end; data++) {
        *data = 1;
    }
}
┌─→vmovnt %ymm0,(%rdx)
│  add    $0x20,%rdx
│  cmp    %rcx,%rdx
└──jb     40

Remarque: J'ai dû faire un calcul manuel du pointeur afin d'obtenir des boucles si compactes. Sinon, il ferait une indexation vectorielle dans la boucle, probablement en raison de la confusion intrinsèque de l'optimiseur.

29
Zulan