diff --git a/README.md b/README.md index 5349b70..57f8b1f 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,12 @@ cache := adaptsize.New(adaptsize.Options{ }) defer cache.Close() -_ = cache.Set("k1", []byte("v")) -if v, ok := cache.Get("k1"); ok { _ = v } +// For every request, record it and decide admission on misses. +req := adaptsize.Request{Key: "k1", SizeBytes: 1234, Hit: false} // hit comes from your cache +admit := cache.Request(req) +if !req.Hit && admit { + // insert into your cache implementation +} c := cache.ParameterC() _ = c diff --git a/adaptsize/adaptsize.go b/adaptsize/adaptsize.go index 2b87b61..668231c 100644 --- a/adaptsize/adaptsize.go +++ b/adaptsize/adaptsize.go @@ -1,7 +1,6 @@ package adaptsize import ( - "container/list" crand "crypto/rand" "encoding/binary" "math" @@ -25,11 +24,6 @@ type Options struct { type Cache struct { opts Options - mu sync.RWMutex - used int64 - items map[string]*entry - lru *list.List - // parameter c stored atomically cBits atomic.Uint64 @@ -44,18 +38,19 @@ type Cache struct { stopCh chan struct{} } -type entry struct { - key string - val []byte - size int64 - node *list.Element -} - type obs struct { size int64 cnt int64 } +// Request holds metrics about a cache access. The caller is responsible for +// determining whether it was a hit in their underlying cache. +type Request struct { + Key string + SizeBytes int64 + Hit bool +} + func defaultRandom() *rand.PCG { var s1, s2 uint64 if err := binary.Read(crand.Reader, binary.LittleEndian, &s1); err != nil { @@ -93,8 +88,6 @@ func New(opts Options) *Cache { c := &Cache{ opts: opts, - items: make(map[string]*entry), - lru: list.New(), obs: make(map[string]*obs), prevR: make(map[string]float64), tuneCh: make(chan struct{}, 1), @@ -106,82 +99,29 @@ func New(opts Options) *Cache { } // Close stops background tuning. -func (c *Cache) Close() { close(c.stopCh) } - -// Len returns number of cached entries. -func (c *Cache) Len() int { c.mu.RLock(); defer c.mu.RUnlock(); return len(c.items) } - -// UsedBytes returns used capacity. -func (c *Cache) UsedBytes() int64 { c.mu.RLock(); defer c.mu.RUnlock(); return c.used } +func (c *Cache) Close() { + close(c.stopCh) +} // ParameterC returns current c. -func (c *Cache) ParameterC() float64 { return math.Float64frombits(c.cBits.Load()) } - -// Get returns value and ok. Touches LRU and records stats. -func (c *Cache) Get(key string) ([]byte, bool) { - c.mu.Lock() - defer c.mu.Unlock() - if e, ok := c.items[key]; ok { - c.lru.MoveToFront(e.node) - c.record(key, e.size) - return e.val, true - } - c.record(key, 0) - return nil, false +func (c *Cache) ParameterC() float64 { + return math.Float64frombits(c.cBits.Load()) } -// Set inserts or updates value. Admission is probabilistic. -func (c *Cache) Set(key string, value []byte) error { - size := int64(len(value)) - if size > c.opts.CapacityBytes { - return nil // never admit larger than capacity +// Request records a cache request and returns whether a miss should be +// admitted. On misses (Hit=false), the return value is the probabilistic +// admission decision based on exp(-size/c). +func (c *Cache) Request(req Request) bool { + c.record(req.Key, req.SizeBytes) + if req.Hit { + return false + } + if req.SizeBytes > c.opts.CapacityBytes { + return false // never admit larger than capacity } // admission using atomic c cVal := math.Float64frombits(c.cBits.Load()) - admit := c.opts.Rand.Float64() < math.Exp(-float64(size)/cVal) - if !admit { - c.record(key, size) - return nil - } - - c.mu.Lock() - defer c.mu.Unlock() - - if e, ok := c.items[key]; ok { - c.used += size - e.size - e.val = value - e.size = size - c.lru.MoveToFront(e.node) - } else { - for c.used+size > c.opts.CapacityBytes { - c.evictOne() - } - n := c.lru.PushFront(&entry{key: key}) - e := &entry{key: key, val: value, size: size, node: n} - n.Value = e - c.items[key] = e - c.used += size - } - // stats - c.record(key, size) - return nil -} - -func (c *Cache) evictOne() { - if c.lru.Len() == 0 { - return - } - b := c.lru.Back() - if b == nil { - return - } - e, ok := b.Value.(*entry) - if !ok { - return - } - delete(c.items, e.key) - c.used -= e.size - c.lru.Remove(b) + return c.opts.Rand.Float64() < math.Exp(-float64(req.SizeBytes)/cVal) } func (c *Cache) record(key string, size int64) { @@ -207,4 +147,6 @@ func (c *Cache) record(key string, size int64) { } } -func (c *Cache) setC(v int64) { c.cBits.Store(math.Float64bits(float64(v))) } +func (c *Cache) setC(v int64) { + c.cBits.Store(math.Float64bits(float64(v))) +} diff --git a/adaptsize/adaptsize_test.go b/adaptsize/adaptsize_test.go index 271c16e..8b99cf1 100644 --- a/adaptsize/adaptsize_test.go +++ b/adaptsize/adaptsize_test.go @@ -29,12 +29,10 @@ func TestAdmissionMonotonic(t *testing.T) { admittedSmall := 0 admittedLarge := 0 for i := 0; i < N; i++ { - _ = c.Set(randKey("s", i), make([]byte, 1<<10)) // 1 KiB - if _, ok := c.Get(randKey("s", i)); ok { + if c.Request(Request{Key: randKey("s", i), SizeBytes: 1 << 10, Hit: false}) { // miss admittedSmall++ } - _ = c.Set(randKey("L", i), make([]byte, 4<<20)) // 4 MiB - if _, ok := c.Get(randKey("L", i)); ok { + if c.Request(Request{Key: randKey("L", i), SizeBytes: 4 << 20, Hit: false}) { // miss admittedLarge++ } } @@ -45,17 +43,26 @@ func TestAdmissionMonotonic(t *testing.T) { } } -func TestLRUEviction(t *testing.T) { - c := newDeterministic(1024) // 1 KiB +func TestRequestHitRecordsMetrics(t *testing.T) { + c := newDeterministic(1 << 20) defer c.Close() - c.cBits.Store(math.Float64bits(1 << 30)) // admit almost always - _ = c.Set("a", make([]byte, 800)) - _ = c.Set("b", make([]byte, 400)) // should evict a - if _, ok := c.Get("a"); ok { - t.Fatal("expected a evicted") + if c.Request(Request{Key: "a", SizeBytes: 800, Hit: true}) { // hit should not request admission + t.Fatal("expected hit to return false for admission") } - if _, ok := c.Get("b"); !ok { - t.Fatal("expected b present") + c.winMu.Lock() + obs := c.obs["a"] + c.winMu.Unlock() + if obs == nil || obs.cnt != 1 || obs.size != 800 { + t.Fatalf("hit metrics not recorded: %+v", obs) + } + if c.Request(Request{Key: "a", SizeBytes: 1200, Hit: true}) { + t.Fatal("expected hit to return false for admission") + } + c.winMu.Lock() + obs = c.obs["a"] + c.winMu.Unlock() + if obs.cnt != 2 || obs.size != 1200 { + t.Fatalf("hit metrics not updated: %+v", obs) } } @@ -77,11 +84,11 @@ func TestBackgroundTuningMovesC(t *testing.T) { for i := 0; i < 30_000; i++ { // small hot keys cycle k := randKey("hot", i%128) - _ = c.Set(k, make([]byte, 512)) - c.Get(k) + _ = c.Request(Request{Key: k, SizeBytes: 512, Hit: false}) // miss then admit decision ignored here + _ = c.Request(Request{Key: k, SizeBytes: 512, Hit: true}) // subsequent hit to record hotness // occasional large misses if i%50 == 0 { - _ = c.Set(randKey("cold", i), make([]byte, 256<<10)) + _ = c.Request(Request{Key: randKey("cold", i), SizeBytes: 256 << 10, Hit: false}) } } // give time for tuner to run @@ -95,6 +102,65 @@ func TestBackgroundTuningMovesC(t *testing.T) { } } +func TestRequestOversize(t *testing.T) { + c := newDeterministic(1024) + defer c.Close() + admit := c.Request(Request{Key: "big", SizeBytes: 2048, Hit: false}) + if admit { + t.Fatal("expected oversize object not to be admitted") + } + c.winMu.Lock() + obs := c.obs["big"] + c.winMu.Unlock() + if obs == nil || obs.cnt != 1 || obs.size != 2048 { + t.Fatalf("oversize request not recorded: %+v", obs) + } +} + +func TestBuildRatesEMA(t *testing.T) { + c := newDeterministic(1 << 20) + prevAOld := 10.0 + c.prevR["a"] = prevAOld + snap := map[string]obs{ + "a": {size: 100, cnt: 2}, + "b": {size: 200, cnt: 3}, + "z": {size: 0, cnt: 5}, // should be ignored due to size 0 + } + items, total := c.buildRates(snap) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if total <= 0 { + t.Fatalf("expected positive total, got %f", total) + } + expA := 0.5*float64(snap["a"].cnt) + 0.5*prevAOld + expB := 0.5 * float64(snap["b"].cnt) + if c.prevR["a"] != expA || c.prevR["b"] != expB { + t.Fatalf("unexpected EMA values: prevR=%v", c.prevR) + } + rate := func(size int64) float64 { + for _, it := range items { + if it.s == size { + return it.r + } + } + return -1 + } + if rate(100) != expA || rate(200) != expB { + t.Fatalf("unexpected rates: %+v", items) + } +} + +func TestTuneOnceNoDataKeepsC(t *testing.T) { + c := newDeterministic(1 << 20) + defer c.Close() + c0 := c.ParameterC() + c.TuneOnce() + if c.ParameterC() != c0 { + t.Fatalf("expected c unchanged without data, got %f -> %f", c0, c.ParameterC()) + } +} + func randKey(prefix string, i int) string { return prefix + "-" + strconvI(i) } func strconvI(i int) string { diff --git a/go.mod b/go.mod index 0cf2c6b..7342fc5 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module example.com/adaptsize -go 1.22 +go 1.25.3