web-dev-qa-db-fra.com

boost :: flat_map et ses performances comparées à map et unordered_map

Il est de notoriété publique dans la programmation que la localisation de la mémoire améliore considérablement les performances en raison des accès au cache. J'ai récemment découvert l'existence de boost::flat_map qui est une implémentation vectorielle d’une carte. Il ne semble pas être aussi populaire que votre typique map/unordered_map donc je n'ai pas pu trouver de comparaison de performances. Comment se compare-t-il et quels sont les meilleurs cas d'utilisation?

Merci!

96
naumcho

J'ai récemment effectué un test de performance sur différentes structures de données dans mon entreprise et j'ai donc le sentiment que je dois laisser tomber un mot. Il est très compliqué d'analyser quelque chose correctement.

Benchmarking

Sur le Web, nous trouvons rarement (voire jamais) une référence bien conçue. Jusqu'à aujourd'hui, je ne trouvais que des repères réalisés à la manière des journalistes (assez rapidement et balayant des dizaines de variables sous le tapis).

1) Vous devez prendre en compte le réchauffement du cache

La plupart des utilisateurs de benchmarks craignent les écarts de temps. Ils exécutent leur travail des milliers de fois et prennent tout le temps. Ils prennent juste soin de prendre les mêmes milliers de fois pour chaque opération, puis considèrent que cela est comparable.

La vérité est que, dans le monde réel, cela n’a aucun sens, car votre mémoire cache ne sera pas chaude et votre opération sera appelée une seule fois. Par conséquent, vous devez analyser en utilisant RDTSC, et ne pas les appeler une seule fois. Intel a fait un papier décrivant comment utiliser RDTSC (en utilisant une instruction cpuid pour vider le pipeline et en l'appelant au moins 3 fois au début du programme pour le stabiliser).

2) mesure de la précision RDTSC

Je recommande également de faire ceci:

u64 g_correctionFactor;  // number of clocks to offset after each measurement to remove the overhead of the measurer itself.
u64 g_accuracy;

static u64 const errormeasure = ~((u64)0);

#ifdef _MSC_VER
#pragma intrinsic(__rdtsc)
inline u64 GetRDTSC()
{
    int a[4];
    __cpuid(a, 0x80000000);  // flush OOO instruction pipeline
    return __rdtsc();
}

inline void WarmupRDTSC()
{
    int a[4];
    __cpuid(a, 0x80000000);  // warmup cpuid.
    __cpuid(a, 0x80000000);
    __cpuid(a, 0x80000000);

    // measure the measurer overhead with the measurer (crazy he..)
    u64 minDiff = LLONG_MAX;
    u64 maxDiff = 0;   // this is going to help calculate our PRECISION ERROR MARGIN
    for (int i = 0; i < 80; ++i)
    {
        u64 tick1 = GetRDTSC();
        u64 tick2 = GetRDTSC();
        minDiff = Aska::Min(minDiff, tick2 - tick1);   // make many takes, take the smallest that ever come.
        maxDiff = Aska::Max(maxDiff, tick2 - tick1);
    }
    g_correctionFactor = minDiff;

    printf("Correction factor %llu clocks\n", g_correctionFactor);

    g_accuracy = maxDiff - minDiff;
    printf("Measurement Accuracy (in clocks) : %llu\n", g_accuracy);
}
#endif

Il s'agit d'un mesureur de divergence, et il faudra le minimum de toutes les valeurs mesurées pour éviter d'obtenir de temps à autre un -10 ** 18 (premières valeurs négatives de 64 bits).

Notez l'utilisation de composants intrinsèques et non pas l'assemblage en ligne. Le premier assemblage en ligne est rarement pris en charge par les compilateurs de nos jours, mais pire encore, le compilateur crée une barrière d'ordonnancement complète autour de l'assemblage en ligne car il ne peut pas analyser de manière statique l'intérieur. une fois que. Donc, un élément intrinsèque convient ici, car il ne rompt pas le réordonnancement des instructions du compilateur.

3) paramètres

