go map 探索和内存优化
曦子本文介绍 golang runtime.map 相关和自定一个内存占用相对少的 map.
缘起
最近业务碰到了一个内存 OOM 的问题, 业务要加载一个模型, 容量大概为 2.2 亿条的 map, 其中 key 是 int64, value 是 float64.
源二进制文件(紧凑的int64 float64 int64 float64 ….)大概是 3.1G, 但是使用 runtime.map 加载到内存后发现, 占用 10G 左右, 而实例的内存是 16G, 这个文件每天更新, 由于每次更新的 value 是变化的为了保持版本一致无法增量更新, 所以在加载模型的时候会导致 OOM.
为什么 runtime.Map 占用那么大?
我们知道 map 数据结构不是一个定容量的数据结构, 在一定条件下会触发扩容, 扩容一般会翻倍。如果我们加载的键值对刚刚超过阈值, 而 map 扩容了一倍这个时候是确实会浪费内存, 去看 runtime.map 分配内存 的源码:
base := bucketShift(b)
nbuckets := base
// For small b, overflow buckets are unlikely.
// Avoid the overhead of the calculation.
if b >= 4 {
// Add on the estimated number of overflow buckets
// required to insert the median number of elements
// used with this value of b.
nbuckets += bucketShift(b - 4)
sz := t.bucket.size * nbuckets
up := roundupsize(sz)
if up != sz {
nbuckets = up / t.bucket.size
}
}其中 bucketShift 在计算 2^N , 当 b (b 代表找到的能存放目标简直对的 2 的幂) > 4 的时候, 会创建一个 2^(b-4) 的溢出桶, 用来处理冲突的键值对。
我们再来看 map 的具体数据结构:
// map struct
type hmap struct {
count int // 元素数量
flags uint8 // 二进制位,用来判断
// 是否isWriting/是否是sameSizeGrow
B uint8 // 当前持有2^B个bucket
// 可以容纳loadFactor[^装载因子] * 2^B个元素
noverflow uint16 // 溢出桶的数量
hash0 uint32 // hash seed
buckets unsafe.Pointer // 当前bucket
oldbuckets unsafe.Pointer // 老bucket
nevacuate uintptr // TODO
extra *mapextra
}
// 溢出桶信息
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}
// bucket struct
type bmap struct {
tophash [bucketCnt]uint8
}其中 buckets 是存放当前的 key-value 值, 而 oldbuckets 保留了老的 key-value 值, 所以当触发扩缩的时候, 会出现占用两倍内存的情况.
发生扩缩的原因会有两种
负载因子过高 LoadFactor(负载因子)= hash表中已存储的键值对的总数量/hash桶的个数(即hmap结构中buckets数组的个数)
溢出桶使用过多 (冲突过多)
那什么时候会把 oldbuckets 清空呢? 扩容的时候不会一次性把 oldbuckets 直接迁移到 buckets, 这是一个随着访问慢慢迁移的过程. 那么
未迁移完成前那么会一直多占用内存。
思考
我们加载的模型完成后其实不会再更改, 只会整体替换成新模型
业务上发现 value 的值在 -1 ~ 1 之间徘徊, 不需要 float64 的精度
检查了下冲突率, 大部分冲突的情况是出现了两次, 两次以上的情况很少.
考虑 1 的情况, runtime.map 的扩缩带来了浪费, 完全不需要 oldbuckets. 而且额外创建的溢出桶也不是必要的, 可以不要或者不要那么多.
考虑 2 的情况, float64 可以换成 float32 (ps: 立省25% 内存…
假设不自实现 map , 可以考虑想办法让扩容后的 map 进行快速迁移, 考虑到量级很大, 迁移行为可能影响线上业务 - pass
那么考虑自实现 map .
方案
5层slice + map: 引入map是为了解决在slice中多次冲突的数据,期望在保证slice层数的同时落入map中的数据尽可能的少。 slice为倒金字塔形,下一层的长度为当前的一半,期望每层尽可能的紧凑存储数据,落到下一层的冲突数据越来越少。测试了3-7层的数据分布,3层的map元素数量在3.5kw,5层的map元素在1kw,7层的map在0.7kw。最终选取了5层保证一定的查询效率。
package xmap
// 层数 过大查询效率低 过小冲突率高
const layer = 5
type Map struct {
vs [][]float32
ks [][]uint
m map[uint]float32
hint uint // 最高层个数 1<<b
b uint // 最高层数
l uint // 最低层数
count int
}
func New(hint uint) (m *Map) {
b := findB(hint)
m = &Map{
m: make(map[uint]float32, int(float32(hint)*0.04)),
hint: uint(1 << b),
b: b,
}
if b < layer {
m.l = 0
} else {
m.l = b - layer
}
for i := b; i > m.l; i-- {
m.vs = append(m.vs, make([]float32, 1<<i))
m.ks = append(m.ks, make([]uint, 1<<i))
}
return
}
func findB(hint uint) uint {
i := uint(1)
for {
if 1<<i > hint {
return i - 1
}
i++
}
}
func (m *Map) Get(k uint) float32 {
mr := fastModN(k, m.b)
hint := m.hint
for i := 0; i < int(m.b-m.l); i++ {
if m.ks[i][mr] == k {
return m.vs[i][mr]
}
hint = hint >> 1
mr &= hint - 1
}
return m.m[k]
}
func (m *Map) Set(k uint, v float32) {
m.count++
mr := fastModN(k, m.b)
hint := m.hint
for i := 0; i < int(m.b-m.l); i++ {
if m.ks[i][mr] == 0 {
m.ks[i][mr] = k
m.vs[i][mr] = v
return
}
hint = hint >> 1
mr &= hint - 1
}
m.m[k] = v
}
func (m *Map) Len() int {
if m == nil {
return 0
}
return m.count
}
func fastModN(x uint, b uint) uint {
return x & (1<<b - 1)
}