Isaac Parker

Cache coherence

Some recent work on database internals made me want to strengthen my mental model for concurrent programming primitives. I wrote a thread-safe, lock-free bounded ring buffer entirely by hand (yes, by typing out code). When attempting to optimize the lock-free performance, results were at first confusing, then became clear in a way that sharpened my intuition for these concepts, so I’m documenting my notes here.

This is a single reader, single writer (or SPSC) bounded circular buffer. It uses monotonic read and write pointers, and wraps them around the buffer capacity to identify a slot.

The reader calls ring_buffer.pop() and the writer calls ring_buffer.push(); both operations read both pointer values:

A naive first optimization I tried was cache padding: if we pad the read & writer pointers so that they’re on separate cache lines we should have less cache invalidation between the threads and better performance. I ran benchmarks, and the elapsed time rose roughly 100%, twice the baseline.

I then pursued another idea: because we use monotonic pointers, when the reader and writer read the opposite thread’s indices, they don’t actually need the current value:

The inverse also holds for the writer reading the read pointer.

After making this change, I once again ran benchmarks, and saw no statistically significant change.

I then tried adding back the cache padding between the pointers along with the local caching, and saw a 2x speedup from baseline.

My understanding:

Relative elapsed time:

No cache padding With cache padding
No caching of opposite pointer Baseline ~ 2x
With caching of opposite pointer ~ 1x ~ 0.5x

Cache padding alone did not reduce contention. It only helped once the access pattern let each thread stay mostly on its own cache line.