原文:Using the Right Datastructure for the job
译者:杰微刊—刘祥明
键查找 Searching for a key
选择适当的数据结构是一件大家都认同,却很少有人会考虑的事。从我的经验来看, 这不仅是因为它很难引起人们的好奇心,更重要的原因是它需要不厌其烦的进行实验和基准测试,这可能会增加很多不必要的负担。让我们来举个例子。我在日常工作中遇到的很多软件操作都是查找一个key然后对其进行操作,要么检查它是否存在, 要么取出与其相关联的值。为了简单起见, 我们把操作限于检查键是否存在。
基于同样的目的,我们同时将key的类型限制为int。因为int类型是编程语言中最常见、最容易被理解的数据类型。它也是最有可能在各种应用中被用作’id’的类型。
假设我们有一个使用整型id的应用,我们用它来识别客户的身份,我们还打算为它构建一个缓存。缓存是否被命中,决定了我们是否查找数据库。为该需求进行数据结构选择时,hashmap是几乎所有计算机科学学生的首选,它现在在C++中的实现被称为unordered_map。这是由于大O表示法(Big O notation)告诉我们查找哈希表所需要的时间是恒定的。还有哪些备选项?
1. 向量(vector),它的底层实现是一个数组,亦被称为连续的内存(contiguous memory)。
2. 树,它使我们可以进行分类遍历(sorted traversal),但是它也会导致更多的指针和可能的缺页异常(page fault)。
3. 链表(Linked list)。
实际上我们还可以有更多的选项,在这里我就不一一列举了。现在请大家思考一下,既然大O表示法告诉我们使用hashmap已经足够好了, 为什么我们还要考虑其他的选项呢?这是因为有时候大O表示法对我们的应用而言并不能起到很好的指导作用,这时使用小O表示法也许更恰当。我希望读者从中得到的启发是:测量所有东西(Measure Everything)。当需要在自己的机器上进行性能预测时,我们要养成测量的好习惯,因为它是你获得真正答案的唯一方式。 某些人可能会说,“使用向量!那样的话就可以对缓存线(cache lines)进行优化!”事实上,如果我们不把这些不同的方案放到一起进行测量,我们就不可能知道哪个更好。
键生成 Key generation
1
2
3
|
#include#includestd::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis( 0, MAX_VAL );
|
以上是我们使用的键提供者,我们用它来生成在某个范围内均匀分布的键。我们尽可能地使用dis,以确保生成再多的key,也能均匀分布在我们指定的范围内。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
###构建索引
//初始化数据结构
std::unordered_map<int,int> myMap;
std::vector<int> myVector;
//生成键
std::cout <<
"Generating keys."
<< std::endl;
for
(size_t i = 0; i != NUM_KEYS; ++i) {
int key = dis(gen);
int val = dis(gen);
if
(myMap.find(key) == myMap.end()) {
myMap[key] = val;
// 添加 key,value 到 map 中
myVector.push_back(key);
}
}
|
我们将同一个key分别放入map和vector(以及任何其它我们想要测量的数据结构)。这为我们提供了一种性能比较和检查代码正确性的方法。不同的数据结构,会在执行相同的查询时,返回相同的结果么?
构建查询条件 Building Queries
1
2
3
4
5
6
|
<pre class=
"brush:js"
>std::cout <<
"Generating Queries."
<< std::endl;
std::vector<int> queries;
queries.resize(NUM_QUERIES);
for
(size_t i = 0; i != NUM_QUERIES; ++i) {
queries[i] = dis(gen);
}</pre>
|
上面的代码很重要。我们事先构建查询条件并将他们存储起来。为此我们需要保证:
1.产生查询条件所需时间不会被纳入真正的查询测量时间中
2.在map和vector上执行相同的查询
3.可以不同的方式来执行查询(sorted, shuffled, 等等)
查询 Querying
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// 查询 map 并记录时间跨度
std::cout <<
"Querying map..."
<< std::endl;
std::chrono::time_point<std::chrono::system_clock> start, end;
start = std::chrono::system_clock::now();
int numMatchesMap = 0;
for
(size_t i = 0; i != NUM_QUERIES; ++i) {
if
(myMap.find(queries[i]) != myMap.end())
++numMatchesMap;
}
end = std::chrono::system_clock::now();
std::chrono::duration<double> elapsed_seconds = end-start;
std::cout <<
"Elapsed time for "
<< NUM_QUERIES <<
" queries in map:"
<< elapsed_seconds.count()
<<
"\nNum matches in map: "
<< numMatchesMap
<<
"\nQuerying vector..."
<< std::endl;
|
上面的代码逻辑很简单,顺序迭代查询条件,然后检查它们是否存在于索引中, 计算时间跨度。最后, 输出结果。测量vector的代码与此相似, 我将不在此贴出,但是我会上传完整的代码。
结果 Results
哎, 我们的计算机科学教授所教我们的是正确的。果真如此吗? 为了对其进行验证,我尝试了不同的参数。简单起见,我将key空间限制在10000以内。
1
2
3
4
|
//常量
int NUM_KEYS = atoi(argv[1]);
int MAX_VAL = atoi(argv[2]);
int NUM_QUERIES = 1000000;
|
Key数量 | Unordered Map查询时间 (s) | Vector 查询时间 (s) |
5 | 0.092408 | 0.048035 |
10 | 0.095659 | 0.067716 |
20 | 0.100039 | 0.130688 |
50 | 0.087938 | 0.254695 |
100 | 0.081867 | 0.479975 |
200 | 0.098481 | 0.888507 |
400 | 0.096329 | 1.73241 |
800 | 0.098282 | 3.29157 |
1600 | 0.097052 | 6.12024 |
3200 | 0.093558 | 10.463 |
6400 | 0.093106 | 16.1336 |
在大多数情况下, unordered map 都优于 vector,有时候甚至比vector超出若干个数量级。但是vector在key数量很小的情况下(<10)却胜出unordered map。 假设这样一个场景:一个应用将查询分发到集群中,集群机器的数量很少但是查询量非常大。在这种场景下,vector应该是正确的选择。但是在大多数其他情况下, 则应该使用hashmap。
组合key Compound Keys
在每个软件工程师的职业生涯中,总会碰到使用组合key的情况。在这种场景中,我们需要将两个key组合成一个单独的key来使用。例如一个应用可能需要在给定领域id的情况下,确定用户是否存在。又如在一个图书馆应用中,我们可能需要使用作者和书名来作为key。这里的键是否有相应的值并不重要。我曾经数次遇到过这种情形,默认情况下,我都选择了对我来说最简单的方案。但是更好的做法是进行测量并理解不同方案之间的利弊,然后做出选择。
数据结构 Datastructures
std::unordered_map。这是我默认使用的方案,创建一个组合key和一些能凑合着用的hasher(makeshift hasher)。然后使用它们来创建unordered_map。
unordered_map >。这是另一个可选的解决方案,将key分成两部分,一部分作为unordered map 的key,另一部分存放在vector中。
我们还可以有其他解决方案:trees,stacks,linear probing maps等,但是我们的目的是演示过程而非答案,所以我只考虑上面这两种方法。在此,我一并展示hashing,key生成,构造查询条件以及查询的相关代码片段。以下代码展示了如何通过组合键来构造map,即构造一个自定义的哈希函数。它们是自包含的,可以编译并运行它们。
代码 The Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
|
#include <unordered_map>
#include <algorithm>
#include <vector>
#include <list>
#include <random>
#include <chrono>
#include <ctime>
#include <iostream>
#include <string>
#include <assert.h>
template <class T>
inline void hash_combine(std::size_t & seed, const T & v)
{
std::hash<T> hasher;
seed ^= hasher(v) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}
namespace std
{
template<typename S, typename T> struct hash<pair<S, T> >
{
inline size_t operator()(const pair<S, T> & v) const
{
size_t seed = 0;
::hash_combine(seed, v.first);
::hash_combine(seed, v.second);
return
seed;
}
};
}
int main(int argc, char** argv)
{
assert(argc == 3);
// constants
int NUM_KEYS = atoi(argv[1]);
int NUM_2ND_KEYS = atoi(argv[2]);
int MAX_VAL = 100000;
int NUM_QUERIES = 1000000;
// initialize random engine
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis( 0, MAX_VAL );
// initialize datastructures
std::unordered_map< std::pair<int,int>, int> myMap;
std::unordered_map< int, std::vector<int> > myMapVector;
typedef std::vector<int> IntVec;
// generate keys
std::cout <<
"Generating keys."
<< std::endl;
for
(size_t i = 0; i != NUM_KEYS; ++i) {
int key = dis(gen);
for
(size_t j = 0; j != NUM_2ND_KEYS; ++j)
{
int key2 = dis(gen);
int val = dis(gen);
if
(myMapVector.find(key) == myMapVector.end()) {
myMapVector[key] = IntVec();
}
std::pair<int,int> compoundKey = std::make_pair(key,key2);
if
(myMap.find(compoundKey) == myMap.end()) {
myMap[compoundKey] = val;
// add key,value to map
myMapVector[key].push_back(key2);
}
}
}
std::cout <<
"Generating Queries."
<< std::endl;
std::vector< std::pair<int,int> > queries;
queries.resize(NUM_QUERIES);
for
(size_t i = 0; i != NUM_QUERIES; ++i) {
queries[i] = std::make_pair(dis(gen), dis(gen));
}
//query map and time duration
std::cout <<
"Querying map"
<< std::endl;
std::chrono::time_point<std::chrono::system_clock> start, end;
start = std::chrono::system_clock::now();
int numMatchesMap = 0;
for
(size_t i = 0; i != NUM_QUERIES; ++i) {
if
(myMap.find(queries[i]) != myMap.end())
++numMatchesMap;
}
end = std::chrono::system_clock::now();
std::chrono::duration<double> elapsed_seconds = end-start;
std::cout <<
"Elapsed time for "
<< NUM_QUERIES <<
" queries in map:"
<< elapsed_seconds.count()
<<
"\nNum matches in map: "
<< numMatchesMap
<<
"\n Querying map vector"
<< std::endl;
// querying map vector
start = std::chrono::system_clock::now();
int numMatchesVector = 0;
for
(size_t i = 0; i != NUM_QUERIES; ++i) {
if
( myMapVector.find(queries[i].first) != myMapVector.end() &&
std::find(myMapVector[queries[i].first].begin(),
myMapVector[queries[i].first].end(),
queries[i].second) !=
myMapVector[queries[i].first].end())
++numMatchesVector;
}
end = std::chrono::system_clock::now();
elapsed_seconds = end-start;
std::cout <<
"Elapsed time for "
<< NUM_QUERIES <<
" queries in map vector:"
<< elapsed_seconds.count()
<<
"\nNum matches: "
<< numMatchesVector
<< std::endl;
}
|
哈希函数是从Boost库中拿过来的,Boost库为我们提供了一种优雅的方式(模板),将任意多的key组合在哈希函数里来使用。我们只用它来哈希一个组合键。因此我们在pair的第一和第二个元素上应用了::hash_combine函数, 并把该模板具化到pair上, 这样我们就可以使用pair作为key来构建unordered_map。我们再看一下哈希相关的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
template <class T>
inline void hash_combine(std::size_t & seed, const T & v)
{
std::hash<T> hasher;
seed ^= hasher(v) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}
namespace std
{
template<typename S, typename T> struct hash<pair<S, T> >
{
inline size_t operator()(const pair<S, T> & v) const
{
size_t seed = 0;
::hash_combine(seed, v.first);
::hash_combine(seed, v.second);
return
seed;
}
};
}
|
结果 Results
上面的3d分散图用x和y轴代表两个相互依赖的变量,用z轴代表输出变量。
+X:数据结构中主键的数量
+Y:每个主键对应的从键的数量
+Z:查询数据结构1000000次花费的时间
分散图中的红点为测量std::unordered_map的结果,
蓝点为测量unordered_map >的结果。
对于大多数情况来说unordered_map >优于std::unordered_map,这完全违背大O对于在组合键map上的查询为常量时间这一预测。
分析 Analysis
一个低劣的哈希函数会压垮性能
使用组合键迫使我们使用了专有的哈希函数(an ad-hoc hash function)。尽管操作诸如整型等基本类型的哈希函数变得越来越通用,并且业界对它们都已经有了很好的研究,但是操作组合键的哈希函数情况却并非如此。我们使用了Boost库的::hash_combine实现,但是性能依然降低了。糟糕的哈希函数会导致冲突,并在链表中增加额外的链接(请查看hashmap的工作原理)。在链表中进行查找需要对指针进行检查,这意味着缓存未命中的概率增加了。
从另一方面来说,unordered_map >有可预测的查询性能,是因为哈希函数作用在单个整数而不是两个整数的组合上。而一旦我们查找到vector,在其上进行扫描将会因为引用局部性而变得非常快。
主要收获 Main takeaway
该练习的目的不是告诉你应该使用哪个数据结构,而是根据具体的应用自己回答这些问题。我们为这个实验做了很多假设,例如把key空间设定为任意值,将查询条件随机化。这些假设在你自己的应用中并不一定适用,你需要自己决定改变哪些参数并构造尽可能和现实情况相适应的实验。例如你可能需要将key空间限制在1000内,或者对于相同的主键序列,你需要查找大量的从键。
这类练习对每个工程师都非常有用,对于构建低延迟的应用尤其重要。希望本文对你有所帮助。Happy Benchmarking。
-------------------好久不见的分割线-------------------
[转载请保留原文出处、译者和审校者。 可以不保留我们的链接]