Le dernier problème est que les gens testent généralement trop peu de variations du scénario. Une performance de conteneur est affectée par:

  1. Répartiteur
  2. taille du type contenu
  3. coût de mise en oeuvre d'une opération de copie, d'une opération d'affectation, d'une opération de déplacement, d'une opération de construction, du type contenu.
  4. nombre d'éléments dans le conteneur (taille du problème)
  5. le type a 3. opérations triviales
  6. le type est POD

Le point 1 est important car les conteneurs allouent de temps en temps et il est très important qu’ils allouent en utilisant le CRT "nouveau" ou une opération définie par l’utilisateur, comme une allocation de pool, une liste libre ou autre ...

( pour les personnes intéressées par le point 1, rejoignez le fil de discussion mystère sur gamedev à propos de l'impact de la performance de l'allocateur système)

Le point 2 est dû au fait que certains conteneurs (disons A) perdront du temps à copier des éléments, et que plus le type est grand, plus le temps système est important. Le problème est que lorsqu'on compare à un autre conteneur B, A peut l'emporter sur B pour les petits types et perdre pour les plus grands.

Le point 3 est identique au point 2, sauf qu'il multiplie le coût par un facteur de pondération.

Le point 4 est une question de gros O mélangée à des problèmes de cache. Certains conteneurs peu complexes peuvent largement surpasser les conteneurs peu complexes pour un petit nombre de types (comme map par rapport à vector, car leur localisation en cache est bonne, mais map fragmente le Mémoire). Et puis, à un certain point de croisement, ils perdront, car la taille globale confinée commence à "fuir" vers la mémoire principale et à provoquer l’absence de mémoire cache, ce qui ajoute au fait que la complexité asymptotique peut commencer à se faire sentir.

Le point 5 concerne les compilateurs capables d'éliminer des éléments vides ou triviaux au moment de la compilation. Cela permet d’optimiser considérablement certaines opérations, car les conteneurs sont basés sur un modèle. Chaque type aura donc son propre profil de performances.

Au point 6, comme au point 5, les POD peuvent tirer parti du fait que la construction de copie est simplement une mémoire, et certains conteneurs peuvent avoir une implémentation spécifique pour ces cas, en utilisant des spécialisations de modèles partielles, ou SFINAE pour sélectionner des algorithmes en fonction des traits de T.

A propos de la carte plate

Apparemment, la carte plate est un wrapper vectoriel trié, comme Loki AssocVector, mais avec certaines modernisations supplémentaires venant avec C++ 11, exploitant la sémantique du déplacement pour accélérer l'insertion et la suppression d'éléments individuels.

Ceci est toujours un conteneur commandé. La plupart des gens n'ont généralement pas besoin de la partie commande, d'où l'existence de unordered...

Avez-vous pensé que vous avez peut-être besoin d'un flat_unorderedmap? ce qui serait quelque chose comme google::sparse_map ou quelque chose comme ça - une carte de hachage d'adresse ouverte.

Le problème des cartes de hachage d'adresses ouvertes est qu'au moment de rehash elles doivent tout copier dans le nouveau terrain plat étendu, alors qu'une carte non ordonnée standard doit simplement recréer l'index de hachage, tandis que les données allouées restent où est-ce que c'est. L'inconvénient est bien sûr que la mémoire est fragmentée comme l'enfer.

Le critère d'un rehash dans une mappe de hachage d'adresse ouverte est lorsque la capacité dépasse la taille du vecteur de compartiment multiplié par le facteur de charge.

Un facteur de charge typique est 0.8; par conséquent, vous devez vous préoccuper de cela, si vous pouvez pré-dimensionner votre carte de hachage avant de la remplir, toujours pré-dimensionner à: intended_filling * (1/0.8) + epsilon, cela vous garantira de ne jamais avoir à réorganiser et tout recopier pendant le remplissage.

L'avantage des cartes d'adresses fermées (std::unordered..) Est que vous n'avez pas à vous soucier de ces paramètres.

Mais le boost::flat_map Est un vecteur ordonné; par conséquent, il aura toujours une complexité asymptotique de log (N), qui est moins bonne que la table de hachage d'adresse ouverte (temps constant amorti). Vous devriez considérer cela aussi.

Résultats de référence

