Skip to content

Commit

Permalink
Make Cache.Get allocation-free
Browse files Browse the repository at this point in the history
Changed applyOptions and the Option[K,V] to be non-allocating by avoiding pointers

Added the Test_Get_DoesNotAllocate to make sure that Get does not allocate (it fails before this commit)
  • Loading branch information
yiftachkarkason committed Jan 8, 2025
1 parent 1ae4056 commit 826a563
Show file tree
Hide file tree
Showing 4 changed files with 65 additions and 36 deletions.
8 changes: 4 additions & 4 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func New[K comparable, V any](opts ...Option[K, V]) *Cache[K, V] {
c.events.insertion.fns = make(map[uint64]func(*Item[K, V]))
c.events.eviction.fns = make(map[uint64]func(EvictionReason, *Item[K, V]))

applyOptions(&c.options, opts...)
c.options = applyOptions(c.options, opts...)

return c
}
Expand Down Expand Up @@ -233,7 +233,7 @@ func (c *Cache[K, V]) getWithOpts(key K, lockAndLoad bool, opts ...Option[K, V])
disableTouchOnHit: c.options.disableTouchOnHit,
}

applyOptions(&getOpts, opts...)
getOpts = applyOptions(getOpts, opts...)

if lockAndLoad {
c.items.mu.Lock()
Expand Down Expand Up @@ -388,7 +388,7 @@ func (c *Cache[K, V]) GetOrSet(key K, value V, opts ...Option[K, V]) (*Item[K, V
setOpts := options[K, V]{
ttl: c.options.ttl,
}
applyOptions(&setOpts, opts...) // used only to update the TTL
setOpts = applyOptions(setOpts, opts...) // used only to update the TTL

item := c.set(key, value, setOpts.ttl)

Expand All @@ -412,7 +412,7 @@ func (c *Cache[K, V]) GetAndDelete(key K, opts ...Option[K, V]) (*Item[K, V], bo
getOpts := options[K, V]{
loader: c.options.loader,
}
applyOptions(&getOpts, opts...) // used only to update the loader
getOpts = applyOptions(getOpts, opts...) // used only to update the loader

if getOpts.loader != nil {
item := getOpts.loader.Load(c, key)
Expand Down
39 changes: 30 additions & 9 deletions cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,12 @@ func Test_Cache_Set(t *testing.T) {
func Test_Cache_Get(t *testing.T) {
const notFoundKey, foundKey = "notfound", "test1"
cc := map[string]struct {
Key string
DefaultOptions options[string, string]
CallOptions []Option[string, string]
Metrics Metrics
Result *Item[string, string]
Key string
DefaultOptions options[string, string]
CallOptions []Option[string, string]
Metrics Metrics
Result *Item[string, string]
ExpectedNumberOfAllocations int
}{
"Get without loader when item is not found": {
Key: notFoundKey,
Expand All @@ -551,7 +552,8 @@ func Test_Cache_Get(t *testing.T) {
Metrics: Metrics{
Misses: 1,
},
Result: &Item[string, string]{key: "test"},
Result: &Item[string, string]{key: "test"},
ExpectedNumberOfAllocations: 1, // The loader function returns a heap allocated item. If this item was pre-allocated, the number of allocations would be 0.
},
"Get with default loader that returns nil value when item is not found": {
Key: notFoundKey,
Expand Down Expand Up @@ -579,7 +581,8 @@ func Test_Cache_Get(t *testing.T) {
Metrics: Metrics{
Misses: 1,
},
Result: &Item[string, string]{key: "hello"},
Result: &Item[string, string]{key: "hello"},
ExpectedNumberOfAllocations: 1, // The loader function returns a heap allocated item. If this item was pre-allocated, the number of allocations would be 0.
},
"Get with call loader that returns nil value when item is not found": {
Key: notFoundKey,
Expand Down Expand Up @@ -633,7 +636,10 @@ func Test_Cache_Get(t *testing.T) {
oldExpiresAt := cache.items.values[foundKey].Value.(*Item[string, string]).expiresAt
cache.options = c.DefaultOptions

res := cache.Get(c.Key, c.CallOptions...)
var res *Item[string, string]
assert.Equal(t, c.ExpectedNumberOfAllocations, allocsForSingleRun(func() {
res = cache.Get(c.Key, c.CallOptions...)
}))

if c.Key == foundKey {
c.Result = cache.items.values[foundKey].Value.(*Item[string, string])
Expand All @@ -646,7 +652,7 @@ func Test_Cache_Get(t *testing.T) {
return
}

applyOptions(&c.DefaultOptions, c.CallOptions...)
c.DefaultOptions = applyOptions(c.DefaultOptions, c.CallOptions...)

if c.DefaultOptions.disableTouchOnHit {
assert.Equal(t, oldExpiresAt, res.expiresAt)
Expand Down Expand Up @@ -1302,3 +1308,18 @@ func addToCache(c *Cache[string, string], ttl time.Duration, keys ...string) {
}
}
}

func allocsForSingleRun(f func()) (numAllocs int) {
// `testing.AllocsPerRun` "warms up" the function for a single run before
// measuring allocations, so we need do nothing on the first run.
firstRun := false
numAllocs = int(testing.AllocsPerRun(1, func() {
if !firstRun {
firstRun = true
return
}

f()
}))
return
}
33 changes: 20 additions & 13 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import "time"

// Option sets a specific cache option.
type Option[K comparable, V any] interface {
apply(opts *options[K, V])
apply(opts options[K, V]) options[K, V]
}

// optionFunc wraps a function and implements the Option interface.
type optionFunc[K comparable, V any] func(*options[K, V])
type optionFunc[K comparable, V any] func(options[K, V]) options[K, V]

// apply calls the wrapped function.
func (fn optionFunc[K, V]) apply(opts *options[K, V]) {
fn(opts)
func (fn optionFunc[K, V]) apply(opts options[K, V]) options[K, V] {
return fn(opts)
}

// CostFunc is used to calculate the cost of the key and the item to be
Expand All @@ -29,44 +29,49 @@ type options[K comparable, V any] struct {
itemOpts []itemOption[K, V]
}

// applyOptions applies the provided option values to the option struct.
func applyOptions[K comparable, V any](v *options[K, V], opts ...Option[K, V]) {
// applyOptions applies the provided option values to the option struct and returns the modified option struct.
func applyOptions[K comparable, V any](v options[K, V], opts ...Option[K, V]) options[K, V] {
for i := range opts {
opts[i].apply(v)
v = opts[i].apply(v)
}
return v
}

// WithCapacity sets the maximum capacity of the cache.
// It has no effect when used with Get().
func WithCapacity[K comparable, V any](c uint64) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.capacity = c
return opts
})
}

// WithTTL sets the TTL of the cache.
// It has no effect when used with Get().
func WithTTL[K comparable, V any](ttl time.Duration) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.ttl = ttl
return opts
})
}

// WithVersion activates item version tracking.
// If version tracking is disabled, the version is always -1.
// It has no effect when used with Get().
func WithVersion[K comparable, V any](enable bool) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.itemOpts = append(opts.itemOpts, withVersionTracking[K, V](enable))
return opts
})
}

// WithLoader sets the loader of the cache.
// When passing into Get(), it sets an ephemeral loader that
// is used instead of the cache's default one.
func WithLoader[K comparable, V any](l Loader[K, V]) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.loader = l
return opts
})
}

Expand All @@ -76,18 +81,20 @@ func WithLoader[K comparable, V any](l Loader[K, V]) Option[K, V] {
// When used with Get(), it overrides the default value of the
// cache.
func WithDisableTouchOnHit[K comparable, V any]() Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.disableTouchOnHit = true
return opts
})
}

