Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/389ds/design/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ If you are adding a new design document, use the [template](design-template.html
- [Replication Monitoring With Ansible](ansible-replication-monitoring-design.html)


## 389 Directory Server 3.3

- [Normalized DN Cache with sharded S3-FIFO](normalized-dn-cache-sharded-s3fifo.md)


## 389 Directory Server 3.1

- [Session Tracking Control client - replication](session-identifier-clients.html)
Expand Down
224 changes: 224 additions & 0 deletions docs/389ds/design/normalized-dn-cache-sharded-s3fifo.md
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
Comment thread
droideck marked this conversation as resolved.
Outdated
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.

Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Comment thread
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
Comment thread
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
Comment thread
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
Comment thread
droideck marked this conversation as resolved.
Comment thread
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
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 parking_lot::RwLock, not an exclusive mutex. It is still not a zero-write read path though: the hit/miss stats are per-shard AtomicU64 counters with relaxed updates (we don't need great precision there), and the per-entry AtomicU8 freq counter only CASes until it saturates (which is great for the hot DNs). I think that is a reasonable trade for NDN
WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

There are some other things I want to suggest you can try, just for comparison.

  1. set_reader_quiesce to false and spawn a dedicated quiesce thread.
  2. set_look_back_limit to 8 (decreased from 32)

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.

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.

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).

Hits take the shared side of a per-shard parking_lot::RwLock, not an exclusive mutex.

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.

It is still not a zero-write read path though: the hit/miss stats are per-shard AtomicU64 counters with relaxed updates (we don't need great precision there), and the per-entry AtomicU8 freq counter only CASes until it saturates (which is great for the hot DNs). I think that is a reasonable trade for NDN WDYT?

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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I tried -- set_reader_quiesce(false) and a dedicated quiesce thread, look_back_limit = 8.
Plus sampling, yeah, I also thought of this before but wasn't sure for the first iteration. I did a 1-in-10 stats version and it's better, thanks!
And beside that, I additionally tuned my S3-FIFO implementation as I found a couple of issues after more benchmarks (but we'll discuss the Rust details later, I think)

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 memberof_get_groups_r and internal searches; a live modify first walks the nested member graph and then triggers that recompute for each affected entry. The DN work is spread around: the core server normalizes every incoming DN-syntax mod value before the plugin even runs, and the walk builds fresh Slapi_DNs and (member=...) filter values along, each one NDN lookup. So yeah, the cascade is quite representative for the NDN-heavy ops. Plain searches do not hit the same path in the same way.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plus sampling, yeah, I also thought of this before but wasn't sure for the first iteration. I did a 1-in-10 stats version and it's better, thanks!

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.

On memberOf, IIUC the recompute path goes through memberof_get_groups_r and internal searches; a live modify first walks the nested member graph and then triggers that recompute for each affected entry.

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
Comment thread
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.
Comment thread
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