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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 27 additions & 85 deletions adaptsize/adaptsize.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package adaptsize

import (
"container/list"
crand "crypto/rand"
"encoding/binary"
"math"
Expand All @@ -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

Expand All @@ -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 {
Expand Down Expand Up @@ -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),
Expand All @@ -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) {
Expand All @@ -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)))
}
98 changes: 82 additions & 16 deletions adaptsize/adaptsize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
}
}
Expand All @@ -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)
}
}

Expand All @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module example.com/adaptsize

go 1.22
go 1.25.3
Loading