Skip to content

Commit cc03d12

Browse files
committed
Implement LRU sampling + SampleStats; clean benchmarks and docs
This change introduces LRU sampling to reduce contention on hot Get paths. Instead of moving an entry to the front of the LRU list on every hit, we do so with probability p. Non‑sampled hits take a read‑only path (no write lock, no list mutation), which lowers contention and improves throughput under concurrency, at the cost of slightly less precise recency ordering. Backwards compatibility is preserved. The zero‑value configuration behaves exactly as before: 100% LRU updates on every Get and exact stats. Existing users see no behavior change unless they opt in to the new settings. Two new configuration options are added: - LRUSamplingRate (float64, 0.0–1.0): probability of updating LRU on Get. A zero value is treated as 1.0 (traditional behavior). Choose based on workload; a moderate rate typically yields strong gains with minimal impact on eviction quality. - SampleStats (bool): when true, stats are updated only on sampled events and scaled by approximately 1/p so they estimate the totals you would see without sampling. The same sampling decision is reused for both LRU and stats, preserving the relationship Gets ≈ Hits + Misses. When false (default), stats remain exact. Documentation is updated to describe the trade‑offs and suggest a practical starting range for LRUSamplingRate.
1 parent 3c8cb45 commit cc03d12

3 files changed

Lines changed: 346 additions & 13 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,19 @@ cache.Set("foo", "bar")
2929
Full docs are available on [Godoc][godoc].
3030

3131
[godoc]: https://godoc.org/github.com/segmentio/agecache
32+
33+
## LRU Sampling (advanced)
34+
35+
`agecache` supports reducing lock contention on hot `Get` paths by sampling
36+
how often LRU positions are updated.
37+
38+
- Configure with `Config.LRUSamplingRate` in the range `[0.0, 1.0]`.
39+
- `0.0` or the zero value behaves like `1.0` (traditional LRU update on every `Get`).
40+
- Lower values (e.g., `0.2``0.25`) can significantly improve throughput under high concurrency, at the cost of approximate LRU ordering.
41+
- To keep stats inexpensive but useful, you can enable `Config.SampleStats`.
42+
- When `SampleStats` is `true`, stats counters (Gets/Hits/Misses) are updated only when an LRU update would happen, and are scaled by approximately `1/LRUSamplingRate` so they estimate the unsampled totals.
43+
- When `SampleStats` is `false` (default), stats are exact but may add contention in very hot paths.
44+
45+
Notes:
46+
- Sampling changes eviction accuracy. For many workloads a rate around `0.2–0.25` is a good starting point; benchmark for your use case.
47+
- With `SampleStats: true`, values are estimates and may vary slightly; with `false`, they are exact.

cache.go

Lines changed: 115 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ package agecache
44
import (
55
"container/list"
66
"errors"
7+
"math"
78
"math/rand"
89
"sync"
10+
"sync/atomic"
911
"time"
1012
)
1113

@@ -85,6 +87,14 @@ type Config struct {
8587
// Optional on refresh callback invoked when the cache is refreshed
8688
// Both RefreshInterval and OnRefresh must be provided to enable background cache refresh
8789
OnRefresh func() map[interface{}]interface{}
90+
// LRUSamplingRate controls how often LRU position updates occur on Get().
91+
// Valid range: 0.0-1.0. Default: 1.0 (traditional LRU behavior).
92+
// Lower values reduce lock contention but may affect eviction accuracy.
93+
LRUSamplingRate float64
94+
// SampleStats, when true, updates Gets/Hits/Misses using the same
95+
// sampling decision as LRUSamplingRate. When false (default), stats
96+
// counters are exact at the cost of additional atomic operations.
97+
SampleStats bool
8898
}
8999

