Skip to content

Commit 2fcd14c

Browse files
author
Thomas Stromberg
committed
add MemoryStats structure for monitoring
1 parent fc67a42 commit 2fcd14c

3 files changed

Lines changed: 181 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,27 @@ fido has been hyper-tuned for high performance, and deviates from the original p
103103
## License
104104

105105
Apache 2.0
106+
107+
### Memory diagnostics
108+
109+
`Cache.MemoryStats` and `TieredCache.MemoryStats` sample the memory tier:
110+
111+
```go
112+
stats, ok := cache.MemoryStats(func(key string, value []byte) uint64 {
113+
return uint64(len(key) + cap(value))
114+
})
115+
if ok {
116+
log.Printf("entries=%d capacity=%d pending=%d estimated_bytes=%d",
117+
stats.Entries, stats.Capacity, stats.PendingEntries, stats.Bytes())
118+
}
119+
```
120+
121+
The callback estimates referenced storage beyond the inline key/value headers.
122+
Use `nil` for structural accounting only. Sampling includes expired entries and
123+
entries pending eviction because they still retain memory. It takes O(N) time;
124+
run it periodically and retain the last sample when `ok` is false (writer busy).
125+
The full scan and callback run without the FIFO writer lock. Counts and bytes
126+
are approximate under concurrent changes, and shared backing storage may be
127+
counted more than once. Estimates exclude allocator rounding, xsync map/lock
128+
internals, in-flight loads and process overhead. Use Go heap profiles for an
129+
independent measurement. This method never contacts a persistent Store.

‎stats.go‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package fido
2+
3+
import "unsafe"
4+
5+
// MemoryStats describes retained memory, including expired entries and entries
6+
// awaiting eviction. Counts and bytes are estimates during concurrent updates.
7+
// Bytes excludes allocator rounding, goroutine stacks, in-flight loads, and the
8+
// internal allocations of xsync maps and locks. It is not process RSS.
9+
type MemoryStats struct {
10+
Entries int
11+
Capacity int
12+
PendingEntries int
13+
RetainedEntries int
14+
StructuralBytes uint64
15+
PayloadBytes uint64
16+
}
17+
18+
// Bytes returns the estimated bytes accounted for by this snapshot.
19+
func (s MemoryStats) Bytes() uint64 { return s.StructuralBytes + s.PayloadBytes }
20+
21+
// MemoryStats samples the cache without waiting for its writer lock. False
22+
// means the cache is busy; retain the previous sample and retry later. With a
23+
// non-nil sizeOf it takes O(N) time and should run periodically, not per request.
24+
// sizeOf reports referenced key/value storage beyond their inline Go headers;
25+
// for string keys and []byte values this is len(key)+cap(value). It must be fast,
26+
// must not mutate the cache or its values. Shared backing storage
27+
// can be counted multiple times. A nil sizeOf reports only structural bytes.
28+
func (c *Cache[K, V]) MemoryStats(sizeOf func(K, V) uint64) (MemoryStats, bool) {
29+
return c.memory.memoryStats(sizeOf)
30+
}
31+
32+
// MemoryStats samples only the memory tier. Persistent storage is owned by
33+
// Store and is deliberately not scanned or contacted by this method.
34+
func (c *TieredCache[K, V]) MemoryStats(sizeOf func(K, V) uint64) (MemoryStats, bool) {
35+
return c.memory.memoryStats(sizeOf)
36+
}
37+
38+
func (c *s3fifo[K, V]) memoryStats(sizeOf func(K, V) uint64) (MemoryStats, bool) {
39+
var s MemoryStats
40+
var recycledKey K
41+
var recycledValue V
42+
var recycled bool
43+
// Only queue metadata needs the writer lock. Traverse the concurrent map
44+
// and call user code after releasing it, like Range; diagnostics must not
45+
// stop insertions for the duration of a full-cache scan.
46+
if !func() bool {
47+
if !c.mu.TryLock() {
48+
return false
49+
}
50+
defer c.mu.Unlock()
51+
s = MemoryStats{Entries: c.len(), Capacity: c.capacity, RetainedEntries: c.entries.Size()}
52+
for _, e := range c.deathRow {
53+
if e != nil {
54+
s.PendingEntries++
55+
}
56+
}
57+
var e entry[K, V]
58+
s.StructuralBytes = uint64(unsafe.Sizeof(*c)) + 2*uint64(unsafe.Sizeof(bloomFilter{})) +
59+
uint64(cap(c.ghostActive.data)+cap(c.ghostAging.data))*8 +
60+
uint64(cap(c.deathRow))*uint64(unsafe.Sizeof(&e)) +
61+
uint64(s.RetainedEntries)*uint64(unsafe.Sizeof(e))
62+
if c.freeEntry != nil {
63+
s.StructuralBytes += uint64(unsafe.Sizeof(e))
64+
if sizeOf != nil {
65+
recycledKey = c.freeEntry.key
66+
recycledValue, recycled = c.freeEntry.loadValue()
67+
}
68+
}
69+
return true
70+
}() {
71+
return MemoryStats{}, false
72+
}
73+
if sizeOf != nil {
74+
c.entries.Range(func(k K, e *entry[K, V]) bool {
75+
if v, ok := e.loadValue(); ok {
76+
s.PayloadBytes += sizeOf(k, v)
77+
}
78+
return true
79+
})
80+
if recycled {
81+
s.PayloadBytes += sizeOf(recycledKey, recycledValue)
82+
}
83+
}
84+
return s, true
85+
}

