FIFO can be Better than LRU [pdf]
jasony.me
jasony.me
Start with a dumb-cache using "FIFO" (first in/first out)
Keep track of anything with "hits" while in the cache (simple boolean/bitmap instead of complicated locks/data structures)
Re-insert "hit" items when they would normally be evicted/age out (Lazy Promotion).
BTW, also have a mini-cache in front of the main cache (10% of cache size) which tries to "drop off quickly" (effectively: must have a "hit" within first 10% of being cached).
BTW, also keep track of a "ghost cache" ("hits only", not contents??) for the duration of the overall FIFO-cache, and use that to guide eviction/re-insertion. I'm a little bit unclear on this aspect, but it seems like an "obvious in retrospect" set of guidance.
Probationary 10% + Traditional 90% (of memory).
The idea is that most items are not requested again prior to dropping out of probationary and so don't take up space and process in the Traditional Cache.
Some infrequently used items however would remain inside the Traditional cache but never make it through the probationary cache. So a small "ghost" cache is used to specifically detect these items rejected from the Probationary cache.
Next time we go to fetch a ghost item they go directly into the Traditional cache. The ghost cache is just storing metadata (pointers essentially).
There is an "active" queue and an "inactive" queue. The pageout daemon tries to maintain them in a 1:2 ratio. Active drains to inactive; inactive drains to be paged out. Both are simple FIFO, but the daemon clears the page table referenced bit of pages as they are enqueued onto inactive.
When dequeueing a page from inactive, the referenced bit is checked. If it's been set (i.e., the page was referenced since being moved to inactive), the page is diverted back up to active. Otherwise, it's paged out.
https://developer.apple.com/library/archive/documentation/Pe...
1. You've got a cache miss 2. You need to evict something, so look at the next item due for eviction. 3. It's a candidate for promotion, so you reinsert it. 4. You still need to evict something, so goto 2.
Isn't that a potential infinite loop? And even if not (because there's some other exit clause), how is it quick?
The second is quick demotion which uses a small FIFO to filter out most objects that are not requested soon after insertion in the cache.
Your algorithm on the other hand does not need to modify the order of the queue on a Get (just mark single item as hit if not marked already) which is really good. But on Insert your algorithm needs to do a reverse scan through the queue until it finds an item it can evict (not marked as hit). This might need to walk the whole queue if unlucky. And it can get so bad that it needs to do that on every single eviction so latency and CPU usage could skyrocket. Granted, this is a worst case scenario that shouldn't happen usually but if it does then it could be a real problem.
So in that sense LRU would be O(m) in terms of queue modifications and reads but yours would be O(n*m) for queue reads.
The O(n) I mentioned was for the number of items the algorithm needs to check on a single insert that results in an eviction aka the usual case.
I don't understand the O(n*m) argument, does the answer invalidates it?
On the tail latency part, if it becomes a problem, we can bound the number of objects it checks.
Fetch("1"); Fetch("1"); Fetch("2"); Fetch("2"); Fetch("3"); Fetch("3"); ...
This will result in your queue being 100% filled with items that are marked as hit because of the second Fetch for each key. After the queue is full each first Fetch will result in an eviction (new key to insert) which has to walk the whole queue because the only item marked not-hit is at the head of the queue due to being lazy promoted.My O(n*m) reply was because your O(m) was considering a whole run over multiple requests and my O(n) was only considering one request. So if I were to also talk about multiple requests then your eviction algorithm needs to look at O(n*m) items for evictions in the example pattern I presented. O(n) per request for m requests.
A malicious actor could abuse this fact to cause a DoS.
No you can't just bound the number of objects it checks because that would result in a flurry of cache misses as it would mean you have to fail the insert.
and it will trigger an eviction, and requires resetting 2 (or O(m)) objects with the following state changes b(1) a(1) -> b(1) a(0) -> b(0) a(0) -> c(0) b(0)
the next c will change the bit to 1, and we will have c(1) b(0),
When d arrives, b will be evicted, and we have d(0) c(1)
Note that we only check/reset one object this time instead of O(m).
Therefore, in the worst case between each O(m) eviction, you need O(m) requests to set the m objects to "visited", strictly no more than a LRU cache.
When d arrives state changes c(0) b(1) a(1) -> d(0) c(0) b(0). When e arrives state changes d(1) c(0) b(0) -> e(0) d(0) c(0).
Note how b got evicted from the cache even though it was requested more recently and more frequently than c because d cleared out the hit bit for both b and c.
Now you traded off hitrate vs latency. But the worst case latency is still O(n) just can't happen multiple times in a row anymore.
Imagine you have a cache of 1 million items all marked as hit and you get a new request which would evict all 1 million items. You gained latency for some subsequent requests but increased it in the current one. Crucially you also just cleared out the hit marker of whole cache.
Any eviction algorithm would have an adversarial workload, so a simpler algorithm is better because you know what the workload was and whether it happens in production; on the other end, the complicated one only makes this worse and the failure of a cache is often catastrophic. :)
You don't always know the workload beforehand and can pick the appropriate cache policy. Workloads can even change during runtime.
I haven't seen a simple cache algorithm that performs well in every workload and I believe any that wants to succeed needs to incorporate different sub-algorithms for different workloads and be adaptive.
Having an ideal adaptive algorithm is certainly better, and I do observe different workloads favor different algorithms, but all adaptive algorithms have parameters that are not optimal for a portion of workloads and also hard to tune (including ARC).
If you give me an algorithm, I can always find a production workload for you that the algorithm does not work well.
"Simple algorithms are the ones most likely to fail catastrophically because they are made for a certain range of access patterns" I do not see how FIFO and LRU fail catastrophically except on scanning/streaming workloads. Happy to learn about it.
A friend of mine works on a online-shop / distribution system that during most of the day has a zipf like distribution but after normal working hours when retail shops close he gets completely different patterns as the shops push their data for the day into the system and then all kinds of BI tools run for a few hours.
Of course adaptive caches are never completely optimal because it takes time for them to learn which workload is actually currently happening but after a while they can get very close. A good adaptive cache should not need tuning to get good results because the whole reason for it to be adaptive is to tune itself. Of course there are quite a few adaptive caches, especially the first ones that pioneered this, that still needed tuning.
I don't understand the last question. You say you can't see how FIFO can fail catastrophically and at the same time state a case how it can. Scanning workloads are something that does show up in the real world all the time. NovaX seems to have run your algorithm through some traces where it had a near zero hitrate. It can be more important to avoid these extreme cases than having a slightly better hitrate in the normal case.
https://github.com/ben-manes/caffeine/wiki/Efficiency Shows that it really outperforms ARC as well and they also have an optimal oracle version that they evaluate against to show how much room there is left (admittedly the oracle version itself implies you’re picking some global criterion to optimize but that’s trickier when in reality there are multiple axes along which to optimize and you can’t simultaneously do well across all of them).
Also the lack of evaluation of the cache strategy against shifting workloads is also problematic.
They refer to the W-TinyLFU paper as reference 34.
> Moreover, admission algorithms, e.g., TinyLFU [ 33, 34], Bloom Filter [18, 54 ], probabilistic [ 15] and ML-based [ 35] admission algorithms, can be viewed as a form of QD — albeit some of them are too aggressive at demotion (rejecting objects from entering the cache).
The stress workload that I use is to chain corda-large [1], 5x loop [2], corda-large at a cache size of 512 entries (6M requests). This shifts from a strongly LRU-biased pattern to an MRU one, and then back again. My solution to this was to use hill climbing by sampling the hit rate to adaptively size of the admission window (aka your FIFO) to reconfigure the cache region sizes. You already have similar code in your CACHEUS implementation which built on that idea to apply it to a multi-agent policy.
Caffeine adjusts the frequency comparison for admission slightly to allow ~1% of losing warm candidates to enter the main region. This is to protect against hash flooding attack (HashDoS) [3]. That isn't intended to improve or correct the policy's decision making so should be unrelated to your observations, but an important change for real-world usage.
I believe LIRS2 [4] adaptively sizes their LIR region, but I do not recall the details as a complex algorithm. It did very well across different workloads when I tried it out and the authors were able to make a few performance fixes based on my feedback. Unfortunately I find LIRS algorithms to be too difficult to maintain for an industry setting because while excellent, the implementation logic is not intuitive which makes it frustrating to debug.
[1] https://github.com/ben-manes/caffeine/blob/master/simulator/...
[2] https://github.com/ben-manes/caffeine/blob/master/simulator/...
[3] https://github.com/ben-manes/caffeine/blob/59df43e522686b707...
The follow up paper, which you also cite, explored adaptive techniques to resolve this deficiency. From there we introduced the idea of using hill climbing by monitoring the hit rate change over a sample period and adjusting the step direction. Caffeine incorporates this with a step size decay to allow for convergence. The implementation that you have does not include this improvement, but it is very simple to add and works surprisingly well.
I know the ARC paper discusses that other algorithms are often better if properly tuned, although ARC is usually consistently decent in a variety of situations without tuning. It would be awesome to have a new algorithm in that space.
My experience trying out cache algorithms is that they are all very generic, cache benchmarks are typically based on random distributions or on web-request datasets (twitter, CDNs, ...) that may not match your use case. Mine is about caching data stream transformations with specific ORDER BYs. Hits may come at a very wide point in time and LFU ended-up working better for me. Also your eviction policy choice is very important like number of items or RAM use (my case). So don't go running to the latest "benchmark proven" cache algorithm paper without weighting in your specific needs.