90100
// Entry pointed to by each list.Element
@@ -104,8 +114,10 @@ type Cache struct {
104114
expirationInterval time.Duration
105115
onEviction func(key, value interface{})
106116
onExpiration func(key, value interface{})
117+
lruSamplingRate float64
118+
sampleStats bool
107119

108-
// Cache statistics
120+
// Cache statistics (atomic)
109121
sets int64
110122
gets int64
111123
hits int64
@@ -143,6 +155,10 @@ func New(config Config) *Cache {
143155
panic("Must supply a zero or positive config.RefreshInterval")
144156
}
145157

158+
if config.LRUSamplingRate < 0 || config.LRUSamplingRate > 1.0 {
159+
panic("Must supply a config.LRUSamplingRate between 0.0 and 1.0")
160+
}
161+
146162
minAge := config.MinAge
147163
if minAge == 0 {
148164
minAge = config.MaxAge
@@ -155,6 +171,11 @@ func New(config Config) *Cache {
155171

156172
seed := rand.NewSource(time.Now().UnixNano())
157173

174+
samplingRate := config.LRUSamplingRate
175+
if samplingRate == 0 {
176+
samplingRate = 1.0
177+
}
178+
158179
cache := &Cache{
159180
capacity: config.Capacity,
160181
maxAge: config.MaxAge,
@@ -163,11 +184,15 @@ func New(config Config) *Cache {
163184
expirationInterval: interval,
164185
onEviction: config.OnEviction,
165186
onExpiration: config.OnExpiration,
187+
lruSamplingRate: samplingRate,
188+
sampleStats: config.SampleStats,
166189
items: make(map[interface{}]*list.Element),
167190
evictionList: list.New(),
168191
rand: rand.New(seed),
169192
}
170193

194+
// No additional RNG state required for sampling decision
195+
171196
if config.ExpirationType == ActiveExpiration && interval > 0 {
172197
go func() {
173198
for range time.Tick(interval) {
@@ -201,7 +226,7 @@ func (cache *Cache) Set(key, value interface{}) bool {
201226
cache.mutex.Lock()
202227
defer cache.mutex.Unlock()
203228

204-
cache.sets++
229+
atomic.AddInt64(&cache.sets, 1)
205230
timestamp := cache.getTimestamp()
206231

207232
if element, ok := cache.items[key]; ok {
@@ -227,32 +252,109 @@ func (cache *Cache) Set(key, value interface{}) bool {
227252
// not the value was found. The OnExpiration callback is invoked if the value
228253
// had expired on access
229254
func (cache *Cache) Get(key interface{}) (interface{}, bool) {
255+
shouldSample := cache.lruSamplingRate == 1.0 || rand.Float64() < cache.lruSamplingRate
256+
257+
if shouldSample {
258+
return cache.getWithLRUUpdate(key, shouldSample)
259+
}
260+
return cache.getReadOnly(key, shouldSample)
261+
}
262+
263+
// getWithLRUUpdate performs a Get operation with LRU position update (traditional behavior).
264+
func (cache *Cache) getWithLRUUpdate(key interface{}, shouldSample bool) (interface{}, bool) {
230265
cache.mutex.Lock()
231266
defer cache.mutex.Unlock()
232267

233-
cache.gets++
268+
w := cache.statsWeight(shouldSample)
269+
if w != 0 {
270+
atomic.AddInt64(&cache.gets, w)
271+
}
234272

235273
if element, ok := cache.items[key]; ok {
236274
entry := element.Value.(*cacheEntry)
237275
if cache.maxAge == 0 || time.Since(entry.timestamp) <= cache.maxAge {
238276
cache.evictionList.MoveToFront(element)
239-
cache.hits++
277+
if w != 0 {
278+
atomic.AddInt64(&cache.hits, w)
279+
}
240280
return entry.value, true
241281
}
242282

243283
// Entry expired
244284
cache.deleteElement(element)
245-
cache.misses++
285+
if w != 0 {
286+
atomic.AddInt64(&cache.misses, w)
287+
}
246288
if cache.onExpiration != nil {
247289
cache.onExpiration(entry.key, entry.value)
248290
}
249291
return nil, false
250292
}
251293

252-
cache.misses++
294+
if w != 0 {
295+
atomic.AddInt64(&cache.misses, w)
296+
}
253297
return nil, false
254298
}
255299

300+
// getReadOnly performs a Get operation without LRU position update (fast read-only path).
301+
func (cache *Cache) getReadOnly(key interface{}, shouldSample bool) (interface{}, bool) {
302+
cache.mutex.RLock()
303+
defer cache.mutex.RUnlock()
304+
305+
w := cache.statsWeight(shouldSample)
306+
if w != 0 {
307+
atomic.AddInt64(&cache.gets, w)
308+
}
309+
310+
element, exists := cache.items[key]
311+
if !exists {
312+
if w != 0 {
313+
atomic.AddInt64(&cache.misses, w)
314+
}
315+
return nil, false
316+
}
317+
318+
entry := element.Value.(*cacheEntry)
319+
if cache.maxAge > 0 && time.Since(entry.timestamp) > cache.maxAge {
320+
// Expired - just return miss, don't delete (bounded by capacity)
321+
if w != 0 {
322+
atomic.AddInt64(&cache.misses, w)
323+
}
324+
return nil, false
325+
}
326+
327+
if w != 0 {
328+
atomic.AddInt64(&cache.hits, w)
329+
}
330+
return entry.value, true
331+
}
332+
333+
// statsWeight returns the integer weight to add to stats counters for a single
334+
// Get event, based on sampling configuration and the event's sampling decision.
335+
// - When SampleStats is disabled, always returns 1.
336+
// - When SampleStats is enabled and the event was not sampled, returns 0.
337+
// - When SampleStats is enabled and the event was sampled, returns
338+
// round(1 / LRUSamplingRate) to approximate the unsampled totals.
339+
func (cache *Cache) statsWeight(shouldSample bool) int64 {
340+
if !cache.sampleStats {
341+
return 1
342+
}
343+
if !shouldSample {
344+
return 0
345+
}
346+
p := cache.lruSamplingRate
347+
if p <= 0 {
348+
// Should not happen because zero defaults to 1.0, but be defensive.
349+
return 1
350+
}
351+
w := int64(math.Round(1.0 / p))
352+
if w < 1 {
353+
w = 1
354+
}
355+
return w
356+
}
357+
256358
// RefreshCache refreshes the entire cache with the new items map
257359
func (cache *Cache) RefreshCache(items map[interface{}]interface{}) {
258360
cache.mutex.Lock()
@@ -262,7 +364,7 @@ func (cache *Cache) RefreshCache(items map[interface{}]interface{}) {
262364
cache.evictionList.Init()
263365

264366
for key, value := range items {
265-
cache.sets++
367+
atomic.AddInt64(&cache.sets, 1)
266368
timestamp := cache.getTimestamp()
267369

268370
if element, ok := cache.items[key]; ok {
@@ -446,11 +548,11 @@ func (cache *Cache) Stats() Stats {
446548
return Stats{
447549
Capacity: int64(cache.capacity),
448550
Count: int64(cache.evictionList.Len()),
449-
Sets: cache.sets,
450-
Gets: cache.gets,
451-
Hits: cache.hits,
452-
Misses: cache.misses,
453-
Evictions: cache.evictions,
551+
Sets: atomic.LoadInt64(&cache.sets),
552+
Gets: atomic.LoadInt64(&cache.gets),
553+
Hits: atomic.LoadInt64(&cache.hits),
554+
Misses: atomic.LoadInt64(&cache.misses),
555+
Evictions: atomic.LoadInt64(&cache.evictions),
454556
}
455557
}
456558

@@ -502,7 +604,7 @@ func (cache *Cache) evictOldest() bool {
502604
return false
503605
}
504606

505-
cache.evictions++
607+
atomic.AddInt64(&cache.evictions, 1)
506608
entry := cache.deleteElement(element)
507609
if cache.onEviction != nil {
508610
cache.onEviction(entry.key, entry.value)

0 commit comments

Comments
 (0)