diff --git a/README.md b/README.md index b17eabd..2a754e1 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,22 @@ Every package takes `WithShards`. `twoq` and `s3fifo` also have options for tuni queues ([what that is worth for `s3fifo`](bench/results/tuning.md)); `tinylfu` tunes its own. +### Statistics + +[`stats.Wrap`](https://pkg.go.dev/github.com/floatdrop/evict/stats) counts the hits, misses +and evictions of any of them: + +```go +cache := stats.Wrap(tinylfu.New[string, int](10_000)) + +s := cache.Stats() +fmt.Println(s.HitRatio(), s.Evictions, s.Len, s.Cap) +``` + +The counts only go up; `s.Sub(prev)` gives those of an interval. A `Get` that hits took +10–14% longer through the wrapper (`tinylfu`, one and eight CPUs, Apple M3 Max), and the +counters take 2–4 KiB per CPU. + ## Choosing a policy | | Use it when | Watch out for | @@ -92,8 +108,8 @@ repository, it was level with S3-FIFO and W-TinyLFU. It trails `lru` by up to 4 Mixed at large sizes; [its docs](https://pkg.go.dev/github.com/floatdrop/evict/tinylfu) say where else it does badly. -Use [otter](https://github.com/maypok86/otter) instead if you need expiry, weights, loading or -statistics, or if many CPUs overwrite a tenth to a quarter of what they read. +Use [otter](https://github.com/maypok86/otter) instead if you need expiry, weights or loading, +or if many CPUs overwrite a tenth to a quarter of what they read. ## Benchmarks @@ -210,8 +226,8 @@ measured. ## What it does not do -No expiry, no weights or sizes in bytes, no loading functions, no statistics. If you need -those, use [otter](https://github.com/maypok86/otter). +No expiry, no weights or sizes in bytes, no loading functions. If you need those, use +[otter](https://github.com/maypok86/otter). ## License diff --git a/evict.go b/evict.go index b635479..eb8687e 100644 --- a/evict.go +++ b/evict.go @@ -42,6 +42,8 @@ // WithShards option of each package sets the number for the others. // // This package holds what the caches have in common: the [Cache] interface. +// [github.com/floatdrop/evict/stats] counts the hits, misses and evictions of +// any of them. package evict import "iter" diff --git a/internal/inlinecheck/inline_test.go b/internal/inlinecheck/inline_test.go index 8dffdad..4ae5255 100644 --- a/internal/inlinecheck/inline_test.go +++ b/internal/inlinecheck/inline_test.go @@ -25,7 +25,7 @@ import ( ) // hot matches the functions that every operation on a cache goes through. -var hot = regexp.MustCompile(`/(lru|twoq|sieve|s3fifo|tinylfu)\.\(\*(Cache|shard)\[.*\]\)\.(Get|Peek|Set|set|evict|Delete|look|victim|promote|asked)$|` + +var hot = regexp.MustCompile(`/(lru|twoq|sieve|s3fifo|tinylfu|stats)\.\(\*(Cache|shard)\[.*\]\)\.(Get|Peek|Set|set|evict|Delete|look|victim|promote|asked)$|` + `/c?slab\.\(\*Slab\[.*\]\)\.(Find|Lookup|Insert|Replace|Remove)$`) // slow matches the calls that must not show up in them: functions that are @@ -33,7 +33,8 @@ var hot = regexp.MustCompile(`/(lru|twoq|sieve|s3fifo|tinylfu)\.\(\*(Cache|shard var slow = regexp.MustCompile(`^sync\.\(\*(RW)?Mutex\)\.|^sync/atomic\.|` + `slab\.\(\*Slab\[.*\]\)\.(MoveToFront|PushFront|Unlink|Len|Cap|Overdue|Hash)$|` + `cslab\.\(\*(Readers|Slot)\)\.(Pin|Unpin)$|/s3fifo\.(tagOf|ghostOf)$|` + - `/tinylfu\.(tagOf|kept|keep|printOf|printIn)$|/tinylfu\.\(\*climber\)\.(hit|miss)$`) + `/tinylfu\.(tagOf|kept|keep|printOf|printIn)$|/tinylfu\.\(\*climber\)\.(hit|miss)$|` + + `/stats\.\(\*Cache\[.*\]\)\.stripe$`) func TestHotPathsAreInlined(t *testing.T) { t.Parallel() @@ -101,7 +102,7 @@ func TestHotPathsAreInlined(t *testing.T) { } // The test is only worth something if it has looked at the right code. - for _, pkg := range []string{"lru", "twoq", "sieve", "s3fifo", "tinylfu"} { + for _, pkg := range []string{"lru", "twoq", "sieve", "s3fifo", "tinylfu", "stats"} { for _, method := range []string{"Get", "Set", "Peek", "Delete"} { found := false for name := range seen { diff --git a/internal/inlinecheck/probe/stats/probe.go b/internal/inlinecheck/probe/stats/probe.go new file mode 100644 index 0000000..d9f6166 --- /dev/null +++ b/internal/inlinecheck/probe/stats/probe.go @@ -0,0 +1,21 @@ +// Package probe uses a counted cache the way a program does: with key and +// value types of its own, and without importing anything but the counter, not +// even a cache of this module. The test two directories up compiles it and +// looks at what the compiler made of it. +package probe + +import "github.com/floatdrop/evict/stats" + +// Key is a key type that only this package instantiates the cache with. +type Key struct{ Tenant, ID string } + +// Value is a value type that only this package instantiates the cache with. +type Value struct{ Size, Hits int } + +// Use keeps the compiler from discarding the methods of the cache. +func Use(c *stats.Cache[Key, Value], k Key, v Value) (Value, bool) { + c.Set(k, v) + c.Peek(k) + c.Delete(k) + return c.Get(k) +} diff --git a/internal/inlinecheck/probe/statswrap/probe.go b/internal/inlinecheck/probe/statswrap/probe.go new file mode 100644 index 0000000..9a81900 --- /dev/null +++ b/internal/inlinecheck/probe/statswrap/probe.go @@ -0,0 +1,17 @@ +// Package probe is a generic type that wraps a counted cache and instantiates +// nothing, like a program's own cache type might. The package that uses it, +// statswrapuser, is where its methods and those of the counted cache are +// compiled, knowing only what this package has passed on, which is next to +// nothing. +package probe + +import "github.com/floatdrop/evict/stats" + +// Cache wraps a counted cache. +type Cache[K comparable, V any] struct{ C *stats.Cache[K, V] } + +// Get reads from the counted cache. +func (c Cache[K, V]) Get(key K) (V, bool) { return c.C.Get(key) } + +// Set writes to the counted cache. +func (c Cache[K, V]) Set(key K, value V) { c.C.Set(key, value) } diff --git a/internal/inlinecheck/probe/statswrapuser/probe.go b/internal/inlinecheck/probe/statswrapuser/probe.go new file mode 100644 index 0000000..30fe528 --- /dev/null +++ b/internal/inlinecheck/probe/statswrapuser/probe.go @@ -0,0 +1,18 @@ +// Package probe uses a counted cache through statswrap, a generic type of +// another package that instantiates nothing. The test two directories up +// compiles it and looks at what the compiler made of it. +package probe + +import wrap "github.com/floatdrop/evict/internal/inlinecheck/probe/statswrap" + +// Key is a key type that only this package instantiates the cache with. +type Key struct{ Tenant, ID string } + +// Value is a value type that only this package instantiates the cache with. +type Value struct{ Size, Hits int } + +// Use keeps the compiler from discarding the methods of the cache. +func Use(c wrap.Cache[Key, Value], k Key, v Value) (Value, bool) { + c.Set(k, v) + return c.Get(k) +} diff --git a/stats/bench_test.go b/stats/bench_test.go new file mode 100644 index 0000000..af43663 --- /dev/null +++ b/stats/bench_test.go @@ -0,0 +1,71 @@ +package stats_test + +import ( + "math/rand/v2" + "testing" + + "github.com/floatdrop/evict" + "github.com/floatdrop/evict/stats" + "github.com/floatdrop/evict/tinylfu" +) + +const benchCapacity = 1 << 14 + +func benchCache() *tinylfu.Cache[int, int] { + c := tinylfu.New[int, int](benchCapacity) + for key := range benchCapacity { + c.Set(key, key) + } + return c +} + +// BenchmarkGet reads entries that are all there, on every CPU at once: from +// the cache itself, from the same cache behind the interface, and through the +// counter. The loops are written out, so that each makes the call it is +// about and no other. +func BenchmarkGet(b *testing.B) { + b.Run("direct", func(b *testing.B) { + c := benchCache() + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + i := rand.Int() + for pb.Next() { + c.Get(i & (benchCapacity - 1)) + i++ + } + }) + }) + + b.Run("interface", func(b *testing.B) { + var c evict.Cache[int, int] = benchCache() + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + i := rand.Int() + for pb.Next() { + c.Get(i & (benchCapacity - 1)) + i++ + } + }) + }) + + b.Run("stats", func(b *testing.B) { + c := stats.Wrap(benchCache()) + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + i := rand.Int() + for pb.Next() { + c.Get(i & (benchCapacity - 1)) + i++ + } + }) + }) +} + +// BenchmarkStats takes a snapshot of a cache that nobody else is using. +func BenchmarkStats(b *testing.B) { + c := stats.Wrap(benchCache()) + b.ReportAllocs() + for b.Loop() { + c.Stats() + } +} diff --git a/stats/example_test.go b/stats/example_test.go new file mode 100644 index 0000000..a6061de --- /dev/null +++ b/stats/example_test.go @@ -0,0 +1,33 @@ +package stats_test + +import ( + "fmt" + + "github.com/floatdrop/evict/lru" + "github.com/floatdrop/evict/stats" +) + +func Example() { + cache := stats.Wrap(lru.New[string, int](2)) + + cache.Set("a", 1) + cache.Set("b", 2) + cache.Get("a") // A hit. + cache.Get("c") // A miss, + cache.Set("c", 3) // and "b" is evicted for "c". + + s := cache.Stats() + fmt.Printf("%d hits, %d misses, hit ratio %.2f\n", s.Hits, s.Misses, s.HitRatio()) + fmt.Printf("%d evicted, %d of %d entries\n", s.Evictions, s.Len, s.Cap) + + // Rates are counts over an interval: subtract the snapshot taken at its + // start. + cache.Get("c") + cache.Get("c") + fmt.Printf("since then: %d hits\n", cache.Stats().Sub(s).Hits) + + // Output: + // 1 hits, 1 misses, hit ratio 0.50 + // 1 evicted, 2 of 2 entries + // since then: 2 hits +} diff --git a/stats/export_test.go b/stats/export_test.go new file mode 100644 index 0000000..1a64e68 --- /dev/null +++ b/stats/export_test.go @@ -0,0 +1,9 @@ +package stats + +import "unsafe" + +// StripeSize is the size of a stripe in bytes. +const StripeSize = unsafe.Sizeof(stripe{}) + +// StripeOf returns the stripe that the calling goroutine counts in. +func StripeOf[K comparable, V any](c *Cache[K, V]) any { return c.stripe() } diff --git a/stats/stats.go b/stats/stats.go new file mode 100644 index 0000000..dfb11d7 --- /dev/null +++ b/stats/stats.go @@ -0,0 +1,211 @@ +// Package stats counts the hits, misses and evictions of a cache, so that it +// can be monitored. +// +// [Wrap] puts a counter in front of any [evict.Cache]: +// +// cache := stats.Wrap(tinylfu.New[string, int](10_000)) +// // ... use cache as before ... +// s := cache.Stats() +// fmt.Println(s.HitRatio(), s.Evictions, s.Len, s.Cap) +// +// What is counted: +// +// - Get: a hit if it finds the key, a miss if it does not. +// - Set: an eviction if it pushed out the entry of another key to make +// room, or if the cache has no capacity and handed the new entry back. +// Replacing the entry of a key is not an eviction. +// - Peek, Delete, Clear and All: nothing. +// +// The counts only go up, and Clear does not reset them. For rates, take a +// [Snapshot] at intervals and subtract the one before with [Snapshot.Sub]. +// +// # Cost +// +// The counts are kept in stripes of a cache line each, sixteen for every CPU +// that Go may use when the cache is wrapped. A goroutine picks a stripe by the +// address of its stack, so goroutines on different CPUs rarely count in the +// same line, and the reads of the lock-free caches keep scaling with the CPUs. +// The stripes take 2 to 4 KiB per CPU for every wrapped cache, whatever its +// capacity. If GOMAXPROCS goes up later, goroutines share stripes more often +// and counting slows down. +// +// On an Apple M3 Max, a Get of tinylfu that finds its key took 10 to 14% +// longer through the wrapper, on one CPU and on eight. +// +// The wrapper reaches the cache through an interface, which the compiler +// cannot see through: a key that would otherwise stay where it is may have to +// move to the heap, as one converted from a []byte on the spot does. +package stats + +import ( + "iter" + "math/bits" + "runtime" + "sync/atomic" + "unsafe" + + "github.com/floatdrop/evict" +) + +// cacheLine is a size that no two CPUs' cache lines straddle on the +// platforms Go runs on. +const cacheLine = 128 + +// Snapshot is what a cache had counted at one moment, and how full it was. +// +// Each count is read on its own while the cache may be in use, so the counts +// of one snapshot may be from slightly different moments. A count is never +// less than that of a snapshot taken before it. +type Snapshot struct { + // Hits and Misses count the calls to Get that found their key, and + // those that did not. + Hits, Misses uint64 + + // Evictions counts the entries that Set pushed out to make room, and + // those that a cache without capacity handed straight back. + Evictions uint64 + + // Len is the number of entries in the cache, and Cap the number it can + // hold. + Len, Cap int +} + +// HitRatio returns the share of calls to Get that found their key, from 0 to +// 1, or 0 if there were none. +func (s Snapshot) HitRatio() float64 { + if s.Hits+s.Misses == 0 { + return 0 + } + return float64(s.Hits) / float64(s.Hits+s.Misses) +} + +// Sub returns what was counted from prev to s, with the Len and Cap of s. +// Both must be snapshots of the same cache, prev the earlier one. +func (s Snapshot) Sub(prev Snapshot) Snapshot { + return Snapshot{ + Hits: s.Hits - prev.Hits, + Misses: s.Misses - prev.Misses, + Evictions: s.Evictions - prev.Evictions, + Len: s.Len, + Cap: s.Cap, + } +} + +// Cache is an [evict.Cache] that counts what is done with it; see the package +// documentation. Its methods are safe for concurrent use as long as those of +// the cache it wraps are, as they are for every cache in this module. +type Cache[K comparable, V any] struct { + cache evict.Cache[K, V] + stripes []stripe + shift uint8 // 64 minus log2 of the number of stripes + zero bool // the cache has no capacity, and hands every entry back +} + +// A stripe holds the counts of the goroutines that pick it, in a cache line +// of its own. +// +// The counts are plain integers that are only ever accessed with the +// functions of sync/atomic. The compiler builds those in wherever it compiles +// the methods of a Cache, which is in the package that uses them. The methods +// of the atomic types, on the other hand, it inlines there only if a package +// that is imported there has inlined them itself. This one does, by way of +// the assertion at the end of the file, but a program that gets to a Cache +// through a generic type of its own package which instantiates nothing would +// import only that package, and make a call more for every count. The +// statswrap probe of internal/inlinecheck checks for that. +type stripe struct { + hits uint64 //nolint:modernize // See above. + misses uint64 //nolint:modernize // See above. + evictions uint64 //nolint:modernize // See above. + + _ [cacheLine - 24]byte +} + +// Wrap returns a Cache that counts what is done with cache, which it uses from +// then on. Using cache directly as well is fine, but goes uncounted. +func Wrap[K comparable, V any](cache evict.Cache[K, V]) *Cache[K, V] { + // Sixteen stripes per CPU make it unlikely that two goroutines that run + // at the same time pick the same one. + n := 1 << bits.Len(uint(16*runtime.GOMAXPROCS(0)-1)) + return &Cache[K, V]{ + cache: cache, + stripes: make([]stripe, n), + shift: uint8(64 - bits.TrailingZeros(uint(n))), + zero: cache.Cap() == 0, + } +} + +// stripe returns the stripe that the calling goroutine counts in. +// +// There is no way to ask which goroutine or CPU this is, but the whereabouts +// of the stack tell one goroutine from another: stacks are at least 2 KiB in +// size. The bits of the address below that say how deep into its stack a +// goroutine is, and are dropped. A goroutine that counts from about the same +// depth thus picks the same stripe, which stays in the cache of its CPU, until +// its stack grows and moves; from much deeper, it picks another. +func (c *Cache[K, V]) stripe() *stripe { + var onStack byte + //nolint:gosec // The address is only hashed, never turned back into a pointer. + stack := uint64(uintptr(unsafe.Pointer(&onStack))) >> 11 + return &c.stripes[(stack*0x9e3779b97f4a7c15)>>c.shift] +} + +// Get returns the value stored under key, and counts a hit or a miss. +func (c *Cache[K, V]) Get(key K) (value V, ok bool) { + value, ok = c.cache.Get(key) + s := c.stripe() + if ok { + atomic.AddUint64(&s.hits, 1) + } else { + atomic.AddUint64(&s.misses, 1) + } + return value, ok +} + +// Peek returns the value stored under key, and counts nothing. +func (c *Cache[K, V]) Peek(key K) (value V, ok bool) { return c.cache.Peek(key) } + +// Set stores value under key, and counts an eviction if the entry it reports +// is not the one that key had. +func (c *Cache[K, V]) Set(key K, value V) (oldKey K, oldValue V, displaced bool) { + oldKey, oldValue, displaced = c.cache.Set(key, value) + // A key that is replaced comes back equal to the new one, and so does + // the new entry itself from a cache without capacity, which counts. + if displaced && (oldKey != key || c.zero) { + atomic.AddUint64(&c.stripe().evictions, 1) + } + return oldKey, oldValue, displaced +} + +// Delete removes key and returns the value it had, and counts nothing. +func (c *Cache[K, V]) Delete(key K) (value V, ok bool) { return c.cache.Delete(key) } + +// Len returns the number of entries in the cache. +func (c *Cache[K, V]) Len() int { return c.cache.Len() } + +// Cap returns the number of entries the cache can hold. +func (c *Cache[K, V]) Cap() int { return c.cache.Cap() } + +// Clear removes every entry. It leaves the counts as they are. +func (c *Cache[K, V]) Clear() { c.cache.Clear() } + +// All iterates over the entries of the cache, and counts nothing. +func (c *Cache[K, V]) All() iter.Seq2[K, V] { return c.cache.All() } + +// Stats returns the counts so far, and the number of entries in the cache and +// its capacity. It reads every stripe and calls Len, which locks the shards of +// some caches one after the other: call it when a monitor asks, not for every +// request. +func (c *Cache[K, V]) Stats() Snapshot { + var s Snapshot + for i := range c.stripes { + st := &c.stripes[i] + s.Hits += atomic.LoadUint64(&st.hits) + s.Misses += atomic.LoadUint64(&st.misses) + s.Evictions += atomic.LoadUint64(&st.evictions) + } + s.Len, s.Cap = c.cache.Len(), c.cache.Cap() + return s +} + +var _ evict.Cache[int, int] = (*Cache[int, int])(nil) diff --git a/stats/stats_test.go b/stats/stats_test.go new file mode 100644 index 0000000..ce9e8df --- /dev/null +++ b/stats/stats_test.go @@ -0,0 +1,304 @@ +package stats_test + +import ( + "math" + "sync" + "sync/atomic" + "testing" + + "github.com/floatdrop/evict" + "github.com/floatdrop/evict/internal/cachetest" + "github.com/floatdrop/evict/lru" + "github.com/floatdrop/evict/s3fifo" + "github.com/floatdrop/evict/sieve" + "github.com/floatdrop/evict/stats" + "github.com/floatdrop/evict/tinylfu" + "github.com/floatdrop/evict/twoq" +) + +// A wrapped cache must still be a cache. One with a lock and one without get +// the suite. +var ( + lruFactories = cachetest.Factories{ + Int: func(capacity, shards int) evict.Cache[int, int] { + return stats.Wrap(lru.New[int, int](capacity, lru.WithShards(shards))) + }, + String: func(capacity, shards int) evict.Cache[string, string] { + return stats.Wrap(lru.New[string, string](capacity, lru.WithShards(shards))) + }, + Float: func(capacity, shards int) evict.Cache[float64, int] { + return stats.Wrap(lru.New[float64, int](capacity, lru.WithShards(shards))) + }, + Blob: func(capacity, shards int) evict.Cache[int, *cachetest.Blob] { + return stats.Wrap(lru.New[int, *cachetest.Blob](capacity, lru.WithShards(shards))) + }, + BlobKey: func(capacity, shards int) evict.Cache[*cachetest.Blob, int] { + return stats.Wrap(lru.New[*cachetest.Blob, int](capacity, lru.WithShards(shards))) + }, + Wide: func(capacity, shards int) evict.Cache[string, cachetest.Wide] { + return stats.Wrap(lru.New[string, cachetest.Wide](capacity, lru.WithShards(shards))) + }, + } + + s3fifoFactories = cachetest.Factories{ + Int: func(capacity, shards int) evict.Cache[int, int] { + return stats.Wrap(s3fifo.New[int, int](capacity, s3fifo.WithShards(shards))) + }, + String: func(capacity, shards int) evict.Cache[string, string] { + return stats.Wrap(s3fifo.New[string, string](capacity, s3fifo.WithShards(shards))) + }, + Float: func(capacity, shards int) evict.Cache[float64, int] { + return stats.Wrap(s3fifo.New[float64, int](capacity, s3fifo.WithShards(shards))) + }, + Blob: func(capacity, shards int) evict.Cache[int, *cachetest.Blob] { + return stats.Wrap(s3fifo.New[int, *cachetest.Blob](capacity, s3fifo.WithShards(shards))) + }, + BlobKey: func(capacity, shards int) evict.Cache[*cachetest.Blob, int] { + return stats.Wrap(s3fifo.New[*cachetest.Blob, int](capacity, s3fifo.WithShards(shards))) + }, + Wide: func(capacity, shards int) evict.Cache[string, cachetest.Wide] { + return stats.Wrap(s3fifo.New[string, cachetest.Wide](capacity, s3fifo.WithShards(shards))) + }, + } +) + +func TestCache(t *testing.T) { + t.Parallel() + + t.Run("lru", func(t *testing.T) { t.Parallel(); cachetest.Run(t, lruFactories) }) + t.Run("s3fifo", func(t *testing.T) { t.Parallel(); cachetest.Run(t, s3fifoFactories) }) +} + +// TestNoAllocs is not parallel: counting allocations needs the process to +// itself. +func TestNoAllocs(t *testing.T) { + t.Run("lru", func(t *testing.T) { cachetest.NoAllocs(t, lruFactories) }) + t.Run("s3fifo", func(t *testing.T) { cachetest.NoAllocs(t, s3fifoFactories) }) +} + +func TestCounts(t *testing.T) { + t.Parallel() + + c := stats.Wrap(lru.New[string, int](2)) + want := func(step string, hits, misses, evictions uint64, n int) { + t.Helper() + got := c.Stats() + w := stats.Snapshot{Hits: hits, Misses: misses, Evictions: evictions, Len: n, Cap: 2} + if got != w { + t.Errorf("after %s: Stats() = %+v, want %+v", step, got, w) + } + } + + want("nothing", 0, 0, 0, 0) + + c.Set("a", 1) + c.Set("b", 2) + want("two new entries", 0, 0, 0, 2) + + c.Get("a") + c.Get("a") + c.Get("x") + want("two hits and a miss", 2, 1, 0, 2) + + if k, _, displaced := c.Set("a", 10); !displaced || k != "a" { + t.Fatalf("Set(a) displaced %q, %t; want a replacement", k, displaced) + } + want("a replacement", 2, 1, 0, 2) + + if k, _, displaced := c.Set("c", 3); !displaced || k != "b" { + t.Fatalf("Set(c) displaced %q, %t; want b to be evicted", k, displaced) + } + want("an eviction", 2, 1, 1, 2) + + c.Peek("a") + c.Peek("x") + c.Delete("a") + c.Delete("x") + for range c.All() { + } + want("Peek, Delete and All", 2, 1, 1, 1) + + c.Clear() + want("Clear", 2, 1, 1, 0) +} + +// A cache without capacity hands every new entry straight back, with the key +// it was given. That is not a replacement. +func TestZeroCapacity(t *testing.T) { + t.Parallel() + + c := stats.Wrap(lru.New[int, int](0)) + c.Set(1, 1) + c.Set(1, 1) + c.Get(1) + + want := stats.Snapshot{Misses: 1, Evictions: 2} + if got := c.Stats(); got != want { + t.Errorf("Stats() = %+v, want %+v", got, want) + } +} + +// A key that is not equal to itself is never found, so an entry that it +// pushes out is not its own, though it has the same key. +func TestNaN(t *testing.T) { + t.Parallel() + + c := stats.Wrap(lru.New[float64, int](1, lru.WithShards(1))) + c.Set(math.NaN(), 1) + c.Set(math.NaN(), 2) + + if got := c.Stats().Evictions; got != 1 { + t.Errorf("Evictions = %d, want 1", got) + } +} + +// Every policy reports what it evicts through Set. Each of those has to be +// counted, and no replacement: not even of +0 by -0, which are equal but +// tell apart. +func TestEveryPolicy(t *testing.T) { + t.Parallel() + + const capacity = 100 + policies := map[string]func() evict.Cache[float64, int]{ + "lru": func() evict.Cache[float64, int] { return lru.New[float64, int](capacity, lru.WithShards(4)) }, + "twoq": func() evict.Cache[float64, int] { return twoq.New[float64, int](capacity, twoq.WithShards(4)) }, + "sieve": func() evict.Cache[float64, int] { return sieve.New[float64, int](capacity, sieve.WithShards(4)) }, + "s3fifo": func() evict.Cache[float64, int] { return s3fifo.New[float64, int](capacity, s3fifo.WithShards(4)) }, + "tinylfu": func() evict.Cache[float64, int] { return tinylfu.New[float64, int](capacity, tinylfu.WithShards(4)) }, + } + for name, newCache := range policies { + t.Run(name, func(t *testing.T) { + t.Parallel() + + c := stats.Wrap(newCache()) + c.Set(0, 0) + c.Set(math.Copysign(0, -1), 0) + if s := c.Stats(); s.Evictions != 0 || s.Len != 1 { + t.Fatalf("-0 replacing +0: %d evictions and %d entries, want 0 and 1", s.Evictions, s.Len) + } + + // Some keys are read, so that the policies that promote entries + // have some to promote, and twoq some to remember. + added := uint64(1) + for i := 1; i < 20*capacity; i++ { + key := float64(i % (3 * capacity)) + if _, ok := c.Get(key); !ok { + added++ + } + c.Set(key, i) + c.Get(float64(i % 50)) + } + + s := c.Stats() + if s.Evictions != added-uint64(s.Len) { + t.Errorf("%d entries added, %d evicted and %d left", added, s.Evictions, s.Len) + } + }) + } +} + +// Goroutines that run at the same time should count in different stripes, +// most of them: two that share one take its cache line from each other on +// every count. A count that lands in the wrong stripe is still counted, so +// only this test notices. +func TestStripes(t *testing.T) { + t.Parallel() + + const goroutines = 16 + c := stats.Wrap(lru.New[int, int](1)) + + stripes := make(chan any, goroutines) + hold := make(chan struct{}) + var wg sync.WaitGroup + for range goroutines { + wg.Go(func() { + stripes <- stats.StripeOf(c) + // A goroutine that has ended may hand its stack on to the + // next one, which would then pick the same stripe. + <-hold + }) + } + distinct := map[any]bool{} + for range goroutines { + distinct[<-stripes] = true + } + close(hold) + wg.Wait() + + if len(distinct) < goroutines/4 { + t.Errorf("%d goroutines counted in %d stripes, want %d or more", goroutines, len(distinct), goroutines/4) + } +} + +// Every request has to be counted once, however many goroutines make them +// and whichever stripes they share. +func TestConcurrent(t *testing.T) { + t.Parallel() + + const ( + goroutines = 16 + requests = 2000 + capacity = 64 + ) + c := stats.Wrap(s3fifo.New[int, int](capacity)) + + // A miss is followed by a Set, which replaces an entry if another + // goroutine has stored one for the key in between, and otherwise adds + // one. What was added and is no longer there was evicted. + var replaced atomic.Uint64 + var wg sync.WaitGroup + for g := range goroutines { + wg.Go(func() { + for i := range requests { + key := (g*requests + i*7) % (4 * capacity) + if _, ok := c.Get(key); ok { + continue + } + if k, _, displaced := c.Set(key, i); displaced && k == key { + replaced.Add(1) + } + } + }) + } + wg.Wait() + + s := c.Stats() + if s.Hits+s.Misses != goroutines*requests { + t.Errorf("%d hits and %d misses, want %d requests", s.Hits, s.Misses, goroutines*requests) + } + if s.Hits == 0 || s.Evictions == 0 { + t.Errorf("%d hits and %d evictions; the test wants both", s.Hits, s.Evictions) + } + if added := s.Misses - replaced.Load(); s.Evictions != added-uint64(s.Len) { + t.Errorf("%d entries added, %d evicted and %d left", added, s.Evictions, s.Len) + } +} + +func TestSnapshot(t *testing.T) { + t.Parallel() + + if got := (stats.Snapshot{}).HitRatio(); got != 0 { + t.Errorf("HitRatio() = %v with no requests, want 0", got) + } + if got := (stats.Snapshot{Hits: 3, Misses: 1}).HitRatio(); got != 0.75 { + t.Errorf("HitRatio() = %v, want 0.75", got) + } + + before := stats.Snapshot{Hits: 10, Misses: 5, Evictions: 2, Len: 7, Cap: 10} + after := stats.Snapshot{Hits: 13, Misses: 6, Evictions: 3, Len: 9, Cap: 10} + want := stats.Snapshot{Hits: 3, Misses: 1, Evictions: 1, Len: 9, Cap: 10} + if got := after.Sub(before); got != want { + t.Errorf("Sub() = %+v, want %+v", got, want) + } +} + +// Stats are counted in an array of stripes, which must each fill whole cache +// lines, or goroutines that count in neighbouring stripes would keep taking +// a line away from each other. +func TestStripeSize(t *testing.T) { + t.Parallel() + + if size := stats.StripeSize; size%128 != 0 { + t.Errorf("a stripe takes %d bytes, want a multiple of 128", size) + } +}