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!
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.
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:
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.
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.
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:
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é):
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).
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
en taille = 10000
Itération
plus de taille 100 (seulement type MediumPod)
plus de taille 10000 (type MediumPod seulement)
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
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:
size
dépasse capacity
.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
std::map
en raison de la localisation du cache, recherche binaire présentant les mêmes caractéristiques de performance que std::map
autrementLa 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.