F14是一个通过二次哈希方式来解决冲突的14路探查hash表。最多可以有14个key存储到哈希表的一个块中。CPU向量指令(Intel平台的SSE2和x86_64,或者aarch64平台的NEON)被用来在一个块里面进行快速的过滤;块内的搜索值需要少量的指令。F14的算法实现采用的这样的一个事实:一次可以最大过滤14个key。这个策略让哈希表能够在高最大负载因子(12/14)的情况下还能够保持很短的探查链。
F14为Facebook在产品中使用的大多数哈希表提供了有竞争力的替代方案。切换到F14能够提升内存的使用效率,同时又能够提升性能。在Facebook中,广泛部署在C++应用程序中的哈希表始终伴随着空间/时间之间的权衡(所谓鱼与熊掌不可兼得)。最快的就以为着最低的空间效率,而空间效率最高的(google::sparse_hash_map)比其他的要慢得多。F14改变了这种窘境,相比于现存的各种哈希算法,在提升空间效率的同时能够提升性能。
F14的核心哈希表实现有一个可插拔的存储策略(storage strategy),F14提供了三种策略:
F14NodeMap 采用间接方式进行值的存储,像std::unordered_map一样,每次insert的时候调用malloc进行内存分配。这种实现方式对于中等的乃至大型的key来说是一种空间效率最高的。它同时提供了迭代器(iterator)和引用(reference)稳定性,来确保表现得像stl标准的map一样,并且相比来说能够表现得更快并且空间效率更高。因此,你可以在产品的代码中安全地用F14NodeMap来代替std::unordered_map。与std::unordered_map相比,F14的过滤算法大大的减少了缓存未命中的情况。
F14ValueMap 采用直接行内方式(inline)进行值的存储,就像google::dense_hash_map一样。对于小型key来说,inline模式的存储方式是空间效率最高的,但是对于中大型key来说,它却是比较浪费空间的。因为它能够冗余更高的负载因子,所以F14ValueMap相比于google::dense_hash_map来说,空间效率最高是后者的2倍,同时在大多数的负载情况下能够表现得更快。
F14VectorMap 采用紧凑方式将值存储在连续的数组空间中,而主哈希表则存储了32-bit的索引来指向数组空间。与使用类似策略的内部实现相比,对于具有简单key的中小型哈希表来说,F14表现得更慢一些(由于比特位混合(bit mixing)所引起的损耗),而对于具有复杂key的大型哈希表来说,则标的得更快一些了,并且为每个哈希表中的条目大约节省了16个字节。
我们也提供了F14FastMap:
F14FastMap 依赖于哈希表中的条目的大小,或者从F14ValueMap进行派生,或者从F14VectorMap进行派生。当key和mapped_type(值)小于24个字节的时候,它从F14ValueMap进行派生。对于中大型的条目,它从F14VectorMap来进行派生。这种策略相比于在Facebook中使用的dense_hash_map和其他哈希表来说,不需要单独地为哈希表节点分配内存,提供了最好的性能的同时,也提供了最好的空间效率。
F14FastMap是一个好的默认选择。如果你更多地关心空间效率而不是性能,F14NodeMap对于中大型哈希条目来说是更好的选择。F14NodeMap是F14变种中唯一不会移动存储在里面的元素的实现方式,因此在一些使用场景中,你在使用哈希表的时候需要引用稳定性的特性。
在某些情况下,跨类型来定义哈希和key的判等是有意义的。举个例子:因为std::string能够隐式转换为folly中的StringPiece,因此StringPice能够接受string::string跨类型的哈希和key的判等。如果你将哈希函数和key判等函数标记为transparent,那么F14允许你在搜索哈希表的时候直接使用任何可以接受的key类型,而不需要进行key的类型转换。
譬如:
using H = folly::transparent<folly::hasher
using E = folly::transparent<std::equal_to
F14FastSet
跨类型查询和删除操作在任何key类型能够被传递到hasher和key_equal的()操作符重载函数中去的情况下就能够使用。对于类似于[]的可能进行插入操作的操作符重载函数类型,则需要更多的限制—传递进来的key的类型必须能够被显式转换为哈希表的key_type。F14 能够理解所有能够被用来构造std::pair
假设你有一个魔法棒,能够让你在一步操作内搜索在一个分块中的所有的key(我们的魔法棒被称为 _mm_cmpeq_epi8 ),那么使用分块从根本性上来讲提升了负载因子/冲突的权衡值。搜索的开销只和为了找到需要的key而访问过的分块的数量成正比。
这有点像反过来的生日悖论。在一个有23个人的房间里面,其中有两个人在同一天生日的为50%的概率(分块的块大小为1的情况),但是如果考虑有8个人在同一周生日的情况(分块的块大小为7的情况)那么这个概率就晓得多了。即使有两个人出生在同一周的概率更高了(1/52而不是1/365),但是要求的更多数量的冲突(更多的人同时在同一周出生)意味着最终的概率将会更低(不到百万分之一)。在同一周有8个人出生的概率要达到50%的概率,那么需要160人。
在冲突发生的时候将新的分块链接起来的方式空间效率是不太高的,因为新的分块几乎总是非填充满的。我们尝试链接到独立的条目,但是这会使得查找代码膨胀,并且和采用探查模式相比性能也表现得更差。
在我们最大的负载因子为12/14的情况下,当搜索一个存在的key(查找命中)的情况下,平均探查长度为1.04,其中少于1%的key在前3个分片中没有命中。当查找没有在哈希表中的key(查找未命中)的情况下,平均探查长度在最大负载因子(12/14)的情况下,是1.275,并且99%的探查长度小于4。
具有复杂的探查策略(二次或双重哈希)的哈希表,通常在删除的时候使用逻辑删除,因为很难找到那些在满的哈希桶(或者说F14中的分块)中已经被置换掉的key。如果探查策略允许为一个被置换的key(线性探查,罗宾汉哈希, 布谷鸟哈希) 仅仅提供一小部分的潜在目标地址,这也是一种查找被一个被置换的key的选择,重新定位然后递归地修复新的空洞。
逻辑删除最终必须被召回,才能处理持续的插入和删除操作。譬如:google::dense_hash_map在这种情况下,最终出发一个rehash(重哈希)。不幸的是,为了避免二次哈希行为,这个rehash操作可能不能不将哈希表的负载因子减半,从而引起空间效率的大幅下降。
大多数的探查算法需要持续探查直到找到一个空的槽位,通过跟踪一个桶是否实际上已经拒绝了一个key,那么探查的长度可以被大幅度缩减。当尝试将一个key存放到哈希桶但是桶已经满的情况下,“溢出位(overflow bit)将在被设置。(一个特别不走运的key可能要尝试多个哈希桶,那么为每个哈希桶设置溢出位)。Amble和Knuth在”Ordered hash tables” 的”Further development”部分详细描述了这个溢出位。
(https://academic.oup.com/comjnl/article/17/2/135/525363)
溢出位包含逻辑删除的角色,因为逻辑删除的唯一作用是让探查能够继续。然而和逻辑删除不一样,溢出位是被置换掉的key的一个属性而不是被删除的key本身。它不但用作被置换掉的key的计数器,而且可以在删除的时候被递减。溢出计数器让我们在探查的时候能够更早地退出(更早地判定key不存在),也让我们对逻辑删除能够进行引用计数。他们会在稳定的插入和删除的工作负载状态下自动清理自己,这样子为我们提供了双哈希的优点,而避免了逻辑删除的缺点。
F14为每个key计算一个我们叫做key tag的辅助哈希值。tag是一个1个字节的值,即最高位被置位的具有7个比特位的熵。 这14个tag和2个额外的元数据字节连起来,在分块的开始处构成一个16字节对齐的__m128i。当我们查找一个key的时候,我们可以并行地将这个needle的tag与所有14个tag进行比较。比较的结果是比特位掩码,用来表示分块中哪些槽位可能有不为空的可匹配的key。对于查找失败的情形,不太可能执行任何key的比较;对于查找成功的情形,很可能仅执行一次比较,而且所有产生的分支很容易预测。
向量搜索是用SIMD intrinsics进行编码的,x86_64上的SSE2,或者aarch64上的NEON。这些指令集是在那些平台上是非可选部分(与后来的SIMD指令集,如AVX2或SVE不同)因此不需要特殊的编译器flag。 向量运算在x86_64和aarch64上表现得有些差异,由于aarch64缺少一个movemask指令,但是F14算法是相同的。
F14算法对于大型哈希表来说工作得很好,因为这些tag能够存放进CPU缓存行中,即使哈希键和值不能。然而小型哈希表显然是应用得很多的,因此在哈希表为空的或者只有1、2个元素的时候,我们必须尽量使它的空间占用最小化。通常,tag导致key被密集地聚集在分块的底部,而且导致了访问分块中所有没有使用的内存。这意味着在不改变搜索和插入算法的前提下,我们还可以支持容量为一个分片的一部分。唯一的改变是检查是否需要进行rehash 操作。F14的前三个容量都使用一个分块和一个16字节的元数据向量,但是为2、6,然后是12个key分配空间。
不是的。F14确实对有状态的空间分配器(allocator)、各种指针的提供了全面的支持,以及和C++标准无序关联性容器(unordered associative containers)尽可能多的兼容,但是它没有完全标准兼容。
在使用双哈希探查的哈哈希表中,我们不知道如何高效地实现完整的bucket API,特别是size_type bucket(key_type const&)。这个函数必须为每一个key计算桶的索引,甚至在它被插入的哈希表之前。这意味着一个local_iterator 区间在插入期间不能按照分块来划分key空间,从而终止探查;
只有具备合理的分区本地性的分区选项才是首选分块。在双重哈希中对一个key的探查序列依赖于这个key,而不是首选分块,所以仅仅给定key的首选位置去查找所有被置换掉的key是不可行的。我们不愿去仅仅为了支持完整的哈希桶API而采用次等的探查策略或者为所需要的元数据开辟专用空间。实现这些剩余的哈希桶API,譬如local_iterator begin( size_type)不是太困难。
F14不允许调整max_load_factor。因为探查表不能支持负载因子大于1,所以标准要求具备这种能力,能够通过临时设置一个非常高的不太可能的负载因子来临时的禁止重新哈希。我们还测量了强制低的负载银子没有性能优势,所以最好忽略这个字段,从而为每个F14实例节省空间。这是我们让空的映射表降低到32字节的方法的一部分。 void max_load_factor(float) 方法仍然存在,但是不做任何事情。我们总是使用默认的max_load_factor为1.0f调整从方法size_type bucket_count()返回的值,以便外部可见的负载因子达到1,就像实际内部负载因子达到我们的12/14的阈值一样。
标准要求一个哈希表能够以O(size())时间复杂度进行迭代,无论他的负载因子是多少(而不是O(bucket_count())。这意味着你插入1百万个key,然后删除除10条之外的所有的key,那么迭代的时间复杂度将是O(10)。对于std::unordered_map,为了支持这个场景的开销是每次读和每次写操作都引入了额外间接的层级,这是部分我们能够大幅度改善性能的部分原因。低负载银子的迭代实际发生在迭代的过程中进行key的删除(譬如通过持续的调用map.erase(map.begin())),所以在迭代的过程中删除任何前缀后,对迭代的时间复杂度O(size())提供弱保证。F14VectorMap没有这个问题。
标准要求clear()为O(size())时间复杂度,具有禁止对哈希桶的计数进行改变的实际效果。如果F14所拥有的内存空间超过100个key,它在clear()的时候释放所有内存空间,以避免留下一个大的表,这将对迭代带来昂贵的代价(请参阅前面一段)。Google::dense_hash_map通过同时提供clear()和clear_no_resize()的变通的方法来解决这个问题;我们也可以类似地这么做。
F14NodeMap目前不支持C++17 Node API,但是也可以被添加进去。
* Nathan Bronson --
* Xiao Shi --