Memory Allocation and Performance in Golang Maps
By: Ricardo Erikson
Tags: Memory Allocation, Performance, Maps, Golang, Empty Struct

Long Answer

Before we get to the answers, we need to understand map initialization and map structure. We’ll go through these topics first, and then we will analyze some benchmarks to see the performance of the two map types we are trying to understand here. I created a repository that contains some tests to help in the understanding of this answer. So, if you see some reference to a test or benchmark in the text, it’s probably in the repository.

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 to a map.

: when we have no idea about how many entries will be added to a map. 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 of a map.

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. This value is hardcoded and was chosen to optimize memory usage.

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

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

As you can see in the link above, map internals is complex. In the links below, you can find some insights on how to hack a map type. Aleksandr Kochetkov did a terrific job showing some details of map internals.

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 Analysis

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. This will avoid unnecessary scanning through the map and we can expect improved execution speed. In addition, we can also expect more memory allocations for a map of type map[int]struct{} since it creates more overflow buckets as it grows.

An interface{} type, differently from the empty struct, can hold ANY values, including pointers. To comprehend how this impacts map performance, we need to understand that map buckets keep track of the size of the memory prefix holding all pointers ( ptrdata field, line 33). Map types that can hold pointer don’t allocate additional overflow buckets. However, these types require scanning by GC.

In the map internal implementation, we can see how the ptrdata field is used to decide whether more overflow buckets will be created or not (map.go, line 265). Refer to this link to see the size of the 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 method (*hmap)createOverflow 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)

Benchmarks Results

I customized the benchmark 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.

As the number of entries gets bigger we clearly see a difference in speed when we compare Interface and EmptyStruct . Interface benchmarks are much slower than EmptyStruct benchmarks. We can also see that when the initial capacity of the map is bigger than the number of entries, maps don’t require too many allocations because there’s a pre-allocated space for all entries.

Conclusion