如何在程序中嵌入有大量字符串的 HashMap #26
quininer
started this conversation in
Deep Dive CN
Replies: 1 comment 1 reply
-
一般来说, |
Beta Was this translation helpful? Give feedback.
1 reply
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
当需要在应用程序中静态的嵌入大量可查询的数据时,你会怎么做?
常见的做法是直接嵌入 JSON 数据,然后在初始访问时做解析:
缺点显而易见:
我们在这里介绍一些技术,构造完全静态、高效的 Map:
高效的静态可查询 Map
通常我们认为 hashmap 的时间复杂度
O(1)
,额外空间开销为 0,但这仅限于所有 keys 的 hash 不发生冲突的情况。当发生冲突时,通常冲突的 key 将会被分配到同一个 bucket 中,
这导致时间复杂度可能回落到
O(N)
,并且 bucket 总是引入额外的空间占用,不能保证空间紧凑。而 mphf 技术可以使得 hashmap 时间、空间总处在完美状态。
最坏时间复杂度
O(1)
,不存在空槽,空间布局紧凑。最简单的 MPHF
mphf 定义上是一种完全单射的 hash function,可以将指定的 key 集合一对一映射到同等大小的值域。
此结构最简单的 mphf 构造方法是:
f(x) = h(seed, x) mod n
seed
可以使得所有 x 产生的 hash mod n 无冲突但对于大量 keys 的情况,期待有单个 seed 能够使得 hash 无冲突的映射所有 x 是不现实的
更实用的 MPHF 构造
对于大量 keys 的情况,常见的处理和传统 hashmap 颇为类似
我们在这里简单描述几种 MPHF 构造:FCH、CDH、PtHash,三者大同小异,主要区别是使用 displace 的方式不同
f(x) = (h(x) + d[i]) mod n
f(x) = φσ[g(x)](x) mod n
f(x) = (h(x) ⊕ h'(k[i])) mod n
k
做 xor。兼具以上两者的好处,调整范围足够大,也不需要进行较重的 hash。更先进的 MHPF 构造:PtrHash
Ptr-hash 在 PtHash 基础上有许多改进,其中最有趣的是 bucket 调整使用了 cuckoo hash 策略。
Cuckoo hash 是一种经典的 hashmap 去冲突方案
高效的去冲突策略使得 ptr-hash 可以使用质量更低的 hash 算法,以及为每个 displace 仅使用 1byte,相比传统方案时间、空间开销更小。
面临体积膨胀问题…
MPHF 解决了静态数据的查询问题,但… 如果我们写出以下类似以下的代码
当我们朴素的使用 MPHF 来索引
&[&str]
等数据,会发现它编译过慢、产物体积过大,问题出在哪里呢?
&[&str]
膨胀&str
开销如果对 Rust 的内存布局有一定了解,很容易注意到
&str
其本质是(*const u8, usize)
。这意味着
&[&str]
中,每个字符串要有 16byte 的额外体积占用。对于短字符串而言,其索引的二进制体积开销甚至要大于其字符串本身。
但这还不是全部
relocation 开销
我们构造一个使用大量
&[&str]
的程序,打印 section headers 会发现它最大的 section 并不是用于存放不变数据的
.rodata
,而是.rela.dyn
和.data.rel.ro
!回想一下我们定义的全局变量
NAMES
,这是一个&str
的列表,里面存放的是指向字符串的指针。但我们的 so 在被加载前,它的基地址尚未确定,编译器怎么能产生一个还不确定的指针列表呢?
答案是 dynamic linker 需要在 so 被加载时对数据进行调整,该过程被称为 relocation。
.data.rel.ro
段和.rela.dyn
段.data.rel.ro
存放的是仅在 relocation 过程中可变的 readonly data.rela.dyn
存放的是需要 relocation 的数据的 metadata.rela.dyn
段对.data.rel.ro
段(及其他)进行调整这意味着
&[&str]
中每个字符串还额外有 24byte 的体积开销,并且会影响整个程序的启动性能!打包字符串和手动索引
Position index
既然编译器产生的字符串索引有那么多开销,那么我们可以打包字符串并且手动建立索引。
一个简单方案是拼接所有字符串,然后使用 string end position 作为索引
这使得每个字符串索引开销仅有 4byte,并且完全消除了 relocation 开销。
.rodata
中,可以直接通过相对地址访问。访问过程仅涉及简单的运算,性能没有太大牺牲。
String pool
Position index 很紧凑,我们还可以使用 Elias-Fano 技术使得它更紧凑。
但简单的拼接字符串会使得编译器无法合并重复的字符串,导致它在特定场景下反而有所劣化。
例如以下代码,可以观察到
S[0]
、S[1]
的两个字符串地址完全相同,证明编译器对它们进行了合并。我们可以使用 string pool 自行对字符串进行合并,来避免这一缺点。
作为节省空间的技巧,我们可以将 offset 和 length 通过 bitpack 打包到单个 u32 中。
使得它和 position index 一样,每个字符串的索引开销仅为 4byte,但代价是单个字符串的最大长度被限制为 255。
编译速度
作为打包字符串的另一个好处,这也极大的改善了编译时间和内存使用。
朴素的
&[&str]
方案中 rustc 耗时极大的三个阶段会在字符串打包后减少至可忽略。before:
after:
主要是避免让编译器产生大量对象、以及避免对其进行不必要的优化。
总结
嵌入静态 HashMap 看起來很简单,但 naive 的方案总有各种代价需要牺牲。
我们介绍了 MPHF 技术和字符串打包技术,可以在不牺牲任何方面的情况下实现该功能。
&[&str]
Beta Was this translation helpful? Give feedback.
All reactions