web-dev-qa-db-fra.com

L'implémentation de gcc std :: unordered_map est-elle lente? Si oui, pourquoi?

Nous développons un logiciel hautement performant en C++. Là, nous avons besoin d'une carte de hachage simultanée et implémentée. Nous avons donc écrit un point de référence pour comprendre, à quel point notre carte de hachage simultanée est plus lente que std::unordered_map.

Mais, std::unordered_map semble être incroyablement lent ... Il s'agit donc de notre micro-benchmark (pour la carte simultanée, nous avons généré un nouveau thread pour nous assurer que le verrouillage ne soit pas optimisé et notez que je n'insère jamais 0 car je compare également avec google::dense_hash_map, qui a besoin d'une valeur nulle):

boost::random::mt19937 rng;
boost::random::uniform_int_distribution<> dist(std::numeric_limits<uint64_t>::min(), std::numeric_limits<uint64_t>::max());
std::vector<uint64_t> vec(SIZE);
for (int i = 0; i < SIZE; ++i) {
    uint64_t val = 0;
    while (val == 0) {
        val = dist(rng);
    }
    vec[i] = val;
}
std::unordered_map<int, long double> map;
auto begin = std::chrono::high_resolution_clock::now();
for (int i = 0; i < SIZE; ++i) {
    map[vec[i]] = 0.0;
}
auto end = std::chrono::high_resolution_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - begin);
std::cout << "inserts: " << elapsed.count() << std::endl;
std::random_shuffle(vec.begin(), vec.end());
begin = std::chrono::high_resolution_clock::now();
long double val;
for (int i = 0; i < SIZE; ++i) {
    val = map[vec[i]];
}
end = std::chrono::high_resolution_clock::now();
elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - begin);
std::cout << "get: " << elapsed.count() << std::endl;

