perf: take the keyed table read lock on hits and duplicate waiters - #205
perf: take the keyed table read lock on hits and duplicate waiters#205aisk wants to merge 2 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@tisonkun This PR trades an auto trait for the read fast path: |
|
I can reproduce a stable macOS/Apple Silicon regression under sustained read contention. Environment: Apple M4 Max (14 cores), macOS/Darwin 25.3.0, rustc 1.96.0, divan 0.1.21. I ran head → base → head to control for ordering: cargo +1.96.0 bench --workspace --all-features --bench '*' -- \
contended_get_hit_same_key --threads 8 --sample-count 100 --sample-size 10000
Replacing the filter with This does not contradict the Windows result, but I think the optimization needs Linux/macOS coverage (or a different locking choice) before merging. |
|
Fair enough. If I were to test this today, the only option would be WSL2, and that would be inaccurate anyway. Its virtualization can distort a lock contention microbenchmark by itself, and my machine is busy updating Honkai: Star Rail on top of that. Also this Claude Code session has already burned through a fair chunk of tokens. If no better approach lands here by tomorrow, I'll run the same methodology on a real Linux environment and report back. |
|
Yep. A result of a Linux environment would be helpful since I typically deploy this code on a Linux server, although I'm using a MacBook Pro for development.
Enjoy 4.5! I haven't checked it out today. |
|
Adding a WSL2 data point. I benchmarked base
For each row, I ran base and PR three times in an interleaved order at t=8 and report the median of the three run medians. The hit benchmarks used fixed
Sharing these as an additional WSL2 result; the default adaptive runs were much noisier on this machine as well. |
An initialized hit in compute previously cloned the entry Arc under the read lock and read the value only after releasing it, costing two atomic RMWs on the entry's shared reference count on the hottest path. Resolve the hit to its value while the read lock is still held, so contended hits never touch the entry reference count. The cleanup protocol is unchanged: entry Arcs are still cloned only under the table lock, and only initialized values escape it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Bare-metal Linux results, a follow-up optimization they motivated (pushed as 6c55ca4), and a comparison with #225. Environment: AMD Ryzen 7 5700X (8C/16T, same CPU as the Windows numbers above), NixOS 26.11, kernel 7.2.0, glibc 2.42, rustc 1.96.0. Same methodology as the M4 Max run: interleaved head → base, 3 rounds each, This PR (dba8ecd) vs base (c461305), divan medians, t=8
~1.8× on Follow-up: 6c55ca4On both Linux and macOS,
Platform split
vs #225 (its t=8 numbers, relative to baseline)
Overall the sharded direction looks stronger; happy to help port the reader-path pieces over, or proceed however the maintainers prefer. |
Summary
This implements step 2 of #200, using the contention baseline from #202. The keyed table shared by
OnceMapandsingleflight::Groupmoves from a singleMutexto an internalRwLock. Lookups that do not mutate the table —OnceMap::get,computeon an already-initialized entry, and a singleflight call joining an in-flight leader as a duplicate waiter — now take the read lock and run concurrently. A miss falls back to the write lock, whereget_or_insertdouble-checks before inserting. Removals and the cleanup guards keep running under the write lock.The baseline showed that read paths degrade worst under contention (about 37–49x at 8 threads for initialized hits) while write-heavy singleflight churn degrades least, so a read fast path targets exactly the measured pain. Unlike sharding, it also helps the same-key hotspot, and it leaves the
HashTableand hasher ownership untouched. Sharding was evaluated and deferred: it cannot help same-key traffic, its benefit lands on the write-churn scenarios whose measurements are the noisiest, and it can still be layered on top of this change later if real workloads warrant it.Correctness
The
Arc::strong_count == 2cleanup protocol is preserved. EntryArcs are only cloned out of the table while holding the table lock, and the count check runs under the write lock, which excludes all readers, so the check can never miss a concurrent new owner. Callers that release their clone outside the lock only do so after the cell is initialized or after the entry left the table, which the existing!initialized()andArc::ptr_eqguards already reject. No lock is held across an.await, and the read guard is always released before the write lock is requested.New regression tests pin the races this design touches: a cancelled duplicate waiter must not remove an in-flight or initialized entry, and an abandoned first caller must not remove a replacement installed for the same key (via
discardforOnceMapandforgetforGroup).Breaking change:
Syncnow requires the hasher to beSyncstd::sync::Mutex<T>isSyncwhenT: Send, butRwLock<T>requiresT: Sync, soOnceMapandGroupare nowSynconly when the hasherSis alsoSync. This is inherent to any design where concurrent readers hash keys through a shared&S, not an artifact of the implementation. The defaultRandomStateand common third-party hashers are allSync; a hasher that isSendbut notSyncstill yields aSend(but no longerSync) map. This is recorded in the CHANGELOG, and a new trait test pins the semantics with aSend + !Synchasher. Flagging it explicitly since #200 lists expected auto traits as a compatibility constraint — if this trade-off is unacceptable, the read fast path cannot be kept.Results
Test environment: AMD Ryzen 7 5700X (8 cores / 16 threads), Windows 11 Pro, rustc 1.96.0, divan 0.1.21,
cargo bench --bench benchmarks -- contended. As in #202, t=8 is the meaningful contention point on this machine and t=32 medians are distorted by oversubscription.Median time per operation at t=8, single
Mutexbaseline vs this change:once_map::contended_get_hit_same_keyonce_map::contended_get_hit_disjoint(1024)once_map::contended_compute_hit_same_keyonce_map::contended_compute_hit_disjoint(1024)singleflight::contended_work_same_keyThe churn rows need the caveat spelled out: re-running the same build back to back swings their t=8 medians by up to 5x (the baseline itself measured 600 ns in one run and 1.78 µs in another for
contended_work_disjoint_churn), and both builds land in the same band, so no regression is distinguishable from noise there. The stable cost is single-threaded: a miss now pays a read-lock probe before the write lock, which shows up as roughly +10% at t=1 on the miss/churn paths (for examplecontended_compute_miss_churn113 ns → 127 ns). Initialized hits at t=1 are unchanged (getstays at 13 ns). The coalesced benches stay flat across thread counts, dominated by the leader's in-flight window as intended.Full divan output (this PR)
The baseline numbers are the ones reported in #202, measured on the same machine.
🤖 Generated with Claude Code