// WithMaxCost sets the maximum cost the cache is allowed to use (e.g. the used memory).
// The actual cost calculation for each inserted item happens by making use of the
// callback CostFunc.
func WithMaxCost[K comparable, V any](s uint64, callback CostFunc[K, V]) Option[K, V] {
return optionFunc[K, V](func(opts *options[K, V]) {
return optionFunc[K, V](func(opts options[K, V]) options[K, V] {
opts.maxCost = s
opts.itemOpts = append(opts.itemOpts, withCostFunc[K, V](callback))
return opts
})
}

Expand Down
21 changes: 11 additions & 10 deletions options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ func Test_optionFunc_apply(t *testing.T) {

var called bool

optionFunc[string, string](func(_ *options[string, string]) {
optionFunc[string, string](func(opts options[string, string]) options[string, string] {
called = true
}).apply(nil)
return opts
}).apply(options[string, string]{})
assert.True(t, called)
}

Expand All @@ -24,7 +25,7 @@ func Test_applyOptions(t *testing.T) {

var opts options[string, string]

applyOptions(&opts,
opts = applyOptions(opts,
WithCapacity[string, string](12),
WithTTL[string, string](time.Hour),
)
Expand All @@ -38,7 +39,7 @@ func Test_WithCapacity(t *testing.T) {

var opts options[string, string]

WithCapacity[string, string](12).apply(&opts)
opts = WithCapacity[string, string](12).apply(opts)
assert.Equal(t, uint64(12), opts.capacity)
}

Expand All @@ -47,7 +48,7 @@ func Test_WithTTL(t *testing.T) {

var opts options[string, string]

WithTTL[string, string](time.Hour).apply(&opts)
opts = WithTTL[string, string](time.Hour).apply(opts)
assert.Equal(t, time.Hour, opts.ttl)
}

Expand All @@ -57,13 +58,13 @@ func Test_WithVersion(t *testing.T) {
var opts options[string, string]
var item Item[string, string]

WithVersion[string, string](true).apply(&opts)
opts = WithVersion[string, string](true).apply(opts)
assert.Len(t, opts.itemOpts, 1)
opts.itemOpts[0].apply(&item)
assert.Equal(t, int64(0), item.version)

opts.itemOpts = []itemOption[string, string]{}
WithVersion[string, string](false).apply(&opts)
opts = WithVersion[string, string](false).apply(opts)
assert.Len(t, opts.itemOpts, 1)
opts.itemOpts[0].apply(&item)
assert.Equal(t, int64(-1), item.version)
Expand All @@ -77,7 +78,7 @@ func Test_WithLoader(t *testing.T) {
l := LoaderFunc[string, string](func(_ *Cache[string, string], _ string) *Item[string, string] {
return nil
})
WithLoader[string, string](l).apply(&opts)
opts = WithLoader[string, string](l).apply(opts)
assert.NotNil(t, opts.loader)
}

Expand All @@ -86,7 +87,7 @@ func Test_WithDisableTouchOnHit(t *testing.T) {

var opts options[string, string]

WithDisableTouchOnHit[string, string]().apply(&opts)
opts = WithDisableTouchOnHit[string, string]().apply(opts)
assert.True(t, opts.disableTouchOnHit)
}

Expand All @@ -96,7 +97,7 @@ func Test_WithMaxCost(t *testing.T) {
var opts options[string, string]
var item Item[string, string]

WithMaxCost[string, string](1024, func(item *Item[string, string]) uint64 { return 1 }).apply(&opts)
opts = WithMaxCost[string, string](1024, func(item *Item[string, string]) uint64 { return 1 }).apply(opts)

assert.Equal(t, uint64(1024), opts.maxCost)
assert.Len(t, opts.itemOpts, 1)
Expand Down

0 comments on commit 826a563

Please sign in to comment.