diff --git a/blockchain/utxocache.go b/blockchain/utxocache.go new file mode 100644 index 00000000..0c2e99c5 --- /dev/null +++ b/blockchain/utxocache.go @@ -0,0 +1,167 @@ +// Copyright (c) 2023 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package blockchain + +import ( + "sync" + + "github.com/btcsuite/btcd/wire" +) + +// mapSlice is a slice of maps for utxo entries. The slice of maps are needed to +// guarantee that the map will only take up N amount of bytes. As of v1.20, the +// go runtime will allocate 2^N + few extra buckets, meaning that for large N, we'll +// allocate a lot of extra memory if the amount of entries goes over the previously +// allocated buckets. A slice of maps allows us to have a better control of how much +// total memory gets allocated by all the maps. +type mapSlice struct { + // mtx protects against concurrent access for the map slice. + mtx sync.Mutex + + // maps are the underlying maps in the slice of maps. + maps []map[wire.OutPoint]*UtxoEntry + + // maxEntries is the maximum amount of elements that the map is allocated for. + maxEntries []int + + // maxTotalMemoryUsage is the maximum memory usage in bytes that the state + // should contain in normal circumstances. + maxTotalMemoryUsage uint64 +} + +// length returns the length of all the maps in the map slice added together. +// +// This function is safe for concurrent access. +func (ms *mapSlice) length() int { + ms.mtx.Lock() + defer ms.mtx.Unlock() + + var l int + for _, m := range ms.maps { + l += len(m) + } + + return l +} + +// size returns the size of all the maps in the map slice added together. +// +// This function is safe for concurrent access. +func (ms *mapSlice) size() int { + ms.mtx.Lock() + defer ms.mtx.Unlock() + + var size int + for _, num := range ms.maxEntries { + size += calculateRoughMapSize(num, bucketSize) + } + + return size +} + +// get looks for the outpoint in all the maps in the map slice and returns +// the entry. nil and false is returned if the outpoint is not found. +// +// This function is safe for concurrent access. +func (ms *mapSlice) get(op wire.OutPoint) (*UtxoEntry, bool) { + ms.mtx.Lock() + defer ms.mtx.Unlock() + + var entry *UtxoEntry + var found bool + + for _, m := range ms.maps { + entry, found = m[op] + if found { + return entry, found + } + } + + return nil, false +} + +// put puts the outpoint and the entry into one of the maps in the map slice. If the +// existing maps are all full, it will allocate a new map based on how much memory we +// have left over. Leftover memory is calculated as: +// maxTotalMemoryUsage - (totalEntryMemory + mapSlice.size()) +// +// This function is safe for concurrent access. +func (ms *mapSlice) put(op wire.OutPoint, entry *UtxoEntry, totalEntryMemory uint64) { + ms.mtx.Lock() + defer ms.mtx.Unlock() + + for i, maxNum := range ms.maxEntries { + m := ms.maps[i] + _, found := m[op] + if found { + // If the key is found, overwrite it. + m[op] = entry + return // Return as we were successful in adding the entry. + } + if len(m) >= maxNum { + // Don't try to insert if the map already at max since + // that'll force the map to allocate double the memory it's + // currently taking up. + continue + } + + m[op] = entry + return // Return as we were successful in adding the entry. + } + + // We only reach this code if we've failed to insert into the map above as + // all the current maps were full. We thus make a new map and insert into + // it. + m := ms.makeNewMap(totalEntryMemory) + m[op] = entry +} + +// delete attempts to delete the given outpoint in all of the maps. No-op if the +// outpoint doesn't exist. +// +// This function is safe for concurrent access. +func (ms *mapSlice) delete(op wire.OutPoint) { + ms.mtx.Lock() + defer ms.mtx.Unlock() + + for i := 0; i < len(ms.maps); i++ { + delete(ms.maps[i], op) + } +} + +// makeNewMap makes and appends the new map into the map slice. +// +// This function is NOT safe for concurrent access and must be called with the +// lock held. +func (ms *mapSlice) makeNewMap(totalEntryMemory uint64) map[wire.OutPoint]*UtxoEntry { + // Get the size of the leftover memory. + memSize := ms.maxTotalMemoryUsage - totalEntryMemory + for _, maxNum := range ms.maxEntries { + memSize -= uint64(calculateRoughMapSize(maxNum, bucketSize)) + } + + // Get a new map that's sized to house inside the leftover memory. + // -1 on the returned value will make the map allocate half as much total + // bytes. This is done to make sure there's still room left for utxo + // entries to take up. + numMaxElements := calculateMinEntries(int(memSize), bucketSize+avgEntrySize) + numMaxElements -= 1 + ms.maxEntries = append(ms.maxEntries, numMaxElements) + ms.maps = append(ms.maps, make(map[wire.OutPoint]*UtxoEntry, numMaxElements)) + + return ms.maps[len(ms.maps)-1] +} + +// deleteMaps deletes all maps except for the first one which should be the biggest. +// +// This function is safe for concurrent access. +func (ms *mapSlice) deleteMaps() { + ms.mtx.Lock() + defer ms.mtx.Unlock() + + size := ms.maxEntries[0] + ms.maxEntries = []int{size} + ms.maps = ms.maps[:1] +} diff --git a/blockchain/utxocache_test.go b/blockchain/utxocache_test.go new file mode 100644 index 00000000..41478efd --- /dev/null +++ b/blockchain/utxocache_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2023 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package blockchain + +import ( + "crypto/sha256" + "encoding/binary" + "reflect" + "sync" + "testing" + + "github.com/btcsuite/btcd/wire" +) + +func TestMapSlice(t *testing.T) { + tests := []struct { + keys []wire.OutPoint + }{ + { + keys: func() []wire.OutPoint { + outPoints := make([]wire.OutPoint, 1000) + for i := uint32(0); i < uint32(len(outPoints)); i++ { + var buf [4]byte + binary.BigEndian.PutUint32(buf[:], i) + hash := sha256.Sum256(buf[:]) + + op := wire.OutPoint{Hash: hash, Index: i} + outPoints[i] = op + } + return outPoints + }(), + }, + } + + for _, test := range tests { + m := make(map[wire.OutPoint]*UtxoEntry) + + maxSize := calculateRoughMapSize(1000, bucketSize) + + maxEntriesFirstMap := 500 + ms1 := make(map[wire.OutPoint]*UtxoEntry, maxEntriesFirstMap) + ms := mapSlice{ + maps: []map[wire.OutPoint]*UtxoEntry{ms1}, + maxEntries: []int{maxEntriesFirstMap}, + maxTotalMemoryUsage: uint64(maxSize), + } + + for _, key := range test.keys { + m[key] = nil + ms.put(key, nil, 0) + } + + // Put in the same elements twice to test that the map slice won't hold duplicates. + for _, key := range test.keys { + m[key] = nil + ms.put(key, nil, 0) + } + + if len(m) != ms.length() { + t.Fatalf("expected len of %d, got %d", len(m), ms.length()) + } + + for _, key := range test.keys { + expected, found := m[key] + if !found { + t.Fatalf("expected key %s to exist in the go map", key.String()) + } + + got, found := ms.get(key) + if !found { + t.Fatalf("expected key %s to exist in the map slice", key.String()) + } + + if !reflect.DeepEqual(got, expected) { + t.Fatalf("expected value of %v, got %v", expected, got) + } + } + } +} + +// TestMapsliceConcurrency just tests that the mapslice won't result in a panic +// on concurrent access. +func TestMapsliceConcurrency(t *testing.T) { + tests := []struct { + keys []wire.OutPoint + }{ + { + keys: func() []wire.OutPoint { + outPoints := make([]wire.OutPoint, 10000) + for i := uint32(0); i < uint32(len(outPoints)); i++ { + var buf [4]byte + binary.BigEndian.PutUint32(buf[:], i) + hash := sha256.Sum256(buf[:]) + + op := wire.OutPoint{Hash: hash, Index: i} + outPoints[i] = op + } + return outPoints + }(), + }, + } + + for _, test := range tests { + maxSize := calculateRoughMapSize(1000, bucketSize) + + maxEntriesFirstMap := 500 + ms1 := make(map[wire.OutPoint]*UtxoEntry, maxEntriesFirstMap) + ms := mapSlice{ + maps: []map[wire.OutPoint]*UtxoEntry{ms1}, + maxEntries: []int{maxEntriesFirstMap}, + maxTotalMemoryUsage: uint64(maxSize), + } + + var wg sync.WaitGroup + + wg.Add(1) + go func(m *mapSlice, keys []wire.OutPoint) { + defer wg.Done() + for i := 0; i < 5000; i++ { + m.put(keys[i], nil, 0) + } + }(&ms, test.keys) + + wg.Add(1) + go func(m *mapSlice, keys []wire.OutPoint) { + defer wg.Done() + for i := 5000; i < 10000; i++ { + m.put(keys[i], nil, 0) + } + }(&ms, test.keys) + + wg.Add(1) + go func(m *mapSlice) { + defer wg.Done() + for i := 0; i < 10000; i++ { + m.size() + } + }(&ms) + + wg.Add(1) + go func(m *mapSlice) { + defer wg.Done() + for i := 0; i < 10000; i++ { + m.length() + } + }(&ms) + + wg.Add(1) + go func(m *mapSlice, keys []wire.OutPoint) { + defer wg.Done() + for i := 0; i < 10000; i++ { + m.get(keys[i]) + } + }(&ms, test.keys) + + wg.Add(1) + go func(m *mapSlice, keys []wire.OutPoint) { + defer wg.Done() + for i := 0; i < 5000; i++ { + m.delete(keys[i]) + } + }(&ms, test.keys) + + wg.Wait() + } +}