I’ve been toying around with the idea of implementing a low & predictable latency caching library in Rust for a while. It’s interesting to me from a couple of perspectives: having low-level control over memory layout, while honouring Rust’s type system and its guarantees; as well as trying to reconcile the efficiency guarantees, with that level of control, and possibly supporting pluggable eviction algorithms while not hurting performance. The latter I actually think isn’t possible: i.e. some sacrifice on the design side on the altar of performance will forever be required, which will come at odds with the pluggable eviction. But I still would like to toy with the challenge.
Warning
|
This is all just me fooling around as of now. I’m merely trying to get a thing that works & exposes a somewhat sensible API. Once I have that sorted out, I’ll iterate over the implementation and start making it faster… |
-
✓ Cache through API
-
✓ Once-and-only-once loading
-
✓ Clock eviction
-
✓ Thread-safe, with interior mutability
-
❏ Segmented storage, leveraging
std::hash
-
❏ Fine(r) grained locking (i.e. don’t lock the entire Cache/Segment on populating, as populating could take a while)
-
❏ Perf optimizations on
CacheThrough
-
❏ Start adding other cache APIs (i.e. other than
CacheThrough
, maybe a cache-aside?)
The key part in this I think, but that’s the point behind the whole project, is to be able to walk the clock hand in a CPU cache friendly way. Probably requiring the clock bit information to be held in a different data structure than the cache entries themselves. This will come at a slight overhead on cache hits to update the bit.
The other interesting part is making this work in an optimistic way with regards to coordination primitives. Hence the idea to use interior mutability for cache entries, but fall to different strategies for the eviction data; possibly lowering the guarantees for these.
Finally, trying to plug another eviction algorithm (probably LRU, which requires a different approach to storing the eviction data) and see how that affects the design.
let cache = cachers::CacheThrough::new(size); // (1)
let value: Option<Arc<V>> = cache.get(key, populating_fn); // (2)
let updated: Option<Arc<V>> = cache.udpate(key, updating_fn); // (3)
cache.remove(key); // (4)
-
cache
is notmut
, and<K, V>
is inferred; the cache would contain at mostsize
entries; -
key
in this case would be a miss, invokingpopulating_fn
only once, whethercache
even if cache was shared across threads all trying to access the samekey
.populating_fn
returns the valueSome<V>
to populate entry forkey: K
. The.get
then returns aOption<Arc<V>>
, withNone
if thepopulating_fn
returned aNone
-
.udpate
forces an update to thekey
, whether it was already present or not.updating_fn
receives the key but also, unlikepopulating_fn
, aOption<Arc<V>>
that represents the previous value if present;.update
then returns the updatedV
-
remove
invalidates the mapping tokey
without proposing a new value. Which is effectively the equivalent of an.update(key, |_, _| None)
. I wonder if this is even necesary… it’s here tho!