Alignment and Performance
Alignment and performance are directly coupled in modern CPU architectures because memory subsystems transfer data in fixed-size cache lines (typically 64 bytes on x86_64 and 64 or 128 bytes on AArch64) and execute wide SIMD instructions that operate on 16-, 32-, or 64-byte aligned registers.
Hardware Effects of Alignment
- Single-instruction aligned loads: When an
8-byteu64lives at an address divisible by8, it is guaranteed never to straddle two cache lines or two virtual memory pages. The CPU fetches it in a single cache transaction. - Cache-line splits on unaligned accesses: An unaligned
u64(#[repr(packed)]orptr.read_unaligned()) that crosses a64-byte boundary forces the CPU load-store unit to issue two separate cache-line reads and shift/merge the bytes in hardware. On strict-alignment architectures (such as Cortex-M0 or older ARM targets), unaligned hardware load instructions trap with an alignment fault. - Atomics requirement: Hardware atomic instructions (
AtomicU64,AtomicUsize) require natural alignment; unaligned atomic operations either fault in hardware or fall back to global spinlocks.
False Sharing and Cache-Line Alignment (#[repr(align(64))])
When two threads concurrently modify independent variables that happen to sit on the same 64-byte cache line, the CPU cache-coherency protocol (MESI/MOESI) repeatedly invalidates the entire cache line between cores. This performance degradation is called false sharing.
Raising a per-thread state struct's alignment with #[repr(align(64))] pads its size to a multiple of 64 bytes, guaranteeing that adjacent elements in an array [PaddedWorker; N] occupy distinct cache lines.
Without #[repr(align(64))] — False Sharing (Both counters in one 64B cache line):
|<-------------------------- 64-Byte Cache Line -------------------------->|
+------------------+------------------+------------------------------------+
| Core 0: count_a | Core 1: count_b | other stack/heap data |
| (8 bytes) | (8 bytes) | (48 bytes) |
+------------------+------------------+------------------------------------+
With #[repr(align(64))] — Isolated Cache Lines:
|<-------------------------- Cache Line 0 (64B) -------------------------->|
+------------------+-------------------------------------------------------+
| Core 0: count_a | 56 bytes padding |
+------------------+-------------------------------------------------------+
|<-------------------------- Cache Line 1 (64B) -------------------------->|
+------------------+-------------------------------------------------------+
| Core 1: count_b | 56 bytes padding |
+------------------+-------------------------------------------------------+
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
#[repr(align(64))]
struct CachePaddedAtomic {
value: AtomicU64,
}
fn main() {
let counters = [
CachePaddedAtomic {
value: AtomicU64::new(0),
},
CachePaddedAtomic {
value: AtomicU64::new(0),
},
];
println!(
"size of CachePaddedAtomic: {} bytes",
size_of::<CachePaddedAtomic>()
);
println!(
"align of CachePaddedAtomic: {} bytes",
align_of::<CachePaddedAtomic>()
);
println!(
"distance between counters: {} bytes",
(&counters[1] as *const _ as usize) - (&counters[0] as *const _ as usize)
);
thread::scope(|s| {
for counter in &counters {
s.spawn(|| {
for _ in 0..10_000 {
counter.value.fetch_add(1, Ordering::Relaxed);
}
});
}
});
println!(
"totals: [{}, {}]",
counters[0].value.load(Ordering::Relaxed),
counters[1].value.load(Ordering::Relaxed)
);
}
size of CachePaddedAtomic: 64 bytes
align of CachePaddedAtomic: 64 bytes
distance between counters: 64 bytes
totals: [10000, 10000]
SIMD Buffer Alignment
Vectorized loops (SSE2 16-byte, AVX2 32-byte, AVX-512 64-byte) achieve peak throughput when buffers are aligned to the vector register width. Wrapping an array in a #[repr(C, align(32))] struct guarantees 32-byte alignment on both the stack and the heap.
#[repr(C, align(32))]
struct SimdBlock {
lanes: [f32; 8],
}
fn add_blocks(a: &SimdBlock, b: &SimdBlock) -> SimdBlock {
let mut out = SimdBlock { lanes: [0.0; 8] };
for i in 0..8 {
out.lanes[i] = a.lanes[i] + b.lanes[i];
}
out
}
fn main() {
let a = SimdBlock {
lanes: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
};
let b = SimdBlock {
lanes: [10.0; 8],
};
let sum = add_blocks(&a, &b);
println!("SimdBlock size: {}, align: {}", size_of::<SimdBlock>(), align_of::<SimdBlock>());
println!("address % 32 == {}", (&sum as *const SimdBlock as usize) % 32);
println!("sum.lanes: {:?}", sum.lanes);
}
SimdBlock size: 32, align: 32
address % 32 == 0
sum.lanes: [11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0]
Array-of-Structs (AoS) vs. Struct-of-Arrays (SoA)
When a hot loop only accesses one or two fields of a large struct, an Array of Structs (AoS, Vec<Particle>) pulls unused cold fields and padding bytes into every 64-byte cache line. Splitting fields into a Struct of Arrays (SoA) packs homogeneous values contiguously, maximizing cache-line utilization and enabling automatic SIMD vectorization.
| Layout Pattern | Structure | Cache Utilization on Single-Field Scan |
|---|---|---|
| Array of Structs (AoS) | struct Particle { pos: [f32; 3], vel: [f32; 3], name_id: u64 } |
Only 12 of every 32 bytes read (37.5%) are pos |
| Struct of Arrays (SoA) | struct Particles { pos_x: Vec<f32>, pos_y: Vec<f32>, ... } |
100% of every 64-byte cache line holds active f32 coordinates |