Bien sûr, les performances de recherche d'un unordered_map sont constantes en moyenne, et les performances de recherche d'une carte sont O (logN).
Mais bien sûr, pour trouver un objet dans une carte non ordonnée, nous devons:
Alors que dans une carte, nous avons simplement besoin de moins_ que de comparer la clé recherchée avec les clés log2 (N), où N est le nombre d'éléments dans la carte.
Je me suis demandé quelle serait la véritable différence de performances, étant donné que la fonction de hachage ajoute des frais généraux et qu'une égalité_compare n'est pas moins chère qu'une comparaison less_than.
Plutôt que de déranger la communauté avec une question à laquelle je pouvais répondre moi-même, j'ai écrit un test.
J'ai partagé les résultats ci-dessous, au cas où quelqu'un d'autre trouverait cela intéressant ou utile.
Plus de réponses sont bien sûr invitées si quelqu'un est capable et disposé à ajouter plus d'informations.
En réponse aux questions sur les performances par rapport au nombre de recherches manquées, j'ai refactorisé le test pour paramétrer cela.
Exemples de résultats:
searches=1000000 set_size= 0 miss= 100% ordered= 4384 unordered= 12901 flat_map= 681
searches=1000000 set_size= 99 miss= 99.99% ordered= 89127 unordered= 42615 flat_map= 86091
searches=1000000 set_size= 172 miss= 99.98% ordered= 101283 unordered= 53468 flat_map= 96008
searches=1000000 set_size= 303 miss= 99.97% ordered= 112747 unordered= 53211 flat_map= 107343
searches=1000000 set_size= 396 miss= 99.96% ordered= 124179 unordered= 59655 flat_map= 112687
searches=1000000 set_size= 523 miss= 99.95% ordered= 132180 unordered= 51133 flat_map= 121669
searches=1000000 set_size= 599 miss= 99.94% ordered= 135850 unordered= 55078 flat_map= 121072
searches=1000000 set_size= 695 miss= 99.93% ordered= 140204 unordered= 60087 flat_map= 124961
searches=1000000 set_size= 795 miss= 99.92% ordered= 146071 unordered= 64790 flat_map= 127873
searches=1000000 set_size= 916 miss= 99.91% ordered= 154461 unordered= 50944 flat_map= 133194
searches=1000000 set_size= 988 miss= 99.9% ordered= 156327 unordered= 54094 flat_map= 134288
Clé:
searches = number of searches performed against each map
set_size = how big each map is (and therefore how many of the searches will result in a hit)
miss = the probability of generating a missed search. Used for generating searches and set_size.
ordered = the time spent searching the ordered map
unordered = the time spent searching the unordered_map
flat_map = the time spent searching the flat map
note: time is measured in std::system_clock::duration ticks.
TL; DR
Résultats: le unordered_map montre sa supériorité dès qu'il y a des données dans la carte. La seule fois où elle présente des performances moins bonnes que la carte commandée, c'est lorsque les cartes sont vides.
Voici le nouveau code:
#include <iostream>
#include <iomanip>
#include <random>
#include <algorithm>
#include <string>
#include <vector>
#include <map>
#include <unordered_map>
#include <unordered_set>
#include <chrono>
#include <Tuple>
#include <future>
#include <stdexcept>
#include <sstream>
using namespace std;
// this sets the length of the string we will be using as a key.
// modify this to test whether key complexity changes the performance ratios
// of the various maps
static const size_t key_length = 20;
// the number of keys we will generate (the size of the test)
const size_t nkeys = 1000000;
// use a virtual method to prevent the optimiser from detecting that
// our sink function actually does nothing. otherwise it might skew the test
struct string_user
{
virtual void sink(const std::string&) = 0;
virtual ~string_user() = default;
};
struct real_string_user : string_user
{
virtual void sink(const std::string&) override
{
}
};
struct real_string_user_print : string_user
{
virtual void sink(const std::string& s) override
{
cout << s << endl;
}
};
// generate a sink from a string - this is a runtime operation and therefore
// prevents the optimiser from realising that the sink does nothing
std::unique_ptr<string_user> make_sink(const std::string& name)
{
if (name == "print")
{
return make_unique<real_string_user_print>();
}
if (name == "noprint")
{
return make_unique<real_string_user>();
}
throw logic_error(name);
}
// generate a random key, given a random engine and a distribution
auto gen_string = [](auto& engine, auto& dist)
{
std::string result(key_length, ' ');
generate(begin(result), end(result), [&] {
return dist(engine);
});
return result;
};
// comparison predicate for our flat map.
struct pair_less
{
bool operator()(const pair<string, string>& l, const string& r) const {
return l.first < r;
}
bool operator()(const string& l, const pair<string, string>& r) const {
return l < r.first;
}
};
template<class F>
auto time_test(F&& f, const vector<string> keys)
{
auto start_time = chrono::system_clock::now();
for (auto const& key : keys)
{
f(key);
}
auto stop_time = chrono::system_clock::now();
auto diff = stop_time - start_time;
return diff;
}
struct report_key
{
size_t nkeys;
int miss_chance;
};
std::ostream& operator<<(std::ostream& os, const report_key& key)
{
return os << "miss=" << setw(2) << key.miss_chance << "%";
}
void run_test(string_user& sink, size_t nkeys, double miss_prob)
{
// the types of map we will test
unordered_map<string, string> unordered;
map<string, string> ordered;
vector<pair<string, string>> flat_map;
// a vector of all keys, which we can shuffle in order to randomise
// access order of all our maps consistently
vector<string> keys;
unordered_set<string> keys_record;
// generate keys
auto eng = std::default_random_engine(std::random_device()());
auto alpha_dist = std::uniform_int_distribution<char>('A', 'Z');
auto prob_dist = std::uniform_real_distribution<double>(0, 1.0 - std::numeric_limits<double>::epsilon());
auto generate_new_key = [&] {
while(true) {
// generate a key
auto key = gen_string(eng, alpha_dist);
// try to store it in the unordered map
// if it already exists, force a regeneration
// otherwise also store it in the ordered map and the flat map
if(keys_record.insert(key).second) {
return key;
}
}
};
for (size_t i = 0 ; i < nkeys ; ++i)
{
bool inserted = false;
auto value = to_string(i);
auto key = generate_new_key();
if (prob_dist(eng) >= miss_prob) {
unordered.emplace(key, value);
flat_map.emplace_back(key, value);
ordered.emplace(key, std::move(value));
}
// record the key for later use
keys.emplace_back(std::move(key));
}
// turn our vector 'flat map' into an actual flat map by sorting it by pair.first. This is the key.
sort(begin(flat_map), end(flat_map),
[](const auto& l, const auto& r) { return l.first < r.first; });
// shuffle the keys to randomise access order
shuffle(begin(keys), end(keys), eng);
auto unordered_lookup = [&](auto& key) {
auto i = unordered.find(key);
if (i != end(unordered)) {
sink.sink(i->second);
}
};
auto ordered_lookup = [&](auto& key) {
auto i = ordered.find(key);
if (i != end(ordered)) {
sink.sink(i->second);
}
};
auto flat_map_lookup = [&](auto& key) {
auto i = lower_bound(begin(flat_map),
end(flat_map),
key,
pair_less());
if (i != end(flat_map) && i->first == key) {
sink.sink(i->second);
}
};
// spawn a thread to time access to the unordered map
auto unordered_future = async(launch::async,
[&]()
{
return time_test(unordered_lookup, keys);
});
// spawn a thread to time access to the ordered map
auto ordered_future = async(launch::async, [&]
{
return time_test(ordered_lookup, keys);
});
// spawn a thread to time access to the flat map
auto flat_future = async(launch::async, [&]
{
return time_test(flat_map_lookup, keys);
});
// synchronise all the threads and get the timings
auto ordered_time = ordered_future.get();
auto unordered_time = unordered_future.get();
auto flat_time = flat_future.get();
cout << "searches=" << setw(7) << nkeys;
cout << " set_size=" << setw(7) << unordered.size();
cout << " miss=" << setw(7) << setprecision(6) << miss_prob * 100.0 << "%";
cout << " ordered=" << setw(7) << ordered_time.count();
cout << " unordered=" << setw(7) << unordered_time.count();
cout << " flat_map=" << setw(7) << flat_time.count() << endl;
}
int main()
{
// generate the sink, preventing the optimiser from realising what it
// does.
stringstream ss;
ss << "noprint";
string arg;
ss >> arg;
auto puser = make_sink(arg);
for (double chance = 1.0 ; chance >= 0.0 ; chance -= 0.0001)
{
run_test(*puser, 1000000, chance);
}
return 0;
}
Dans ce test suivant, que j'ai compilé sur Apple clang avec -O3, j'ai pris des mesures pour m'assurer que le test est juste, comme:
appeler une fonction puits avec le résultat de chaque recherche dans une table virtuelle, pour éviter que l'optimiseur n'inclue des recherches entières!
exécuter des tests sur 3 types de cartes différents, contenant les mêmes données, dans le même ordre en parallèle. Cela signifie que si un test commence à `` prendre de l'avance '', il commence à entrer dans le territoire sans cache pour l'ensemble de recherche (voir code). Cela signifie qu'aucun test n'obtient un avantage injuste de rencontrer un cache "chaud".
paramétrer la taille de la clé (et donc la complexité)
paramétré la taille de la carte
testé trois différents types de cartes (contenant les mêmes données) - une carte non ordonnée, une carte et un vecteur trié de paires clé/valeur.
vérifié la sortie de l'assembleur pour s'assurer que l'optimiseur n'a pas été en mesure d'optimiser des morceaux entiers de logique en raison de l'analyse de code mort.
Voici le code:
#include <iostream>
#include <random>
#include <algorithm>
#include <string>
#include <vector>
#include <map>
#include <unordered_map>
#include <chrono>
#include <Tuple>
#include <future>
#include <stdexcept>
#include <sstream>
using namespace std;
// this sets the length of the string we will be using as a key.
// modify this to test whether key complexity changes the performance ratios
// of the various maps
static const size_t key_length = 20;
// the number of keys we will generate (the size of the test)
const size_t nkeys = 1000000;
// the types of map we will test
unordered_map<string, string> unordered;
map<string, string> ordered;
vector<pair<string, string>> flat_map;
// a vector of all keys, which we can shuffle in order to randomise
// access order of all our maps consistently
vector<string> keys;
// use a virtual method to prevent the optimiser from detecting that
// our sink function actually does nothing. otherwise it might skew the test
struct string_user
{
virtual void sink(const std::string&) = 0;
virtual ~string_user() = default;
};
struct real_string_user : string_user
{
virtual void sink(const std::string&) override
{
}
};
struct real_string_user_print : string_user
{
virtual void sink(const std::string& s) override
{
cout << s << endl;
}
};
// generate a sink from a string - this is a runtime operation and therefore
// prevents the optimiser from realising that the sink does nothing
std::unique_ptr<string_user> make_sink(const std::string& name)
{
if (name == "print")
{
return make_unique<real_string_user_print>();
}
if (name == "noprint")
{
return make_unique<real_string_user>();
}
throw logic_error(name);
}
// generate a random key, given a random engine and a distribution
auto gen_string = [](auto& engine, auto& dist)
{
std::string result(key_length, ' ');
generate(begin(result), end(result), [&] {
return dist(engine);
});
return result;
};
// comparison predicate for our flat map.
struct pair_less
{
bool operator()(const pair<string, string>& l, const string& r) const {
return l.first < r;
}
bool operator()(const string& l, const pair<string, string>& r) const {
return l < r.first;
}
};
int main()
{
// generate the sink, preventing the optimiser from realising what it
// does.
stringstream ss;
ss << "noprint";
string arg;
ss >> arg;
auto puser = make_sink(arg);
// generate keys
auto eng = std::default_random_engine(std::random_device()());
auto alpha_dist = std::uniform_int_distribution<char>('A', 'Z');
for (size_t i = 0 ; i < nkeys ; ++i)
{
bool inserted = false;
auto value = to_string(i);
while(!inserted) {
// generate a key
auto key = gen_string(eng, alpha_dist);
// try to store it in the unordered map
// if it already exists, force a regeneration
// otherwise also store it in the ordered map and the flat map
tie(ignore, inserted) = unordered.emplace(key, value);
if (inserted) {
flat_map.emplace_back(key, value);
ordered.emplace(key, std::move(value));
// record the key for later use
keys.emplace_back(std::move(key));
}
}
}
// turn our vector 'flat map' into an actual flat map by sorting it by pair.first. This is the key.
sort(begin(flat_map), end(flat_map),
[](const auto& l, const auto& r) { return l.first < r.first; });
// shuffle the keys to randomise access order
shuffle(begin(keys), end(keys), eng);
// spawn a thread to time access to the unordered map
auto unordered_future = async(launch::async, [&]()
{
auto start_time = chrono::system_clock::now();
for (auto const& key : keys)
{
puser->sink(unordered.at(key));
}
auto stop_time = chrono::system_clock::now();
auto diff = stop_time - start_time;
return diff;
});
// spawn a thread to time access to the ordered map
auto ordered_future = async(launch::async, [&]
{
auto start_time = chrono::system_clock::now();
for (auto const& key : keys)
{
puser->sink(ordered.at(key));
}
auto stop_time = chrono::system_clock::now();
auto diff = stop_time - start_time;
return diff;
});
// spawn a thread to time access to the flat map
auto flat_future = async(launch::async, [&]
{
auto start_time = chrono::system_clock::now();
for (auto const& key : keys)
{
auto i = lower_bound(begin(flat_map),
end(flat_map),
key,
pair_less());
if (i != end(flat_map) && i->first == key)
puser->sink(i->second);
else
throw invalid_argument(key);
}
auto stop_time = chrono::system_clock::now();
auto diff = stop_time - start_time;
return diff;
});
// synchronise all the threads and get the timings
auto ordered_time = ordered_future.get();
auto unordered_time = unordered_future.get();
auto flat_time = flat_future.get();
// print
cout << " ordered time: " << ordered_time.count() << endl;
cout << "unordered time: " << unordered_time.count() << endl;
cout << " flat map time: " << flat_time.count() << endl;
return 0;
}
Résultats:
ordered time: 972711
unordered time: 335821
flat map time: 559768
Comme vous pouvez le voir, le unordered_map bat de manière convaincante la carte et le vecteur de paire trié. Le vecteur de paires est deux fois plus rapide que la solution de carte. C'est intéressant car lower_bound et map :: at ont une complexité presque équivalente.
dans ce test, la carte non ordonnée est environ 3 fois plus rapide (pour les recherches) qu'une carte ordonnée, et un vecteur trié bat de manière convaincante une carte.
J'ai été vraiment choqué de voir à quel point c'est plus rapide.