Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions evict.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 4 additions & 3 deletions internal/inlinecheck/inline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,16 @@ 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
// small enough to be inlined, and are, as long as the compiler knows them.
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()
Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions internal/inlinecheck/probe/stats/probe.go
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 17 additions & 0 deletions internal/inlinecheck/probe/statswrap/probe.go
Original file line number Diff line number Diff line change
@@ -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) }
18 changes: 18 additions & 0 deletions internal/inlinecheck/probe/statswrapuser/probe.go
Original file line number Diff line number Diff line change
@@ -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)
}
71 changes: 71 additions & 0 deletions stats/bench_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
33 changes: 33 additions & 0 deletions stats/example_test.go
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions stats/export_test.go
Original file line number Diff line number Diff line change
@@ -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() }
Loading
Loading