-
Notifications
You must be signed in to change notification settings - Fork 5
Add Normalized DN Cache with sharded S3-FIFO design #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| --- | ||
| title: "Normalized DN Cache with sharded S3-FIFO" | ||
| --- | ||
|
|
||
| # Normalized DN Cache with sharded S3-FIFO | ||
| -------------------------- | ||
|
|
||
| Overview | ||
| -------- | ||
|
|
||
| DN normalization is expensive. The same DNs flow through the server many times | ||
| per session. The normalized DN cache turns the repeated transform into a hash | ||
| lookup. | ||
|
|
||
| The [first NDN cache version](https://www.port389.org/docs/389ds/design/normalized-dn-cache.html) | ||
| (2014) used an NSPR hashtable with 2053 buckets and a linked-list LRU. On | ||
| overflow it evicted 10000 entries and kept a minimum of 1000. Then we | ||
| switched to the Adaptive Replacement Cache (ARC) through the | ||
| [`concread`](https://crates.io/crates/concread) crate (2017). After a good | ||
| run for a few years I propose a sharded S3-FIFO cache written in Rust and | ||
| called from the same C entry points (`ndn_cache_lookup`, `ndn_cache_add`). | ||
|
|
||
| The 2017 [cache redesign vision](https://www.port389.org/docs/389ds/design/cache_redesign.html) | ||
| (William Brown) listed five properties a server cache should have: parallel | ||
| reads, low LRU maintenance cost, dynamic resizing, enforced size bounds, and | ||
| low tuning burden. The new S3-FIFO algorithm satisfies all five for this | ||
| workload. The algorithm postdates the 2017 document. | ||
|
|
||
| Use Cases | ||
| --------- | ||
|
|
||
| The bind path normalizes the user-supplied DN to find the entry, and the | ||
| same bind DNs recur across sessions. Filters and assertions that hold | ||
| DN-syntax values (`member`, `uniqueMember`, `owner`, `seeAlso`) normalize | ||
| each value on every search and modify, so one search over a group entry | ||
| runs as many normalizations as there are members. A modify of a nested | ||
| group cascades through every parent group and re-normalizes member DNs at | ||
| each step, keeping a small working set very hot. Replication CSN and | ||
| conflict-resolution paths normalize the same DNs occasionally. | ||
|
|
||
| Production servers run this cache at over 99% hit ratio in steady state, | ||
| with millions of tries between restarts. | ||
|
|
||
|
droideck marked this conversation as resolved.
|
||
| Design | ||
| ------ | ||
|
|
||
| Keys are raw DN byte strings, values are normalized DN byte strings, and | ||
| `slapi_dn_normalize_ext` is deterministic, so the cache is a pure-function | ||
| lookup table. An evicted entry can be recomputed for the cost of one | ||
| normalize call. There is no transaction model, no snapshot semantics, and | ||
| no notion of stale entries. | ||
|
|
||
| The cache is split into 64 shards. Each shard runs the eviction algorithm | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Idea - shards == threads * 4? That way it reduces the likelihood of thread-shard contention? Probably a niche/micro-optimisation so not hugely important, could be a future improvement. |
||
| independently over its own hash table and its own lock, so reads on | ||
|
Firstyear marked this conversation as resolved.
|
||
| different shards run in parallel and a writer on one shard blocks readers | ||
| only on that shard. The shard index comes from the low bits of the key's | ||
|
droideck marked this conversation as resolved.
Outdated
|
||
| hash, which leaves the high bits free for hashbrown's SIMD tag. The shard | ||
| struct is padded to 128 bytes so neighbouring locks do not share a cache | ||
|
droideck marked this conversation as resolved.
Outdated
|
||
| line. Layout: | ||
|
|
||
| ```rust | ||
| struct S3FifoShard { | ||
| table: HashTable<Arc<S3Entry>>, | ||
| small: VecDeque<Arc<[u8]>>, | ||
| main: VecDeque<Arc<[u8]>>, | ||
| ghost: VecDeque<u64>, | ||
| ghost_set: HashSet<u64>, | ||
| small_cap: usize, | ||
| main_cap: usize, | ||
| ghost_cap: usize, | ||
| } | ||
| ``` | ||
|
|
||
| - `table` — per-shard hash table keyed by the key's hash. Each `S3Entry` | ||
| holds the raw DN, the normalized DN value, and a 2-bit frequency | ||
| counter stored in an `AtomicU8`. | ||
| - `small`, `main` — the S and M FIFO queues. Each entry is an | ||
| `Arc<[u8]>` pointing at the same key bytes as the matching | ||
| `S3Entry.key` in `table`, so the key allocation is shared between the | ||
| queue and the table entry, not duplicated. | ||
| - `ghost` — the G FIFO queue, holding 64-bit key hashes only. | ||
| - `ghost_set` — the same hashes held as a `HashSet` so membership lookup | ||
| is O(1). | ||
| - `small_cap`, `main_cap`, `ghost_cap` — configured size limits for the | ||
| three queues; `ghost_cap` equals `main_cap`. | ||
|
|
||
| Each shard runs [S3-FIFO](https://dl.acm.org/doi/10.1145/3600006.3613147) | ||
| (Yang, Zhang, Qiu, Yue, Rashmi, SOSP 2023). New entries land in a small | ||
| FIFO queue S (10% of the shard's capacity). Entries that prove popular | ||
| get promoted from S to a main FIFO queue M (the remaining 90%). Hashes of | ||
|
droideck marked this conversation as resolved.
droideck marked this conversation as resolved.
|
||
| entries that fell out of S without earning promotion live for a while in | ||
| a ghost queue G (sized like M, hashes only, no values). Each cached entry | ||
| carries a 2-bit frequency counter that saturates at 3 and is bumped with | ||
| a compare-and-swap on every hit. | ||
|
|
||
| On a miss, the inserter takes the shard's write lock. If the key's hash | ||
| is in G (the entry was evicted recently and the caller came back for it), | ||
| the new entry goes straight into M. Otherwise it goes into S. Either way | ||
| the counter starts at 0. | ||
|
|
||
| When S is full, eviction pops the head and reads its counter. A counter | ||
| above 1 means the entry was hit at least twice in S, so it gets promoted | ||
|
droideck marked this conversation as resolved.
|
||
| to M (evicting M's head first if M is full). A counter at or below 1 | ||
| means the entry never warmed up, so it is removed from the table and its | ||
| hash appended to G. | ||
|
|
||
| When M is full, eviction pops the head. A counter above 0 means at least | ||
| one hit since the entry entered M, so the counter is decremented and the | ||
| entry is re-appended at M's tail. A counter at 0 means the entry has | ||
| fallen out of use, so it is removed from the table. Eviction from M does | ||
| not write to G. | ||
|
|
||
| A scan inserts keys into S with the counter at 0; they reach the tail of | ||
| S without earning promotion and are evicted to G, and they reach M only | ||
| if re-requested while still in G. A fixup sweep over many DNs therefore | ||
| does not displace the hot set in M. | ||
|
|
||
| A visualisation of the algorithm lives at <https://s3fifo.com>. | ||
|
|
||
| Alternatives Considered | ||
| ----------------------- | ||
|
|
||
| Concread's `ARCache` is a transactional, snapshot-consistent ARC: readers | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interestingly the transactional nature is separate from the algorithm used - if S3-FIFO is very 'good' then there is no reason we couldn't use it in concread instead of ARC. Also within the way this works in 389-ds, we actually only use readers and we async-submit changes for the main cache to include.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This isn't really a concread-vs-S3-FIFO question. It's whether the NDN cache shape we have today needs the transactional power concread provides. Concread's ARCache gives readers a transactionally consistent snapshot, a haunted set so evicted-then-revived keys can't clobber newer writes, and an mpsc channel from readers to the writer so reader hits influence eviction (as you describe in CACHE.md). IIUC, these properties are correct for caches whose values can change under readers and whose readers need a stable view across a transaction. The entry cache and the index cache fit that description, yep. IMO, the NDN cache doesn't. The value is a pure function of the key. Two readers asking for the same DN at different transaction ids get the same answer or a cache miss, never a stale-but-different answer. There's no rollback to support and no temporal ordering between readers and writers to protect. The haunted set and the reader-to-writer channel pay for guarantees the NDN cache doesn't need. So for this proposal the relevant comparison is concread's transactional shell vs no shell, for a cache whose values are immutable functions of the key. For this specific cache, no shell wins on the hot path, I think. Which is why the new design is sharded S3-FIFO directly rather than ARC-inside-concread. The reader-doesnt-block-writer property you get from async-submit carries over to the design through a different mechanism: shared read lock with a relaxed atomic counter on hit, write lock only on miss-insert. No reader-to-writer mpsc, but readers stay off the write path on the hot case. Plus the cache is hash sharded across per-shard locks, so reads on different shards never contend at the lock level.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Part of the goal historically of using concread in 389 was to show the cache was effective transactionally, and could then be used in other places where it matters - especially with LMDB for example since it shares an identical transaction model. However, no one else on the team really ever showed interest in that, and the changes to make 389-ds strongly transactional don't seem to have eventuated even with the changes to support LMDB. So with that in mind, in this case yes, maybe the s3-fifo is a better option. That's fine. The number of cases for the NDN to rely on transactions would be low. One area the concread cache has a possibility to outshine the s3-fifo is a majority read-only workset. This is because we end up with most cache lines in the shared state (where s3-fifo will be invalidating them frequently due to atomics). But this is very subjective. Something to try in your benchmarking is different cache sizes - extremely small, moderate, and large ndn cache sizes. This also affects the performance.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point. I added a cache-size + multithreaded sweep. For the multithreaded memberOf cascade run, changing the configured cache size did not change the direction of the result. This run is mostly lookup-path and contention, not eviction pressure. S3-FIFO stayed around +12% ops/s vs concread, with lower p95 in the same runs. So yeah, on the read-mostly point, I agree this is where concread has the strongest argument. The useful thing from the new runs is that we see that the hot-DN shape stays close to concread, while the multi-key memberOf shape stays ahead across the cache sizes. Hits take the shared side of a per-shard
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There are some other things I want to suggest you can try, just for comparison.
My guess is these two are the biggest factors youre seeing that causes the p95 changes, as well as some of the operational performance difference, especially the with quiesce.
A good question is why is memberOf hitting the NDN cache so hard in the firstplace? I assume we're still using the "old" memberOf algorithm (compared to the one I proposed around 2017 that wasn't adopted - note; yes, it's still the slow algo).
It will make the write side of the equation much slower though as you need all readers to drains before a write can proceed. So be mindful of that consequence.
Yeah, that's fine. Realistically if you plan to only do "sampling" based stats and are willing to forego some precision for performance, you can do a trick. You actually only need to record some results, not all to get a hit ratio. For 100,000 events, if you sample 1/10th of them (10,000) as a hit or miss, then you actually end up with a result that is statistically likely to be within 1% of the true value. This means you can probably forgo a lot of atomic calls by sampling instead of recording everything. (check https://www.abs.gov.au/websitedbs/D3310114.nsf/home/Sample+Size+Calculator for your own homework)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, I tried -- Regarding your concread tuning suggestion, yep! It's better with a dedicated quiesce thread. It's clearly visible in Criterion. The tuned concread is a lot faster than the wrapper path we use today, and it wins the isolated single hotkey bench outright (including against S3-FIFO). But only in the isolated Rust microbench. But with the integration tests... I still can't see it at the LDAP level though. On the memberOf cascade the tuned concread run stays inside the production concread spread. p99 is a bit worse even, and the p95 gap to S3-FIFO does not really move. So my read is that the per-read quiesce cost is measurable, but it is not the thing dominating the server-level p95 here. S3-FIFO still lands around +12% to +21% ops/s on the cascade against either concread mode. One catch from setting this up: exported env never reaches ns-slapd (systemctl drops it, lib389's direct exec passes an empty env), so the benchmark now checks for the quiesce worker thread inside the running process before accepting a run. Side note on the doc tables: the numbers changed because this is a fresh run with all four variants on one VM and one build, and the fixture is smaller than the earlier draft's (10,220 entries vs ~100k). Hit ratio stays good either way, so the cascade comparison is lookup-path bound; the eviction side is covered by the scan test and the Criterion capacity sweep, which runs a ~100k-DN working set through caps from 1k to 500k entries. So. On memberOf, IIUC the recompute path goes through On the write-side drain, yup.. That's the part I got worried about after your past comment, so I'm glad the measurements look ok so far. The scan test forces a miss-insert per scanned DN through an undersized cache. That is the worst cache-write/read mix I could come up with at server level. In that run, S3-FIFO scan wall time stays within about 7% of running with no cache at all, so the miss-inserts are nearly free. Concread is 16% to 32% slower than no-cache on the same scan. So I do not see a write-drain cliff there. Mechanically the drain is per shard, not global. A miss-insert only waits for readers on the shard for that DN. In the warm NDN cases we are mostly looking at hit ratios near 1.0, so cache insert writes are not very common after warmup. That could still be wrong for some workloads, I think... On the sampled stats: it showed the benefit in Criterion nicely. At the integration bench/LDAP level I don't see the difference... We're still not at the NDN bottle neck in the many places where NDN's involved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yep - as I was taught long ago, atomics are fast but they are not free. So that's probably really not helping the comparisons here.
Again, because it's using the slower MO algo. Still as we previously mentioned, concread does a lot to create a transactional cache - something you don't (*) need here in the NDN. (*) technically you should need it, but 389-ds transactions are .... weird. |
||
| take a transactional snapshot and writers copy-on-write a new version on | ||
| commit. That is the design for caches whose values can change during a | ||
| read, and it remains a candidate for the 389 DS entry cache. The NDN | ||
| cache does not need those guarantees, and on the memberOf suite below | ||
|
Firstyear marked this conversation as resolved.
Outdated
|
||
| S3-FIFO is 12% faster end-to-end and an order of magnitude faster on the | ||
| per-op microbench. | ||
|
|
||
| [`moka`](https://crates.io/crates/moka) implements W-TinyLFU and reaches | ||
| higher hit ratios on some workloads. At our 99% hit ratio the marginal | ||
| hit-ratio improvement is small, and `moka` pulls a larger Rust dependency | ||
| set than this component needs. | ||
|
|
||
| [SIEVE](https://www.usenix.org/conference/nsdi24/presentation/zhang-yazhuo) | ||
| (Zhang, Yang, Qiu, Vigfusson, Rashmi, NSDI 2024) comes from the same | ||
| research group as S3-FIFO and has a similar profile but as they note themselves | ||
| it's not scan resistant; hence, we skip it. | ||
|
|
||
| [`quick_cache`](https://crates.io/crates/quick_cache) implements a Clock-PRO | ||
| variant and would be a reasonable off-the-shelf choice. But one of the key | ||
| considerations was to have something to keep the dependency set minimal. | ||
| So our in-tree S3-FIFO module is simple enough to have minimal dependencies | ||
| and still fulfill its purpose. | ||
|
|
||
| Benchmarks | ||
| ---------- | ||
|
|
||
| End-to-end runs are on a Fedora 45 VM (kernel 7.1, 389-ds-base 3.2.1). | ||
| Microbenchmarks are on an Apple M4 Pro (14 cores) using `criterion`. | ||
|
|
||
| End-to-end memberOf performance suite (`dirsrvtests/tests/perf/memberof_test.py`, | ||
| 22 tests, total pytest wall time): | ||
|
|
||
| ``` | ||
| backend total | ||
| disabled 5440.96 s (1:30:40) | ||
| concread 5569.46 s (1:32:49) | ||
| s3fifo 4880.70 s (1:21:20) | ||
| ``` | ||
|
|
||
| S3-FIFO finishes 12% sooner than the concread run on the same suite. | ||
|
|
||
| Slowest individual tests from the same run, in seconds: | ||
|
|
||
| ``` | ||
| test concread s3fifo | ||
| test_nestgrps_add[20000-50-50-10-10] 1882.71 1568.82 | ||
| test_nestgrps_add[40000-40-20-10-10] 891.31 744.06 | ||
| test_del_nestgrp[40000-400-100-20-20] 380.16 329.40 | ||
| test_mod_nestgrp[40000-400-100-20-20] 238.34 218.90 | ||
| test_nestgrps_import[40000-400-100-20-20] 196.55 166.05 | ||
| ``` | ||
|
|
||
| The gaps are on the deeply nested-group tests, where memberOf cascades hit | ||
| the cache hardest. | ||
|
|
||
| Hit-path microbenchmark, Zipfian access (`zipf_1`, 50K-entry corpus, | ||
| criterion median of 30 samples): | ||
|
|
||
| ``` | ||
| threads s3fifo concread | ||
| 1 179.81 µs 1.9131 ms | ||
| 16 6.0811 ms 98.733 ms | ||
| 64 24.982 ms 377.34 ms | ||
| ``` | ||
|
|
||
| The hit path never mutates a shared list, so the gap holds up as thread | ||
| count climbs. The VM result above shows the same effect on a real server. | ||
|
|
||
| Major Configuration Options and Enablement | ||
| ------------------------------------------ | ||
|
|
||
| The configuration stays the same. Only the implementation is changed. | ||
|
|
||
| | Attribute | Default | Effect | | ||
| | ---------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- | | ||
| | `nsslapd-ndn-cache-enabled` | `on` | Turns the cache on or off. Restart required. | | ||
| | `nsslapd-ndn-cache-max-size` | `20971520` (20 MB) | Maximum cache bytes. Converted to an entry count using a 168-byte per-entry estimate. Restart required. | | ||
|
|
||
| The cache uses 64 shards, which is enough for the workloads we have seen | ||
| and can be raised in the future if needed. | ||
|
droideck marked this conversation as resolved.
Outdated
|
||
|
|
||
| External Impact | ||
| --------------- | ||
|
|
||
| The existing `normalizedDNcache*` monitor counters are preserved. | ||
| `normalizedDNcachetries` equals hits plus misses, and | ||
| `normalizedDNcachehitratio` is computed from those two as before. | ||
|
|
||
| The `concread` crate is removed from `src/librslapd`, taking out its | ||
| `crossbeam-*` and `smallvec` transitive crates. The S3-FIFO module pulls | ||
| in `hashbrown`, `ahash`, and `parking_lot`. | ||
|
|
||
| Origin | ||
| ------ | ||
|
|
||
| <https://github.com/389ds/389-ds-base/issues/7489> | ||
|
|
||
| Author | ||
| ------ | ||
|
|
||
| spichugi@redhat.com | ||
Uh oh!
There was an error while loading. Please reload this page.