Skip to content

Commit cccdbbd

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
micro optimizations
1 parent 21116c3 commit cccdbbd

File tree

4 files changed

+122
-19
lines changed

4 files changed

+122
-19
lines changed

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# bdcache - Big Dumb Cache
22

3+
<img src="media/logo-small.png" alt="bdcache logo" width="256" align="right">
4+
35
[![Go Reference](https://pkg.go.dev/badge/github.com/codeGROOVE-dev/bdcache.svg)](https://pkg.go.dev/github.com/codeGROOVE-dev/bdcache)
46
[![Go Report Card](https://goreportcard.com/badge/github.com/codeGROOVE-dev/bdcache)](https://goreportcard.com/report/github.com/codeGROOVE-dev/bdcache)
57
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
@@ -10,7 +12,6 @@ Simple, fast, secure Go cache with [S3-FIFO eviction](https://s3fifo.com/) - bet
1012

1113
- **S3-FIFO Algorithm** - [Superior cache hit rates](https://s3fifo.com/) compared to LRU/LFU
1214
- **Fast** - ~20ns per operation, zero allocations
13-
- **Secure** - Hardened input validation, no path traversal
1415
- **Reliable** - Memory cache always works, even if persistence fails
1516
- **Smart Persistence** - Local files for dev, Cloud Datastore for Cloud Run
1617
- **Minimal Dependencies** - Only one (Cloud Datastore)
@@ -48,9 +49,17 @@ cache, err := bdcache.New[string, User](ctx, bdcache.WithBestStore("myapp"))
4849

4950
## Performance
5051

52+
Benchmarks from MacBook Pro M4 Max:
53+
5154
```
52-
BenchmarkCache_Get_Hit-16 63M ops/sec 19.9 ns/op 0 allocs
53-
BenchmarkCache_Set-16 57M ops/sec 20.8 ns/op 0 allocs
55+
Memory-only operations:
56+
BenchmarkCache_Get_Hit-16 56M ops/sec 17.8 ns/op 0 B/op 0 allocs
57+
BenchmarkCache_Set-16 56M ops/sec 17.8 ns/op 0 B/op 0 allocs
58+
59+
With file persistence enabled:
60+
BenchmarkCache_Get_PersistMemoryHit-16 85M ops/sec 11.8 ns/op 0 B/op 0 allocs
61+
BenchmarkCache_Get_PersistDiskRead-16 73K ops/sec 13.8 µs/op 7921 B/op 178 allocs
62+
BenchmarkCache_Set_WithPersistence-16 9K ops/sec 112.3 µs/op 2383 B/op 36 allocs
5463
```
5564

5665
## License

cache_test.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -772,7 +772,7 @@ func BenchmarkCache_Set_WithPersistence(b *testing.B) {
772772
}
773773
}
774774

775-
func BenchmarkCache_Get_WithPersistence(b *testing.B) {
775+
func BenchmarkCache_Get_PersistMemoryHit(b *testing.B) {
776776
ctx := context.Background()
777777
cacheID := "bench-persist-get-" + time.Now().Format("20060102150405")
778778
cache, err := New[int, int](ctx, WithLocalStore(cacheID))
@@ -785,7 +785,7 @@ func BenchmarkCache_Get_WithPersistence(b *testing.B) {
785785
}
786786
}()
787787

788-
// Populate cache
788+
// Populate cache with keys 0-999
789789
for i := range 1000 {
790790
if err := cache.Set(ctx, i, i, 0); err != nil {
791791
b.Fatalf("Set: %v", err)
@@ -794,7 +794,43 @@ func BenchmarkCache_Get_WithPersistence(b *testing.B) {
794794

795795
b.ResetTimer()
796796
for i := range b.N {
797-
// Most hits from memory, occasional disk miss
798-
_, _, _ = cache.Get(ctx, i%10000) //nolint:errcheck // Benchmark code
797+
// All hits from memory (keys 0-999)
798+
_, _, _ = cache.Get(ctx, i%1000) //nolint:errcheck // Benchmark code
799+
}
800+
}
801+
802+
func BenchmarkCache_Get_PersistDiskRead(b *testing.B) {
803+
ctx := context.Background()
804+
cacheID := "bench-persist-disk-" + time.Now().Format("20060102150405")
805+
806+
// Create cache with small memory capacity to force disk reads
807+
cache, err := New[int, int](ctx, WithLocalStore(cacheID), WithMemorySize(10))
808+
if err != nil {
809+
b.Fatalf("New: %v", err)
810+
}
811+
defer func() {
812+
if err := cache.Close(); err != nil {
813+
b.Logf("Close error: %v", err)
814+
}
815+
}()
816+
817+
// Populate cache with 100 items (memory only holds 10)
818+
for i := range 100 {
819+
if err := cache.Set(ctx, i, i, 0); err != nil {
820+
b.Fatalf("Set: %v", err)
821+
}
822+
}
823+
824+
// Force eviction of first 90 items from memory
825+
for i := 100; i < 110; i++ {
826+
if err := cache.Set(ctx, i, i, 0); err != nil {
827+
b.Fatalf("Set: %v", err)
828+
}
829+
}
830+
831+
b.ResetTimer()
832+
for i := range b.N {
833+
// Read evicted items from disk (keys 0-89)
834+
_, _, _ = cache.Get(ctx, i%90) //nolint:errcheck // Benchmark code
799835
}
800836
}

persist_file.go

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package bdcache
22

33
import (
4+
"bufio"
45
"context"
56
"encoding/gob"
67
"errors"
@@ -10,14 +11,32 @@ import (
1011
"path/filepath"
1112
"sort"
1213
"strings"
14+
"sync"
1315
"time"
1416
)
1517

1618
const maxKeyLength = 127 // Maximum key length to avoid filesystem constraints
1719

20+
var (
21+
// Pool for bufio.Writer to reduce allocations
22+
writerPool = sync.Pool{
23+
New: func() any {
24+
return bufio.NewWriterSize(nil, 4096)
25+
},
26+
}
27+
// Pool for bufio.Reader to reduce allocations
28+
readerPool = sync.Pool{
29+
New: func() any {
30+
return bufio.NewReaderSize(nil, 4096)
31+
},
32+
}
33+
)
34+
1835
// filePersist implements PersistenceLayer using local files with gob encoding.
1936
type filePersist[K comparable, V any] struct {
20-
dir string
37+
dir string
38+
subdirsMu sync.RWMutex
39+
subdirsMade map[string]bool // Cache of created subdirectories
2140
}
2241

2342
// ValidateKey checks if a key is valid for file persistence.
@@ -69,7 +88,10 @@ func newFilePersist[K comparable, V any](cacheID string) (*filePersist[K, V], er
6988

7089
slog.Debug("initialized file persistence", "dir", dir)
7190

72-
return &filePersist[K, V]{dir: dir}, nil
91+
return &filePersist[K, V]{
92+
dir: dir,
93+
subdirsMade: make(map[string]bool),
94+
}, nil
7395
}
7496

7597
// keyToFilename converts a cache key to a filename with squid-style directory layout.
@@ -105,8 +127,13 @@ func (f *filePersist[K, V]) Load(ctx context.Context, key K) (V, time.Time, bool
105127
}
106128
}()
107129

130+
// Get reader from pool and reset it for this file
131+
reader := readerPool.Get().(*bufio.Reader)
132+
reader.Reset(file)
133+
defer readerPool.Put(reader)
134+
108135
var entry Entry[K, V]
109-
dec := gob.NewDecoder(file)
136+
dec := gob.NewDecoder(reader)
110137
if err := dec.Decode(&entry); err != nil {
111138
// File corrupted, remove it
112139
if err := os.Remove(filename); err != nil && !os.IsNotExist(err) {
@@ -129,10 +156,22 @@ func (f *filePersist[K, V]) Load(ctx context.Context, key K) (V, time.Time, bool
129156
// Store saves a value to a file.
130157
func (f *filePersist[K, V]) Store(ctx context.Context, key K, value V, expiry time.Time) error {
131158
filename := filepath.Join(f.dir, f.keyToFilename(key))
159+
subdir := filepath.Dir(filename)
160+
161+
// Check if subdirectory already created (cache to avoid syscalls)
162+
f.subdirsMu.RLock()
163+
exists := f.subdirsMade[subdir]
164+
f.subdirsMu.RUnlock()
132165

133-
// Create subdirectory if it doesn't exist (for squid-style layout)
134-
if err := os.MkdirAll(filepath.Dir(filename), 0o750); err != nil {
135-
return fmt.Errorf("create subdirectory: %w", err)
166+
if !exists {
167+
// Create subdirectory if needed
168+
if err := os.MkdirAll(subdir, 0o750); err != nil {
169+
return fmt.Errorf("create subdirectory: %w", err)
170+
}
171+
// Cache that we created it
172+
f.subdirsMu.Lock()
173+
f.subdirsMade[subdir] = true
174+
f.subdirsMu.Unlock()
136175
}
137176

138177
entry := Entry[K, V]{
@@ -149,8 +188,19 @@ func (f *filePersist[K, V]) Store(ctx context.Context, key K, value V, expiry ti
149188
return fmt.Errorf("create temp file: %w", err)
150189
}
151190

152-
enc := gob.NewEncoder(file)
191+
// Get writer from pool and reset it for this file
192+
writer := writerPool.Get().(*bufio.Writer)
193+
writer.Reset(file)
194+
195+
enc := gob.NewEncoder(writer)
153196
encErr := enc.Encode(entry)
197+
if encErr == nil {
198+
encErr = writer.Flush() // Ensure buffered data is written
199+
}
200+
201+
// Return writer to pool
202+
writerPool.Put(writer)
203+
154204
closeErr := file.Close()
155205

156206
if encErr != nil {
@@ -227,10 +277,15 @@ func (f *filePersist[K, V]) LoadRecent(ctx context.Context, limit int) (<-chan E
227277
return nil
228278
}
229279

280+
// Get reader from pool and reset it for this file
281+
reader := readerPool.Get().(*bufio.Reader)
282+
reader.Reset(file)
283+
230284
var e Entry[K, V]
231-
dec := gob.NewDecoder(file)
285+
dec := gob.NewDecoder(reader)
232286
if err := dec.Decode(&e); err != nil {
233287
slog.Warn("failed to decode cache file", "file", path, "error", err)
288+
readerPool.Put(reader)
234289
if err := file.Close(); err != nil {
235290
slog.Debug("failed to close file after decode error", "file", path, "error", err)
236291
}
@@ -239,6 +294,7 @@ func (f *filePersist[K, V]) LoadRecent(ctx context.Context, limit int) (<-chan E
239294
}
240295
return nil
241296
}
297+
readerPool.Put(reader)
242298
if err := file.Close(); err != nil {
243299
slog.Debug("failed to close file", "file", path, "error", err)
244300
}

s3fifo.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,24 +60,26 @@ func newS3FIFO[K comparable, V any](capacity int) *s3fifo[K, V] {
6060
// get retrieves a value from the cache.
6161
func (c *s3fifo[K, V]) get(key K) (V, bool) {
6262
c.mu.Lock()
63-
defer c.mu.Unlock()
64-
6563
ent, ok := c.items[key]
6664
if !ok {
65+
c.mu.Unlock()
6766
var zero V
6867
return zero, false
6968
}
7069

71-
// Check expiration
70+
// Check expiration (skip time.Now() if no expiry set)
7271
if !ent.expiry.IsZero() && time.Now().After(ent.expiry) {
72+
c.mu.Unlock()
7373
var zero V
7474
return zero, false
7575
}
7676

7777
// Increment frequency counter (used during eviction)
7878
ent.freq++
79+
val := ent.value
80+
c.mu.Unlock()
7981

80-
return ent.value, true
82+
return val, true
8183
}
8284

8385
// set adds or updates a value in the cache.

0 commit comments

Comments
 (0)