├── .gitignore ├── LICENSE ├── README.md ├── assert_test.go ├── go.mod ├── go.sum ├── sieve.go ├── sieve_bench_custom_test.go ├── sieve_bench_test.go └── sieve_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | bin/* 27 | .*.sw? 28 | .idea 29 | logs/* 30 | 31 | # gg ignores 32 | vendor/src/* 33 | vendor/pkg/* 34 | servers.iml 35 | *.DS_Store 36 | 37 | # vagrant ignores 38 | tools/vagrant/.vagrant 39 | tools/vagrant/adsrv-conf/.frontend 40 | tools/vagrant/adsrv-conf/.bidder 41 | tools/vagrant/adsrv-conf/.transcoder 42 | tools/vagrant/redis-cluster-conf/7777/nodes.conf 43 | tools/vagrant/redis-cluster-conf/7778/nodes.conf 44 | tools/vagrant/redis-cluster-conf/7779/nodes.conf 45 | *.aof 46 | *.rdb 47 | *.deb 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2024, Sudhi Herle 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-sieve - SIEVE is simpler than LRU 2 | 3 | ## What is it? 4 | 5 | `go-sieve` is a golang implementation of the 6 | [SIEVE](https://yazhuozhang.com/assets/pdf/nsdi24-sieve.pdf) cache 7 | eviction algorithm. 8 | 9 | This implementation closely follows the paper's pseudo-code - but uses 10 | golang generics to provide an ergonomic interface. 11 | 12 | ## Key Design Features 13 | 14 | ### Custom Memory Allocator for Reduced GC Pressure 15 | 16 | This implementation uses a custom memory allocator designed to minimize 17 | GC pressure: 18 | 19 | - **Pre-allocated Node Pool**: Rather than allocating nodes 20 | individually, the cache pre-allocates all nodes at initialization time 21 | in a single contiguous array based on cache capacity. 22 | 23 | - **Efficient Freelist**: Recycled nodes are managed through a 24 | zero-overhead freelist that repurposes the existing node pointers, 25 | avoiding the need for auxiliary data structures. 26 | 27 | - **Single-Allocation Strategy**: By allocating all memory upfront in a 28 | single operation, the implementation reduces heap fragmentation and 29 | minimizes the number of objects the garbage collector must track. 30 | 31 | 32 | ## Usage 33 | 34 | The API is designed to be simple and intuitive. See the test files for 35 | examples of how to use the cache in your applications. 36 | -------------------------------------------------------------------------------- /assert_test.go: -------------------------------------------------------------------------------- 1 | // assert_test.go - utility function for tests 2 | // 3 | // (c) 2024 Sudhi Herle 4 | // 5 | // Licensing Terms: GPLv2 6 | // 7 | // If you need a commercial license for this work, please contact 8 | // the author. 9 | // 10 | // This software does not come with any express or implied 11 | // warranty; it is provided "as is". No claim is made to its 12 | // suitability for any purpose. 13 | 14 | package sieve_test 15 | 16 | import ( 17 | "fmt" 18 | "runtime" 19 | "testing" 20 | ) 21 | 22 | func newAsserter(t *testing.T) func(cond bool, msg string, args ...interface{}) { 23 | return func(cond bool, msg string, args ...interface{}) { 24 | if cond { 25 | return 26 | } 27 | 28 | _, file, line, ok := runtime.Caller(1) 29 | if !ok { 30 | file = "???" 31 | line = 0 32 | } 33 | 34 | s := fmt.Sprintf(msg, args...) 35 | t.Fatalf("%s: %d: Assertion failed: %s\n", file, line, s) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/opencoff/go-sieve 2 | 3 | go 1.24.3 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencoff/go-sieve/38fa293bccb612016cb0e4316cdcabed8c2af43f/go.sum -------------------------------------------------------------------------------- /sieve.go: -------------------------------------------------------------------------------- 1 | // sieve.go - SIEVE - a simple and efficient cache 2 | // 3 | // (c) 2024 Sudhi Herle 4 | // 5 | // Copyright 2024- Sudhi Herle 6 | // License: BSD-2-Clause 7 | // 8 | // If you need a commercial license for this work, please contact 9 | // the author. 10 | // 11 | // This software does not come with any express or implied 12 | // warranty; it is provided "as is". No claim is made to its 13 | // suitability for any purpose. 14 | 15 | // This is golang implementation of the SIEVE cache eviction algorithm 16 | // The original paper is: 17 | // https://yazhuozhang.com/assets/pdf/nsdi24-sieve.pdf 18 | // 19 | // This implementation closely follows the paper - but uses golang generics 20 | // for an ergonomic interface. 21 | 22 | // Package sieve implements the SIEVE cache eviction algorithm. 23 | // SIEVE stands in contrast to other eviction algorithms like LRU, 2Q, ARC 24 | // with its simplicity. The original paper is in: 25 | // https://yazhuozhang.com/assets/pdf/nsdi24-sieve.pdf 26 | // 27 | // SIEVE is built on a FIFO queue - with an extra pointer (called "hand") in 28 | // the paper. This "hand" plays a crucial role in determining who to evict 29 | // next. 30 | package sieve 31 | 32 | import ( 33 | "fmt" 34 | "strings" 35 | "sync" 36 | "sync/atomic" 37 | ) 38 | 39 | // node contains the tuple as a node in a linked list. 40 | type node[K comparable, V any] struct { 41 | sync.Mutex 42 | key K 43 | val V 44 | visited atomic.Bool 45 | next *node[K, V] 46 | prev *node[K, V] 47 | } 48 | 49 | // allocator manages a fixed pool of pre-allocated nodes and a freelist 50 | type allocator[K comparable, V any] struct { 51 | nodes []node[K, V] // Pre-allocated array of all nodes 52 | freelist *node[K, V] // Head of freelist of available nodes 53 | backing []node[K, V] // backing array - to help with reset/purge 54 | } 55 | 56 | // newAllocator creates a new allocator with capacity nodes 57 | func newAllocator[K comparable, V any](capacity int) *allocator[K, V] { 58 | a := make([]node[K, V], capacity) 59 | return &allocator[K, V]{ 60 | nodes: a, 61 | freelist: nil, 62 | backing: a, 63 | } 64 | } 65 | 66 | // alloc retrieves a node from the allocator 67 | // It first tries the freelist, then falls back to the pre-allocated array 68 | func (a *allocator[K, V]) alloc() *node[K, V] { 69 | // If freelist is not empty, use a node from there 70 | if a.freelist != nil { 71 | n := a.freelist 72 | a.freelist = n.next 73 | return n 74 | } 75 | 76 | // If we've used all pre-allocated nodes, return nil 77 | if len(a.nodes) == 0 { 78 | return nil 79 | } 80 | 81 | // Take a node from the pre-allocated array and shrink it 82 | n := &a.nodes[0] 83 | a.nodes = a.nodes[1:] 84 | return n 85 | } 86 | 87 | // free returns a node to the freelist 88 | func (a *allocator[K, V]) free(n *node[K, V]) { 89 | // Add the node to the head of the freelist 90 | n.next = a.freelist 91 | a.freelist = n 92 | } 93 | 94 | // reset resets the allocator as if newAllocator() is called 95 | func (a *allocator[K, V]) reset() { 96 | a.freelist = nil 97 | a.nodes = a.backing 98 | } 99 | 100 | // capacity returns the capacity of the cache 101 | func (a *allocator[K, V]) capacity() int { 102 | return cap(a.backing) 103 | } 104 | 105 | // Sieve represents a cache mapping the key of type 'K' with 106 | // a value of type 'V'. The type 'K' must implement the 107 | // comparable trait. An instance of Sieve has a fixed max capacity; 108 | // new additions to the cache beyond the capacity will cause cache 109 | // eviction of other entries - as determined by the SIEVE algorithm. 110 | type Sieve[K comparable, V any] struct { 111 | mu sync.Mutex 112 | cache *syncMap[K, *node[K, V]] 113 | head *node[K, V] 114 | tail *node[K, V] 115 | hand *node[K, V] 116 | size int 117 | 118 | allocator *allocator[K, V] 119 | } 120 | 121 | // New creates a new cache of size 'capacity' mapping key 'K' to value 'V' 122 | func New[K comparable, V any](capacity int) *Sieve[K, V] { 123 | s := &Sieve[K, V]{ 124 | cache: newSyncMap[K, *node[K, V]](), 125 | allocator: newAllocator[K, V](capacity), 126 | } 127 | return s 128 | } 129 | 130 | // Get fetches the value for a given key in the cache. 131 | // It returns true if the key is in the cache, false otherwise. 132 | // The zero value for 'V' is returned when key is not in the cache. 133 | func (s *Sieve[K, V]) Get(key K) (V, bool) { 134 | 135 | if v, ok := s.cache.Get(key); ok { 136 | v.visited.Store(true) 137 | return v.val, true 138 | } 139 | 140 | var x V 141 | return x, false 142 | } 143 | 144 | // Add adds a new element to the cache or overwrite one if it exists 145 | // Return true if we replaced, false otherwise 146 | func (s *Sieve[K, V]) Add(key K, val V) bool { 147 | 148 | if v, ok := s.cache.Get(key); ok { 149 | v.Lock() 150 | v.visited.Store(true) 151 | v.val = val 152 | v.Unlock() 153 | return true 154 | } 155 | 156 | s.mu.Lock() 157 | s.add(key, val) 158 | s.mu.Unlock() 159 | return false 160 | } 161 | 162 | // Probe adds if not present in the cache. 163 | // Returns: 164 | // 165 | // when key is present in the cache 166 | // when key is not present in the cache 167 | func (s *Sieve[K, V]) Probe(key K, val V) (V, bool) { 168 | 169 | if v, ok := s.cache.Get(key); ok { 170 | v.visited.Store(true) 171 | return v.val, true 172 | } 173 | 174 | s.mu.Lock() 175 | s.add(key, val) 176 | s.mu.Unlock() 177 | return val, false 178 | } 179 | 180 | // Delete deletes the named key from the cache 181 | // It returns true if the item was in the cache and false otherwise 182 | func (s *Sieve[K, V]) Delete(key K) bool { 183 | 184 | if v, ok := s.cache.Del(key); ok { 185 | s.mu.Lock() 186 | s.remove(v) 187 | s.mu.Unlock() 188 | return true 189 | } 190 | 191 | return false 192 | } 193 | 194 | // Purge resets the cache 195 | func (s *Sieve[K, V]) Purge() { 196 | s.mu.Lock() 197 | s.cache = newSyncMap[K, *node[K, V]]() 198 | s.head = nil 199 | s.tail = nil 200 | s.hand = nil 201 | 202 | // Reset the allocator 203 | s.allocator.reset() 204 | s.size = 0 205 | s.mu.Unlock() 206 | } 207 | 208 | // Len returns the current cache utilization 209 | func (s *Sieve[K, V]) Len() int { 210 | return s.size 211 | } 212 | 213 | // Cap returns the max cache capacity 214 | func (s *Sieve[K, V]) Cap() int { 215 | return s.allocator.capacity() 216 | } 217 | 218 | // String returns a string description of the sieve cache 219 | func (s *Sieve[K, V]) String() string { 220 | s.mu.Lock() 221 | m := s.desc() 222 | s.mu.Unlock() 223 | return m 224 | } 225 | 226 | // Dump dumps all the cache contents as a newline delimited 227 | // string. 228 | func (s *Sieve[K, V]) Dump() string { 229 | var b strings.Builder 230 | 231 | s.mu.Lock() 232 | b.WriteString(s.desc()) 233 | b.WriteRune('\n') 234 | for n := s.head; n != nil; n = n.next { 235 | h := " " 236 | if n == s.hand { 237 | h = ">>" 238 | } 239 | b.WriteString(fmt.Sprintf("%svisited=%v, key=%v, val=%v\n", h, n.visited.Load(), n.key, n.val)) 240 | } 241 | s.mu.Unlock() 242 | return b.String() 243 | } 244 | 245 | // -- internal methods -- 246 | 247 | // add a new tuple to the cache and evict as necessary 248 | // caller must hold lock. 249 | func (s *Sieve[K, V]) add(key K, val V) { 250 | // cache miss; we evict and fnd a new node 251 | if s.size == s.allocator.capacity() { 252 | s.evict() 253 | } 254 | 255 | n := s.newNode(key, val) 256 | 257 | // Eviction is guaranteed to remove one node; so this should never happen. 258 | if n == nil { 259 | msg := fmt.Sprintf("%T: add <%v>: objpool empty after eviction", s, key) 260 | panic(msg) 261 | } 262 | 263 | s.cache.Put(key, n) 264 | 265 | // insert at the head of the list 266 | n.next = s.head 267 | n.prev = nil 268 | if s.head != nil { 269 | s.head.prev = n 270 | } 271 | s.head = n 272 | if s.tail == nil { 273 | s.tail = n 274 | } 275 | 276 | s.size += 1 277 | } 278 | 279 | // evict an item from the cache. 280 | // NB: Caller must hold the lock 281 | func (s *Sieve[K, V]) evict() { 282 | hand := s.hand 283 | if hand == nil { 284 | hand = s.tail 285 | } 286 | 287 | for hand != nil { 288 | if !hand.visited.Load() { 289 | s.cache.Del(hand.key) 290 | s.remove(hand) 291 | s.hand = hand.prev 292 | return 293 | } 294 | hand.visited.Store(false) 295 | hand = hand.prev 296 | // wrap around and start again 297 | if hand == nil { 298 | hand = s.tail 299 | } 300 | } 301 | s.hand = hand 302 | } 303 | 304 | func (s *Sieve[K, V]) remove(n *node[K, V]) { 305 | s.size -= 1 306 | 307 | // remove node from list 308 | if n.prev != nil { 309 | n.prev.next = n.next 310 | } else { 311 | s.head = n.next 312 | } 313 | if n.next != nil { 314 | n.next.prev = n.prev 315 | } else { 316 | s.tail = n.prev 317 | } 318 | 319 | // Return the node to the allocator's freelist 320 | s.allocator.free(n) 321 | } 322 | 323 | func (s *Sieve[K, V]) newNode(key K, val V) *node[K, V] { 324 | // Get a node from the allocator 325 | n := s.allocator.alloc() 326 | if n == nil { 327 | return nil 328 | } 329 | 330 | n.key, n.val = key, val 331 | n.next, n.prev = nil, nil 332 | n.visited.Store(false) 333 | 334 | return n 335 | } 336 | 337 | // desc describes the properties of the sieve 338 | func (s *Sieve[K, V]) desc() string { 339 | m := fmt.Sprintf("cache<%T>: size %d, cap %d, head=%p, tail=%p, hand=%p", 340 | s, s.size, s.allocator.capacity(), s.head, s.tail, s.hand) 341 | return m 342 | } 343 | 344 | // generic sync.Map 345 | type syncMap[K comparable, V any] struct { 346 | m sync.Map 347 | } 348 | 349 | func newSyncMap[K comparable, V any]() *syncMap[K, V] { 350 | m := syncMap[K, V]{} 351 | return &m 352 | } 353 | 354 | func (m *syncMap[K, V]) Get(key K) (V, bool) { 355 | v, ok := m.m.Load(key) 356 | if ok { 357 | return v.(V), true 358 | } 359 | 360 | var z V 361 | return z, false 362 | } 363 | 364 | func (m *syncMap[K, V]) Put(key K, val V) { 365 | m.m.Store(key, val) 366 | } 367 | 368 | func (m *syncMap[K, V]) Del(key K) (V, bool) { 369 | x, ok := m.m.LoadAndDelete(key) 370 | if ok { 371 | return x.(V), true 372 | } 373 | 374 | var z V 375 | return z, false 376 | } 377 | -------------------------------------------------------------------------------- /sieve_bench_custom_test.go: -------------------------------------------------------------------------------- 1 | // sieve_bench_custom_test.go - benchmarks for Sieve cache with custom memory allocator 2 | // 3 | // (c) 2024 Sudhi Herle 4 | // 5 | // Copyright 2024- Sudhi Herle 6 | // License: BSD-2-Clause 7 | 8 | package sieve_test 9 | 10 | import ( 11 | "fmt" 12 | "math/rand" 13 | "runtime" 14 | "runtime/debug" 15 | "testing" 16 | "time" 17 | 18 | "github.com/opencoff/go-sieve" 19 | ) 20 | 21 | // BenchmarkSieveAdd benchmarks the Add operation 22 | func BenchmarkSieveAdd(b *testing.B) { 23 | // Test with various cache sizes 24 | for _, cacheSize := range []int{1024, 8192, 32768} { 25 | b.Run(fmt.Sprintf("CacheSize_%d", cacheSize), func(b *testing.B) { 26 | cache := sieve.New[int, int](cacheSize) 27 | 28 | // Generate keys with some predictable access pattern 29 | keys := make([]int, b.N) 30 | for i := 0; i < b.N; i++ { 31 | if i%3 == 0 { 32 | // Reuse some keys for cache hits 33 | keys[i] = i % (cacheSize / 2) 34 | } else { 35 | // Use new keys for cache misses 36 | keys[i] = i + cacheSize 37 | } 38 | } 39 | 40 | b.ResetTimer() 41 | 42 | // Perform add operations that will cause evictions 43 | for i := 0; i < b.N; i++ { 44 | key := keys[i] 45 | cache.Add(key, key) 46 | 47 | // Occasionally delete some items to test the node recycling 48 | if i%5 == 0 && i > 0 { 49 | cache.Delete(keys[i-1]) 50 | } 51 | } 52 | }) 53 | } 54 | } 55 | 56 | // BenchmarkSieveGetHitMiss benchmarks Get operations with a mix of hits and misses 57 | func BenchmarkSieveGetHitMiss(b *testing.B) { 58 | cacheSize := 8192 59 | cache := sieve.New[int, int](cacheSize) 60 | 61 | // Fill the cache with initial data 62 | for i := 0; i < cacheSize; i++ { 63 | cache.Add(i, i) 64 | } 65 | 66 | // Generate a mix of hit and miss patterns 67 | keys := make([]int, b.N) 68 | for i := 0; i < b.N; i++ { 69 | if i%2 == 0 { 70 | // Cache hit 71 | keys[i] = rand.Intn(cacheSize) 72 | } else { 73 | // Cache miss 74 | keys[i] = cacheSize + rand.Intn(cacheSize) 75 | } 76 | } 77 | 78 | b.ResetTimer() 79 | 80 | // Perform get operations 81 | var hit, miss int 82 | for i := 0; i < b.N; i++ { 83 | key := keys[i] 84 | if _, ok := cache.Get(key); ok { 85 | hit++ 86 | } else { 87 | miss++ 88 | // Add key that was a miss 89 | cache.Add(key, key) 90 | } 91 | } 92 | 93 | b.StopTimer() 94 | hitRatio := float64(hit) / float64(b.N) 95 | b.ReportMetric(hitRatio, "hit-ratio") 96 | } 97 | 98 | // BenchmarkSieveConcurrency benchmarks high concurrency operations 99 | func BenchmarkSieveConcurrency(b *testing.B) { 100 | cacheSize := 16384 101 | cache := sieve.New[int, int](cacheSize) 102 | 103 | b.ResetTimer() 104 | 105 | // Run a highly concurrent benchmark with many goroutines 106 | b.RunParallel(func(pb *testing.PB) { 107 | // Each goroutine gets its own random seed 108 | r := rand.New(rand.NewSource(rand.Int63())) 109 | 110 | for pb.Next() { 111 | // Random operation: get, add, or delete 112 | op := r.Intn(10) 113 | key := r.Intn(cacheSize * 2) // Half will be misses 114 | 115 | if op < 6 { // 60% gets 116 | cache.Get(key) 117 | } else if op < 9 { // 30% adds 118 | cache.Add(key, key) 119 | } else { // 10% deletes 120 | cache.Delete(key) 121 | } 122 | } 123 | }) 124 | } 125 | 126 | // BenchmarkSieveGCPressure specifically measures the impact on garbage collection 127 | func BenchmarkSieveGCPressure(b *testing.B) { 128 | // Run with different cache sizes 129 | for _, cacheSize := range []int{1000, 10000, 50000} { 130 | b.Run(fmt.Sprintf("CacheSize_%d", cacheSize), func(b *testing.B) { 131 | // Fixed number of operations for consistent measurement 132 | operations := 1000000 133 | 134 | // Force GC before test 135 | runtime.GC() 136 | 137 | // Capture GC stats before 138 | var statsBefore debug.GCStats 139 | debug.ReadGCStats(&statsBefore) 140 | var memStatsBefore runtime.MemStats 141 | runtime.ReadMemStats(&memStatsBefore) 142 | 143 | startTime := time.Now() 144 | 145 | // Create cache with custom allocator 146 | cache := sieve.New[int, int](cacheSize) 147 | 148 | // Run the workload 149 | runSieveWorkload(cache, operations) 150 | 151 | elapsedTime := time.Since(startTime) 152 | 153 | // Force GC to get accurate stats 154 | runtime.GC() 155 | 156 | // Capture GC stats after 157 | var statsAfter debug.GCStats 158 | debug.ReadGCStats(&statsAfter) 159 | var memStatsAfter runtime.MemStats 160 | runtime.ReadMemStats(&memStatsAfter) 161 | 162 | // Report metrics 163 | gcCount := statsAfter.NumGC - statsBefore.NumGC 164 | pauseTotal := statsAfter.PauseTotal - statsBefore.PauseTotal 165 | 166 | b.ReportMetric(float64(gcCount), "GC-cycles") 167 | b.ReportMetric(float64(pauseTotal.Nanoseconds())/float64(operations), "GC-pause-ns/op") 168 | b.ReportMetric(float64(memStatsAfter.HeapObjects)/float64(operations), "heap-objs/op") 169 | b.ReportMetric(float64(operations)/elapsedTime.Seconds(), "ops/sec") 170 | }) 171 | } 172 | } 173 | 174 | // runWorkload performs a consistent workload that stresses node allocation/deallocation 175 | func runSieveWorkload(cache *sieve.Sieve[int, int], operations int) { 176 | capacity := cache.Cap() 177 | 178 | // Create a workload that ensures significant cache churn 179 | for i := 0; i < operations; i++ { 180 | key := i % (capacity * 2) // Ensure we cycle through keys causing evictions 181 | 182 | // Mix of operations: 70% adds, 20% gets, 10% deletes 183 | op := i % 10 184 | if op < 7 { 185 | // Add - heavy on adds to stress allocation 186 | cache.Add(key, i) 187 | } else if op < 9 { 188 | // Get 189 | cache.Get(key) 190 | } else { 191 | // Delete - to trigger freelist recycling 192 | cache.Delete(key) 193 | } 194 | 195 | // Every so often, add a burst of new entries to trigger evictions 196 | if i > 0 && i%10000 == 0 { 197 | for j := 0; j < capacity/10; j++ { 198 | cache.Add(i+j+capacity, i+j) 199 | } 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /sieve_bench_test.go: -------------------------------------------------------------------------------- 1 | // sieve_bench_test.go -- benchmark testing 2 | // 3 | // (c) 2024 Sudhi Herle 4 | // 5 | // Copyright 2024- Sudhi Herle 6 | // License: BSD-2-Clause 7 | // 8 | // If you need a commercial license for this work, please contact 9 | // the author. 10 | // 11 | // This software does not come with any express or implied 12 | // warranty; it is provided "as is". No claim is made to its 13 | // suitability for any purpose. 14 | 15 | package sieve_test 16 | 17 | import ( 18 | "math/rand" 19 | "sync/atomic" 20 | "testing" 21 | 22 | "github.com/opencoff/go-sieve" 23 | ) 24 | 25 | func BenchmarkSieve_Add(b *testing.B) { 26 | c := sieve.New[int, int](8192) 27 | ent := make([]int, b.N) 28 | 29 | for i := 0; i < b.N; i++ { 30 | var k int 31 | if i%2 == 0 { 32 | k = int(rand.Int63() % 16384) 33 | } else { 34 | k = int(rand.Int63() % 32768) 35 | } 36 | ent[i] = k 37 | } 38 | 39 | b.ResetTimer() 40 | for i := 0; i < b.N; i++ { 41 | k := ent[i] 42 | c.Add(k, k) 43 | } 44 | } 45 | 46 | func BenchmarkSieve_Get(b *testing.B) { 47 | c := sieve.New[int, int](8192) 48 | ent := make([]int, b.N) 49 | for i := 0; i < b.N; i++ { 50 | var k int 51 | if i%2 == 0 { 52 | k = int(rand.Int63() % 16384) 53 | } else { 54 | k = int(rand.Int63() % 32768) 55 | } 56 | c.Add(k, k) 57 | ent[i] = k 58 | } 59 | 60 | b.ResetTimer() 61 | 62 | var hit, miss int64 63 | for i := 0; i < b.N; i++ { 64 | if _, ok := c.Get(ent[i]); ok { 65 | atomic.AddInt64(&hit, 1) 66 | } else { 67 | atomic.AddInt64(&miss, 1) 68 | } 69 | } 70 | 71 | b.Logf("%d: hit %d, miss %d, ratio %4.2f", b.N, hit, miss, float64(hit)/float64(hit+miss)) 72 | } 73 | -------------------------------------------------------------------------------- /sieve_test.go: -------------------------------------------------------------------------------- 1 | // sieve_test.go - test harness for sieve cache 2 | // 3 | // (c) 2024 Sudhi Herle 4 | // 5 | // Copyright 2024- Sudhi Herle 6 | // License: BSD-2-Clause 7 | // 8 | // If you need a commercial license for this work, please contact 9 | // the author. 10 | // 11 | // This software does not come with any express or implied 12 | // warranty; it is provided "as is". No claim is made to its 13 | // suitability for any purpose. 14 | 15 | package sieve_test 16 | 17 | import ( 18 | "encoding/binary" 19 | "fmt" 20 | "math/rand" 21 | "runtime" 22 | "strings" 23 | "sync" 24 | "sync/atomic" 25 | "testing" 26 | "time" 27 | 28 | "github.com/opencoff/go-sieve" 29 | ) 30 | 31 | func TestBasic(t *testing.T) { 32 | assert := newAsserter(t) 33 | 34 | s := sieve.New[int, string](4) 35 | ok := s.Add(1, "hello") 36 | assert(!ok, "empty cache: expected clean add of 1") 37 | 38 | ok = s.Add(2, "foo") 39 | assert(!ok, "empty cache: expected clean add of 2") 40 | ok = s.Add(3, "bar") 41 | assert(!ok, "empty cache: expected clean add of 3") 42 | ok = s.Add(4, "gah") 43 | assert(!ok, "empty cache: expected clean add of 4") 44 | 45 | ok = s.Add(1, "world") 46 | assert(ok, "key 1: expected to replace") 47 | 48 | ok = s.Add(5, "boo") 49 | assert(!ok, "adding 5: expected to be new add") 50 | 51 | _, ok = s.Get(2) 52 | assert(!ok, "evict: expected 2 to be evicted") 53 | 54 | } 55 | 56 | func TestEvictAll(t *testing.T) { 57 | assert := newAsserter(t) 58 | 59 | size := 128 60 | s := sieve.New[int, string](size) 61 | 62 | for i := 0; i < size*2; i++ { 63 | val := fmt.Sprintf("val %d", i) 64 | _, ok := s.Probe(i, val) 65 | assert(!ok, "%d: exp new add", i) 66 | } 67 | 68 | // the first half should've been all evicted 69 | for i := 0; i < size; i++ { 70 | _, ok := s.Get(i) 71 | assert(!ok, "%d: exp to be evicted", i) 72 | } 73 | 74 | // leaving the second half intact 75 | for i := size; i < size*2; i++ { 76 | ok := s.Delete(i) 77 | assert(ok, "%d: exp del on existing cache elem") 78 | } 79 | } 80 | 81 | func TestAllOps(t *testing.T) { 82 | size := 8192 83 | vals := randints(size * 3) 84 | 85 | s := sieve.New[uint64, uint64](size) 86 | 87 | for i := range vals { 88 | k := vals[i] 89 | s.Add(k, k) 90 | } 91 | 92 | vals = shuffle(vals) 93 | 94 | var hit, miss int 95 | for i := range vals { 96 | k := vals[i] 97 | _, ok := s.Get(k) 98 | if ok { 99 | hit++ 100 | } else { 101 | miss++ 102 | } 103 | } 104 | 105 | t.Logf("%d items: hit %d, miss %d, ratio %4.2f\n", len(vals), hit, miss, float64(hit)/float64(hit+miss)) 106 | } 107 | 108 | type timing struct { 109 | typ string 110 | d time.Duration 111 | hit, miss uint64 112 | } 113 | 114 | type barrier atomic.Uint64 115 | 116 | func (b *barrier) Wait() { 117 | v := (*atomic.Uint64)(b) 118 | for { 119 | if v.Load() == 1 { 120 | return 121 | } 122 | runtime.Gosched() 123 | } 124 | } 125 | 126 | func (b *barrier) Signal() { 127 | v := (*atomic.Uint64)(b) 128 | v.Store(1) 129 | } 130 | 131 | func TestSpeed(t *testing.T) { 132 | size := 32768 133 | vals := randints(size * 3) 134 | //valr := shuffle(vals) 135 | 136 | // we will start 4 types of workers: add, get, del, probe 137 | // each worker will be working on a shuffled version of 138 | // the uint64 array. 139 | 140 | for ncpu := 2; ncpu <= 32; ncpu *= 2 { 141 | var wg sync.WaitGroup 142 | 143 | wg.Add(ncpu) 144 | s := sieve.New[uint64, uint64](size) 145 | 146 | var bar barrier 147 | 148 | // number of workers of each type 149 | m := ncpu / 2 150 | ch := make(chan timing, m) 151 | for i := 0; i < m; i++ { 152 | 153 | go func(ch chan timing, wg *sync.WaitGroup) { 154 | var hit, miss uint64 155 | 156 | bar.Wait() 157 | st := time.Now() 158 | 159 | // shuffled array 160 | for _, x := range vals { 161 | v := x % 16384 162 | if _, ok := s.Get(v); ok { 163 | hit++ 164 | } else { 165 | miss++ 166 | } 167 | } 168 | d := time.Now().Sub(st) 169 | ch <- timing{ 170 | typ: "get", 171 | d: d, 172 | hit: hit, 173 | miss: miss, 174 | } 175 | wg.Done() 176 | }(ch, &wg) 177 | 178 | go func(ch chan timing, wg *sync.WaitGroup) { 179 | var hit, miss uint64 180 | bar.Wait() 181 | st := time.Now() 182 | for _, x := range vals { 183 | v := x % 16384 184 | if _, ok := s.Probe(v, v); ok { 185 | hit++ 186 | } else { 187 | miss++ 188 | } 189 | } 190 | d := time.Now().Sub(st) 191 | ch <- timing{ 192 | typ: "probe", 193 | d: d, 194 | hit: hit, 195 | miss: miss, 196 | } 197 | wg.Done() 198 | }(ch, &wg) 199 | } 200 | 201 | bar.Signal() 202 | 203 | // wait for goroutines to end and close the chan 204 | go func() { 205 | wg.Wait() 206 | close(ch) 207 | }() 208 | 209 | // now harvest timing 210 | times := map[string]timing{} 211 | for tm := range ch { 212 | if v, ok := times[tm.typ]; ok { 213 | z := (int64(v.d) + int64(tm.d)) / 2 214 | v.d = time.Duration(z) 215 | v.hit = (v.hit + tm.hit) / 2 216 | v.miss = (v.miss + tm.miss) / 2 217 | times[tm.typ] = v 218 | } else { 219 | times[tm.typ] = tm 220 | } 221 | } 222 | 223 | var out strings.Builder 224 | fmt.Fprintf(&out, "Tot CPU %d, workers/type %d %d elems\n", ncpu, m, len(vals)) 225 | for _, v := range times { 226 | var ratio string 227 | ns := toNs(int64(v.d), len(vals), m) 228 | ratio = hitRatio(v.hit, v.miss) 229 | fmt.Fprintf(&out, "%6s %4.2f ns/op%s\n", v.typ, ns, ratio) 230 | } 231 | t.Logf("%s", out.String()) 232 | } 233 | } 234 | 235 | func dup[T ~[]E, E any](v T) []E { 236 | n := len(v) 237 | g := make([]E, n) 238 | copy(g, v) 239 | return g 240 | } 241 | 242 | func shuffle[T ~[]E, E any](v T) []E { 243 | i := len(v) 244 | for i--; i >= 0; i-- { 245 | j := rand.Intn(i + 1) 246 | v[i], v[j] = v[j], v[i] 247 | } 248 | return v 249 | } 250 | 251 | func toNs(tot int64, nvals, ncpu int) float64 { 252 | return (float64(tot) / float64(nvals)) / float64(ncpu) 253 | } 254 | 255 | func hitRatio(hit, miss uint64) string { 256 | r := float64(hit) / float64(hit+miss) 257 | return fmt.Sprintf(" hit-ratio %4.2f (hit %d, miss %d)", r, hit, miss) 258 | } 259 | 260 | func randints(sz int) []uint64 { 261 | var b [8]byte 262 | 263 | v := make([]uint64, sz) 264 | 265 | for i := 0; i < sz; i++ { 266 | n, err := rand.Read(b[:]) 267 | if n != 8 || err != nil { 268 | panic("can't generate rand") 269 | } 270 | 271 | v[i] = binary.BigEndian.Uint64(b[:]) 272 | } 273 | return v 274 | } 275 | --------------------------------------------------------------------------------