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

I created a repository that contains some tests to help in the understanding of this answer.

TL;DR

The internal design of maps in Golang is highly optimized for performance and memory management. Maps keep track of keys and values that can hold pointers. If the entries in a bucket can't hold pointers, maps just create overflow buckets to avoid unnecessary overhead with GC, which results in more allocations (the case of map[int]struct{}).

Long answer

We need to understand map initialization and map structure. Then, we will analyze the benchmarks.

Map initialization

There are two methods for map initialization:

  • make(map[int]string) when we have no idea about how many entries will be added.
  • make(map[int]string, hint) when we have an idea about how many entries will be added. hint is an estimate of the initial capacity.

Maps are mutable and they will grow on-demand, no matter which initialization method we choose. However, the second method pre-allocates memory for at least hint entries, which results in increased performance.

Map structure

A map in Go is a hash table that stores its key/value pairs into buckets. Each bucket is an array that holds up to 8 entries. The default number of buckets is 1. Once the number of entries across each bucket reaches an average load of buckets (aka load factor), the map gets bigger by doubling the number of buckets. Every time a map grows, it allocates memory for the new-coming entries. In practice, every time the load of the buckets reaches 6.5 or more, the map grows.

Behind the scenes, a map is a pointer to the hmap struct. There is also the maptype struct, which holds some information about the type of the map. The source code for map can be found here:

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

Below you can find some insights on how to hack the map type and how to see a map growing:

  • https://hackernoon.com/some-insights-on-maps-in-golang-rm5v3ywh
  • https://play.golang.org/p/NaoC8fkmy9x

One important thing to note is that maps keep track of keys and values that can hold pointers. If the entries in a bucket can't hold any pointers, the bucket is marked as containing no pointers and maps just create overflow buckets (which means more memory allocations). This avoids unnecessary overhead with GC. See this comment in the mapextra struct (line 132) and this post for reference.

Benchmarks

An empty struct struct{} has no fields and cannot hold any pointers. As a result, the buckets in the empty struct case will be marked as containing no pointers and we can expect more memory allocations for a map of type map[int]struct{} as it grows. On the other hand, interface{} can hold any values, including pointers. Map buckets keep track of the size of memory prefix holding pointers (ptrdata field, line 33) to decide if more overflow buckets will be created (map.go, line 265). Refer to this link to see the size of memory prefix holding all pointers for map[int]struct{} and map[int]interface{}.

The difference between the two benchmarks (Benchmark_EmptyStruct and Benchmark_Interface) is clear when we see the CPU profile. Benchmark_Interface does not have the (*hmap) createOverflow method that results in an additional memory allocation flow:

Benchmark_EmptyStruct CPU profile

Benchmark_EmptyStruct CPU profile [png, svg]

Benchmark_Interface CPU profile

Benchmark_Interface CPU profile [png, svg]

I customized the tests to pass the number of entries and the map's initial capacity (hint). Here are the results of the executions. Results are basically the same when there are few entries or when the initial capacity is greater than the number of entries. If you have many entries and an initial capacity of 0, you will get quite a different number for allocations.

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

Conclusion

There's nothing wrong with the benchmarking methodology. It's all related to map optimization for performance and memory management. The benchmarks show the average value per iteration. Maps of type map[int]interface{} are slower because they suffer performance degradation when GC scans the buckets that can hold pointers. Maps of type map[int]struct{} use less memory because they, in fact, use less memory (Test_EmptyStructValueSize shows that struct{}{} has zero size). Despite nil being the zero value for interface{}, this type requires some space to store ANY value (Test_NilInterfaceValueSize test shows the size of an interface{} holding a nil value is not zero). Finally, the empty struct benchmark allocations are higher because the type map[int]struct{} requires more overflow buckets (for performance optimization) since its buckets don't hold any pointers.