Skip to content

Mapping Values by a Combination of Two Keys

The topic was found during optimization. When the combination of 2 string type keys maps to a value, how do you maintain the mapping from keys to the value? Generally we can come up with 3 approaches listed below, which one is better?

type Result string

// 1d map, you need to concatenate 2 strings
var d1M map[string]Result

// 1d map and use struct as key
type Key struct {
    S1, S2 string
}
var d1MS map[Key]Result

// 2d map
var d2M map[string]map[string]Result

The 1d mapping needs pay for the string concatenation, the 2d mapping is required for an additional map query per query and the 1d map with struct key requires additional calculation on map.

The conclusion is 2d map is fastest because string concatenation costs more. What's worse, inappropriate string concatenations causes higher memory usages and triggers more GC(result got from pprof). It has been verified in the real practice by my teammates as well.

Note that here the keys are string. If they're small integers we can consider to use bit operation to append them together. Hence, we can finish the map querying in 1 operation.

This blog is not intended to analyze which method to append string(fmt.Sprintf, +, strings.Builder, etc...) is better and why it's fastest. It shows the result only.

Map Allocation

To avoid map initialization disturbs benchmark of querying, I use TestMain to initialize them. However, this approach has missed the memory usage of each approach. I tried to use runtime.ReadMemStats to test the map allocation. Here it's a bit tricky to tackle temporary object allocations and GC.

  • TotalAlloc: cumulative bytes allocated for heap object and never decreases when GC frees objects.
  • Alloc: the allocations on the heap, which will be affected by GC.

Hence, when I want to check the memory usage of each map, I cannot get rid of temporary object allocations or the GC. Finally, I choose to use Alloc and force GC by runtime.GC when I read the memory state.

func TestMain(m *testing.M) {
    // ignore many lines...
    runtime.GC()
    var m1 runtime.MemStats
    runtime.ReadMemStats(&m1)
    for _, item := range list {
        d1M[item.CombinedKey] = item.V
    }
    runtime.GC()
    var m2 runtime.MemStats
    runtime.ReadMemStats(&m2)
    mapMemory := m2.Alloc - m1.Alloc
    fmt.Printf("Memory consumed by map d1M:  %7d bytes\n", mapMemory)
    // ignore many lines...
    m.Run()
}
Memory consumed by map d1M:  5225480 bytes
Memory consumed by map d2M:  5502816 bytes
Memory consumed by map d1MS: 7667608 bytes

    func TestMain(m *testing.M) {
        // ignore many lines...
        var m1 runtime.MemStats
        runtime.ReadMemStats(&m1)
        for _, item := range list {
            d1M[item.CombinedKey] = item.V
        }
        var m2 runtime.MemStats
        runtime.ReadMemStats(&m2)
        mapMemory := m2.TotalAlloc - m1.TotalAlloc
        fmt.Printf("Memory consumed by map d1M:  %7d bytes\n", mapMemory)
        // ignore many lines...
        m.Run()
    }
Memory consumed by map d1M:  10646744 bytes
Memory consumed by map d2M:  10662256 bytes
Memory consumed by map d1MS: 15624848 bytes

The memory usage of d1M and d2M are similar, but the d1MS is 1.5x bigger.

Benchmark result

After checking the memory allocations for each map, I want to focus on the map query here. As the result output by running go test -benchmem -bench . -gcflags=all="-N -l" shows, 2D map is faster without additional allocation(depends on the string). So in the next time, using 2d map is a better choice!

Benchmark                         Iterations        Time/op (ns)               Bytes/op           Allocs/op
===========================================================================================================
Benchmark1DMap_fmtSprintf-10           67          15794759 ns/op         3942296 B/op     300000 allocs/op
Benchmark1DMap_RawAddition-10         170           6998775 ns/op               0 B/op          0 allocs/op
Benchmark1DMap_StringBuilder-10       140           8981449 ns/op          800000 B/op     100000 allocs/op
Benchmark1DMap_BytesAppend-10         135           9052651 ns/op               0 B/op          0 allocs/op
Benchmark1DMap_WithoutConcatenate-10  219           5299534 ns/op               0 B/op          0 allocs/op
Benchmark2DMap-10                     214           5281945 ns/op               0 B/op          0 allocs/op
Benchmark2DMap_Struct-10              178           6463023 ns/op               0 B/op          0 allocs/op

Benchmark1DMap_fmtSprintf-10           68          15930520 ns/op         3942300 B/op     300000 allocs/op
Benchmark1DMap_RawAddition-10         171           7429119 ns/op               0 B/op          0 allocs/op
Benchmark1DMap_StringBuilder-10       133           8249174 ns/op          800000 B/op     100000 allocs/op
Benchmark1DMap_BytesAppend-10         136           8124544 ns/op               0 B/op          0 allocs/op
Benchmark1DMap_WithoutConcatenate-10  253           4736944 ns/op               0 B/op          0 allocs/op
Benchmark2DMap-10                     225           5321423 ns/op               0 B/op          0 allocs/op
Benchmark1DMap_Struct-10              177           6121964 ns/op               0 B/op          0 allocs/op

