Skip to content

Commit

Permalink
Add ARC backend
Browse files Browse the repository at this point in the history
  • Loading branch information
motoki317 committed Apr 5, 2022
1 parent 5309c18 commit caddfb8
Show file tree
Hide file tree
Showing 10 changed files with 342 additions and 14 deletions.
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,25 @@ sc is a simple golang in-memory caching library, with easily configurable implem
- There is no Set() method - this is an intentional design choice to make the use easier.
- Supports 1.18 generics - both key and value are generic.
- No `interface{}` even in internal implementations.
- Supported cache backends
- Built-in map (default) - lightweight, but does not evict items.
- LRU (`WithLRUBackend(cap)` option) - automatically evicts overflown items.
- Supports multiple cache backends.
- Prevents [cache stampede](https://en.wikipedia.org/wiki/Cache_stampede) problem idiomatically.
- All methods are safe to be called from multiple goroutines.
- Allows graceful cache replacement (if `freshFor` < `ttl`) - only one goroutine is launched in the background to re-fetch the value.
- Allows strict request coalescing (`EnableStrictCoalescing()` option) - ensures that all returned values are fresh (a niche use-case).

## Supported cache backends (cache replacement policy)

The default backend is the built-in map.
This is ultra-lightweight, but does **not** evict items.
You should only use the built-in map backend if your key's cardinality is finite,
and you are comfortable holding **all** values in-memory.

Otherwise, you should use LRU or ARC backend which automatically evicts overflown items.

- Built-in map (default)
- LRU (Least Recently Used)
- ARC (Adaptive Replacement Cache)

## Usage

See [reference](https://pkg.go.dev/github.com/motoki317/sc).
Expand Down
194 changes: 194 additions & 0 deletions arc/arc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package arc

import (
"sync"

"github.com/motoki317/lru"
)

// Below includes modified code from https://github.com/hashicorp/golang-lru/blob/80c98217689d6df152309d574ccc682b21dc802c/arc.go.

// Cache is a thread-safe fixed size Adaptive Replacement Cache (ARC).
// ARC is an enhancement over the standard LRU cache in that tracks both
// frequency and recency of use. This avoids a burst in access to new
// entries from evicting the frequently used older entries. It adds some
// additional tracking overhead to a standard LRU cache, computationally
// it is roughly 2x the cost, and the extra memory overhead is linear
// with the size of the cache. ARC has been patented by IBM, but is
// similar to the TwoQueueCache (2Q) which requires setting parameters.
type Cache[K comparable, V any] struct {
size int // Size is the total capacity of the cache
p int // p is the dynamic preference towards t1 over t2

t1 *lru.Cache[K, V] // t1 is the LRU for recently accessed items
b1 *lru.Cache[K, struct{}] // b1 is the LRU for evictions from t1

t2 *lru.Cache[K, V] // t2 is the LRU for frequently accessed items
b2 *lru.Cache[K, struct{}] // b2 is the LRU for evictions from t2

lock sync.RWMutex
}

// New creates an ARC of the given size.
func New[K comparable, V any](size int) *Cache[K, V] {
// Create the sub LRUs
b1 := lru.New[K, struct{}](lru.WithCapacity(size))
b2 := lru.New[K, struct{}](lru.WithCapacity(size))
t1 := lru.New[K, V](lru.WithCapacity(size))
t2 := lru.New[K, V](lru.WithCapacity(size))

// Initialize the ARC
return &Cache[K, V]{
size: size,
p: size / 2,
t1: t1,
b1: b1,
t2: t2,
b2: b2,
}
}

// Get looks up a key's value from the cache.
func (c *Cache[K, V]) Get(key K) (value V, ok bool) {
c.lock.Lock()
defer c.lock.Unlock()

// If the value is contained in T1 (recent), then
// promote it to T2 (frequent)
if val, ok := c.t1.Peek(key); ok {
c.t1.Delete(key)
c.t2.Set(key, val)
return val, ok
}

// Check if the value is contained in T2 (frequent)
if val, ok := c.t2.Get(key); ok {
return val, ok
}

// No hit
return
}

// Set adds a value to the cache.
func (c *Cache[K, V]) Set(key K, value V) {
c.lock.Lock()
defer c.lock.Unlock()

// Check if the value is contained in T1 (recent), and potentially
// promote it to frequent T2
if _, ok := c.t1.Peek(key); ok {
c.t1.Delete(key)
c.t2.Set(key, value)
return
}

// Check if the value is already in T2 (frequent) and update it
if _, ok := c.t2.Peek(key); ok {
c.t2.Set(key, value)
return
}

// Check if this value was recently evicted as part of the
// recently used list
if _, ok := c.b1.Peek(key); ok {
// T1 set is too small, increase P appropriately
delta := 1
b1Len := c.b1.Len()
b2Len := c.b2.Len()
if b2Len > b1Len {
delta = b2Len / b1Len
}
if c.p+delta >= c.size {
c.p = c.size
} else {
c.p += delta
}

// Remove from B1
c.b1.Delete(key)

// Add the key to the frequently used list
c.t2.Set(key, value)

// Potentially need to make room in the cache
c.replace()
return
}

// Check if this value was recently evicted as part of the
// frequently used list
if _, ok := c.b2.Peek(key); ok {
// T2 set is too small, decrease P appropriately
delta := 1
b1Len := c.b1.Len()
b2Len := c.b2.Len()
if b1Len > b2Len {
delta = b1Len / b2Len
}
if delta >= c.p {
c.p = 0
} else {
c.p -= delta
}

// Remove from B2
c.b2.Delete(key)

// Add the key to the frequently used list
c.t2.Set(key, value)

// Potentially need to make room in the cache
c.replace()
return
}

// Add to the recently seen list
c.t1.Set(key, value)

// Potentially need to make room in the cache
c.replace()
}

// replace is used to adaptively evict from either T1 or T2
// based on the current learned value of P
func (c *Cache[K, V]) replace() {
if c.t1.Len()+c.t2.Len() <= c.size {
return
}
if c.t1.Len() > c.p {
k, _, ok := c.t1.DeleteOldest()
if ok {
c.b1.Set(k, struct{}{})
if c.b1.Len() > c.size-c.p {
c.b1.DeleteOldest()
}
}
} else {
k, _, ok := c.t2.DeleteOldest()
if ok {
c.b2.Set(k, struct{}{})
if c.b2.Len() > c.p {
c.b2.DeleteOldest()
}
}
}
}

// Delete is used to purge a key from the cache
func (c *Cache[K, V]) Delete(key K) {
c.lock.Lock()
defer c.lock.Unlock()
if c.t1.Delete(key) {
return
}
if c.t2.Delete(key) {
return
}
if c.b1.Delete(key) {
return
}
if c.b2.Delete(key) {
return
}
}
9 changes: 8 additions & 1 deletion backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package sc
import (
"sync"

"github.com/dboslee/lru"
"github.com/motoki317/lru"

"github.com/motoki317/sc/arc"
)

// backend represents a cache backend.
Expand All @@ -18,6 +20,7 @@ type backend[K comparable, V any] interface {
var (
_ backend[string, string] = &mapBackend[string, string]{}
_ backend[string, string] = lruBackend[string, string]{}
_ backend[string, string] = arcBackend[string, string]{}
)

type mapBackend[K comparable, V any] struct {
Expand Down Expand Up @@ -59,3 +62,7 @@ func (l lruBackend[K, V]) Set(key K, v V) {
func (l lruBackend[K, V]) Delete(key K) {
l.SyncCache.Delete(key)
}

type arcBackend[K comparable, V any] struct {
*arc.Cache[K, V]
}
63 changes: 60 additions & 3 deletions benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"time"
)

func BenchmarkCache_Single(b *testing.B) {
func BenchmarkCache_Single_SameKey(b *testing.B) {
for _, c := range allCaches {
c := c
b.Run(c.name, func(b *testing.B) {
Expand All @@ -27,6 +27,34 @@ func BenchmarkCache_Single(b *testing.B) {
}
}

func BenchmarkCache_Single_Zipfian(b *testing.B) {
const (
size = 1000
s = 1.001
v = 100
)

for _, c := range allCaches {
c := c
b.Run(c.name, func(b *testing.B) {
replaceFn := func(ctx context.Context, key string) (string, error) {
return "value", nil
}
cache, err := New[string, string](replaceFn, 1*time.Second, 1*time.Second, append(append([]CacheOption{}, c.cacheOpts...), WithCapacity(size))...)
if err != nil {
b.Error(err)
}

ctx := context.Background()
keys := newKeys(newZipfian(s, v, size*4), size*10)
b.StartTimer()
for i := 0; i < b.N; i++ {
_, _ = cache.Get(ctx, keys[i%(size*10)])
}
})
}
}

func BenchmarkCache_Parallel_SameKey(b *testing.B) {
for _, c := range allCaches {
c := c
Expand All @@ -49,15 +77,44 @@ func BenchmarkCache_Parallel_SameKey(b *testing.B) {
}
}

// BenchmarkCache_Parallel_Zipfian benchmarks caches with simulated real world load - zipfian distributed keys
// and replace func that takes 1ms to load.
func BenchmarkCache_Parallel_Zipfian(b *testing.B) {
const (
size = 1000
s = 1.001
v = 100
)

for _, c := range allCaches {
c := c
b.Run(c.name, func(b *testing.B) {
replaceFn := func(ctx context.Context, key string) (string, error) {
return "value", nil
}
cache, err := New[string, string](replaceFn, 1*time.Second, 1*time.Second, append(append([]CacheOption{}, c.cacheOpts...), WithCapacity(size))...)
if err != nil {
b.Error(err)
}

ctx := context.Background()
keys := newKeys(newZipfian(s, v, size*4), size*10)
b.RunParallel(func(pb *testing.PB) {
for i := 0; pb.Next(); i++ {
_, _ = cache.Get(ctx, keys[i%(size*10)])
}
})
})
}
}

// BenchmarkCache_RealWorkLoad benchmarks caches with simulated real world load - zipfian distributed keys
// and replace func that takes 1ms to load.
func BenchmarkCache_RealWorkLoad(b *testing.B) {
const (
size = 1000
s = 1.001
v = 100
)

for _, c := range evictingCaches {
c := c
b.Run(c.name, func(b *testing.B) {
Expand Down
9 changes: 8 additions & 1 deletion cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"sync"
"time"

"github.com/dboslee/lru"
"github.com/motoki317/lru"

"github.com/motoki317/sc/arc"
)

type replaceFunc[K comparable, V any] func(ctx context.Context, key K) (V, error)
Expand Down Expand Up @@ -42,6 +44,11 @@ func New[K comparable, V any](replaceFn replaceFunc[K, V], freshFor, ttl time.Du
return nil, errors.New("capacity needs to be greater than 0 for LRU cache")
}
b = lruBackend[K, value[V]]{lru.NewSync[K, value[V]](lru.WithCapacity(config.capacity))}
case cacheBackendARC:
if config.capacity <= 0 {
return nil, errors.New("capacity needs to be greater than 0 for ARC cache")
}
b = arcBackend[K, value[V]]{arc.New[K, value[V]](config.capacity)}
default:
return nil, errors.New("unknown cache backend")
}
Expand Down
Loading

0 comments on commit caddfb8

Please sign in to comment.