False Sharing Alignment

signa111 pts0 comments

False Sharing Alignment | Monoidal Tranformation<br>Table of ContentsWhat is false sharing<br>What to do<br>BenchmarkDigital Ocean<br>Amazon Web Services, c5d (Skylake)<br>Results<br>Amazon Web Services, c6i (Ice Lake)<br>Apple Silicon M1

Conclusion<br>Reading list<br>Disclaimer

What is false sharing#<br>What is false sharing? When CPUs<br>and their cores read and update atomic variables, special hardware protocols<br>make it correct and efficient, while keeping each core&rsquo;s caches consistent. The<br>coordination doesn&rsquo;t happen per-address: these protocols work on<br>cache-line-sized chunks.<br>What happens when two atomic variables happen to reside on the same cache line?<br>For example:<br>an array of atomics where each atomic is used by a separate thread (e.g. per-thread counters);<br>a queue with two atomic pointers where a writer updates the tail and a<br>reader updates the head. Of course, you declare both pointers in the same<br>struct and they have adjacent addresses!<br>Each update operation makes the cache line dirty and requires coordination<br>between CPUs. The CPUs are essentially playing ping pong with the chunk of<br>memory.<br>What to do#<br>The solution is simple: separate the atomics by making them reside<br>in different cache lines. It is done with some language-dependent alignment<br>directive, though it is not the alignment but the spacing that matters.<br>Rust#[repr(align(N))] on the type declaration (or use a type wrapper)C11_Alignas(N) on type or variable declarationC++11alignas(N) on type, field or variable declarationGCC/Clang__attribute__((aligned(N)))Both Rust Atomics and Locks by Mara Bos and Performance Analysis and Tuning on<br>Modern CPUs by Denis Bakhvalov recommend using cache line size, which is<br>64 bytes for x86_64.<br>However, libraries (crossbeam-utils, Facebook&rsquo;s<br>folly)<br>use 128 bytes here. Why?<br>The reason is that since the Intel Sandy Bridge architecture, the spatial<br>prefetcher may load cache lines in pairs. It doesn&rsquo;t make the<br>effective cache line size equal to 128 bytes, but still has its side-effects.<br>Moreover, this behavior is controlled by a per-core MSR setting called either<br>&ldquo;Adjacent Cache Line Prefetcher Disable&rdquo; or &ldquo;L2 Adjacent Cache Line Prefetcher<br>Disable&rdquo;. Check it before benchmarking! If you can.<br>Benchmark#<br>I tried to reproduce the 128-byte alignment improvement on a real benchmark. It<br>wasn&rsquo;t easy! Just starting 2 threads updating an atomic each is not enough: once<br>the atomics are in the respective CPU caches, no MESI interaction seems to be<br>done. Let&rsquo;s try something more involved: a vector of atomics should do the<br>trick.<br>Let&rsquo;s imitate a classical two-pointer atomic queue: an atomic for head being<br>incremented by reader, and an atomic for tail incremented by writer. Using<br>either #[repr(align(64))] or #[repr(align(128))] in the Rust code below for<br>a wrapper type similar to crossbeam-utils will place the atomics either 64<br>or 128 bytes apart.<br>Single pair of atomics may be not enough to create a pressure to the memory;<br>let&rsquo;s get a vector of records of length SIZE. Each thread of two modifies<br>either add or sub field of each record in loop.<br>The thread body looks bit odd:<br>for _ in 0..(N / SIZE) {<br>for elt in addsub {<br>elt.add.fetch_add(1u64, ORDERING);

You may notice that number of operations is SIZE*floor(N/SIZE) which is not<br>equal to N. It makes comparing different SIZE harder&mldr; until we notice that<br>difference is really negligible, because our SIZE are small and N is large.<br>But the inner loop is very simple, and it makes the benchmark more reliable.<br>You can get the runnable code at<br>https://github.com/monoid/junk/tree/master/false_sharing, but for your<br>convenience, simplified code is below.<br>Source codeuse std::ops::Deref;<br>use std::sync::atomic::{AtomicU64, Ordering};

// Number of steps.<br>const N: usize = 1_000_000_000;<br>const ORDERING: Ordering = Ordering::AcqRel;<br>// The length of vector of AddSubs.<br>const SIZE: usize = 8;

// Uncomment any of these lines.<br>// #[repr(align(64))]<br>// #[repr(align(128))]<br>#[derive(Default, Debug)]<br>struct CachePaddedT> {<br>value: T,

implT> Deref for CachePaddedT> {<br>type Target = T;

fn deref(&self) -> &Self::Target {<br>&self.value

// Two atomics side-by-side separated by cache padding configured above.<br>#[derive(Default, Debug)]<br>struct AddSub {<br>// Updated by the first thread.<br>add: CachePaddedAtomicU64>,<br>// Updated by the second thread.<br>sub: CachePaddedAtomicU64>,

impl AddSub {<br>fn get_value(&self) -> u64 {<br>let add = self.add.load(Ordering::Relaxed);<br>let sub = self.sub.load(Ordering::Relaxed);<br>add.wrapping_sub(sub)

fn main() {<br>let data = Vec::from_iter(std::iter::repeat_with(|| AddSub::default()).take(SIZE));<br>let addsub: Box[AddSub]> = data.into();<br>let addsub = &*Box::leak(addsub);

let t1 = std::thread::spawn(move || {<br>#[cfg(target_arch = "x86_64")]<br>affinity::set_thread_affinity(&[0]).unwrap();

for _ in 0..(N / SIZE) {<br>for elt in addsub {<br>elt.add.fetch_add(1u64, ORDERING);<br>});<br>let t2 = std::thread::spawn(move || {<br>#[cfg(target_arch = "x86_64")]<br>// Not...

size cache atomic addsub rsquo atomics

Related Articles