FreeLRU allows you to cache objects without introducing GC overhead. It uses Go generics for simplicity, type-safety and performance over interface types. It performs better than other LRU implementations in the Go benchmarks provided. The API is simple in order to ease migrations from other LRU implementations. The function to calculate hashes from the keys needs to be provided by the caller.
- Add helper function with default hasher
- Add more test cases
- Add more fields to Metrics
package freelru // import "github.com/fufuok/freelru"
func MakeHasher[T comparable]() func(T) uint32
type Cache[K comparable, V any] interface{ ... }
type HashKeyCallback[K comparable] func(K) uint32
type LRU[K comparable, V any] struct{ ... }
func New[K comparable, V any](capacity uint32, hash HashKeyCallback[K]) (*LRU[K, V], error)
func NewDefault[K comparable, V any](capacity uint32, lifetime ...time.Duration) (*LRU[K, V], error)
func NewWithSize[K comparable, V any](capacity, size uint32, hash HashKeyCallback[K]) (*LRU[K, V], error)
func NewWithSizeDefault[K comparable, V any](capacity, size uint32, lifetime ...time.Duration) (*LRU[K, V], error)
type Metrics struct{ ... }
type OnEvictCallback[K comparable, V any] func(K, V)
type ShardedLRU[K comparable, V any] struct{ ... }
func NewSharded[K comparable, V any](capacity uint32, hash HashKeyCallback[K]) (*ShardedLRU[K, V], error)
func NewShardedDefault[K comparable, V any](capacity uint32, lifetime ...time.Duration) (*ShardedLRU[K, V], error)
func NewShardedWithSize[K comparable, V any](shards, capacity, size uint32, hash HashKeyCallback[K]) (*ShardedLRU[K, V], error)
func NewShardedWithSizeDefault[K comparable, V any](shards, capacity, size uint32, lifetime ...time.Duration) (*ShardedLRU[K, V], error)
type SyncedLRU[K comparable, V any] struct{ ... }
func NewSynced[K comparable, V any](capacity uint32, hash HashKeyCallback[K]) (*SyncedLRU[K, V], error)
func NewSyncedDefault[K comparable, V any](capacity uint32, lifetime ...time.Duration) (*SyncedLRU[K, V], error)
func NewSyncedWithSize[K comparable, V any](capacity, size uint32, hash HashKeyCallback[K]) (*SyncedLRU[K, V], error)
func NewSyncedWithSizeDefault[K comparable, V any](capacity, size uint32, lifetime ...time.Duration) (*SyncedLRU[K, V], error)
LRU
is a single-threaded LRU hashmap implementation.
It uses a fast exact LRU algorithm and has no locking overhead.
It has been developed for low-GC overhead and type-safety.
For thread-safety, pick one of SyncedLRU
or ShardedLRU
or do locking by yourself.
Get (key and value are both of type int
)
BenchmarkFreeLRUGet 73456962 15.17 ns/op 0 B/op 0 allocs/op
BenchmarkSimpleLRUGet 91878808 12.09 ns/op 0 B/op 0 allocs/op
BenchmarkMapGet 173823274 6.884 ns/op 0 B/op 0 allocs/op
Add
BenchmarkFreeLRUAdd_int_int 39446706 30.04 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_int_int128 39622722 29.71 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_uint32_uint64 43750496 26.97 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_string_uint64 25839464 39.31 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_int_string 37269870 30.55 ns/op 0 B/op 0 allocs/op
BenchmarkSimpleLRUAdd_int_int 12471030 86.33 ns/op 48 B/op 1 allocs/op
BenchmarkSimpleLRUAdd_int_int128 11981545 85.70 ns/op 48 B/op 1 allocs/op
BenchmarkSimpleLRUAdd_uint32_uint64 11506755 87.52 ns/op 48 B/op 1 allocs/op
BenchmarkSimpleLRUAdd_string_uint64 8674652 142.8 ns/op 49 B/op 1 allocs/op
BenchmarkSimpleLRUAdd_int_string 12267968 87.77 ns/op 48 B/op 1 allocs/op
BenchmarkMapAdd_int_int 34951609 48.08 ns/op 0 B/op 0 allocs/op
BenchmarkMapAdd_int_int128 31082216 47.05 ns/op 0 B/op 0 allocs/op
BenchmarkMapAdd_uint32_uint64 36277005 48.08 ns/op 0 B/op 0 allocs/op
BenchmarkMapAdd_string_uint64 29380040 49.37 ns/op 0 B/op 0 allocs/op
BenchmarkMapAdd_int_string 30325861 47.35 ns/op 0 B/op 0 allocs/op
The comparison with Map is just for reference - Go maps don't implement LRU functionality and thus should be significantly faster than LRU implementations.
SyncedLRU
is a concurrency-safe LRU hashmap implementation wrapped around LRU
.
It is best used in low-concurrency environments where lock contention isn't a thing to worry about.
It uses an exact LRU algorithm.
ShardedLRU
is a sharded, concurrency-safe LRU hashmap implementation.
It is best used in high-concurrency environments where lock contention is a thing.
Due to the sharded nature, it uses an approximate LRU algorithm.
FreeLRU is for single-threaded use only. For thread-safety, the locking of operations needs to be controlled by the caller.
Add with GOMAXPROCS=1
BenchmarkParallelSyncedFreeLRUAdd_int_int128 42022706 28.27 ns/op 0 B/op 0 allocs/op
BenchmarkParallelShardedFreeLRUAdd_int_int128 35353412 33.33 ns/op 0 B/op 0 allocs/op
BenchmarkParallelFreeCacheAdd_int_int128 14825518 79.58 ns/op 0 B/op 0 allocs/op
BenchmarkParallelRistrettoAdd_int_int128 5565997 206.1 ns/op 121 B/op 3 allocs/op
BenchmarkParallelPhusluAdd_int_int128 28041186 41.26 ns/op 0 B/op 0 allocs/op
BenchmarkParallelCloudflareAdd_int_int128 6300747 185.0 ns/op 48 B/op 2 allocs/op
Add with GOMAXPROCS=1000
BenchmarkParallelSyncedFreeLRUAdd_int_int128-1000 12251070 138.9 ns/op 0 B/op 0 allocs/op
BenchmarkParallelShardedFreeLRUAdd_int_int128-1000 112706306 10.59 ns/op 0 B/op 0 allocs/op
BenchmarkParallelFreeCacheAdd_int_int128-1000 47873679 24.14 ns/op 0 B/op 0 allocs/op
BenchmarkParallelRistrettoAdd_int_int128-1000 69838436 16.93 ns/op 104 B/op 3 allocs/op
BenchmarkParallelOracamanMapAdd_int_int128-1000 25694386 40.48 ns/op 37 B/op 0 allocs/op
BenchmarkParallelPhusluAdd_int_int128-1000 89379122 14.19 ns/op 0 B/op 0 allocs/op
Ristretto
offloads the LRU functionality of Add()
to a separate goroutine, which is why it is relatively fast. But the
separate goroutine doesn't show up in the benchmarks, so the numbers are not directly comparable.
Oracaman
is not an LRU implementation, just a thread-safety wrapper around map
.
Get with GOMAXPROCS=1
BenchmarkParallelSyncedGet 43031780 27.35 ns/op 0 B/op 0 allocs/op
BenchmarkParallelShardedGet 51807500 22.86 ns/op 0 B/op 0 allocs/op
BenchmarkParallelFreeCacheGet 21948183 53.52 ns/op 16 B/op 1 allocs/op
BenchmarkParallelRistrettoGet 30343872 33.82 ns/op 7 B/op 0 allocs/op
BenchmarkParallelBigCacheGet 21073627 51.08 ns/op 16 B/op 2 allocs/op
BenchmarkParallelPhusluGet 59487482 20.02 ns/op 0 B/op 0 allocs/op
BenchmarkParallelCloudflareGet 17011405 67.11 ns/op 8 B/op 1 allocs/op
Get with GOMAXPROCS=1000
BenchmarkParallelSyncedGet-1000 10867552 151.0 ns/op 0 B/op 0 allocs/op
BenchmarkParallelShardedGet-1000 287238988 4.061 ns/op 0 B/op 0 allocs/op
BenchmarkParallelFreeCacheGet-1000 78045916 15.33 ns/op 16 B/op 1 allocs/op
BenchmarkParallelRistrettoGet-1000 214839645 6.060 ns/op 7 B/op 0 allocs/op
BenchmarkParallelBigCacheGet-1000 163672804 7.282 ns/op 16 B/op 2 allocs/op
BenchmarkParallelPhusluGet-1000 200133655 6.039 ns/op 0 B/op 0 allocs/op
BenchmarkParallelCloudflareGet-1000 100000000 11.26 ns/op 8 B/op 1 allocs/op
Cloudflare
and BigCache
only accept string
as the key type.
So the ser/deser of int
to string
is part of the benchmarks for a fair comparison
Here you can see that SyncedLRU
badly suffers from lock contention.
ShardedLRU
is ~37x faster than SyncedLRU
in a high-concurrency situation and the second
fastest LRU implementation (Ristretto
and Phuslu
) is 50% slower.
Most LRU implementations combine Go's map
for the key/value lookup and their own implementation of
a circular doubly-linked list for keeping track of the recent-ness of objects.
This requires one additional heap allocation for the list element. A second downside is that the list
elements are not contiguous in memory, which causes more (expensive) CPU cache misses for accesses.
FreeLRU addresses both issues by merging hashmap and ringbuffer into a contiguous array of elements. Each element contains key, value and two indices to keep the cached objects ordered by recent-ness.
The contiguous array of elements is allocated on cache creation time. So there is only a single memory object instead of possibly millions that the GC needs to iterate during a garbage collection phase. The GC overhead can be quite large in comparison with the overall CPU usage of an application. Especially long-running and low-CPU applications with lots of cached objects suffer from the GC overhead.
Using generics allows type-checking at compile time, so type conversions are not needed at runtime.
The interface type or any
requires type conversions at runtime, which may fail.
The interface types (aka any
) is a pointer type and thus require a heap allocation when being stored.
This is true even if you just need an integer to integer lookup or translation.
With generics, the two allocations for key and value can be avoided: as long as the key and value types do not contain pointer types, no allocations will take place when adding such objects to the cache.
Each hashtable implementation tries to avoid hash collisions because collisions are expensive. FreeLRU allows allocating more elements than the maximum number of elements stored. This value is configurable and can be increased to reduce the likeliness of collisions. The performance of the LRU operations will generally become faster by doing so. Setting the size of LRU to a value of 2^N is recognized to replace slow divisions by fast bitwise AND operations.
Below we compare FreeLRU with SimpleLRU, FreeCache and Go map. The comparison with FreeCache is just for reference - it is thread-safe and comes with a mutex/locking overhead. The comparison with Go map is also just for reference - Go maps don't implement LRU functionality and thus should be significantly faster than FreeLRU. It turns out, the opposite is the case.
The numbers are from my laptop (Intel(R) Core(TM) i7-12800H @ 2800 MHz).
The key and value types are part of the benchmark name, e.g. int_int
means key and value are of type int
.
int128
is a struct type made of two uint64
fields.
To run the benchmarks
make benchmarks
FreeLRU is ~3.5x faster than SimpleLRU, no surprise. But it is also significantly faster than Go maps, which is a bit of a surprise.
This is with 0% memory overcommitment (default) and a capacity of 8192.
BenchmarkFreeLRUAdd_int_int-20 43097347 27.41 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_int_int128-20 42129165 28.38 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_uint32_uint64-20 98322132 11.74 ns/op 0 B/op 0 allocs/op (*)
BenchmarkFreeLRUAdd_string_uint64-20 39122446 31.12 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_int_string-20 81920673 14.00 ns/op 0 B/op 0 allocs/op (*)
BenchmarkSimpleLRUAdd_int_int-20 12253708 93.85 ns/op 48 B/op 1 allocs/op
BenchmarkSimpleLRUAdd_int_int128-20 12095150 94.26 ns/op 48 B/op 1 allocs/op
BenchmarkSimpleLRUAdd_uint32_uint64-20 12367568 92.60 ns/op 48 B/op 1 allocs/op
BenchmarkSimpleLRUAdd_string_uint64-20 10395525 119.0 ns/op 49 B/op 1 allocs/op
BenchmarkSimpleLRUAdd_int_string-20 12373900 94.40 ns/op 48 B/op 1 allocs/op
BenchmarkFreeCacheAdd_int_int-20 9691870 122.9 ns/op 1 B/op 0 allocs/op
BenchmarkFreeCacheAdd_int_int128-20 9240273 125.6 ns/op 1 B/op 0 allocs/op
BenchmarkFreeCacheAdd_uint32_uint64-20 8140896 132.1 ns/op 1 B/op 0 allocs/op
BenchmarkFreeCacheAdd_string_uint64-20 8248917 137.9 ns/op 1 B/op 0 allocs/op
BenchmarkFreeCacheAdd_int_string-20 8079253 145.0 ns/op 64 B/op 1 allocs/op
BenchmarkRistrettoAdd_int_int-20 11102623 100.1 ns/op 109 B/op 2 allocs/op
BenchmarkRistrettoAdd_int128_int-20 10317686 113.5 ns/op 129 B/op 4 allocs/op
BenchmarkRistrettoAdd_uint32_uint64-20 12892147 94.28 ns/op 104 B/op 2 allocs/op
BenchmarkRistrettoAdd_string_uint64-20 11266416 105.8 ns/op 122 B/op 3 allocs/op
BenchmarkRistrettoAdd_int_string-20 10360814 107.4 ns/op 129 B/op 4 allocs/op
BenchmarkMapAdd_int_int-20 35306983 46.29 ns/op 0 B/op 0 allocs/op
BenchmarkMapAdd_int_int128-20 30986126 45.16 ns/op 0 B/op 0 allocs/op
BenchmarkMapAdd_string_uint64-20 28406497 49.35 ns/op 0 B/op 0 allocs/op
(*) There is an interesting affect when using increasing number (0..N) as keys in combination with FNV1a(). The number of collisions is strongly reduced here, thus the high performance. Exchanging the sequential numbers with random numbers results in roughly the same performance as the other results.
Just to give you an idea for 100% memory overcommitment: Performance increased by ~20%.
BenchmarkFreeLRUAdd_int_int-20 53473030 21.52 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_int_int128-20 52852280 22.10 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_uint32_uint64-20 100000000 10.15 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_string_uint64-20 49477594 24.55 ns/op 0 B/op 0 allocs/op
BenchmarkFreeLRUAdd_int_string-20 85288306 12.10 ns/op 0 B/op 0 allocs/op
This is with 0% memory overcommitment (default) and a capacity of 8192.
BenchmarkFreeLRUGet-20 83158561 13.80 ns/op 0 B/op 0 allocs/op
BenchmarkSimpleLRUGet-20 146248706 8.199 ns/op 0 B/op 0 allocs/op
BenchmarkFreeCacheGet-20 58229779 19.56 ns/op 0 B/op 0 allocs/op
BenchmarkRistrettoGet-20 31157457 35.37 ns/op 10 B/op 1 allocs/op
BenchmarkPhusluGet-20 55071919 20.63 ns/op 0 B/op 0 allocs/op
BenchmarkMapGet-20 195464706 6.031 ns/op 0 B/op 0 allocs/op
package main
import (
"fmt"
"github.com/cespare/xxhash/v2"
"github.com/elastic/go-freelru"
)
// more hash function in https://github.com/elastic/go-freelru/blob/main/bench/hash.go
func hashStringXXHASH(s string) uint32 {
return uint32(xxhash.Sum64String(s))
}
func main() {
lru, err := freelru.New[string, uint64](8192, hashStringXXHASH)
if err != nil {
panic(err)
}
key := "go-freelru"
val := uint64(999)
lru.Add(key, val)
if v, ok := lru.Get(key); ok {
fmt.Printf("found %v=%v\n", key, v)
}
// Output:
// found go-freelru=999
}
The function hashInt(int) uint32
will be called to calculate a hash value of the key.
Please take a look into bench/
directory to find examples of hash functions.
Here you will also find an amd64 version of the Go internal hash function, which uses AESENC features
of the CPU.
In case you already have a hash that you want to use as the key, you have to provide an "identity" function.
Hashing int
BenchmarkHashInt_MapHash-20 181521530 6.806 ns/op 0 B/op 0 allocs/op
BenchmarkHashInt_MapHasher-20 727805824 1.595 ns/op 0 B/op 0 allocs/op
BenchmarkHashInt_FNV1A-20 621439513 1.919 ns/op 0 B/op 0 allocs/op
BenchmarkHashInt_FNV1AUnsafe-20 706583145 1.699 ns/op 0 B/op 0 allocs/op
BenchmarkHashInt_AESENC-20 1000000000 0.9659 ns/op 0 B/op 0 allocs/op
BenchmarkHashInt_XXHASH-20 516779404 2.341 ns/op 0 B/op 0 allocs/op
BenchmarkHashInt_XXH3HASH-20 562645186 2.127 ns/op 0 B/op 0 allocs/op
Hashing string
BenchmarkHashString_MapHash-20 72106830 15.80 ns/op 0 B/op 0 allocs/op
BenchmarkHashString_MapHasher-20 385338830 2.868 ns/op 0 B/op 0 allocs/op
BenchmarkHashString_FNV1A-20 60162328 19.33 ns/op 0 B/op 0 allocs/op
BenchmarkHashString_AESENC-20 475896514 2.472 ns/op 0 B/op 0 allocs/op
BenchmarkHashString_XXHASH-20 185842404 6.476 ns/op 0 B/op 0 allocs/op
BenchmarkHashString_XXH3HASH-20 375255375 3.182 ns/op 0 B/op 0 allocs/op
As you can see, the speed depends on the object type to hash. I think, it mostly boils down to the size of the object.
MapHasher
is dangerous to use because it is not guaranteed to be stable across Go versions.
AESENC
uses the AES CPU extensions on X86-64. In theory, it should work on ARM64 as well (not tested by me).
For a small number of bytes, FNV1A
is the fastest.
Otherwise, XXH3
looks like a good choice.
The code is licensed under the Apache 2.0 license. See the LICENSE
file for details.