Speaker notes: - Set the stage: common wisdom says "mutexes are slow" - We'll see that the answer is more nuanced - Understanding WHY is the key takeaway
Speaker notes: - These statements sound reasonable - We'll test them empirically - Spoiler: reality is more complicated
Speaker notes: - Simplest possible concurrent operation - Assume that there are _no_ writes for now - Vary: thread count, read/write ratio, critical section length ("critical section" = the code between lock() and unlock()) - Measure: operations per second
Speaker notes: - 1 thread: ~287M ops/sec (uncontended!) - 2+ threads: drops to ~21-29M ops/sec - That's a 10x+ SLOWDOWN just from adding a second thread - Not just "doesn't scale" - actively gets slower
Speaker notes: - Still the same declining trend - "Multiple readers can hold the lock simultaneously" - If 99% reads, RwLock should help... right? - RwLock reads scale slightly better than mutex - But still plateau quickly - something else is going on
Speaker notes: - CPUs don't cache individual bytes - they cache 64-byte "lines" - If Core 0 and Core 1 both have the same cache line cached - And Core 0 wants to write to it - We need a protocol to keep the caches consistent - That protocol is MESI
Speaker notes: - MESI tracks whether a cache line is shared across cores - If Core 0 modifies a cache line that Core 1 has cached, Core 1's copy is now stale. We must invalidate it. - To write when Shared: must invalidate ALL other copies - Wait for acknowledgment from every core - THEN you can write - This is the key insight: writes to shared data cause expensive cross-core communication
Speaker notes: - fetch_add is a WRITE operation - Even though you're "just reading" the data, you're writing to the lock - Every reader fights over the same cache line containing reader_count
Speaker notes: - Walk through each step - Each read causes **two** cache line transfers at high contention - This is "cache line ping-pong" - Each transition costs ~30+ ns - Technically: bus snooping
Speaker notes: - An uncontended mutex is ~17 ns - cheap! - Contention is the problem, not the lock itself - Both Mutex and RwLock do 2 atomics per lock/unlock cycle - Mutex: only one thread holds lock → unlock is uncontended (you have exclusive access to lock state) - RwLock: multiple readers hold lock simultaneously → all contend on reader_count for both acquire AND release - While you hold a read lock, other readers are still doing fetch_add/fetch_sub on the same cache line - This means RwLock readers experience MORE contention than Mutex users for short critical sections
Speaker notes: - For short critical sections: all time is spent waiting for the M - For long critical sections: multiple concurrent read locks still help over Mutex! - Our counter benchmark is worst case: minimal work - This is why real-world code often doesn't show these problems - But high-contention hot paths do
Speaker notes: - We've seen the problem: cache line ping-pong via MESI - Caused by every reader writing to the same cache line (reader_count) - fetch_add is an atomic read-modify-write operation - Can we make each reader write only to its OWN cache line (not shared)
Speaker notes: - Readers access one copy, writer modifies the other - Swap atomically when ready
Speaker notes: - How do we know no readers are left over in the left copy? - Each reader has per-thread counter (on its OWN cache line), which they tick each time they read (through the pointer) - Readers DO write (to their counter/epoch), but not to SHARED state - Writer has to wait until all epochs have "ticked" at least once, indicating that the reader must have observed the pointer swap. - No cache ping-pong between readers, only with writer! - Lots of nuances, like idle readers and applying updates to both copies - Reads become "wait-free" = readers are guaranteed to complete, never block
Speaker notes: - RwLock: ~19-60M reads/sec, doesn't scale (actually gets worse!) - left-right: 206M at 1 reader → 2.86B at 14 readers - At 14 readers: left-right is ~150x faster than RwLock
Speaker notes: - Real workloads have both reads and writes - left-right advantage grows with read/write ratio - At 9:1 (90% reads), left-right is dramatically faster - But: left-right has higher write cost (must update both copies) - Recall: reads contend on reader_count, but writes don't (writers just wait for reader_count → 0) - **9R:1W**: 90% of operations contend on reader_count - **3R:1W**: 75% of operations contend on reader_count - **Counterintuitive observation:** RwLock gets *worse* the more readers there are, because reads (not writes) cause the contention
Speaker notes: - Perfect linear scaling 1-3 cores - Catastrophic drop at exactly 4 cores - Not CCD crossing (that's at core 8 on my 7950X3D) - Not SMT (using physical cores only)
Speaker notes: - If epochs allocated adjacently, they share cache lines → ping-pong - Why 4 specifically? Allocator/slab layout quirks - Now scales linearly: 14 readers = 14x throughput - Contributed upstream (to myself) - "Lock-free" doesn't mean "contention-free" - This is why it's important to know about this stuff!
Speaker notes: - Not linearizable - this matters! - "Linearizable" = operations appear instant and globally ordered (if you write, then read, you see your write) - Instead, left-right is "eventual" = you might see stale data temporarily - Memory doubles (two full copies) - Good for: config, caches, lookup tables - Bad for: financial transactions, anything needing consistency
Speaker notes: - There's no "best" - only appropriate for your workload - left-right: 1000:1+ read/write, staleness OK - Mutex: simple, works, often good enough Aside: Sharded reader-writer locks (drwlock, BRAVO) exist too - Similar cache behavior to left-right (per-thread counters) - But readers can still block if a writer is present - Linearizable, unlike left-right
Speaker notes: - Most code: just use Mutex - Longer read sections: reader-writer lock - Hot path with many readers: consider alternatives - Always measure before optimizing
Speaker notes: - std::atomic operations still cause cache line invalidation - You're trading lock overhead for atomic overhead - Real scalability comes from avoiding shared writes entirely
Speaker notes: - Share the repo so people can run benchmarks on their own hardware - Results vary by CPU architecture, core count, etc.