原文链接: HashMap<string, …> 能有多快 | Terark & Topling 创始人 雷鹏 (nark.cc)
作者: csdn-whinah
发表日期: 2011年09月25日
分类: C++, HashTable
评论: 2 条
阅读次数: 4,270 次
看到很多使用 map<string, ….> 的代码, 也有一些使用了 unordered_map<string, …> 或者 hash_map<string, …>, 当然, hash_map 不是标准的, unordered_map 也只在 boost, tr1 和 c++0x 中可用. 从代码的简洁性和可移植性上讲, 标准的 std::map 是首选.
然而, 从另一方面看, gcc 的 string 是 refcounted & copy on write 的, 64 位环境下, 一个 string 的额外开销是 32 字节, 如果加上 string 内容的额外对齐(8 byte align)开销, 则上升到平均至少 36 字节. 所以哪怕我的 string 简单到只是 “a”, 它占的总内存是 32+8=40 字节. 还有, 如果我们查找时用的是 string literal, 也就是 smap[“a”] 这样的简单用法, 系统会创建一个 temp string, 然后传递过去…..
2022 年注:c++11 之后,gcc 的 std::string 不再使用 refcnt,反而是新增了 SSO(Short String Optimization)
于是我打算自己写一个专门针对 Key 为 string 的 hash map, 那天下午, 用了 2 个小时, 完成了一个初步版本:
- 使用 strpool, 所有的 string 都存在一块连续的 malloc 出来的内存中
- HashNode 使用固定数组, link 使用整数
- bucket 使用一个单独的 int 数组
HashNode 定义如下:
struct Node { // in template hash_strmap
int link; // link to next Node in the same bucket, -1 indicate list end
unsigned offset; // to strpool, align to 4 or 8
size_t hash; // cached hash code
Value value; // could be eliminate from Node and save at a parallel array
};
string 的长度, 可以由数组中两个相邻的 Node.offset 相减获得, 数组最后包含一个dummy node, 其 offset 指向 strpool 末尾.
另有一个 fstring( 2022 年注:相当于 Slice 或 std::string_view):
struct fstring { // 最简单的版本
const char* p;
ptrdiff_t n;
fstring(const char* s) : p(s), strlen(s) {}
fstring(const char* s, ptrdiff_t len) : p(s), n(len) {}
fstring(const std::string& s) : p(s.data()), n(s.size()) {}
};
加上内部的一些其它代码, 如 hash function, equal function, …. 总共约 130 行, 当然, 这个实现的接口和标准 stl 容器有些差异。
写了一个测试程序, 分别对 map, unordered_map, 和我这个 hash_string 做测试. 结果让人很吃惊: 针对不同的数据量, 我的 hash_strmap 比 map 快 30~40 倍, 比 unordered_map 快 5~8 倍, 32 字节长的 Key, 每秒钟可以查找20M次; 并且, 根据预估, 内存用量也比 map 和 unordered_map 小很多(map 每个结点有4ptr的空间开销), 具体数据需要进一步测试。
看到这个令人鼓舞的结果, 我又花了很长一段时间, 将 hash_strmap 的接口实现得跟标准 stl 容器一致, 其中有一个地方稍微麻烦一点:
标准的 *map, 其 value_type 是 std::pair<const Key, Value> (注意 value_type 和 Value 是两个东西), 而我这个实现, 其作为 Key 的 string 是指向 strpool 的的一个偏移, 虽然可以通过构造出 fstring(base+offset, len), 但是, Value 怎么办, std::pair 不支持 reference, std::pair<fstring, Value&> 是非法的!
我的解决方法是, 将 value_type 定义成看上去象 std::pair 的一个东西.
还有, 解决了 value_type 的问题, 作为 iterator, 需要实现 operator* 和 operator->, operator* 好实现, 只需要返回 value_type, 而非 value_type& 就可以可以了. operator-> 怎么办? 返回的指针不能指向一个临时对象, 那就只能把它放到 iterator 中, 但是这样会增加 iterator 的尺寸, 而不管用户是否使用 operator->, 并且会使 operator++ 和 operator–复杂化. 有什么解决的办法?
当然有, C++ 标准规定: operator-> 并非必须返回一个指针, 只要它返回的那个东西支持 operator-> 就可以了, 于是, 我把 iterator::pointer 定义成一个对象, 然后…… 所有的问题都解决了.
还有一个比较麻烦的问题: 元素删除, 因为Node放在数组里面, 数组的元素删除操作复杂度是 O(n), 这是无法接受的! 怎么办? 有两个办法:
- 打标记, 因为 Node.link >= -1, 只需要把它设成 < -1 的值就可以了, 然而这会在数组中留下空洞
▶ 这些空洞不影响查找, 但是影响 iterator, iterate 时需要跳过空洞 - 删除时, 将数组末尾的 Node 移动到被删除的地方, 并修改相应的 link, 将数组大小减一, 这样就没有空洞, 但是会有另一个问题, key string 的长度是由相邻Node.offset 相减获得的, 移动 Node 会破坏这个约定. 所以, 如果要实现这种策略, 就只能将 key string 的长度另外存储, 或者存到 Node 中, 或者存到 string 的内容之前
▶ 这个策略使得 iterator 非常容易实现, 并且 iterator 是 random_access iterator - strpool 中会留下空洞, 怎么办? 曾想过将这些空洞链接起来作为 freelist, 但是, 仍然有问题:
a. strpool 中的这些空洞长短不一, 是为每个不同的长度都分配一个 list 呢? 还是放到同一个list?
b. 放同一个 list 查找合适尺寸的块会很慢
c. 放到不同的 list, 需要一个 freelisthead 数组, 而非单个元素
d. Map 删除元素一般用的比较少, 为一个很少使用的特性, 付出太多, 值得吗?
鉴于这些问题, 1, 2 两种策略我都实现了, strpool 则不释放空闲空间, 只有到空闲空间大于一定阈值时, 将它进行紧缩, 把那些空洞消除, 对于策略1, 紧缩时, 将 Node 数组也一起紧缩.
内部支持 ValueOut, 也就是 Value 和 Node 不存放在相邻的空间,而是存放在另一个平行的数组中。
最后, 因为 Node 是存放在数组中的, 所以, 该 hash map 可以支持排序, 范围查找! 当然, 这里的排序和范围查找是有局限性的, 只有在 map 创建好之后, 并且不再插入和删除. sort 和 lower_bound/upper_bound/equal_range 已经实现了, 当然, 排序之后需要重新链接以保证同时还能按 hash 进行精确查找. 并且, 排序既可以按 key 排序, 也可以按 value 排序, 如果按 value 排序, ValueOut 可以提高二分查找时的速度(内存访问局部性,特别是到最后几轮循环时).
还有一些其它细节问题, 以后有机会再写.
最终的实现, 相比开始 130 行的代码, 膨胀到了 1400 多行, 当然, 效率是相同的. iterator 的实现, 更是比 std::map 和 unorded_map 快了一个数量级, 并且有非常好的内存访问局部性.
后来, 对 google sparsehashmap 中的那个性能测试代码, 做了一点点改动, 使得它能接受我的 hash_strmap, 最终得到的结果也非常好, 几乎每项操作都比参加测试的每种 map 都快, 最关键的插入/查找操作则比其它所有map中最快的 map 还要快 3 倍以上, 并且, 因为那个性能测试并非为 StringKey 写的, hash_strmap在这方面并没有发挥自己的长处, 改天贴出详细结果.
有了这个 hash_strmap, 在数据分析, 数据挖掘中做 aggregation 时, 就不必担心性能问题, 如果不犯其它低级错误, 就只有IO问题. 最简单的应用: WordCount, 按平均Word 32字节记,每秒钟可以处理 32*20M = 640M 的数据, 对一个低端的 8 核服务器, 就是 640M*8=5120M, 如果 IO+parse 有这么快的话.
2011-09-28: 最新的测试达到了每秒 30M 的查询.
2011-09-30: 测试 iteration 的速度, 比 std::map 快 150 倍以上, 比 unordered_map 快 130 倍, 这是当然的, 因为 Node 是存放在数组中的, 其它再怎么快的遍历, 能比遍历数组快?