gcc std::unordered_map 实现慢吗?如果是,为什么?

102
我们正在开发一款高性能关键软件,使用 C++ 编写。我们需要一个并发哈希映射表并实现了一个。因此,我们编写了一个基准测试来确定我们的并发哈希映射表与 std::unordered_map 相比慢了多少。
但是,std::unordered_map 似乎非常慢... 这是我们的微基准测试(对于并发映射表,我们生成了一个新线程,以确保锁定不会被优化掉,并注意我从未插入0,因为我还使用了 google::dense_hash_map 进行了基准测试,它需要一个空值):
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;

(编辑:完整源代码可以在这里找到:http://pastebin.com/vPqf7eya

std::unordered_map的结果为:

inserts: 35126
get    : 2959

对于google::dense_map

inserts: 3653
get    : 816

对于我们手工编写的并发映射(进行锁定,尽管基准测试是单线程的 - 但在一个单独的派生线程中):

inserts: 5213
get    : 2594

如果我禁用pthread支持并在主线程中运行所有内容来编译基准测试程序,则对于我们手工支持的并发映射,我会获得以下结果:

inserts: 4441
get    : 1180

我使用以下命令进行编译:

g++-4.7 -O3 -DNDEBUG -I/tmp/benchmap/sparsehash-2.0.2/src/ -std=c++11 -pthread main.cc
特别是对于std::unordered_map的插入操作似乎非常耗费时间,相比其他映射容器的3-5秒,需要35秒。此外,查找时间似乎也很长。我的问题是:为什么会这样?我在stackoverflow上看到了另一个问题,有人问为什么std::tr1::unordered_map比他自己实现的版本要慢。最高评分的答案中提到,std::tr1::unordered_map需要实现一个更复杂的接口。但我不能理解这个论点:我们在我们的concurrent_map中使用桶(bucket)方法,std::unordered_map也使用桶方法(即google::dense_hash_map没有使用),但是std::unordered_map至少应该和我们手写的并发安全版本一样快才对吧?除此之外,我在接口中没有看到任何强制执行性能不佳的特性... 所以我的问题是: std::unordered_map是否确实非常慢?如果不是:出了什么问题?如果是:原因是什么。而我的主要问题是:为什么向std::unordered_map插入值如此耗费时间(即使我们在开始时预留了足够的空间,它也不会表现得更好——因此重新哈希似乎不是问题)?

编辑:
目前,大多数评论都解释了,可以通过为其预先分配足够的空间来加快unordered_map的速度。在我们的应用程序中,这是不可能的:我们正在开发一个数据库管理系统,需要使用哈希映射在事务期间存储一些数据(例如锁定信息)。因此,这个映射可以是从1(用户只插入一次并提交)到数十亿条记录(如果进行完整表扫描)的任何东西。在这里预先分配足够的空间是不可能的(而且只在开始时分配很多会消耗太多内存)。
此外,我向你道歉,我没有清楚地表达我的问题: 我真正关心的不是使unordered_map变快(使用googles dense hash map对我们来说效果很好),我只是不太理解这种巨大的性能差异来自哪里。这不能仅仅是预分配的问题(即使有足够预分配的内存,稠密映射的速度也比unordered_map快一个数量级,我们手写的并发映射以大小为64的数组开始-所以比unordered_map小)。
那么std::unordered_map性能不佳的原因是什么?或者换句话说:是否可以编写std::unordered_map接口的实现,该实现符合标准并且(几乎)与googles dense hash map一样快?还是标准中有些东西强制实施者选择了低效的实现方式吗?

编辑2:
通过分析,我发现很多时间用于整数除法。std::unordered_map使用质数作为数组大小,而其他实现则使用2的幂。为什么std::unordered_map使用质数呢?为了在哈希不好的情况下表现更好吗?对于良好的哈希函数来说,这似乎没有什么区别。

编辑3:inserts: 16462 get : 16978

为什么向std::map中插入比向std::unordered_map中插入更快呢?我是说,啥?std::map具有更差的局部性(树与数组之间),需要进行更多的分配(每次插入相对于每次重哈希+每个冲突加1)并且最重要的是:具有另一种算法复杂度 (O(logn) vs O(1))!


1
标准库中的大多数容器对其估计非常保守,建议检查您在构造函数中指定的桶计数,并将其增加到更好地估计您的“SIZE”。 - Ylisar
1
@MadScientist 我们考虑过TBB。问题在于许可证:这是一个研究项目,我们还不确定如何发布它(最有可能是开源的 - 但如果我们想允许在商业产品中使用,GPLv2太过限制)。此外,这又是另一个依赖项。但也许我们将在以后的某个时间点使用它,目前我们可以很好地没有它。 - Markus Pilman
1
在分析器(例如valgrind)下运行它可能会有所启发。 - Maxim Egorushkin
如果您发布完整的源代码,我们将能够编译它并查看您所见到的内容,这将非常有帮助。 - Maxim Egorushkin
1
哈希表中的局部性最多只比树略好,至少如果哈希函数是“随机”的话。该哈希函数确保您很少在相邻时间访问附近的项。您唯一的优势是哈希表数组是一个连续的块。如果堆没有碎片化并且一次构建整个树,则对于树来说也可能是真实的。一旦大小大于缓存,局部性的差异将对性能几乎没有影响。 - user180247
显示剩余4条评论
3个回答

87
我找到了原因:这是gcc-4.7的问题!!
使用gcc-4.7
inserts: 37728
get    : 2985

使用 gcc-4.6

inserts: 2531
get    : 1565

所以,在gcc-4.7中,std::unordered_map存在问题(或者说我的安装有问题,我是在Ubuntu上安装的gcc-4.7.0,另一个是在debian testing上安装的gcc-4.7.1)。

我将提交一个错误报告... 在那之前:不要使用gcc 4.7与std::unordered_map


4.6版本与现在的版本之间是否有任何差异会导致这种情况? - Mark Canlas
30
邮件列表中已经有一份报告。讨论似乎指向了 max_load_factor 处理的“修复”,这导致了性能差异。 - jxh
这个 bug 真是时候不巧!我在使用 unordered_map 时遇到了非常糟糕的性能问题,但很高兴它已经被报告并“修复”了。 - Bo Lu
+1 - 真是个糟糕的BUG.. 我想知道gcc-4.8.2会发生什么。 - ikh
2
这个 bug 有任何更新吗?在后续版本的 GCC(5+)中是否仍然存在? - rph
@rkioji GCC 7的一个变化是“用于_Hashtable内部的新的二次幂重新散列策略”,因此现在性能可能有所不同。 - Jeffrey Bosboom

21

我猜测您没有正确地设置unordered_map的大小,就像Ylisar所建议的那样。当在unordered_map中链变得太长时,g++实现会自动重新哈希到一个更大的哈希表,这将对性能产生很大的拖累。如果我没记错,unordered_map默认为(大于) 100 的最小质数。

由于我的系统上没有chrono,因此我使用了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;
}

