perf: shard the once table behind read-write locks - #228
Conversation
Combines the sharded table from the previous commit with the shared reader path from the whole-table RwLock approach: hits and duplicate waiters probe their shard under the read lock, initialized compute hits resolve to the value without touching the entry reference count, and only an absent key takes the shard write lock to insert. Co-authored-by: Huliiiiii <huliiiiii.nya@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
I think a more significant comparision column is current main 🤣 |
|
In my tests, |
I tend not to use |
|
@Huliiiiii My main purpose here was to run the benchmarks for verification. I support merging #225 first, or continuing this work directly on #225. Happy to close this PR either way. |
|
Fair point. Added a current-main (cbe49ea) column to the table, from a fresh interleaved run: 2.7x on same-key compute hits, 1.7x on same-key get hits, 10-13x on disjoint/churn/mixed, parity on coalesced. |
|
Feel free to continue any of your works @Huliiiiii @aisk However, I found this direction goes too far in complexity for a case-by-case perf benefit. I tend to keep the code simple and only chase for perf when it is a bottleneck or there is a clear neat win. Sorry to catch too much attention here. |
|
Agreed on keeping it simple. I support advancing #225: simplest of the three, 8-12x on miss/churn/disjoint on Linux, with same-key hits ~15% slower than main as the only cost. One note: the |
Builds directly on #225 — its commit is included unchanged, and @Huliiiiii is co-author on the follow-up commit. This keeps the 64-shard table and replaces the per-shard
Mutexwithstd::sync::RwLock, porting the reader path from #205: hits and duplicate waiters probe their shard under the read lock, initializedcomputehits resolve to the value without touching the entry reference count, and only an absent key takes the shard write lock.Benchmarks
Bare-metal Linux, AMD Ryzen 7 5700X (8C/16T), NixOS 26.11, kernel 7.2.0, rustc 1.96.0, t=8,
--sample-count 100 --sample-size 10000, interleaved rounds ×3, medians of round-medians. Multipliers are speedups relative to main (cbe49ea); values below 1× are regressions.compute_hit_same_key(read)get_hit_same_key(read)compute_hit_disjoint(64/1024, read)get_hit_disjoint(64/1024, read)compute_miss_churn(64/1024, write)compute_mixed(64/1024, read+write)work_same_key(write)work_disjoint_churn(write)The main and "this PR" columns are from one interleaved run; the #225 and #205 columns are from an interleaved run earlier the same day on the same machine and method.
* the same-key deltas between this PR and #205 are within this machine's bimodal variance (both sides alternate between ~150 ns and ~270 ns modes across runs).
This PR is the only variant of the three that regresses no scenario. The pattern: the reader path pays off on the read-heavy scenarios (2–13× over main, 2–3× over #225 on same-key hits), costs 2–5% vs #225 on the write-heavy ones (an exclusive
RwLockacquisition is slightly pricier than aMutex), and the wait-bound scenarios are indifferent to the locking scheme.--- config: xyChart: height: 900 themeVariables: xyChart: plotColorPalette: "#59a14f, #4e79a7, #f28e2b, #9e9e9e" --- xychart-beta horizontal title "Speedup over main, t=8 (main = 1x, longer is better)" x-axis ["R cHitSame base", "R cHitSame 205", "R cHitSame 225", "R cHitSame 228", "R gHitSame base", "R gHitSame 205", "R gHitSame 225", "R gHitSame 228", "R cDisj base", "R cDisj 205", "R cDisj 225", "R cDisj 228", "R gDisj base", "R gDisj 205", "R gDisj 225", "R gDisj 228", "RW mixed base", "RW mixed 205", "RW mixed 225", "RW mixed 228", "W churn base", "W churn 205", "W churn 225", "W churn 228", "W workSame base", "W workSame 205", "W workSame 225", "W workSame 228", "W workChurn base", "W workChurn 205", "W workChurn 225", "W workChurn 228", "wait coalesced base", "wait coalesced 205", "wait coalesced 225", "wait coalesced 228"] y-axis "speedup (x)" 0 --> 14 bar [0, 0, 0, 2.7, 0, 0, 0, 1.7, 0, 0, 0, 12.7, 0, 0, 0, 13.4, 0, 0, 0, 12.0, 0, 0, 0, 11.6, 0, 0, 0, 1.0, 0, 0, 0, 11.9, 0, 0, 0, 1.0] bar [0, 0, 0.86, 0, 0, 0, 0.86, 0, 0, 0, 7.9, 0, 0, 0, 7.7, 0, 0, 0, 11.2, 0, 0, 0, 11.9, 0, 0, 0, 1.1, 0, 0, 0, 12.1, 0, 0, 0, 1.0, 0] bar [0, 4.2, 0, 0, 0, 1.5, 0, 0, 0, 4.7, 0, 0, 0, 1.3, 0, 0, 0, 0.77, 0, 0, 0, 0.69, 0, 0, 0, 0.94, 0, 0, 0, 0.76, 0, 0, 0, 1.0, 0, 0] bar [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]Colors: gray = main (baseline, 1x) · orange = #205 (whole-table RwLock) · blue = #225 (Mutex shards) · green = this PR (RwLock shards, combo). Label prefixes: R = read-heavy, W = write-heavy, RW = mixed, wait = wait-bound. Disjoint/churn/mixed use the 64-entry variant.
Other changes
cleanup_abandoned_entrycomment with why thestrong_count == 2check stays sound when entries are cloned under read locks.S: Syncbreaking change — which applies to any design that hashes outside the exclusive lock, perf: shardedOnceTable#225 included.Which PR to merge
My honest ranking:
OnceTable#225, and the extra complexity over perf: shardedOnceTable#225 is the read-probe → write-insert double check plus a slightly longer cleanup argument.OnceTable#225 is the right choice if the maintainers prefer the minimal locking discipline (one exclusive lock per operation). Its only cost is same-key hits, and the reader path here can be layered on later.One honest caveat:
std::sync::RwLockreaders are expensive under sustained same-key contention on macOS (see the M4 Max numbers in #205), and sharding does not spread same-key traffic, so that caveat carries over to this PR. Mutex shards (#225) do not have it.🤖 Generated with Claude Code