Cache Lines and False Sharing
Modern CPUs do not transfer individual bytes or 8-byte words between RAM and L1/L2/L3 caches. Instead, cache hardware transfers memory in fixed-size contiguous blocks called cache lines—typically 64 bytes on x86_64 and Linux ARM64, or 128 bytes on Apple Silicon (M1/M2/M3/M4) and IBM POWER.
False sharing occurs when two independent variables (such as worker_0_count: AtomicU64 and worker_1_count: AtomicU64) reside inside the exact same cache line. Even though Thread 0 only modifies worker_0_count and Thread 1 only modifies worker_1_count, the CPU's cache coherency protocol (MESI / MOESI) must invalidate and bounce the entire 64/128-byte cache line back and forth between the two cores on every write, degrading throughput by up to 10x–50x!
False Sharing (Both Atomics Sit in the Same 64-Byte Cache Line!):
+-------------------------------------------------------------------+
| Cache Line 0 (64 Bytes) |
| [ worker_0: AtomicU64 (8B) ] [ worker_1: AtomicU64 (8B) ] [ 48B ] |
+-------------------------------------------------------------------+
^ ^
| Core 0 writes | Core 1 writes
+--- Cache Line Ping-Pongs! ---+ (Massive L1 invalidation stalls)
Cache-Line Padded (`#[repr(align(64))]` or `#[repr(align(128))]`):
+-----------------------------------+ +-----------------------------------+
| Cache Line 0 (64B / 128B) | | Cache Line 1 (64B / 128B) |
| [ worker_0: AtomicU64 ] + padding | | [ worker_1: AtomicU64 ] + padding |
+-----------------------------------+ +-----------------------------------+
^ ^
| Exclusive to Core 0 L1 Cache | Exclusive to Core 1 L1 Cache
Eliminating False Sharing with #[repr(align(...))]
Wrapping frequently mutated per-thread or per-shard atomics inside a #[repr(align(128))] (or #[repr(align(64))]) wrapper struct guarantees that each instance starts on its own cache line boundary and is padded to a full cache line multiple:
use std::mem::{align_of, size_of};
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
/// Aligns and pads `T` to 128 bytes (covering both 64B x86_64 and 128B Apple Silicon/ARM64 cache lines).
#[repr(align(128))]
#[derive(Debug, Default)]
pub struct CachePadded<T> {
pub value: T,
}
impl<T> CachePadded<T> {
pub const fn new(value: T) -> Self {
Self { value }
}
}
impl<T> Deref for CachePadded<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl<T> DerefMut for CachePadded<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.value
}
}
pub struct ShardedCounter {
shards: [CachePadded<AtomicU64>; 4],
}
impl ShardedCounter {
pub const fn new() -> Self {
Self {
shards: [
CachePadded::new(AtomicU64::new(0)),
CachePadded::new(AtomicU64::new(0)),
CachePadded::new(AtomicU64::new(0)),
CachePadded::new(AtomicU64::new(0)),
],
}
}
pub fn add(&self, shard_idx: usize, delta: u64) {
self.shards[shard_idx & 3].fetch_add(delta, Ordering::Relaxed);
}
pub fn total(&self) -> u64 {
self.shards
.iter()
.map(|s| s.load(Ordering::Relaxed))
.sum()
}
}
fn main() {
println!(
"CachePadded<AtomicU64>: size={}B, align={}B",
size_of::<CachePadded<AtomicU64>>(),
align_of::<CachePadded<AtomicU64>>()
);
let counter = ShardedCounter::new();
thread::scope(|s| {
for worker_id in 0..4 {
let counter_ref = &counter;
s.spawn(move || {
for _ in 0..1_000 {
counter_ref.add(worker_id, 1);
}
});
}
});
assert_eq!(counter.total(), 4_000);
println!("Total across 4 cache-isolated shards: {}", counter.total());
}
CachePadded<AtomicU64>: size=128B, align=128B
Total across 4 cache-isolated shards: 4000
Grouping Hot Read-Only Fields vs Contended Mutable Fields
| Field Access Pattern | Optimal Struct Layout Strategy |
|---|---|
Read-mostly configuration fields (capacity, mask, hash_seed) |
Pack together on the same cache line so a single L1 cache fetch loads all of them at once (true sharing). |
Frequently written atomic counters/locks (head_idx, tail_idx) |
Separate onto distinct cache lines with CachePadded<T> so producer and consumer threads never invalidate each other's L1 cache line. |