我使用了10000000SIZE,并且不得不为我的版本的boost做一些修改。还要注意,我预先调整了哈希表的大小以匹配SIZE/DEPTH,其中DEPTH是由于哈希冲突导致的桶链长度的估计。
编辑:Howard在评论中指出unordered_map的最大负载因子为1。因此,DEPTH控制代码重新哈希的次数。
#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");
}

编辑:

我修改了代码,以便更轻松地更改DEPTH

#ifndef DEPTH
#define DEPTH 10000000
#endif

因此,默认情况下,哈希表的最差大小被选择。
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

我的结论是,除了将初始哈希表大小设置为预期唯一插入数的整个大小之外,其他任何初始哈希表大小都没有太大的显着性能差异。此外,我没有看到您观察到的数量级性能差异。

6
std::unordered_map 默认的最大负载因子是1。因此除了初始桶数之外,深度参数被忽略。如果需要,可以使用map.max_load_factor(DEPTH)来设置最大负载因子。 - Howard Hinnant
@HowardHinnant:感谢您提供的信息。因此,“DEPTH”被忽略,但仍然控制着地图何时会被重新哈希为更大的地图。答案已更新,再次感谢。 - jxh
@user315052 是的,我知道如果一开始给它一个合理的大小会让它更好,但是在我们的软件中我无法这样做(它是一个研究项目 - 一个数据库管理系统 - 我无法知道我将要插入多少数据,可能在0到10亿之间变化...)。但即使预分配了大小,速度仍然比我们的map慢得多,比谷歌的dense_map慢得多 - 我还在想是什么造成了这么大的差异。 - Markus Pilman
@user315052 哦,对不起,我选择的大小是1'000'000 - 所以和你的一样。 我插入速度也提高了超过2倍,但与其他实现相比仍然非常慢。 - Markus Pilman
1
@MarkusPilman:我的时间已经是以秒为单位了。我以为你的时间是以毫秒为单位的。如果将DEPTH设置为1的插入操作只需要不到3秒钟,那么这怎么会慢上一个数量级呢? - jxh
显示剩余3条评论

2

我已经在一台64位/AMD/4核(2.1GHz)计算机上运行了您的代码,并获得了以下结果:

MinGW-W64 4.9.2:

使用std::unordered_map:

inserts: 9280 
get: 3302

使用 std::map:

inserts: 23946
get: 24824

使用所有我知道的优化标志的VC 2015:

使用 std::unordered_map:

inserts: 7289
get: 1908

使用 std::map:

inserts: 19222 
get: 19711

我没有使用GCC测试过代码,但我认为它的性能可能与VC相当,所以如果是这样的话,那么GCC 4.9的std::unordered_map仍然存在问题。

[编辑]

所以,正如评论中有人说的那样,没有理由认为GCC 4.9.x的性能会与VC的性能相当。 当我有机会时,我将在GCC上测试代码。

我的回答只是为其他答案建立一些知识库。


我没有使用GCC测试过代码,但我认为它的性能可能与VC相当。这是毫无根据的说法,没有任何可比较的基准测试,与原帖中发现的情况不同。这个“答案”根本没有回答问题,更不用说回答“为什么”的问题了。 - 4ae1e1
2
我还没有使用GCC测试过这段代码...你怎么能在对它知之甚少的情况下获得并使用MinGW呢?MinGW基本上是GCC的一个紧密跟踪的端口。 - underscore_d

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接