Is an unordered_map really faster than a map in practice?

前端 未结 2 362
孤独总比滥情好
孤独总比滥情好 2020-12-30 03:09

Sure, the lookup performance of an unordered_map is constant on average, and the lookup performance of a map is O(logN).

But of course in order to find an object in

相关标签:
2条回答
  • 2020-12-30 03:49

    In this following test, which I compiled on apple clang with -O3, I have taken steps to ensure that the test is fair, such as:

    1. call a sink function with the result of each search through a vtable, to prevent the optimiser inlining away entire searches!

    2. run tests on 3 different kinds of maps, containing the same data, in the same order in parallel. This means that if one test starts to 'get ahead', it starts entering cache-miss territory for the search set (see code). This means that no one test gets an unfair advantage of encountering a 'hot' cache.

    3. parameterise the key size (and therefore complexity)

    4. parameterised the map size

    5. tested three different kinds of maps (containing the same data) - an unordered_map, a map and a sorted vector of key/value pairs.

    6. checked the assembler output to ensure that the optimiser has not been able to optimise away entire chunks of logic due to dead code analysis.

    Here is the 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;
    }
    

    Results:

      ordered time: 972711
    unordered time: 335821
     flat map time: 559768
    

    As you can see, the unordered_map convincingly beats the map and the sorted pair vector. The vector of pairs has twice as fast as the map solution. This is interesting as lower_bound and map::at have almost equivalent complexity.

    TL;DR

    in this test, the unordered map is approximately 3 times as fast (for lookups) as an ordered map, and a sorted vector convincingly beats a map.

    I was actually shocked at how much faster it is.

    0 讨论(0)
  • 2020-12-30 03:54

    In response to questions about performance in relation to the number of missed searches, I have refactored the test to parameterise this.

    Example results:

    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
    

    Key:

    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

    Results: the unordered_map shows its superiority as soon as there is data in the map. The only time it exhibits worse performance than the ordered map is when the maps are empty.

    Here's the new 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;
    }
    
    0 讨论(0)
提交回复
热议问题