Il s'agit d'un test impliquant différentes cartes (avec la clé int et la valeur __int64/somestruct) et std::vector.

informations sur les types testés:

typeid=__int64 .  sizeof=8 . ispod=yes
typeid=struct MediumTypePod .  sizeof=184 . ispod=yes

Insertion

EDIT:

Mes résultats précédents incluaient un bogue: ils avaient en fait testé l'insertion ordonnée, qui affichait un comportement très rapide pour les cartes à plat.
J'ai laissé ces résultats plus tard en bas de cette page car ils sont intéressants.
Ceci est le test correct: enter image description here

enter image description here

J'ai vérifié la mise en œuvre, il n'y a pas de tri différé implémenté ici. Chaque insertion trie à la volée, donc ce repère présente les tendances asymptotiques:

carte: O (N * log (N))
hashmaps: O (N)
vecteur et flatmaps: O (N * N)

Avertissement : les deux tests pour std::map Et les deux flat_map Sont buggy et effectivement tester insertion ordonnée (vs insertion aléatoire pour d’autres conteneurs. Oui, désolant, désolé):
random insert of 100 elements without reservation

Nous pouvons voir que l’insertion ordonnée entraîne une poussée arrière et est extrêmement rapide. Cependant, à partir des résultats non cartographiés de mon benchmark, je peux également dire que cela n’est pas proche de l’optimalité absolue pour une insertion arrière. Pour 10k éléments, l’optimalité parfaite de la rétro-insertion est obtenue sur un vecteur pré-réservé. Ce qui nous donne 3 millions de cycles; on observe ici 4,8M pour l’insertion ordonnée dans le flat_map (soit 160% de l’optimum).

random insert of 10000 elements without reservation Analyse: rappelez-vous qu’il s’agit d’une "insertion aléatoire" pour le vecteur. Ainsi, un milliard de cycles provient du fait qu’il faut déplacer la moitié (en moyenne) des données vers le haut (un élément sur un élément) à chaque insertion.

Recherche aléatoire de 3 éléments (horloges renormalisées à 1)

en taille = 100

Rand search within container of 100 elements

en taille = 10000

Rand search within container of 10000 elements

Itération

plus de taille 100 (seulement type MediumPod)

Iteration over 100 medium pods

plus de taille 10000 (type MediumPod seulement)

Iteration over 10000 medium pods

grain de sel final

Finalement, je voulais revenir sur "Benchmarking §3 Pt1" (l'allocateur système). Dans une expérience récente que je fais autour de la performance de ne carte de hachage d'adresse ouverte que j'ai développée , j'ai mesuré un écart de performance de plus de 3000% entre Windows 7 et Windows 8 sur quelque std::unordered_map cas d'utilisation ( discuté ici ).
Ce qui me donne envie d'avertir le lecteur des résultats ci-dessus (ils ont été réalisés sous Win7): votre kilométrage peut varier.

meilleures salutations

175
v.oddou

D'après les documents, cela semble analogue à Loki::AssocVector dont je suis un utilisateur assez important. Comme il est basé sur un vecteur, il a les caractéristiques d’un vecteur, c’est-à-dire:

  • Les itérateurs sont invalidés à chaque fois que size dépasse capacity.
  • Quand il croît au-delà de capacity, il doit réaffecter et déplacer des objets, c’est-à-dire que l’insertion n’est pas garantie dans le temps, sauf dans le cas particulier de l’insertion à end lorsque capacity > size
  • La recherche est plus rapide que std::map en raison de la localisation du cache, recherche binaire présentant les mêmes caractéristiques de performance que std::map autrement
  • Utilise moins de mémoire car ce n'est pas un arbre binaire lié
  • Il ne diminue jamais à moins que vous le lui demandiez de force (car cela déclenche une réallocation)

La meilleure utilisation est lorsque vous connaissez le nombre d'éléments à l'avance (vous pouvez donc reserve au début), ou lorsque l'insertion/la suppression est rare mais que la recherche est fréquente. L’invalidation d’Itérateur rend la tâche un peu lourde dans certains cas d’utilisation et n’est donc pas interchangeable en termes d’exactitude de programme.

6
Ylisar