(EDIT: le code source complet peut être trouvé ici: http://Pastebin.com/vPqf7eya )

Le résultat pour std::unordered_map est:

inserts: 35126
get    : 2959

Pour google::dense_map:

inserts: 3653
get    : 816

Pour notre carte simultanée sauvegardée à la main (qui verrouille, bien que le benchmark soit à thread unique - mais dans un thread d'apparition séparé):

inserts: 5213
get    : 2594

Si je compile le programme de référence sans prise en charge de pthread et exécute tout dans le thread principal, j'obtiens les résultats suivants pour notre carte simultanée sauvegardée à la main:

inserts: 4441
get    : 1180

Je compile avec la commande suivante:

g++-4.7 -O3 -DNDEBUG -I/tmp/benchmap/sparsehash-2.0.2/src/ -std=c++11 -pthread main.cc

Insère donc surtout sur std::unordered_map semble être extrêmement coûteux - 35 secondes contre 3 à 5 secondes pour les autres cartes. Le temps de recherche semble également assez élevé.

Ma question: pourquoi est-ce? J'ai lu une autre question sur stackoverflow où quelqu'un demande, pourquoi std::tr1::unordered_map est plus lent que sa propre implémentation. Là, la réponse la mieux notée indique que le std::tr1::unordered_map doit implémenter une interface plus compliquée. Mais je ne vois pas cet argument: nous utilisons une approche bucket dans notre concurrent_map, std::unordered_map utilise également une approche par compartiment (google::dense_hash_map non, mais que std::unordered_map devrait être au moins aussi rapide que notre version sécurisée avec accès simultané?). En dehors de cela, je ne vois rien dans l'interface qui force une fonctionnalité qui fait mal fonctionner la carte de hachage ...

Donc ma question: est-il vrai que std::unordered_map semble être très lent? Si non: qu'est-ce qui ne va pas? Si oui: quelle en est la raison.

Et ma principale question: pourquoi insérer une valeur dans un std::unordered_map tellement terrible cher (même si on réserve assez d'espace au début, ça ne marche pas beaucoup mieux - donc le ressassement ne semble pas être le problème)?

MODIFIER:

Tout d'abord: oui, le benchmark présenté n'est pas parfait - c'est parce que nous avons beaucoup joué avec et c'est juste un hack (par exemple le uint64 la distribution pour générer des entiers ne serait en pratique pas une bonne idée, exclure 0 dans une boucle est assez stupide etc ...).

Pour le moment, la plupart des commentaires expliquent que je peux rendre la carte unordered_map plus rapide en préallouant suffisamment d'espace pour cela. Dans notre application, cela n'est tout simplement pas possible: nous développons un système de gestion de base de données et avons besoin d'une carte de hachage pour stocker certaines données lors d'une transaction (par exemple des informations de verrouillage). Ainsi, cette carte peut aller de 1 (l'utilisateur ne fait qu'une insertion et valide) à des milliards d'entrées (si des analyses de table complètes se produisent). Il est tout simplement impossible de préallouer suffisamment d'espace ici (et simplement en allouer beaucoup au début consommera trop de mémoire).

De plus, je m'excuse, je n'ai pas formulé ma question assez clairement: je ne suis pas vraiment intéressé à rendre unordered_map rapide (l'utilisation de googles dense hash map fonctionne très bien pour nous), je ne comprends pas vraiment d'où viennent ces énormes différences de performances . Il ne peut pas s'agir d'une simple préallocation (même avec suffisamment de mémoire préallouée, la carte dense est un ordre de grandeur plus rapide que unordered_map, notre carte simultanée sauvegardée à la main commence par un tableau de taille 64 - donc plus petite que unordered_map).

Alors, quelle est la raison de cette mauvaise performance de std::unordered_map? Ou autrement demandé: pourrait-on écrire une implémentation du std::unordered_map interface qui est conforme au standard et (presque) aussi rapide que la carte de hachage dense de Google? Ou y a-t-il quelque chose dans la norme qui oblige l'implémenteur à choisir une manière inefficace de l'implémenter?

EDIT 2:

Par le profilage, je vois que beaucoup de temps est utilisé pour les divisions entières. std::unordered_map utilise des nombres premiers pour la taille du tableau, tandis que les autres implémentations utilisent des puissances de deux. Pourquoi std::unordered_map utilise des nombres premiers? Pour mieux performer si le hachage est mauvais? Pour de bons hachages, cela ne fait aucune différence.

EDIT 3:

Ce sont les chiffres pour std::map:

inserts: 16462
get    : 16978

Sooooooo: pourquoi les insertions dans un std::map plus rapide que l'insertion dans un std::unordered_map... Je veux dire WAT? std::map a une localité pire (arbre vs tableau), doit faire plus d'allocations (par insert vs par rehash + plus ~ 1 pour chaque collision) et, plus important: a une autre complexité algorithmique (O (logn) vs O (1) )!

99
Markus Pilman

J'ai trouvé la raison: c'est un problème de gcc-4.7 !!

Avec gcc-4.7

inserts: 37728
get    : 2985

Avec gcc-4.6

inserts: 2531
get    : 1565

Alors std::unordered_map dans gcc-4.7 est cassé (ou mon installation, qui est une installation de gcc-4.7.0 sur Ubuntu - et une autre installation qui est gcc 4.7.1 sur les tests Debian).

Je soumettrai un rapport de bogue .. jusque-là: NE PAS utiliser std::unordered_map avec gcc 4.7!

85
Markus Pilman

Je suppose que vous n'avez pas correctement dimensionné votre unordered_map, Comme l'a suggéré Ylisar. Lorsque les chaînes se développent trop longtemps dans unordered_map, L'implémentation de g ++ se remaniera automatiquement vers une table de hachage plus grande, et ce serait un gros frein aux performances. Si je me souviens bien, unordered_map Est par défaut (le plus petit plus grand que) 100.

Je n'avais pas chrono sur mon système, j'ai donc chronométré avec times().

template <typename TEST>
void time_test (TEST t, const char *m) {
    struct tms start;
    struct tms finish;
    long ticks_per_second;

    times(&start);
    t();
    times(&finish);
    ticks_per_second = sysconf(_SC_CLK_TCK);
    std::cout << "elapsed: "
              << ((finish.tms_utime - start.tms_utime
                   + finish.tms_stime - start.tms_stime)
                  / (1.0 * ticks_per_second))
              << " " << m << std::endl;
}

J'ai utilisé un SIZE de 10000000, Et j'ai dû changer un peu les choses pour ma version de boost. Notez également que j'ai pré-dimensionné la table de hachage pour qu'elle corresponde à SIZE/DEPTH, Où DEPTH est une estimation de la longueur de la chaîne de compartiment en raison des collisions de hachage.

Edit: Howard me fait remarquer dans les commentaires que le facteur de charge maximum pour unordered_map Est 1. Ainsi, le DEPTH contrôle le nombre de fois que le code va ressasser.

#define SIZE 10000000
#define DEPTH 3
std::vector<uint64_t> vec(SIZE);
boost::mt19937 rng;
boost::uniform_int<uint64_t> dist(std::numeric_limits<uint64_t>::min(),
                                  std::numeric_limits<uint64_t>::max());
std::unordered_map<int, long double> map(SIZE/DEPTH);

void
test_insert () {
    for (int i = 0; i < SIZE; ++i) {
        map[vec[i]] = 0.0;
    }
}

void
test_get () {
    long double val;
    for (int i = 0; i < SIZE; ++i) {
        val = map[vec[i]];
    }
}

int main () {
    for (int i = 0; i < SIZE; ++i) {
        uint64_t val = 0;
        while (val == 0) {
            val = dist(rng);
        }
        vec[i] = val;
    }
    time_test(test_insert, "inserts");
    std::random_shuffle(vec.begin(), vec.end());
    time_test(test_insert, "get");
}

Modifier:

J'ai modifié le code afin de pouvoir changer DEPTH plus facilement.

#ifndef DEPTH
#define DEPTH 10000000
#endif

Ainsi, par défaut, la pire taille pour la table de hachage est choisie.

elapsed: 7.12 inserts, elapsed: 2.32 get, -DDEPTH=10000000
elapsed: 6.99 inserts, elapsed: 2.58 get, -DDEPTH=1000000
elapsed: 8.94 inserts, elapsed: 2.18 get, -DDEPTH=100000
elapsed: 5.23 inserts, elapsed: 2.41 get, -DDEPTH=10000
elapsed: 5.35 inserts, elapsed: 2.55 get, -DDEPTH=1000
elapsed: 6.29 inserts, elapsed: 2.05 get, -DDEPTH=100
elapsed: 6.76 inserts, elapsed: 2.03 get, -DDEPTH=10
elapsed: 2.86 inserts, elapsed: 2.29 get, -DDEPTH=1

Ma conclusion est qu'il n'y a pas beaucoup de différence de performance significative pour une taille de table de hachage initiale autre que de la rendre égale au nombre total prévu d'insertions uniques. De plus, je ne vois pas la différence de performance de l'ordre de grandeur que vous observez.

21
jxh

J'ai exécuté votre code à l'aide d'un ordinateur 64 bits/AMD/4 cœurs (2,1 GHz) et cela m'a donné les résultats suivants:

MinGW-W64 4.9.2:

Utilisation de std :: unordered_map:

inserts: 9280 
get: 3302

Utilisation de std :: map:

inserts: 23946
get: 24824

VC 2015 avec tous les drapeaux d'optimisation que je connais:

Utilisation de std :: unordered_map:

inserts: 7289
get: 1908

Utilisation de std :: map:

inserts: 19222 
get: 19711

Je n'ai pas testé le code en utilisant GCC mais je pense qu'il peut être comparable aux performances de VC, donc si c'est vrai, alors GCC 4.9 std :: unordered_map il est toujours cassé.

[MODIFIER]

Alors oui, comme quelqu'un l'a dit dans les commentaires, il n'y a aucune raison de penser que les performances de GCC 4.9.x seraient comparables à VC performance. Quand j'aurai le changement, je testerai le code sur GCC.

Ma réponse est simplement d'établir une sorte de base de connaissances pour d'autres réponses.

3
Christian Leon