Skip to content

Commit 5a992dd

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
ghost ratio experiments - 0.5 is a sweet spot
1 parent b2d42e0 commit 5a992dd

File tree

4 files changed

+57
-39
lines changed

4 files changed

+57
-39
lines changed

memory.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func Memory[K comparable, V any](opts ...Option) *MemoryCache[K, V] {
3232
}
3333

3434
return &MemoryCache[K, V]{
35-
memory: newS3FIFO[K, V](cfg.size),
35+
memory: newS3FIFO[K, V](cfg),
3636
defaultTTL: cfg.defaultTTL,
3737
}
3838
}
@@ -133,11 +133,15 @@ type config struct {
133133
size int
134134
defaultTTL time.Duration
135135
warmup int
136+
smallRatio float64
137+
ghostRatio float64
136138
}
137139

138140
func defaultConfig() *config {
139141
return &config{
140-
size: 16384, // 2^14, divides evenly by numShards
142+
size: 16384, // 2^14, divides evenly by numShards
143+
smallRatio: 0.1, // 10% small queue
144+
ghostRatio: 0.5, // 50% ghost queue
141145
}
142146
}
143147

@@ -151,6 +155,22 @@ func WithSize(n int) Option {
151155
}
152156
}
153157

158+
// WithSmallRatio sets the ratio of the small queue to the total cache size.
159+
// Default is 0.1 (10%).
160+
func WithSmallRatio(r float64) Option {
161+
return func(c *config) {
162+
c.smallRatio = r
163+
}
164+
}
165+
166+
// WithGhostRatio sets the ratio of the ghost queue to the total cache size.
167+
// Default is 1.0 (100%).
168+
func WithGhostRatio(r float64) Option {
169+
return func(c *config) {
170+
c.ghostRatio = r
171+
}
172+
}
173+
154174
// WithTTL sets the default TTL for cache items.
155175
// Items without an explicit TTL will use this value.
156176
func WithTTL(d time.Duration) Option {

persistent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func Persistent[K comparable, V any](ctx context.Context, p PersistenceLayer[K,
4848

4949
cache := &PersistentCache[K, V]{
5050
Store: p,
51-
memory: newS3FIFO[K, V](cfg.size),
51+
memory: newS3FIFO[K, V](cfg),
5252
defaultTTL: cfg.defaultTTL,
5353
warmup: cfg.warmup,
5454
}

s3fifo.go

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ type entry[K comparable, V any] struct {
222222
}
223223

224224
// newS3FIFO creates a new sharded S3-FIFO cache with the given total capacity.
225-
func newS3FIFO[K comparable, V any](capacity int) *s3fifo[K, V] {
225+
func newS3FIFO[K comparable, V any](cfg *config) *s3fifo[K, V] {
226+
capacity := cfg.size
226227
if capacity <= 0 {
227228
capacity = 16384 // 2^14, divides evenly by 16 shards
228229
}
@@ -263,22 +264,22 @@ func newS3FIFO[K comparable, V any](capacity int) *s3fifo[K, V] {
263264
}
264265

265266
for i := range numShards {
266-
c.shards[i] = newShard[K, V](shardCap)
267+
c.shards[i] = newShard[K, V](shardCap, cfg.smallRatio, cfg.ghostRatio)
267268
}
268269

269270
return c
270271
}
271272

272273
// newShard creates a new S3-FIFO shard with the given capacity.
273-
func newShard[K comparable, V any](capacity int) *shard[K, V] {
274-
// Small queue: 10% of cache capacity (S3-FIFO paper recommendation).
275-
smallCap := capacity / 10
274+
func newShard[K comparable, V any](capacity int, smallRatio, ghostRatio float64) *shard[K, V] {
275+
// Small queue: recommended 10%
276+
smallCap := int(float64(capacity) * smallRatio)
276277
if smallCap < 1 {
277278
smallCap = 1
278279
}
279280

280-
// Ghost queue: 100% of cache capacity to maximize re-admission detection.
281-
ghostCap := capacity
281+
// Ghost queue: recommended 100%
282+
ghostCap := int(float64(capacity) * ghostRatio)
282283
if ghostCap < 1 {
283284
ghostCap = 1
284285
}
@@ -421,20 +422,17 @@ func (s *shard[K, V]) getOrSet(key K, value V, expiryNano int64) (V, bool) {
421422
// Fast path: try read lock first (optimistic for cache hits)
422423
s.mu.RLock()
423424
if ent, ok := s.items[key]; ok {
424-
// Check expiration
425-
if ent.expiryNano != 0 && time.Now().UnixNano() > ent.expiryNano {
426-
// Expired - need write lock to update
427-
s.mu.RUnlock()
428-
// Fall through to slow path
429-
} else {
430-
// Hit - mark as accessed and return
425+
// Check if NOT expired - return early with cache hit
426+
if ent.expiryNano == 0 || time.Now().UnixNano() <= ent.expiryNano {
431427
if !ent.accessed.Load() {
432428
ent.accessed.Store(true)
433429
}
434430
v := ent.value
435431
s.mu.RUnlock()
436432
return v, true
437433
}
434+
// Expired - need write lock to update, fall through to slow path
435+
s.mu.RUnlock()
438436
} else {
439437
s.mu.RUnlock()
440438
}

s3fifo_test.go

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
)
99

1010
func TestS3FIFO_BasicOperations(t *testing.T) {
11-
cache := newS3FIFO[string, int](100)
11+
cache := newS3FIFO[string, int](&config{size: 100, smallRatio: 0.1, ghostRatio: 1.0})
1212

1313
// Test set and get
1414
cache.setToMemory("key1", 42, 0)
@@ -35,7 +35,7 @@ func TestS3FIFO_BasicOperations(t *testing.T) {
3535
}
3636

3737
func TestS3FIFO_Capacity(t *testing.T) {
38-
cache := newS3FIFO[int, string](20000)
38+
cache := newS3FIFO[int, string](&config{size: 20000, smallRatio: 0.1, ghostRatio: 1.0})
3939

4040
// Fill cache well beyond capacity
4141
for i := range 30000 {
@@ -65,7 +65,7 @@ func TestS3FIFO_CapacityAccuracy(t *testing.T) {
6565

6666
for _, tc := range testCases {
6767
t.Run(fmt.Sprintf("capacity_%d", tc.requested), func(t *testing.T) {
68-
cache := newS3FIFO[int, int](tc.requested)
68+
cache := newS3FIFO[int, int](&config{size: tc.requested, smallRatio: 0.1, ghostRatio: 1.0})
6969

7070
// Insert many more items than capacity
7171
for i := range tc.requested * 3 {
@@ -88,7 +88,7 @@ func TestS3FIFO_CapacityAccuracy(t *testing.T) {
8888
}
8989

9090
func TestS3FIFO_Eviction(t *testing.T) {
91-
cache := newS3FIFO[int, int](10000)
91+
cache := newS3FIFO[int, int](&config{size: 10000, smallRatio: 0.1, ghostRatio: 1.0})
9292

9393
// Fill cache to capacity
9494
for i := range 10000 {
@@ -115,7 +115,7 @@ func TestS3FIFO_Eviction(t *testing.T) {
115115
}
116116

117117
func TestS3FIFO_GhostQueue(t *testing.T) {
118-
cache := newS3FIFO[string, int](12) // 3 per shard
118+
cache := newS3FIFO[string, int](&config{size: 12, smallRatio: 0.1, ghostRatio: 1.0})
119119

120120
// Fill one shard's worth
121121
cache.setToMemory("a", 1, 0)
@@ -135,7 +135,7 @@ func TestS3FIFO_GhostQueue(t *testing.T) {
135135
}
136136

137137
func TestS3FIFO_TTL(t *testing.T) {
138-
cache := newS3FIFO[string, int](10)
138+
cache := newS3FIFO[string, int](&config{size: 10, smallRatio: 0.1, ghostRatio: 1.0})
139139

140140
// Set item with past expiry
141141
past := time.Now().Add(-1 * time.Second).UnixNano()
@@ -157,7 +157,7 @@ func TestS3FIFO_TTL(t *testing.T) {
157157
}
158158

159159
func TestS3FIFO_Concurrent(t *testing.T) {
160-
cache := newS3FIFO[int, int](1000)
160+
cache := newS3FIFO[int, int](&config{size: 1000, smallRatio: 0.1, ghostRatio: 1.0})
161161
var wg sync.WaitGroup
162162

163163
// Concurrent writers
@@ -193,7 +193,7 @@ func TestS3FIFO_Concurrent(t *testing.T) {
193193
func TestS3FIFO_FrequencyPromotion(t *testing.T) {
194194
// Use a larger capacity to ensure meaningful per-shard capacity
195195
// With 512 shards, 10000 items = ~20 per shard
196-
cache := newS3FIFO[int, int](10000)
196+
cache := newS3FIFO[int, int](&config{size: 10000, smallRatio: 0.1, ghostRatio: 1.0})
197197

198198
// Fill cache with items using int keys for predictable sharding
199199
for i := range 10000 {
@@ -233,7 +233,7 @@ func TestS3FIFO_FrequencyPromotion(t *testing.T) {
233233

234234
func TestS3FIFO_SmallCapacity(t *testing.T) {
235235
// Test with capacity of 12 (3 per shard)
236-
cache := newS3FIFO[string, int](12)
236+
cache := newS3FIFO[string, int](&config{size: 12, smallRatio: 0.1, ghostRatio: 1.0})
237237

238238
// Fill to capacity
239239
cache.setToMemory("a", 1, 0)
@@ -259,7 +259,7 @@ func TestS3FIFO_SmallCapacity(t *testing.T) {
259259
}
260260

261261
func BenchmarkS3FIFO_Set(b *testing.B) {
262-
cache := newS3FIFO[int, int](10000)
262+
cache := newS3FIFO[int, int](&config{size: 10000, smallRatio: 0.1, ghostRatio: 1.0})
263263
b.ResetTimer()
264264

265265
for i := range b.N {
@@ -268,7 +268,7 @@ func BenchmarkS3FIFO_Set(b *testing.B) {
268268
}
269269

270270
func BenchmarkS3FIFO_Get(b *testing.B) {
271-
cache := newS3FIFO[int, int](10000)
271+
cache := newS3FIFO[int, int](&config{size: 10000, smallRatio: 0.1, ghostRatio: 1.0})
272272
for i := range 10000 {
273273
cache.setToMemory(i, i, 0)
274274
}
@@ -280,7 +280,7 @@ func BenchmarkS3FIFO_Get(b *testing.B) {
280280
}
281281

282282
func BenchmarkS3FIFO_GetParallel(b *testing.B) {
283-
cache := newS3FIFO[int, int](10000)
283+
cache := newS3FIFO[int, int](&config{size: 10000, smallRatio: 0.1, ghostRatio: 1.0})
284284
for i := range 10000 {
285285
cache.setToMemory(i, i, 0)
286286
}
@@ -296,7 +296,7 @@ func BenchmarkS3FIFO_GetParallel(b *testing.B) {
296296
}
297297

298298
func BenchmarkS3FIFO_Mixed(b *testing.B) {
299-
cache := newS3FIFO[int, int](10000)
299+
cache := newS3FIFO[int, int](&config{size: 10000, smallRatio: 0.1, ghostRatio: 1.0})
300300
b.ResetTimer()
301301

302302
for i := range b.N {
@@ -415,7 +415,7 @@ func TestS3FIFODetailed(t *testing.T) {
415415

416416
func TestS3FIFO_Flush(t *testing.T) {
417417
// Use int keys for predictable sharding, large capacity to avoid evictions
418-
cache := newS3FIFO[int, int](10000)
418+
cache := newS3FIFO[int, int](&config{size: 10000, smallRatio: 0.1, ghostRatio: 1.0})
419419

420420
// Add some items (fewer than capacity to avoid eviction)
421421
for i := range 100 {
@@ -457,7 +457,7 @@ func TestS3FIFO_Flush(t *testing.T) {
457457
}
458458

459459
func TestS3FIFO_FlushEmpty(t *testing.T) {
460-
cache := newS3FIFO[string, int](100)
460+
cache := newS3FIFO[string, int](&config{size: 100, smallRatio: 0.1, ghostRatio: 1.0})
461461

462462
// Flush empty cache
463463
removed := cache.flushMemory()
@@ -487,7 +487,7 @@ func TestS3FIFO_VariousKeyTypes(t *testing.T) {
487487
// This exercises different code paths in getShard/shardIndexSlow.
488488

489489
t.Run("int", func(t *testing.T) {
490-
cache := newS3FIFO[int, string](100)
490+
cache := newS3FIFO[int, string](&config{size: 100, smallRatio: 0.1, ghostRatio: 1.0})
491491
cache.setToMemory(42, "forty-two", 0)
492492
cache.setToMemory(-1, "negative", 0)
493493
cache.setToMemory(0, "zero", 0)
@@ -504,7 +504,7 @@ func TestS3FIFO_VariousKeyTypes(t *testing.T) {
504504
})
505505

506506
t.Run("int64", func(t *testing.T) {
507-
cache := newS3FIFO[int64, string](100)
507+
cache := newS3FIFO[int64, string](&config{size: 100, smallRatio: 0.1, ghostRatio: 1.0})
508508
cache.setToMemory(int64(1<<62), "large", 0)
509509
cache.setToMemory(int64(-1), "negative", 0)
510510

@@ -517,7 +517,7 @@ func TestS3FIFO_VariousKeyTypes(t *testing.T) {
517517
})
518518

519519
t.Run("uint", func(t *testing.T) {
520-
cache := newS3FIFO[uint, string](100)
520+
cache := newS3FIFO[uint, string](&config{size: 100, smallRatio: 0.1, ghostRatio: 1.0})
521521
cache.setToMemory(uint(0), "zero", 0)
522522
cache.setToMemory(uint(100), "hundred", 0)
523523

@@ -531,7 +531,7 @@ func TestS3FIFO_VariousKeyTypes(t *testing.T) {
531531

532532
t.Run("uint64", func(t *testing.T) {
533533
// Use larger size to ensure per-shard capacity > 1 (2048 shards)
534-
cache := newS3FIFO[uint64, string](10000)
534+
cache := newS3FIFO[uint64, string](&config{size: 10000, smallRatio: 0.1, ghostRatio: 1.0})
535535
cache.setToMemory(uint64(1<<63), "large", 0)
536536
cache.setToMemory(uint64(0), "zero", 0)
537537

@@ -544,7 +544,7 @@ func TestS3FIFO_VariousKeyTypes(t *testing.T) {
544544
})
545545

546546
t.Run("string", func(t *testing.T) {
547-
cache := newS3FIFO[string, int](100)
547+
cache := newS3FIFO[string, int](&config{size: 100, smallRatio: 0.1, ghostRatio: 1.0})
548548
cache.setToMemory("hello", 1, 0)
549549
cache.setToMemory("", 2, 0) // empty string is valid
550550
unicode := "unicode-\u65e5\u672c\u8a9e"
@@ -563,7 +563,7 @@ func TestS3FIFO_VariousKeyTypes(t *testing.T) {
563563

564564
t.Run("fmt.Stringer", func(t *testing.T) {
565565
// Tests the fmt.Stringer fast path in shardIndexSlow
566-
cache := newS3FIFO[stringerKey, string](100)
566+
cache := newS3FIFO[stringerKey, string](&config{size: 100, smallRatio: 0.1, ghostRatio: 1.0})
567567
k1 := stringerKey{id: 1}
568568
k2 := stringerKey{id: 2}
569569
k3 := stringerKey{id: 999}
@@ -592,7 +592,7 @@ func TestS3FIFO_VariousKeyTypes(t *testing.T) {
592592
t.Run("plain struct", func(t *testing.T) {
593593
// Tests the fmt.Sprintf fallback in shardIndexSlow.
594594
// This is not fast, but should be reliable.
595-
cache := newS3FIFO[plainKey, string](100)
595+
cache := newS3FIFO[plainKey, string](&config{size: 100, smallRatio: 0.1, ghostRatio: 1.0})
596596
k1 := plainKey{a: 1, b: "one"}
597597
k2 := plainKey{a: 2, b: "two"}
598598
k3 := plainKey{a: 1, b: "one"} // Same as k1

0 commit comments

Comments
 (0)