diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d18e2e..5815995 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,8 @@ on: pull_request: jobs: - build: - name: CI + build_v2: + name: Build for v2 runs-on: ubuntu-latest steps: @@ -22,14 +22,13 @@ jobs: echo github.event.changes.title.from=$CI_PR_PREV_TITLE - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v3 with: - go-version: '~1.17.9' + go-version: '~1.18' id: go - name: Install utilities run: | - go install golang.org/x/lint/golint@latest go install golang.org/x/tools/cmd/goimports@latest go install honnef.co/go/tools/cmd/staticcheck@latest # display Go environment for reference @@ -47,21 +46,84 @@ jobs: - name: Get dependencies run: | + cd v2 go mod tidy /usr/bin/git diff --exit-code - name: Build run: | + cd v2 go build -v ./... - name: Check run: | + cd v2 go vet ./... - golint ./... staticcheck ./... goimports -w . /usr/bin/git diff --exit-code + - name: Test + run: | + cd v2 + go test -v ./... + + build_v1: + name: Build for v1 + runs-on: ubuntu-latest + + steps: + - name: Log + env: + CI_EVENT_ACTION: ${{ github.event.action }} + CI_PR_TITLE: ${{ github.event.pull_request.title }} + CI_PR_PREV_TITLE: ${{ github.event.changes.title.from }} + run: | + echo github.event.action=$CI_EVENT_ACTION + echo github.event.pull_request.title=$CI_PR_TITLE + echo github.event.changes.title.from=$CI_PR_PREV_TITLE + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '~1.17' + id: go + + - name: Install utilities + run: | + go install golang.org/x/lint/golint@latest + go install golang.org/x/tools/cmd/goimports@latest + go install honnef.co/go/tools/cmd/staticcheck@latest + # display Go environment for reference + go env + + - name: Check out code + uses: actions/checkout@v2 + + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Get dependencies + run: | + go mod tidy + /usr/bin/git diff --exit-code + + - name: Build + run: | + go build -v ./... + + - name: Check + run: | + go vet ./*.go + golint ./*.go + staticcheck ./*.go + goimports -w ./*.go + /usr/bin/git diff --exit-code + - name: Test run: | go test -v ./... diff --git a/LICENSE b/LICENSE index 9afc260..2622c67 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2021 The Sensible Code Company Ltd +Copyright 2022 The Sensible Code Company Ltd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, diff --git a/README.md b/README.md index 8b9c708..86b0e7b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # faststringmap +## v2 : Latest for Go 1.18 onwards +**v2** is the latest which uses generics and runs on Go 1.18. See [v2/README.md](v2/README.md) for details. + +## v1 : for Go 1.17 and earlier + `faststringmap` is a fast read-only string keyed map for Go (golang). For our use case it is approximately 5 times faster than using Go's built-in map type with a string key. It also has the following advantages: @@ -50,9 +55,5 @@ BenchmarkGoStringToUint32-8 49279 24483 ns/op ## Improvements -You can improve the performance further by using a slice for the ``next`` fields. -This avoids a bounds check when looking up the entry for a byte. However, it -comes at the cost of easy serialization and introduces a lot of pointers which -will have impact on GC. It is not possible to directly construct the slice version -in the same way so that the whole store is one block of memory. Either create as in -this code and then derive the slice version or create distinct slice objects at each level. \ No newline at end of file +[v2](v2/README.md) features a version which has improved performance by using a slice for +the `next` fields. It is also built using generics so you can easily use any value type. \ No newline at end of file diff --git a/uint32_store.go b/uint32_store.go index 81f150c..0faa7f6 100644 --- a/uint32_store.go +++ b/uint32_store.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Sensible Code Company Ltd +// Copyright 2022 The Sensible Code Company Ltd // Author: Duncan Harris package faststringmap diff --git a/uint32_store_example_test.go b/uint32_store_example_test.go index 1b2fab9..e8fc82c 100644 --- a/uint32_store_example_test.go +++ b/uint32_store_example_test.go @@ -1,3 +1,6 @@ +// Copyright 2022 The Sensible Code Company Ltd +// Author: Duncan Harris + package faststringmap_test import ( diff --git a/uint32_store_test.go b/uint32_store_test.go index 579eb9c..1f841c2 100644 --- a/uint32_store_test.go +++ b/uint32_store_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Sensible Code Company Ltd +// Copyright 2022 The Sensible Code Company Ltd // Author: Duncan Harris package faststringmap_test diff --git a/v2/README.md b/v2/README.md new file mode 100644 index 0000000..89fff40 --- /dev/null +++ b/v2/README.md @@ -0,0 +1,65 @@ +# faststringmap + +`faststringmap` is a fast read-only string keyed map for Go (golang). +For our use case it is approximately 5 times faster than using Go's +built-in map type with a string key. It also has the following advantages: + +* look up strings and byte slices without use of the `unsafe` package +* minimal impact on GC due to lack of pointers in the data structure +* data structure can be trivially serialized to disk or network + +faststringmap v2 is built using Go generics for Go 1.18 onwards. + +`faststringmap` is a variant of a data structure called a +[Trie](https://en.wikipedia.org/wiki/Trie). +At each level we use a slice to hold the next possible byte values. +This slice is of length one plus the difference between the lowest and highest +possible next bytes of strings in the map. Not all the entries in the slice are +valid next bytes. `faststringmap` is thus more space efficient for keys using a +small set of nearby runes, for example those using a lot of digits. + +There are two variants provided: + +* `Map` is a version using a single slice and indexes which can be directly + serialized (e.g. to a file). It contains no embedded pointers so has minimal + impact on GC. + +* `MapFaster` has improved performance by using a slice for the `next` fields. + This avoids a bounds check when looking up the entry for a byte. However, it + comes at the cost of easy serialization and introduces a lot of pointers which + will have impact on GC. It is not possible to directly construct the slice version + in the same way so that the whole store is one block of memory. So this code provides + a function to create it from `Map`. An alternative construction might create distinct + slice objects at each level. + +## Example + +Example usage can be found in the tests and also +[`fast_string_map_example_test.go`](fast_string_map_example_test.go) +which shows a populated data structure to aid understanding. + +## Motivation + +I created `faststringmap` in order to improve the speed of parsing CSV +where the fields were category codes from survey data. The majority of these +were numeric (`"1"`, `"2"`, `"3"`...) plus a distinct code for "not applicable". +I was struck that in the simplest possible cases (e.g. `"1"` ... `"5"`) the map +should be a single slice lookup. + +Our fast CSV parser provides fields as byte slices into the read buffer to +avoid creating string objects. So I also wanted to facilitate key lookup from a +`[]byte` rather than a string. This is not possible using a built-in Go map without +use of the `unsafe` package. + +## Benchmarks + +Below are example benchmarks from my laptop which are for looking up every element +in a map of size 1000. So approximate times are 25ns per lookup for the Go native map +and 5ns per lookup for the ``faststringmap``. +``` +cpu: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz +BenchmarkUint32Store +BenchmarkUint32Store-8 218463 4959 ns/op +BenchmarkGoStringToUint32 +BenchmarkGoStringToUint32-8 49279 24483 ns/op +``` diff --git a/v2/fast_string_map.go b/v2/fast_string_map.go new file mode 100644 index 0000000..ac12313 --- /dev/null +++ b/v2/fast_string_map.go @@ -0,0 +1,272 @@ +// Copyright 2022 The Sensible Code Company Ltd +// Author: Duncan Harris + +package faststringmap + +import ( + "sort" +) + +type ( + // Map is a fast read only map from a string type to T. + // Lookups are about 5x faster than the built-in Go map type. + // A Map instance can also be directly persisted to disk. + Map[_ ~string, T any] struct { + store []byteValue[T] + } + + byteValue[T any] struct { + nextLo uint32 // index in store of next byteValues + nextLen byte // number of byteValues in store used for next possible bytes + nextOffset byte // offset from zero byte value of first element of range of byteValues + valid bool // is the byte sequence with no more bytes in the map? + value T // value for byte sequence with no more bytes + } + + // MapFaster is a faster read only map from a string type to T. + // Unlike Map it can't be directly persisted to disk. + MapFaster[_ ~string, T any] struct { + store []byteValueSlice[T] + } + + byteValueSlice[T any] struct { + next []byteValueSlice[T] + nextOffset byte // offset from zero byte value of first element of next + valid bool // is the byte sequence with no more bytes in the map? + value T // value for byte sequence with no more bytes + } + + // builder is used only during construction + builder[K ~string, T any] struct { + all [][]byteValue[T] + src Source[K, T] + len int + } + + // Source is for supplying data to initialise Map + Source[K ~string, T any] interface { + // AppendKeys should append the keys of the map to the supplied slice and return the resulting slice + AppendKeys([]K) []K + // Get should return the value for the supplied key + Get(K) T + } + + // MapSource is an adaptor from a Go map to a Source + MapSource[K ~string, T any] map[K]T +) + +func (m MapSource[K, _]) AppendKeys(a []K) []K { + if cap(a)-len(a) < len(m) { + a = append(make([]K, 0, len(a)+len(m)), a...) + } + for k := range m { + a = append(a, k) + } + return a +} + +func (m MapSource[K, T]) Get(s K) T { return m[s] } + +// NewMap creates a map which can be persisted to disk easily but +// is slightly slower than the "faster" version owing to an unavoidable bounds check. +func NewMap[K ~string, T any](srcMap Source[K, T]) Map[K, T] { + if keys := srcMap.AppendKeys([]K(nil)); len(keys) > 0 { + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + return Map[K, T]{store: build[K, T](keys, srcMap)} + } + return Map[K, T]{store: []byteValue[T]{{}}} +} + +// build constructs the map by allocating memory in blocks +// and then copying into the eventual slice at the end. +// This is more efficient than continually using append. +func build[K ~string, T any](keys []K, src Source[K, T]) []byteValue[T] { + b := builder[K, T]{ + all: [][]byteValue[T]{make([]byteValue[T], 1, firstBufSize(len(keys)))}, + src: src, + len: 1, + } + b.makeByteValue(&b.all[0][0], keys, 0) + // copy all blocks to one slice + s := make([]byteValue[T], 0, b.len) + for _, a := range b.all { + s = append(s, a...) + } + return s +} + +// makeByteValue will initialise the supplied byteValue for +// the sorted strings in slice a considering bytes at byteIndex in the strings +func (b *builder[K, T]) makeByteValue(bv *byteValue[T], a []K, byteIndex int) { + // if there is a string with no-more bytes then it is always first because they are sorted + if len(a[0]) == byteIndex { + bv.valid = true + bv.value = b.src.Get(a[0]) + a = a[1:] + } + if len(a) == 0 { + return + } + bv.nextOffset = a[0][byteIndex] // lowest value for next byte + bv.nextLen = a[len(a)-1][byteIndex] - // highest value for next byte + bv.nextOffset + 1 // minus lowest value +1 = number of possible next bytes + bv.nextLo = uint32(b.len) // first byteValue struct in eventual built slice + next := b.alloc(bv.nextLen) // new byteValues default to "not valid" + + for i, n := 0, len(a); i < n; { + // find range of strings starting with the same byte + iSameByteHi := i + 1 + for iSameByteHi < n && a[iSameByteHi][byteIndex] == a[i][byteIndex] { + iSameByteHi++ + } + b.makeByteValue(&next[(a[i][byteIndex]-bv.nextOffset)], a[i:iSameByteHi], byteIndex+1) + i = iSameByteHi + } +} + +const maxBuildBufSize = 1 << 20 + +func firstBufSize(mapSize int) int { + size := 1 << 4 + for size < mapSize && size < maxBuildBufSize { + size <<= 1 + } + return size +} + +// alloc will grab space in the current block if available or allocate a new one if not +func (b *builder[_, T]) alloc(nByteValues byte) []byteValue[T] { + n := int(nByteValues) + b.len += n + cur := &b.all[len(b.all)-1] // current + curCap, curLen := cap(*cur), len(*cur) + if curCap-curLen >= n { // enough space in current + *cur = (*cur)[: curLen+n : curCap] + return (*cur)[curLen:] + } + newCap := curCap * 2 + for newCap < n { + newCap *= 2 + } + if newCap > maxBuildBufSize { + newCap = maxBuildBufSize + } + a := make([]byteValue[T], n, newCap) + b.all = append(b.all, a) + return a +} + +// NewMapFaster creates a map which is faster than Map +// but can't be directly persisted to disk +func NewMapFaster[K ~string, T any](srcMap Map[K, T]) MapFaster[K, T] { + m := MapFaster[K, T]{store: make([]byteValueSlice[T], len(srcMap.store))} + for i := range srcMap.store { + v, sv := &m.store[i], &srcMap.store[i] + v.nextOffset = sv.nextOffset + v.valid = sv.valid + v.value = sv.value + v.next = m.store[sv.nextLo : sv.nextLo+uint32(sv.nextLen)] + } + return m +} + +// LookupString looks up the supplied string in the map +func (m Map[K, T]) LookupString(s K) (T, bool) { + bv := &m.store[0] + for i, n := 0, len(s); i < n; i++ { + b := s[i] + if b < bv.nextOffset { + var r T + return r, false + } + ni := b - bv.nextOffset + if ni >= bv.nextLen { + var r T + return r, false + } + bv = &m.store[bv.nextLo+uint32(ni)] + } + return bv.value, bv.valid +} + +func (m Map[_, _]) Empty() bool { + return len(m.store) == 1 && !m.store[0].valid +} + +// AppendSortedKeys appends the keys in the map to the supplied slice in sorted order +func (m Map[K, _]) AppendSortedKeys(a []K) []K { + buf := make([]byte, 0, 256) // initially allocate for reasonable max key length, but this is not a maximum + m.appendKeysFrom(0, &buf, &a) + return a +} + +func (m Map[K, _]) appendKeysFrom(storeIndex uint32, prefix *[]byte, a *[]K) { + bv := &m.store[storeIndex] + if bv.valid { + *a = append(*a, K(*prefix)) + } + for i := byte(0); i < bv.nextLen; i++ { + *prefix = append(*prefix, bv.nextOffset+i) + m.appendKeysFrom(bv.nextLo+uint32(i), prefix, a) + *prefix = (*prefix)[:len(*prefix)-1] + } +} + +// LookupBytes looks up the supplied byte slice in the map +func (m Map[_, T]) LookupBytes(s []byte) (T, bool) { + bv := &m.store[0] + for i, n := 0, len(s); i < n; i++ { + b := s[i] + if b < bv.nextOffset { + var r T + return r, false + } + ni := b - bv.nextOffset + if ni >= bv.nextLen { + var r T + return r, false + } + bv = &m.store[bv.nextLo+uint32(ni)] + } + return bv.value, bv.valid +} + +// LookupString looks up the supplied string in the map +func (m MapFaster[_, T]) LookupString(s string) (T, bool) { + bv := &m.store[0] + for i, n := 0, len(s); i < n; i++ { + b := s[i] + if b < bv.nextOffset { + var r T + return r, false + } + // careful to avoid bounds check + ni := int(b - bv.nextOffset) + if ni >= len(bv.next) { + var r T + return r, false + } + bv = &bv.next[ni] + } + return bv.value, bv.valid +} + +// LookupBytes looks up the supplied byte slice in the map +func (m MapFaster[_, T]) LookupBytes(s []byte) (T, bool) { + bv := &m.store[0] + for i, n := 0, len(s); i < n; i++ { + b := s[i] + if b < bv.nextOffset { + var r T + return r, false + } + // careful to avoid bounds check + ni := int(b - bv.nextOffset) + if ni >= len(bv.next) { + var r T + return r, false + } + bv = &bv.next[ni] + } + return bv.value, bv.valid +} diff --git a/v2/fast_string_map_example_test.go b/v2/fast_string_map_example_test.go new file mode 100644 index 0000000..df49f75 --- /dev/null +++ b/v2/fast_string_map_example_test.go @@ -0,0 +1,63 @@ +// Copyright 2022 The Sensible Code Company Ltd +// Author: Duncan Harris + +package faststringmap_test + +import ( + "fmt" + "sort" + "strings" + + "github.com/sensiblecodeio/faststringmap/v2" +) + +func Example() { + m := faststringmap.MapSource[string, int]{ + "key1": 42, + "key2": 27644437, + "l": 2, + } + + fm := faststringmap.NewMap[string, int](m) + + // add an entry that is not in the fast map + m["m"] = 4 + + // sort the keys so output is the same for each test run + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + // lookup every key in the fast map and print the corresponding value + for _, k := range keys { + v, ok := fm.LookupString(k) + fmt.Printf("%q: %d, %v\n", k, v, ok) + } + + // Dump out the store to aid in understanding the implementation + fmt.Println() + dump := fmt.Sprintf("%+v", fm) + dump = strings.ReplaceAll(dump, "}", "}\n") + dump = strings.ReplaceAll(dump, "[", "[\n ") + dump = strings.ReplaceAll(dump, " ", "\t") + fmt.Println(dump) + + // Output: + // + // "key1": 42, true + // "key2": 27644437, true + // "l": 2, true + // "m": 0, false + // + // {store:[ + // {nextLo:1 nextLen:2 nextOffset:107 valid:false value:0} + // {nextLo:3 nextLen:1 nextOffset:101 valid:false value:0} + // {nextLo:0 nextLen:0 nextOffset:0 valid:true value:2} + // {nextLo:4 nextLen:1 nextOffset:121 valid:false value:0} + // {nextLo:5 nextLen:2 nextOffset:49 valid:false value:0} + // {nextLo:0 nextLen:0 nextOffset:0 valid:true value:42} + // {nextLo:0 nextLen:0 nextOffset:0 valid:true value:27644437} + // ]} +} diff --git a/v2/fast_string_map_test.go b/v2/fast_string_map_test.go new file mode 100644 index 0000000..211cf48 --- /dev/null +++ b/v2/fast_string_map_test.go @@ -0,0 +1,152 @@ +// Copyright 2022 The Sensible Code Company Ltd +// Author: Duncan Harris + +package faststringmap_test + +import ( + "fmt" + "math/rand" + "strconv" + "strings" + "testing" + + "github.com/sensiblecodeio/faststringmap/v2" +) + +func TestFastStringToUint32Empty(t *testing.T) { + ms := mapSliceN(map[string]uint32{"": 1, "a": 2, "foo": 3, "ß": 4}, 0) + checkWithMapSlice(t, ms) +} + +func TestFastStringToUint32BigSpan(t *testing.T) { + ms := mapSliceN(map[string]uint32{"a!": 1, "a~": 2}, 2) + checkWithMapSlice(t, ms) +} + +func TestFastStringToUint32(t *testing.T) { + const nStrs = 8192 + m := randomSmallStrings(nStrs, 8) + checkWithMapSlice(t, mapSliceN(m, len(m)/2)) +} + +func checkWithMapSlice(t *testing.T, ms mapSlice) { + m := faststringmap.NewMap[string, uint32](ms) + mf := faststringmap.NewMapFaster(m) + + for _, k := range ms.in { + check := func(actV uint32, ok bool) { + if !ok { + t.Errorf("%q not present", k) + } else if actV != ms.m[k] { + t.Errorf("got %d want %d for %q", actV, ms.m[k], k) + } + } + check(m.LookupString(k)) + check(mf.LookupString(k)) + check(m.LookupBytes([]byte(k))) + check(mf.LookupBytes([]byte(k))) + } + + for _, k := range ms.out { + check := func(actV uint32, ok bool) { + if ok { + t.Errorf("%q present when not expected, got %d", k, actV) + } + } + check(m.LookupString(k)) + check(mf.LookupString(k)) + check(m.LookupBytes([]byte(k))) + check(mf.LookupBytes([]byte(k))) + } +} + +type mapSlice struct { + m map[string]uint32 + in []string + out []string +} + +func mapSliceN(m map[string]uint32, n int) mapSlice { + if n < 0 || n > len(m) { + panic(fmt.Sprintf("n value %d out of range for map size %d", n, len(m))) + } + in := make([]string, 0, n) + out := make([]string, 0, len(m)-n) + nAdded := 0 + + for k := range m { + if nAdded < n { + nAdded++ + in = append(in, k) + } else { + out = append(out, k) + } + } + return mapSlice{m: m, in: in, out: out} +} + +func (m mapSlice) AppendKeys(a []string) []string { return append(a, m.in...) } +func (m mapSlice) Get(s string) uint32 { return m.m[s] } + +func randomSmallStrings(nStrs int, maxLen uint8) map[string]uint32 { + m := map[string]uint32{"": 0} + for len(m) < nStrs { + s := randomSmallString(maxLen) + if _, ok := m[s]; !ok { + m[s] = uint32(len(m)) + } + } + return m +} + +func randomSmallString(maxLen uint8) string { + var sb strings.Builder + n := rand.Intn(int(maxLen) + 1) + for i := 0; i <= n; i++ { + sb.WriteRune(rand.Int31n(94) + 33) + } + return sb.String() +} + +func typicalCodeStrings(n int) mapSlice { + m := make(map[string]uint32, n) + keys := make([]string, 0, n) + add := func(s string) { + m[s] = uint32(len(m)) + keys = append(keys, s) + } + for i := 1; i < n; i++ { + add(strconv.Itoa(i)) + } + add("-9") + return mapSlice{m: m, in: keys} +} + +const nStrsBench = 1000 + +func BenchmarkUint32Store(b *testing.B) { + m := typicalCodeStrings(nStrsBench) + fm := faststringmap.NewMap[string, uint32](m) + b.ResetTimer() + for bi := 0; bi < b.N; bi++ { + for si, n := uint32(0), uint32(len(m.in)); si < n; si++ { + v, ok := fm.LookupString(m.in[si]) + if !ok || v != si { + b.Fatalf("ok=%v, value got %d want %d", ok, v, si) + } + } + } +} + +func BenchmarkGoStringToUint32(b *testing.B) { + m := typicalCodeStrings(nStrsBench) + b.ResetTimer() + for bi := 0; bi < b.N; bi++ { + for si, n := uint32(0), uint32(len(m.in)); si < n; si++ { + v, ok := m.m[m.in[si]] + if !ok || v != si { + b.Fatalf("ok=%v, value got %d want %d", ok, v, si) + } + } + } +} diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 0000000..9ff44f5 --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,3 @@ +module github.com/sensiblecodeio/faststringmap/v2 + +go 1.18