Benchmark Source Code

Benchmark Source Code
package main_test

import (
    "fmt"
    "runtime"
    "strconv"
    "strings"
    "testing"
)

type Result string

type Key struct {
    S1, S2 string
}

var d2M map[string]map[string]Result
var d1M map[string]Result
var d1MS map[Key]Result

type Pair struct {
    S1, S2      string
    CombinedKey string
    V           Result
}

var list []Pair

func TestMain(m *testing.M) {
    d2M = make(map[string]map[string]Result)
    d1M = map[string]Result{}
    d1MS = make(map[Key]Result)
    for i := 0; i < 1000; i++ {
        for j := 0; j < 100; j++ {
            first := strconv.Itoa(i)
            second := strconv.Itoa(j)

            v := Result(strconv.Itoa(i * j))
            list = append(list, Pair{
                S1: first, S2: second,
                // pre-calculate the concatenation to focus on the goal here.
                CombinedKey: first + "+" + second,
                V:           v,
            })
        }
    }
    runtime.GC()
    var m1 runtime.MemStats
    runtime.ReadMemStats(&m1)
    for _, item := range list {
        d1M[item.CombinedKey] = item.V
    }
    runtime.GC()
    var m2 runtime.MemStats
    runtime.ReadMemStats(&m2)
    mapMemory := m2.Alloc - m1.Alloc
    fmt.Printf("Memory consumed by map d1M:  %7d bytes\n", mapMemory)

    for _, item := range list {
        if d2M[item.S1] == nil {
            d2M[item.S1] = map[string]Result{}
        }
        d2M[item.S1][item.S2] = item.V
    }
    runtime.GC()
    var m3 runtime.MemStats
    runtime.ReadMemStats(&m3)
    mapMemory = m3.Alloc - m2.Alloc
    fmt.Printf("Memory consumed by map d2M:  %7d bytes\n", mapMemory)

    for _, item := range list {
        d1MS[Key{
            S1: item.S1,
            S2: item.S2,
        }] = item.V
    }
    runtime.GC()
    var m4 runtime.MemStats
    runtime.ReadMemStats(&m4)
    mapMemory = m4.Alloc - m3.Alloc
    fmt.Printf("Memory consumed by map d1MS: %7d bytes\n", mapMemory)
    m.Run()
}

func Benchmark1DMap_fmtSprintf(b *testing.B) {
    combine := func(s1, s2 string) string {
        return fmt.Sprintf("%s+%s", s1, s2)
    }

    for i := 0; i < b.N; i++ {
        for _, item := range list {
            v := d1M[combine(item.S1, item.S2)]
            if v != item.V {
                panic("not eqaul")
            }
        }
    }
}

func Benchmark1DMap_RawAddition(b *testing.B) {
    for i := 0; i < b.N; i++ {
        for _, item := range list {
            v := d1M[item.S1+"+"+item.S2]
            if v != item.V {
                panic("not eqaul")
            }
        }
    }
}

func Benchmark1DMap_StringBuilder(b *testing.B) {
    combineByBuilder := func(s1, s2 string) string {
        var bd strings.Builder
        bd.WriteString(s1)
        bd.WriteString("+")
        bd.WriteString(s2)
        return bd.String()
    }

    for i := 0; i < b.N; i++ {
        for _, item := range list {
            v := d1M[combineByBuilder(item.S1, item.S2)]
            if v != item.V {
                panic("not eqaul")
            }
        }
    }
}

func Benchmark1DMap_BytesAppend(b *testing.B) {
    combine := func(s1, s2 string) string {
        return string(append([]byte(s1+"+"), []byte(s2)...))
    }

    for i := 0; i < b.N; i++ {
        for _, item := range list {
            v := d1M[combine(item.S1, item.S2)]
            if v != item.V {
                panic("not eqaul")
            }
        }
    }
}

func Benchmark1DMap_WithoutConcatenate(b *testing.B) {
    for i := 0; i < b.N; i++ {
        for _, item := range list {
            v := d1M[item.CombinedKey]
            if v != item.V {
                panic("not eqaul")
            }
        }
    }
}

func Benchmark2DMap(b *testing.B) {
    for i := 0; i < b.N; i++ {
        for _, item := range list {
            v := d2M[item.S1][item.S2]
            if v != item.V {
                panic("not eqaul")
            }
        }
    }
}

func Benchmark2DMap_Struct(b *testing.B) {
    for i := 0; i < b.N; i++ {
        for _, item := range list {
            v := d1MS[Key{
                S1: item.S1,
                S2: item.S2,
            }]
            if v != item.V {
                panic("not eqaul")
            }
        }
    }
}