From ad5873cf0b8935f1d7756c5019ae2a0dc111677f Mon Sep 17 00:00:00 2001 From: Joshua Lockerman <> Date: Wed, 8 Jul 2020 20:09:36 -0400 Subject: [PATCH 1/6] Genericize the cache by using interfaces --- cache.go | 81 ++++++++++++++++++++++++------- cache_test.go | 36 +++++++------- list.go | 130 ++++++++++++++------------------------------------ 3 files changed, 118 insertions(+), 129 deletions(-) diff --git a/cache.go b/cache.go index 45fd473..0dddea4 100644 --- a/cache.go +++ b/cache.go @@ -1,6 +1,7 @@ package smolcache import ( + "encoding/binary" "sync" "sync/atomic" @@ -36,13 +37,13 @@ type Interner struct { type block struct { lock sync.RWMutex // guarded by lock - elements map[string]*Element + elements map[interface{}]*Element // only safe to not use a pointer since blocks never move sweep List // CLOCK sweep state, guarded by clockLock - next *Element + prev *Element // pad blocks out to be cache aligned _padding [16]byte } @@ -54,7 +55,19 @@ func WithMax(max uint64) *Interner { } } -func (i *Interner) Insert(key string, value int64) { +func WithMaxAndShards(max uint64, shards int) *Interner { + //TODO variable number of shards + return &Interner{ + max: max, + seed: maphash.MakeSeed(), + } +} + +func (i *Interner) GetSeed() maphash.Seed { + return i.seed +} + +func (i *Interner) InsertString(key string, value interface{}) { newSize := atomic.AddUint64(&i.count, 1) needsEvict := newSize > i.max if needsEvict { @@ -64,16 +77,34 @@ func (i *Interner) Insert(key string, value int64) { h := maphash.Hash{} h.SetSeed(i.seed) h.WriteString(key) - blockNum := h.Sum64() % 127 + i.InsertWithHash(key, value, h.Sum64()) +} + +func (i *Interner) InsertInt(key uint64, value interface{}) { + newSize := atomic.AddUint64(&i.count, 1) + needsEvict := newSize > i.max + if needsEvict { + i.evict() + } + + h := maphash.Hash{} + h.SetSeed(i.seed) + b := [8]byte{} + binary.LittleEndian.PutUint64(b[:], key) + i.InsertWithHash(key, value, h.Sum64()) +} + +func (i *Interner) InsertWithHash(key interface{}, value interface{}, hash uint64) { + blockNum := hash % 127 block := &i.maps[blockNum] block.insert(key, value) } -func (b *block) insert(key string, value int64) bool { +func (b *block) insert(key interface{}, value interface{}) bool { b.lock.Lock() defer b.lock.Unlock() if b.elements == nil { - b.elements = make(map[string]*Element) + b.elements = make(map[interface{}]*Element) } _, present := b.elements[key] if present { @@ -106,23 +137,25 @@ func (i *Interner) evict() { func (b *block) tryEvict() (evicted bool, reachedEnd bool) { b.lock.Lock() defer b.lock.Unlock() - if b.next == nil { - b.next = b.sweep.Front() - if b.next == nil { - return false, true - } + if b.prev == nil { + b.prev = b.sweep.Root() + } + + elem := b.prev.Next() + if elem == nil { + return false, true } evicted = false reachedEnd = false for !evicted && !reachedEnd { - elem := b.next - b.next = elem.Next() - reachedEnd = b.next == nil if elem.used != 0 { elem.used = 0 + b.prev = elem + elem = b.prev.Next() + reachedEnd = elem == nil } else { - key, _ := b.sweep.Remove(elem) + key, _ := b.sweep.RemoveNext(b.prev) delete(b.elements, key) evicted = true } @@ -131,16 +164,28 @@ func (b *block) tryEvict() (evicted bool, reachedEnd bool) { return evicted, reachedEnd } -func (i *Interner) Get(key string) (int64, bool) { +func (i *Interner) GetString(key string) (interface{}, bool) { h := maphash.Hash{} h.SetSeed(i.seed) h.WriteString(key) - blockNum := h.Sum64() % 127 + return i.GetWithHash(key, h.Sum64()) +} + +func (i *Interner) GetInt(key uint64) (interface{}, bool) { + h := maphash.Hash{} + h.SetSeed(i.seed) + b := [8]byte{} + binary.LittleEndian.PutUint64(b[:], key) + return i.GetWithHash(key, h.Sum64()) +} + +func (i *Interner) GetWithHash(key interface{}, hash uint64) (interface{}, bool) { + blockNum := hash % 127 block := &i.maps[blockNum] return block.get(key) } -func (b *block) get(key string) (int64, bool) { +func (b *block) get(key interface{}) (interface{}, bool) { b.lock.RLock() defer b.lock.RUnlock() if b.elements == nil { diff --git a/cache_test.go b/cache_test.go index 56fa0a8..42e579b 100644 --- a/cache_test.go +++ b/cache_test.go @@ -13,8 +13,8 @@ func TestWriteAndGetOnCache(t *testing.T) { cache := WithMax(100) - cache.Insert("1", 1) - val, found := cache.Get("1") + cache.InsertString("1", 1) + val, found := cache.GetString("1") // then if !found { @@ -30,14 +30,14 @@ func TestEntryNotFound(t *testing.T) { cache := WithMax(100) - val, found := cache.Get("nonExistingKey") + val, found := cache.GetString("nonExistingKey") if found { t.Errorf("found %d for noexistent key", val) } - cache.Insert("key", 1) + cache.InsertString("key", 1) - val, found = cache.Get("nonExistingKey") + val, found = cache.GetString("nonExistingKey") if found { t.Errorf("found %d for noexistent key", val) } @@ -49,18 +49,18 @@ func TestEviction(t *testing.T) { cache := WithMax(10) for i := 0; i < 10; i++ { key := fmt.Sprintf("%d", i) - cache.Insert(key, int64(i)) + cache.InsertString(key, int64(i)) if i != 5 { - cache.Get(key) + cache.GetString(key) } } - cache.Insert("100", 100) - cache.Get("100") + cache.InsertString("100", 100) + cache.GetString("100") for i := 0; i < 10; i++ { key := fmt.Sprintf("%d", i) - val, found := cache.Get(key) + val, found := cache.GetString(key) if i != 5 && (!found || val != int64(i)) { t.Errorf("missing value %d, got %d", i, val) } else if i == 5 && found { @@ -71,17 +71,17 @@ func TestEviction(t *testing.T) { } } - val, found := cache.Get("100") + val, found := cache.GetString("100") if !found || val != 100 { t.Errorf("missing value 100, got %d", val) } - cache.Insert("101", 101) - cache.Get("101") + cache.InsertString("101", 101) + cache.GetString("101") for i := 0; i < 10; i++ { key := fmt.Sprintf("%d", i) - val, found := cache.Get(key) + val, found := cache.GetString(key) if i != 5 && i != 2 && (!found || val != int64(i)) { t.Errorf("missing value %d, (found: %v) got %d", i, found, val) } else if (i == 5 || i == 2) && found { @@ -89,11 +89,11 @@ func TestEviction(t *testing.T) { } } - val, found = cache.Get("100") + val, found = cache.GetString("100") if !found || val != 100 { t.Errorf("missing value 100, got %d", val) } - val, found = cache.Get("101") + val, found = cache.GetString("101") if !found || val != 101 { t.Errorf("missing value 101, got %d", val) } @@ -110,7 +110,7 @@ func TestCacheGetRandomly(t *testing.T) { for i := 0; i < ntest; i++ { r := rand.Int63() % 20000 key := fmt.Sprintf("%d", r) - cache.Insert(key, r+1) + cache.InsertString(key, r+1) } wg.Done() }() @@ -118,7 +118,7 @@ func TestCacheGetRandomly(t *testing.T) { for i := 0; i < ntest; i++ { r := rand.Int63() key := fmt.Sprintf("%d", r) - if val, found := cache.Get(key); found && val != r+1 { + if val, found := cache.GetString(key); found && val != r+1 { t.Errorf("got %s ->\n %x\n expected:\n %x\n ", key, val, r+1) } } diff --git a/list.go b/list.go index e06c3a7..e133fb7 100644 --- a/list.go +++ b/list.go @@ -1,146 +1,90 @@ -// based on go std package "container/list", specialized to our -// usecase. original license: -// Copyright 2009 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - package smolcache // Element is an element of a linked list. type Element struct { // The value stored with this element. - key string - Value int64 - - // pad Elements out to be cache aligned - _padding [8]byte + key interface{} + Value interface{} - // Next and previous pointers in the doubly-linked list of elements. + // Next and previous pointers in the singly-linked list of elements. // To simplify the implementation, internally a list l is implemented - // as a ring, such that &l.root is both the next element of the last - // list element (l.Back()) and the previous element of the first list - // element (l.Front()). - next, prev *Element - - // The list to which this element belongs. - list *List - + // as a ring + next *Element //CLOCK marker if this is recently used used uint32 + + // pad Elements out to be cache aligned + _padding [16]byte } // Next returns the next list element or nil. func (e *Element) Next() *Element { - if p := e.next; e.list != nil && p != &e.list.root { - return p - } - return nil -} - -// Prev returns the previous list element or nil. -func (e *Element) Prev() *Element { - if p := e.prev; e.list != nil && p != &e.list.root { - return p - } - return nil + return e.next } -// List represents a doubly linked list. -// The zero value for List is an empty list ready to use. +// List represents a singly linked list. type List struct { - root Element // sentinel list element, only &root, root.prev, and root.next are used - len int // current list length excluding (this) sentinel element + root Element // sentinel list element, only &root and root.next are used + last *Element } // Init initializes or clears list l. func (l *List) Init() *List { - l.root.next = &l.root - l.root.prev = &l.root - l.len = 0 + l.root.next = nil + l.last = &l.root return l } // New returns an initialized list. func New() *List { return new(List).Init() } -// Len returns the number of elements of list l. -// The complexity is O(1). -func (l *List) Len() int { return l.len } - -// Front returns the first element of list l or nil if the list is empty. -func (l *List) Front() *Element { - if l.len == 0 { - return nil - } - return l.root.next -} - -// lazyInit lazily initializes a zero List value. -func (l *List) lazyInit() { - if l.root.next == nil { +// Front returns the root element of list l. +func (l *List) Root() *Element { + if l.last == nil { l.Init() } + return &l.root } // insert inserts e after at, increments l.len, and returns e. func (l *List) insert(e, at *Element) *Element { n := at.next at.next = e - e.prev = at e.next = n - n.prev = e - e.list = l - l.len++ return e } // insertValue is a convenience wrapper for insert(&Element{Value: v}, at). -func (l *List) insertValue(k string, v int64, at *Element) *Element { +func (l *List) insertValue(k interface{}, v interface{}, at *Element) *Element { return l.insert(&Element{key: k, Value: v}, at) } // remove removes e from its list, decrements l.len, and returns e. -func (l *List) remove(e *Element) *Element { - e.prev.next = e.next - e.next.prev = e.prev - e.next = nil // avoid memory leaks - e.prev = nil // avoid memory leaks - e.list = nil - l.len-- - return e -} - -// move moves e to next to at and returns e. -func (l *List) move(e, at *Element) *Element { - if e == at { - return e - } - e.prev.next = e.next - e.next.prev = e.prev - - n := at.next - at.next = e - e.prev = at - e.next = n - n.prev = e - - return e +func (l *List) removeNext(e *Element) *Element { + next := e.next + e.next = e.next.next + next.next = nil // avoid memory leaks + return next } // Remove removes e from l if e is an element of list l. // It returns the element value e.Value. // The element must not be nil. -func (l *List) Remove(e *Element) (string, int64) { - if e.list == l { - // if e.list == l, l must have been initialized when e was inserted - // in l or l == nil (e is a zero Element) and l.remove will crash - l.remove(e) +func (l *List) RemoveNext(e *Element) (key interface{}, val interface{}) { + if e.next == nil { + return } - return e.key, e.Value + next := l.removeNext(e) + if next == l.last { + l.last = e + } + return next.key, next.Value } // PushBack inserts a new element e with value v at the back of list l and returns e. -func (l *List) PushBack(k string, v int64) *Element { - l.lazyInit() - return l.insertValue(k, v, l.root.prev) +func (l *List) PushBack(k interface{}, v interface{}) *Element { + if l.last == nil { + l.Init() + } + return l.insertValue(k, v, l.last) } From bed950439278f784117b1a12f4a8a641f239bf8d Mon Sep 17 00:00:00 2001 From: Joshua Lockerman <> Date: Thu, 9 Jul 2020 13:12:41 -0400 Subject: [PATCH 2/6] Non-sharded version of the cache. This version has a higher write variance, and more contention between the writers and everyone else --- cache.go | 172 ++++++++++++-------------------------------------- cache_test.go | 66 +++++++++++-------- list.go | 11 ++-- 3 files changed, 86 insertions(+), 163 deletions(-) diff --git a/cache.go b/cache.go index 0dddea4..738c02e 100644 --- a/cache.go +++ b/cache.go @@ -1,11 +1,8 @@ package smolcache import ( - "encoding/binary" "sync" "sync/atomic" - - "hash/maphash" ) // CLOCK based approximate LRU storing mappings from strings to @@ -17,116 +14,61 @@ import ( // block. Eviction locks blocks one at a time looking for a value // that's valid to evict/ type Interner struct { - maps [127]block - - max uint64 - seed maphash.Seed - - // padding so that the count, which changes frequently, doesn't - // share a cache line with the max and seed, which are read only - _padding [48]byte - - count uint64 - - clockLock sync.Mutex - - // CLOCK sweep state, guarded by clockLock - clock uint8 -} - -type block struct { - lock sync.RWMutex - // guarded by lock elements map[interface{}]*Element - + max uint64 // only safe to not use a pointer since blocks never move sweep List - // CLOCK sweep state, guarded by clockLock + // CLOCK sweep state, must have write lock prev *Element - // pad blocks out to be cache aligned - _padding [16]byte + + lock sync.RWMutex + count uint64 } func WithMax(max uint64) *Interner { return &Interner{ - max: max, - seed: maphash.MakeSeed(), + max: max, } } func WithMaxAndShards(max uint64, shards int) *Interner { + if max < 1 { + panic("must have max greater than 0") + } //TODO variable number of shards return &Interner{ - max: max, - seed: maphash.MakeSeed(), + max: max, } } -func (i *Interner) GetSeed() maphash.Seed { - return i.seed -} - -func (i *Interner) InsertString(key string, value interface{}) { +func (i *Interner) Insert(key interface{}, value interface{}) (interface{}, bool) { newSize := atomic.AddUint64(&i.count, 1) needsEvict := newSize > i.max + i.lock.Lock() + defer i.lock.Unlock() if needsEvict { i.evict() } - h := maphash.Hash{} - h.SetSeed(i.seed) - h.WriteString(key) - i.InsertWithHash(key, value, h.Sum64()) -} - -func (i *Interner) InsertInt(key uint64, value interface{}) { - newSize := atomic.AddUint64(&i.count, 1) - needsEvict := newSize > i.max - if needsEvict { - i.evict() - } - - h := maphash.Hash{} - h.SetSeed(i.seed) - b := [8]byte{} - binary.LittleEndian.PutUint64(b[:], key) - i.InsertWithHash(key, value, h.Sum64()) -} - -func (i *Interner) InsertWithHash(key interface{}, value interface{}, hash uint64) { - blockNum := hash % 127 - block := &i.maps[blockNum] - block.insert(key, value) -} - -func (b *block) insert(key interface{}, value interface{}) bool { - b.lock.Lock() - defer b.lock.Unlock() - if b.elements == nil { - b.elements = make(map[interface{}]*Element) + if i.elements == nil { + i.elements = make(map[interface{}]*Element) } - _, present := b.elements[key] + val, present := i.elements[key] if present { - return false + return val.Value, false } - elem := b.sweep.PushBack(key, value) - b.elements[key] = elem - return true + elem := i.sweep.PushBack(key, value) + i.elements[key] = elem + return value, true } func (i *Interner) evict() { - i.clockLock.Lock() - defer i.clockLock.Unlock() if i.count == 0 { return } for { - block := &i.maps[i.clock%127] - evicted, reachedEnd := block.tryEvict() - if reachedEnd { - i.clock += 1 - } + evicted := i.tryEvict() if evicted { atomic.AddUint64(&i.count, ^uint64(0)) break @@ -134,64 +76,41 @@ func (i *Interner) evict() { } } -func (b *block) tryEvict() (evicted bool, reachedEnd bool) { - b.lock.Lock() - defer b.lock.Unlock() - if b.prev == nil { - b.prev = b.sweep.Root() +func (i *Interner) tryEvict() (evicted bool) { + if i.prev == nil || i.prev.Next() == nil { + i.prev = i.sweep.Root() } - elem := b.prev.Next() + elem := i.prev.Next() if elem == nil { - return false, true + return false } evicted = false - reachedEnd = false + reachedEnd := false for !evicted && !reachedEnd { if elem.used != 0 { elem.used = 0 - b.prev = elem - elem = b.prev.Next() + i.prev = elem + elem = i.prev.Next() reachedEnd = elem == nil } else { - key, _ := b.sweep.RemoveNext(b.prev) - delete(b.elements, key) + key, _ := i.sweep.RemoveNext(i.prev) + delete(i.elements, key) evicted = true } } - return evicted, reachedEnd -} - -func (i *Interner) GetString(key string) (interface{}, bool) { - h := maphash.Hash{} - h.SetSeed(i.seed) - h.WriteString(key) - return i.GetWithHash(key, h.Sum64()) -} - -func (i *Interner) GetInt(key uint64) (interface{}, bool) { - h := maphash.Hash{} - h.SetSeed(i.seed) - b := [8]byte{} - binary.LittleEndian.PutUint64(b[:], key) - return i.GetWithHash(key, h.Sum64()) + return evicted } -func (i *Interner) GetWithHash(key interface{}, hash uint64) (interface{}, bool) { - blockNum := hash % 127 - block := &i.maps[blockNum] - return block.get(key) -} - -func (b *block) get(key interface{}) (interface{}, bool) { - b.lock.RLock() - defer b.lock.RUnlock() - if b.elements == nil { +func (i *Interner) Get(key interface{}) (interface{}, bool) { + i.lock.RLock() + defer i.lock.RUnlock() + if i.elements == nil { return 0, false } - elem, present := b.elements[key] + elem, present := i.elements[key] if !present { return 0, false } @@ -204,21 +123,12 @@ func (b *block) get(key interface{}) (interface{}, bool) { } func (i *Interner) Unmark(key string) bool { - h := maphash.Hash{} - h.SetSeed(i.seed) - h.WriteString(key) - blockNum := h.Sum64() % 127 - block := &i.maps[blockNum] - return block.unmark(key) -} - -func (b *block) unmark(key string) bool { - b.lock.RLock() - defer b.lock.RUnlock() - if b.elements == nil { + i.lock.RLock() + defer i.lock.RUnlock() + if i.elements == nil { return false } - elem, present := b.elements[key] + elem, present := i.elements[key] if !present { return false } diff --git a/cache_test.go b/cache_test.go index 42e579b..f81cc71 100644 --- a/cache_test.go +++ b/cache_test.go @@ -13,8 +13,8 @@ func TestWriteAndGetOnCache(t *testing.T) { cache := WithMax(100) - cache.InsertString("1", 1) - val, found := cache.GetString("1") + cache.Insert("1", 1) + val, found := cache.Get("1") // then if !found { @@ -30,14 +30,14 @@ func TestEntryNotFound(t *testing.T) { cache := WithMax(100) - val, found := cache.GetString("nonExistingKey") + val, found := cache.Get("nonExistingKey") if found { t.Errorf("found %d for noexistent key", val) } - cache.InsertString("key", 1) + cache.Insert("key", 1) - val, found = cache.GetString("nonExistingKey") + val, found = cache.Get("nonExistingKey") if found { t.Errorf("found %d for noexistent key", val) } @@ -49,18 +49,18 @@ func TestEviction(t *testing.T) { cache := WithMax(10) for i := 0; i < 10; i++ { key := fmt.Sprintf("%d", i) - cache.InsertString(key, int64(i)) + cache.Insert(key, int64(i)) if i != 5 { - cache.GetString(key) + cache.Get(key) } } - cache.InsertString("100", 100) - cache.GetString("100") + cache.Insert("100", 100) + cache.Get("100") for i := 0; i < 10; i++ { key := fmt.Sprintf("%d", i) - val, found := cache.GetString(key) + val, found := cache.Get(key) if i != 5 && (!found || val != int64(i)) { t.Errorf("missing value %d, got %d", i, val) } else if i == 5 && found { @@ -71,17 +71,17 @@ func TestEviction(t *testing.T) { } } - val, found := cache.GetString("100") + val, found := cache.Get("100") if !found || val != 100 { t.Errorf("missing value 100, got %d", val) } - cache.InsertString("101", 101) - cache.GetString("101") + cache.Insert("101", 101) + cache.Get("101") for i := 0; i < 10; i++ { key := fmt.Sprintf("%d", i) - val, found := cache.GetString(key) + val, found := cache.Get(key) if i != 5 && i != 2 && (!found || val != int64(i)) { t.Errorf("missing value %d, (found: %v) got %d", i, found, val) } else if (i == 5 || i == 2) && found { @@ -89,16 +89,24 @@ func TestEviction(t *testing.T) { } } - val, found = cache.GetString("100") + val, found = cache.Get("100") if !found || val != 100 { t.Errorf("missing value 100, got %d", val) } - val, found = cache.GetString("101") + val, found = cache.Get("101") if !found || val != 101 { t.Errorf("missing value 101, got %d", val) } } +func printCache(cache *Interner, t *testing.T) { + str := "[" + for k, v := range cache.elements { + str = fmt.Sprintf("%s\n\t%v: %v, ", str, k, v) + } + t.Logf("%s]", str) +} + func TestCacheGetRandomly(t *testing.T) { t.Parallel() @@ -110,7 +118,7 @@ func TestCacheGetRandomly(t *testing.T) { for i := 0; i < ntest; i++ { r := rand.Int63() % 20000 key := fmt.Sprintf("%d", r) - cache.InsertString(key, r+1) + cache.Insert(key, r+1) } wg.Done() }() @@ -118,7 +126,7 @@ func TestCacheGetRandomly(t *testing.T) { for i := 0; i < ntest; i++ { r := rand.Int63() key := fmt.Sprintf("%d", r) - if val, found := cache.GetString(key); found && val != r+1 { + if val, found := cache.Get(key); found && val != r+1 { t.Errorf("got %s ->\n %x\n expected:\n %x\n ", key, val, r+1) } } @@ -127,12 +135,12 @@ func TestCacheGetRandomly(t *testing.T) { wg.Wait() } -func TestBlockCacheAligned(t *testing.T) { - blockSize := unsafe.Sizeof(block{}) - if blockSize%64 != 0 { - t.Errorf("unaligned block size: %d", blockSize) - } -} +// func TestBlockCacheAligned(t *testing.T) { +// blockSize := unsafe.Sizeof(block{}) +// if blockSize%64 != 0 { +// t.Errorf("unaligned block size: %d", blockSize) +// } +// } func TestElementCacheAligned(t *testing.T) { elementSize := unsafe.Sizeof(Element{}) @@ -142,9 +150,11 @@ func TestElementCacheAligned(t *testing.T) { } func TestCountOffset(t *testing.T) { - seedOffset := unsafe.Offsetof(Interner{}.seed) - countOffset := unsafe.Offsetof(Interner{}.count) - if seedOffset/64 == countOffset/64 { - t.Errorf("seed and count on same cache line\nseed @ %d (%d)\noffset @ %d (%d)", seedOffset, seedOffset/64, countOffset, countOffset/64) + elementsOffset := unsafe.Offsetof(Interner{}.elements) + lockOffset := unsafe.Offsetof(Interner{}.lock) + t.Logf("elem offset %d", elementsOffset) + t.Logf("lock offset %d", lockOffset) + if elementsOffset/64 == lockOffset/64 { + t.Errorf("read-mostly and mutable on the same line\nseed @ %d (%d)\noffset @ %d (%d)", elementsOffset, elementsOffset/64, lockOffset, elementsOffset/64) } } diff --git a/list.go b/list.go index e133fb7..4980a50 100644 --- a/list.go +++ b/list.go @@ -2,6 +2,10 @@ package smolcache // Element is an element of a linked list. type Element struct { + //CLOCK marker if this is recently used, must be first for atomic alignment + used uint32 + //pad key out to 8 bytes + _padding0 [8]byte // The value stored with this element. key interface{} Value interface{} @@ -10,11 +14,9 @@ type Element struct { // To simplify the implementation, internally a list l is implemented // as a ring next *Element - //CLOCK marker if this is recently used - used uint32 // pad Elements out to be cache aligned - _padding [16]byte + _padding1 [1]byte } // Next returns the next list element or nil. @@ -86,5 +88,6 @@ func (l *List) PushBack(k interface{}, v interface{}) *Element { if l.last == nil { l.Init() } - return l.insertValue(k, v, l.last) + l.last = l.insertValue(k, v, l.last) + return l.last } From e5d5ef1c7abab3c1923eb109f29789125784fe80 Mon Sep 17 00:00:00 2001 From: Joshua Lockerman <> Date: Thu, 9 Jul 2020 13:57:50 -0400 Subject: [PATCH 3/6] array storage This seems to perform a little better on inserts (possibly due to fewer allocations) and about the same on gets --- cache.go | 91 +++++++++++++++++++++++++++++-------------------- cache_test.go | 22 +++++++----- list.go | 93 --------------------------------------------------- 3 files changed, 68 insertions(+), 138 deletions(-) delete mode 100644 list.go diff --git a/cache.go b/cache.go index 738c02e..1b08c7d 100644 --- a/cache.go +++ b/cache.go @@ -14,16 +14,27 @@ import ( // block. Eviction locks blocks one at a time looking for a value // that's valid to evict/ type Interner struct { - elements map[interface{}]*Element + lock sync.RWMutex + // stores indexes into storage + elements map[interface{}]int + storage []Element max uint64 - // only safe to not use a pointer since blocks never move - sweep List + count uint64 // CLOCK sweep state, must have write lock - prev *Element + next int +} - lock sync.RWMutex - count uint64 +type Element struct { + // The value stored with this element. + key interface{} + Value interface{} + + //CLOCK marker if this is recently used + used uint32 + + // pad Elements out to be cache aligned + _padding [24]byte } func WithMax(max uint64) *Interner { @@ -43,65 +54,71 @@ func WithMaxAndShards(max uint64, shards int) *Interner { } func (i *Interner) Insert(key interface{}, value interface{}) (interface{}, bool) { - newSize := atomic.AddUint64(&i.count, 1) - needsEvict := newSize > i.max i.lock.Lock() defer i.lock.Unlock() - if needsEvict { - i.evict() - } - if i.elements == nil { - i.elements = make(map[interface{}]*Element) - } - val, present := i.elements[key] + idx, present := i.elements[key] if present { - return val.Value, false + return i.storage[idx].Value, false } - elem := i.sweep.PushBack(key, value) - i.elements[key] = elem + + newSize := atomic.AddUint64(&i.count, 1) + needsEvict := newSize > i.max + + var insertLocation *Element + var insertIdx int + if needsEvict { + insertLocation, insertIdx = i.evict() + *insertLocation = Element{key: key, Value: value} + } else { + if i.elements == nil { + i.elements = make(map[interface{}]int) + } + insertIdx = len(i.storage) + i.storage = append(i.storage, Element{key: key, Value: value}) + insertLocation = &i.storage[len(i.storage)-1] + } + + i.elements[key] = insertIdx return value, true } -func (i *Interner) evict() { +func (i *Interner) evict() (insertPtr *Element, insertIdx int) { if i.count == 0 { return } for { - evicted := i.tryEvict() + insertLocation, insertIdx, evicted := i.tryEvict() if evicted { atomic.AddUint64(&i.count, ^uint64(0)) - break + return insertLocation, insertIdx } } } -func (i *Interner) tryEvict() (evicted bool) { - if i.prev == nil || i.prev.Next() == nil { - i.prev = i.sweep.Root() - } - - elem := i.prev.Next() - if elem == nil { - return false +func (i *Interner) tryEvict() (insertPtr *Element, insertIdx int, evicted bool) { + if i.next >= len(i.storage) { + i.next = 0 } evicted = false reachedEnd := false for !evicted && !reachedEnd { + elem := &i.storage[i.next] if elem.used != 0 { elem.used = 0 - i.prev = elem - elem = i.prev.Next() - reachedEnd = elem == nil } else { - key, _ := i.sweep.RemoveNext(i.prev) + insertPtr = elem + insertIdx = i.next + key := elem.key delete(i.elements, key) evicted = true } + i.next += 1 + reachedEnd = i.next >= len(i.storage) } - return evicted + return } func (i *Interner) Get(key interface{}) (interface{}, bool) { @@ -110,11 +127,12 @@ func (i *Interner) Get(key interface{}) (interface{}, bool) { if i.elements == nil { return 0, false } - elem, present := i.elements[key] + idx, present := i.elements[key] if !present { return 0, false } + elem := &i.storage[idx] if atomic.LoadUint32(&elem.used) == 0 { atomic.StoreUint32(&elem.used, 1) } @@ -128,11 +146,12 @@ func (i *Interner) Unmark(key string) bool { if i.elements == nil { return false } - elem, present := i.elements[key] + idx, present := i.elements[key] if !present { return false } + elem := &i.storage[idx] if atomic.LoadUint32(&elem.used) != 0 { atomic.StoreUint32(&elem.used, 0) } diff --git a/cache_test.go b/cache_test.go index f81cc71..62d2543 100644 --- a/cache_test.go +++ b/cache_test.go @@ -149,12 +149,16 @@ func TestElementCacheAligned(t *testing.T) { } } -func TestCountOffset(t *testing.T) { - elementsOffset := unsafe.Offsetof(Interner{}.elements) - lockOffset := unsafe.Offsetof(Interner{}.lock) - t.Logf("elem offset %d", elementsOffset) - t.Logf("lock offset %d", lockOffset) - if elementsOffset/64 == lockOffset/64 { - t.Errorf("read-mostly and mutable on the same line\nseed @ %d (%d)\noffset @ %d (%d)", elementsOffset, elementsOffset/64, lockOffset, elementsOffset/64) - } -} +// The entire cache fits on one cache line, but since +// we have a contended write to the lock anyway, it +// doesn't seem that bad that we fetch everything else +// as well +// func TestCountOffset(t *testing.T) { +// elementsOffset := unsafe.Offsetof(Interner{}.elements) +// lockOffset := unsafe.Offsetof(Interner{}.lock) +// t.Logf("elem offset %d", elementsOffset) +// t.Logf("lock offset %d", lockOffset) +// if elementsOffset/64 == lockOffset/64 { +// t.Errorf("read-mostly and mutable on the same line\nseed @ %d (%d)\noffset @ %d (%d)", elementsOffset, elementsOffset/64, lockOffset, elementsOffset/64) +// } +// } diff --git a/list.go b/list.go deleted file mode 100644 index 4980a50..0000000 --- a/list.go +++ /dev/null @@ -1,93 +0,0 @@ -package smolcache - -// Element is an element of a linked list. -type Element struct { - //CLOCK marker if this is recently used, must be first for atomic alignment - used uint32 - //pad key out to 8 bytes - _padding0 [8]byte - // The value stored with this element. - key interface{} - Value interface{} - - // Next and previous pointers in the singly-linked list of elements. - // To simplify the implementation, internally a list l is implemented - // as a ring - next *Element - - // pad Elements out to be cache aligned - _padding1 [1]byte -} - -// Next returns the next list element or nil. -func (e *Element) Next() *Element { - return e.next -} - -// List represents a singly linked list. -type List struct { - root Element // sentinel list element, only &root and root.next are used - last *Element -} - -// Init initializes or clears list l. -func (l *List) Init() *List { - l.root.next = nil - l.last = &l.root - return l -} - -// New returns an initialized list. -func New() *List { return new(List).Init() } - -// Front returns the root element of list l. -func (l *List) Root() *Element { - if l.last == nil { - l.Init() - } - return &l.root -} - -// insert inserts e after at, increments l.len, and returns e. -func (l *List) insert(e, at *Element) *Element { - n := at.next - at.next = e - e.next = n - return e -} - -// insertValue is a convenience wrapper for insert(&Element{Value: v}, at). -func (l *List) insertValue(k interface{}, v interface{}, at *Element) *Element { - return l.insert(&Element{key: k, Value: v}, at) -} - -// remove removes e from its list, decrements l.len, and returns e. -func (l *List) removeNext(e *Element) *Element { - next := e.next - e.next = e.next.next - next.next = nil // avoid memory leaks - return next -} - -// Remove removes e from l if e is an element of list l. -// It returns the element value e.Value. -// The element must not be nil. -func (l *List) RemoveNext(e *Element) (key interface{}, val interface{}) { - if e.next == nil { - return - } - next := l.removeNext(e) - if next == l.last { - l.last = e - } - return next.key, next.Value -} - -// PushBack inserts a new element e with value v at the back of list l and returns e. -func (l *List) PushBack(k interface{}, v interface{}) *Element { - if l.last == nil { - l.Init() - } - l.last = l.insertValue(k, v, l.last) - return l.last -} From 3e4226edd5cccff91d3b3c912ea086f594b6a096 Mon Sep 17 00:00:00 2001 From: Joshua Lockerman <> Date: Fri, 10 Jul 2020 15:08:04 -0400 Subject: [PATCH 4/6] Pre-allocate the storage --- cache.go | 42 +++++++++++++----------------------------- cache_test.go | 3 +++ 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/cache.go b/cache.go index 1b08c7d..279dcab 100644 --- a/cache.go +++ b/cache.go @@ -18,9 +18,7 @@ type Interner struct { // stores indexes into storage elements map[interface{}]int storage []Element - max uint64 - count uint64 // CLOCK sweep state, must have write lock next int } @@ -38,21 +36,19 @@ type Element struct { } func WithMax(max uint64) *Interner { - return &Interner{ - max: max, - } -} - -func WithMaxAndShards(max uint64, shards int) *Interner { if max < 1 { panic("must have max greater than 0") } - //TODO variable number of shards return &Interner{ - max: max, + elements: make(map[interface{}]int, max), + storage: make([]Element, 0, max), } } +func WithMaxAndShards(max uint64, shards int) *Interner { + return WithMax(max) +} + func (i *Interner) Insert(key interface{}, value interface{}) (interface{}, bool) { i.lock.Lock() defer i.lock.Unlock() @@ -62,18 +58,12 @@ func (i *Interner) Insert(key interface{}, value interface{}) (interface{}, bool return i.storage[idx].Value, false } - newSize := atomic.AddUint64(&i.count, 1) - needsEvict := newSize > i.max - var insertLocation *Element var insertIdx int - if needsEvict { + if len(i.storage) >= cap(i.storage) { insertLocation, insertIdx = i.evict() *insertLocation = Element{key: key, Value: value} } else { - if i.elements == nil { - i.elements = make(map[interface{}]int) - } insertIdx = len(i.storage) i.storage = append(i.storage, Element{key: key, Value: value}) insertLocation = &i.storage[len(i.storage)-1] @@ -84,13 +74,9 @@ func (i *Interner) Insert(key interface{}, value interface{}) (interface{}, bool } func (i *Interner) evict() (insertPtr *Element, insertIdx int) { - if i.count == 0 { - return - } for { insertLocation, insertIdx, evicted := i.tryEvict() if evicted { - atomic.AddUint64(&i.count, ^uint64(0)) return insertLocation, insertIdx } } @@ -124,9 +110,7 @@ func (i *Interner) tryEvict() (insertPtr *Element, insertIdx int, evicted bool) func (i *Interner) Get(key interface{}) (interface{}, bool) { i.lock.RLock() defer i.lock.RUnlock() - if i.elements == nil { - return 0, false - } + idx, present := i.elements[key] if !present { return 0, false @@ -143,9 +127,7 @@ func (i *Interner) Get(key interface{}) (interface{}, bool) { func (i *Interner) Unmark(key string) bool { i.lock.RLock() defer i.lock.RUnlock() - if i.elements == nil { - return false - } + idx, present := i.elements[key] if !present { return false @@ -159,6 +141,8 @@ func (i *Interner) Unmark(key string) bool { return true } -func (i *Interner) Len() uint64 { - return atomic.LoadUint64(&i.count) +func (i *Interner) Len() int { + i.lock.RLock() + defer i.lock.RUnlock() + return len(i.storage) } diff --git a/cache_test.go b/cache_test.go index 62d2543..d86d5fd 100644 --- a/cache_test.go +++ b/cache_test.go @@ -147,6 +147,9 @@ func TestElementCacheAligned(t *testing.T) { if elementSize%64 != 0 { t.Errorf("unaligned element size: %d", elementSize) } + if elementSize != 64 { + t.Errorf("unexpected element size: %d", elementSize) + } } // The entire cache fits on one cache line, but since From 7ee6410ffa638b263ce9745fec06c629a28e29be Mon Sep 17 00:00:00 2001 From: Joshua Lockerman <> Date: Fri, 10 Jul 2020 15:08:30 -0400 Subject: [PATCH 5/6] Store pointers directly --- cache.go | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/cache.go b/cache.go index 279dcab..01e83da 100644 --- a/cache.go +++ b/cache.go @@ -16,7 +16,7 @@ import ( type Interner struct { lock sync.RWMutex // stores indexes into storage - elements map[interface{}]int + elements map[interface{}]*Element storage []Element // CLOCK sweep state, must have write lock @@ -40,7 +40,7 @@ func WithMax(max uint64) *Interner { panic("must have max greater than 0") } return &Interner{ - elements: make(map[interface{}]int, max), + elements: make(map[interface{}]*Element, max), storage: make([]Element, 0, max), } } @@ -53,36 +53,34 @@ func (i *Interner) Insert(key interface{}, value interface{}) (interface{}, bool i.lock.Lock() defer i.lock.Unlock() - idx, present := i.elements[key] + elem, present := i.elements[key] if present { - return i.storage[idx].Value, false + return elem.Value, false } var insertLocation *Element - var insertIdx int if len(i.storage) >= cap(i.storage) { - insertLocation, insertIdx = i.evict() + insertLocation = i.evict() *insertLocation = Element{key: key, Value: value} } else { - insertIdx = len(i.storage) i.storage = append(i.storage, Element{key: key, Value: value}) insertLocation = &i.storage[len(i.storage)-1] } - i.elements[key] = insertIdx + i.elements[key] = insertLocation return value, true } -func (i *Interner) evict() (insertPtr *Element, insertIdx int) { +func (i *Interner) evict() (insertPtr *Element) { for { - insertLocation, insertIdx, evicted := i.tryEvict() + insertLocation, evicted := i.tryEvict() if evicted { - return insertLocation, insertIdx + return insertLocation } } } -func (i *Interner) tryEvict() (insertPtr *Element, insertIdx int, evicted bool) { +func (i *Interner) tryEvict() (insertPtr *Element, evicted bool) { if i.next >= len(i.storage) { i.next = 0 } @@ -95,7 +93,6 @@ func (i *Interner) tryEvict() (insertPtr *Element, insertIdx int, evicted bool) elem.used = 0 } else { insertPtr = elem - insertIdx = i.next key := elem.key delete(i.elements, key) evicted = true @@ -111,12 +108,11 @@ func (i *Interner) Get(key interface{}) (interface{}, bool) { i.lock.RLock() defer i.lock.RUnlock() - idx, present := i.elements[key] + elem, present := i.elements[key] if !present { return 0, false } - elem := &i.storage[idx] if atomic.LoadUint32(&elem.used) == 0 { atomic.StoreUint32(&elem.used, 1) } @@ -128,12 +124,11 @@ func (i *Interner) Unmark(key string) bool { i.lock.RLock() defer i.lock.RUnlock() - idx, present := i.elements[key] + elem, present := i.elements[key] if !present { return false } - elem := &i.storage[idx] if atomic.LoadUint32(&elem.used) != 0 { atomic.StoreUint32(&elem.used, 0) } From 20079d181530a3db14700d7e46356423eab2062c Mon Sep 17 00:00:00 2001 From: Joshua Lockerman <> Date: Fri, 10 Jul 2020 16:19:46 -0400 Subject: [PATCH 6/6] add bulk insert/get --- cache.go | 68 ++++++++++++++++++++++++++++++++++++++++++++++++--- cache_test.go | 27 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/cache.go b/cache.go index 01e83da..483c255 100644 --- a/cache.go +++ b/cache.go @@ -1,6 +1,7 @@ package smolcache import ( + "fmt" "sync" "sync/atomic" ) @@ -49,13 +50,37 @@ func WithMaxAndShards(max uint64, shards int) *Interner { return WithMax(max) } -func (i *Interner) Insert(key interface{}, value interface{}) (interface{}, bool) { +// Insert a key/value mapping into the cache if the key is not already present +// returns the value present in the map, and true if it is newley inserted +func (i *Interner) Insert(key interface{}, value interface{}) (canonicalValue interface{}, inserted bool) { i.lock.Lock() defer i.lock.Unlock() + _, canonicalValue, inserted = i.insert(key, value) + return +} + +// Insert a bactch of keys with their corresponding values. +// This function will _overwrite_ the keys and values slices with their +// canonical versions. +func (i *Interner) InsertBatch(keys []interface{}, values []interface{}) { + if len(keys) != len(values) { + panic(fmt.Sprintf("keys and values are not the same len. %d keys, %d values", len(keys), len(values))) + } + values = values[:len(keys)] + i.lock.Lock() + defer i.lock.Unlock() + + for idx := range keys { + keys[idx], values[idx], _ = i.insert(keys[idx], values[idx]) + } + return +} + +func (i *Interner) insert(key interface{}, value interface{}) (canonicalKey interface{}, canonicalValue interface{}, inserted bool) { elem, present := i.elements[key] if present { - return elem.Value, false + return elem.key, elem.Value, false } var insertLocation *Element @@ -68,7 +93,7 @@ func (i *Interner) Insert(key interface{}, value interface{}) (interface{}, bool } i.elements[key] = insertLocation - return value, true + return key, value, true } func (i *Interner) evict() (insertPtr *Element) { @@ -104,9 +129,46 @@ func (i *Interner) tryEvict() (insertPtr *Element, evicted bool) { return } +// tries to get a batch of keys and store the corresponding values is valuesOut +// returns the number of keys that were actually found. +// NOTE: this function does _not_ preserve the order of keys; the first numFound +// keys will be the keys whose values are present, while the remainder +// will be the keys not present in the cache +func (i *Interner) GetValues(keys []interface{}, valuesOut []interface{}) (numFound int) { + if len(keys) != len(valuesOut) { + panic(fmt.Sprintf("keys and values are not the same len. %d keys, %d values", len(keys), len(valuesOut))) + } + valuesOut = valuesOut[:len(keys)] + n := len(keys) + idx := 0 + + i.lock.RLock() + defer i.lock.RUnlock() + + for idx < n { + value, found := i.get(keys[idx]) + if !found { + if n == 0 { + return 0 + } + // no value found for key, swap the key with the last element, and shrink n + n -= 1 + keys[n], keys[idx] = keys[idx], keys[n] + continue + } + valuesOut[idx] = value + idx += 1 + } + return n +} + func (i *Interner) Get(key interface{}) (interface{}, bool) { i.lock.RLock() defer i.lock.RUnlock() + return i.get(key) +} + +func (i *Interner) get(key interface{}) (interface{}, bool) { elem, present := i.elements[key] if !present { diff --git a/cache_test.go b/cache_test.go index d86d5fd..3d63043 100644 --- a/cache_test.go +++ b/cache_test.go @@ -3,6 +3,7 @@ package smolcache import ( "fmt" "math/rand" + "reflect" "sync" "testing" "unsafe" @@ -142,6 +143,32 @@ func TestCacheGetRandomly(t *testing.T) { // } // } +func TestBatch(t *testing.T) { + t.Parallel() + + cache := WithMax(10) + + cache.InsertBatch([]interface{}{3, 6, 9, 12}, []interface{}{4, 7, 10, 13}) + + keys := []interface{}{1, 2, 3, 6, 9, 12, 13} + vals := make([]interface{}, len(keys)) + numFound := cache.GetValues(keys, vals) + + if numFound != 4 { + t.Errorf("found incorrect number of values: expected 4, found %d\n\tkeys: %v\n\t%v", numFound, keys, vals) + } + + expectedKeys := []interface{}{12, 9, 3, 6, 2, 13, 1} + if !reflect.DeepEqual(keys, expectedKeys) { + t.Errorf("unexpected keys:\nexpected\n\t%v\nfound\n\t%v", keys, expectedKeys) + } + + expectedVals := []interface{}{13, 10, 4, 7, nil, nil, nil} + if !reflect.DeepEqual(vals, expectedVals) { + t.Errorf("unexpected values:\nexpected\n\t%v\nfound\n\t%v", expectedVals, vals) + } +} + func TestElementCacheAligned(t *testing.T) { elementSize := unsafe.Sizeof(Element{}) if elementSize%64 != 0 {