‎stats_test.go‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package fido
2+
3+
import "testing"
4+
5+
func TestMemoryStats(t *testing.T) {
6+
c := New[string, []byte](Size(8))
7+
sizeOf := func(k string, v []byte) uint64 { return uint64(len(k) + cap(v)) }
8+
c.Set("abc", make([]byte, 2, 10))
9+
st, ok := c.MemoryStats(sizeOf)
10+
if !ok || st.Entries != 1 || st.RetainedEntries != 1 || st.Capacity != 8 || st.PayloadBytes != 13 || st.StructuralBytes == 0 {
11+
t.Fatalf("unexpected stats: %+v, %v", st, ok)
12+
}
13+
c.memory.mu.Lock()
14+
_, ok = c.MemoryStats(sizeOf)
15+
c.memory.mu.Unlock()
16+
if ok {
17+
t.Fatal("snapshot should report busy without waiting")
18+
}
19+
c.Delete("abc")
20+
st, ok = c.MemoryStats(sizeOf)
21+
if !ok || st.Entries != 0 || st.PayloadBytes != 0 {
22+
t.Fatalf("deleted payload retained: %+v", st)
23+
}
24+
c.Set("expired", make([]byte, 5))
25+
e, _ := c.memory.getEntry("expired")
26+
e.expirySec.Store(1)
27+
st, ok = c.MemoryStats(sizeOf)
28+
if !ok || st.PayloadBytes != 12 {
29+
t.Fatalf("expired storage not counted: %+v", st)
30+
}
31+
}
32+
33+
func TestMemoryStatsPendingEviction(t *testing.T) {
34+
c := New[string, []byte](Size(8))
35+
c.Set("a", make([]byte, 10))
36+
e, _ := c.memory.getEntry("a")
37+
e.setFreqPeak(0, 1)
38+
c.memory.small.remove(e)
39+
c.memory.sendToDeathRow(e)
40+
st, ok := c.MemoryStats(func(k string, v []byte) uint64 { return uint64(len(k) + cap(v)) })
41+
if !ok || st.Entries != 0 || st.PendingEntries != 1 || st.RetainedEntries != 1 || st.PayloadBytes != 11 {
42+
t.Fatalf("pending storage not counted: %+v", st)
43+
}
44+
}
45+
46+
func TestMemoryStatsSizerDoesNotHoldWriterLock(t *testing.T) {
47+
c := New[int, int](Size(8))
48+
c.Set(1, 2)
49+
_, ok := c.MemoryStats(func(int, int) uint64 {
50+
if !c.memory.mu.TryLock() {
51+
t.Fatal("sizer runs under writer lock")
52+
}
53+
c.memory.mu.Unlock()
54+
return 1
55+
})
56+
if !ok {
57+
t.Fatal("unexpected busy cache")
58+
}
59+
}
60+
61+
func BenchmarkMemoryStats128K(b *testing.B) {
62+
c := New[int, []byte](Size(128000))
63+
for i := 0; i < 128000; i++ {
64+
c.Set(i, make([]byte, 256))
65+
}
66+
sizeOf := func(_ int, v []byte) uint64 { return uint64(cap(v)) }
67+
b.ReportAllocs()
68+
b.ResetTimer()
69+
for b.Loop() {
70+
c.MemoryStats(sizeOf)
71+
}
72+
}

0 commit comments

Comments
 (0)