map[int]interface{} 与 map[int]struct{} 的内存分配

Memory allocation of map[int]interface{} vs map[int]struct{}

我们与一位同事讨论了将映射用作列表的性能,并将接口用作值 (map[int]interface{}) 与空结构 (map[int]struct{})) 的使用进行了比较,我们得到了一些基准测试中的奇怪值。

代码

package main

func main() {}

func MapWithInterface() {
    m := map[int]interface{}{}
    for i := 1; i <= 100; i++ {
        m[i] = nil
    }
}

func MapWithEmptyStruct() {
    m := map[int]struct{}{}
    for i := 1; i <= 100; i++ {
        m[i] = struct{}{}
    }
}

测试

package main

import "testing"

func Benchmark_Interface(b *testing.B) {
    for i := 0; i < b.N; i++ {
        MapWithInterface()
    }
}

func Benchmark_EmptyStruct(b *testing.B) {
    for i := 0; i < b.N; i++ {
        MapWithEmptyStruct()
    }
}

结果

go version go1.15.5 darwin/amd64

go test -bench=. -benchmem

goos: darwin
goarch: amd64
pkg: awesomeProject1
Benchmark_Interface-8         130419          8949 ns/op        7824 B/op          7 allocs/op
Benchmark_EmptyStruct-8       165147          6964 ns/op        3070 B/op         17 allocs/op
PASS
ok      awesomeProject1 3.122s

问题

因此,似乎空结构版本 运行 速度更快,使用的内存更少,但分配更多,无法弄清楚原因。有谁知道为什么会这样?

你的测试方法有误。您对向地图添加 100 个项目的 N 个样本进行基准测试。由于将值分配到映射中而完成的分配处理映射索引,这会混淆分配。两个值都不分配任何内容。

为了获得可靠的结果,它应该看起来像这样:

package main

import "testing"

func Benchmark_Interface(b *testing.B) {
    m := map[int]interface{}{}
    for i := 0; i < b.N; i++ {
        m[i] = nil
    }
}

func Benchmark_EmptyStruct(b *testing.B) {
    m := map[int]struct{}{}
    for i := 0; i < b.N; i++ {
        m[i] = struct{}{}
    }
}

这说明总体上没有区别

我创建了一个 repository,其中包含一些测试以帮助理解这个答案。

TL;DR

Golang 中映射的内部设计针对性能和内存管理进行了高度优化。地图跟踪可以保存指针的键和值。如果存储桶中的条目不能容纳指针,映射将创建溢出存储桶以避免不必要的 GC 开销,这会导致更多分配(map[int]struct{} 的情况)。

长答案

我们需要了解地图初始化和地图结构。然后,我们将分析基准。

地图初始化

地图初始化有两种方法:

  • make(map[int]string) 当我们不知道要添加多少条目时。
  • make(map[int]string, hint) 当我们知道将添加多少条目时。 hint 是对初始容量的估计。

地图是 mutable,无论我们选择哪种初始化方法,它们都会增长 on-demand。但是,第二种方法 pre-allocates 内存至少 hint 个条目,这会提高性能。

地图结构

Go 中的映射是一个散列 table,它将其 key/value 对存储到桶中。每个桶是一个最多包含 8 个条目的数组。桶的默认数量是 1。一旦每个桶中的条目数量达到桶的平均负载(也称为负载因子),地图就会通过加倍桶的数量而变大。每次地图增长时,它都会为 new-coming 条目分配内存。实际上,每当桶的负载达到 6.5 或更多时,地图就会增长。

在幕后,映射是指向 hmap 结构的指针。还有 maptype 结构,它包含一些关于 map 类型的信息。地图的源代码可以在这里找到:

https://github.com/golang/go/blob/master/src/runtime/map.go

您可以在下面找到一些关于如何破解 map 类型以及如何查看地图增长的见解:

需要注意的一件重要事情是地图会跟踪可以保存指针的键和值。如果桶中的条目不能容纳任何指针,则该桶被标记为不包含指针并且映射只会创建溢出桶(这意味着更多的内存分配)。这避免了不必要的 GC 开销。请参阅此 comment in the mapextra struct (line 132) and this post 以供参考。

基准

空结构 struct{} 没有字段,不能包含任何指针。因此,空结构案例中的桶将被标记为 不包含指针 并且我们可以预期随着类型 map[int]struct{} 的映射的增长会有更多的内存分配。另一方面,interface{} 可以保存任何值,包括指针。映射桶跟踪保存指针的内存前缀的大小(ptrdata field, line 33) to decide if more overflow buckets will be created (map.go, line 265). Refer to this link 以查看保存 map[int]struct{}map[int]interface{}.

的所有指针的内存前缀的大小

当我们看到 CPU 配置文件时,两个基准(Benchmark_EmptyStructBenchmark_Interface)之间的区别就很明显了。 Benchmark_Interface 没有导致额外内存分配流程的 (*hmap) createOverflow 方法:

Benchmark_EmptyStruct CPU 简介

Benchmark_EmptyStruct CPU 简介 [png, svg]

Benchmark_Interface CPU 简介

Benchmark_Interface CPU 简介 [png, svg]

我自定义测试以通过条目数和地图的初始容量(提示)。这是处决的结果。当条目数很少或初始容量大于条目数时,结果基本相同。如果您有很多条目且初始容量为 0,您将获得完全不同的分配数。

Benchmark Entries InitialCapacity Speed Bytes/op Allocations/op
EmptyStruct 7 0 115 ns/op 0 B/op 0 allocs/op
Interface 7 0 94.8 ns/op 0 B/op 0 allocs/op
EmptyStruct 8 0 114 ns/op 0 B/op 0 allocs/op
Interface 8 0 110 ns/op 0 B/op 0 allocs/op
EmptyStruct 9 0 339 ns/op 160 B/op 1 allocs/op
Interface 9 0 439 ns/op 416 B/op 1 allocs/op
EmptyStruct 16 16 444 ns/op 324 B/op 1 allocs/op
Interface 16 16 586 ns/op 902 B/op 1 allocs/op
EmptyStruct 16 32 448 ns/op 640 B/op 1 allocs/op
Interface 16 32 724 ns/op 1792 B/op 1 allocs/op
EmptyStruct 16 100 634 ns/op 1440 B/op 2 allocs/op
Interface 16 100 1241 ns/op 4128 B/op 2 allocs/op
EmptyStruct 100 0 5339 ns/op 3071 B/op 17 allocs/op
Interface 100 0 6524 ns/op 7824 B/op 7 allocs/op
EmptyStruct 100 128 2665 ns/op 3109 B/op 2 allocs/op
Interface 100 128 3938 ns/op 8224 B/op 2 allocs/op

结论

基准测试方法没有任何问题。这都与性能和内存管理的地图优化有关。基准显示每次迭代的平均值。 map[int]interface{} 类型的映射速度较慢,因为当 GC 扫描可以容纳指针的桶时,它们的性能会下降。 map[int]struct{} 类型的映射使用较少的内存,因为它们实际上使用较少的内存(Test_EmptyStructValueSize 表明 struct{}{} 的大小为零)。尽管 nilinterface{} 的零值,但这种类型需要一些 space 来存储任何值(Test_NilInterfaceValueSize 测试显示 interface{} 的大小持有 nil 值不为零)。最后,空结构基准分配更高,因为类型 map[int]struct{} 需要更多溢出桶(用于性能优化),因为它的桶不包含任何指针。