Understanding Cache Lines
Understanding cache lines is the cornerstone of high-performance programming (e.g., database kernels). If you ignore them, even an algorithmically optimal program can suffer a 10x or even 100x performance degradation due to frequent cache misses.
Why Cache Lines Exist
The CPU accesses main memory far more slowly than it accesses its caches. To bridge this gap, the CPU does not read a single byte at a time from memory — instead it reads a whole contiguous block of data into the cache in one go. The smallest unit of this batch read is called a cache line, typically 64 bytes in size.
You can check the cache line size with:
postgres@slpc:~$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64
Or programmatically:
#include<unistd.h>
long cache_line_size = sysconf(_SC_LEVEL1_DCACHE_LINESIZE);
When you access a 4-byte integer at memory address 0x1000, the CPU loads the entire 64-byte block from 0x1000 to 0x103F into the L1 cache. Subsequent accesses to 0x1004, 0x1008, etc. are cache hits — extremely fast. This is the hardware implementation of spatial locality.
If L1 misses, the CPU must wait for the entire 64-byte cache line to be loaded from the next level of cache (L2/L3) or from main memory (DRAM).
Checking CPU Cache Sizes
Use lscpu:
postgres@slpc:~$ lscpu | grep -i cache
L1d cache: 384 KiB (8 instances) # L1 data cache
L1i cache: 256 KiB (8 instances) # L1 instruction cache
L2 cache: 10 MiB (8 instances) # L2 cache
L3 cache: 36 MiB (2 instances) # L3 cache
# Check CPU0's L1 cache size
postgres@slpc:~$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
48K
The Cost of Cache Misses
Here we benchmark array traversal versus linked list traversal. Both have the same O(N) algorithmic complexity, but the array’s higher cache hit rate yields significantly better performance.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <stdint.h>
#define ARRAY_SIZE 1000000
#define CACHE_LINE_SIZE 64
// Array structure
typedef struct {
int* data;
size_t size;
} Array;
Array* create_array(size_t size) {
Array* arr = (Array*)malloc(sizeof(Array));
arr->size = size;
arr->data = (int*)malloc(size * sizeof(int));
for (size_t i = 0; i < size; i++) {
arr->data[i] = i % 1000;
}
return arr;
}
void free_array(Array* arr) {
free(arr->data);
free(arr);
}
long long traverse_array(Array* arr) {
long long sum = 0;
for (size_t i = 0; i < arr->size; i++) {
sum += arr->data[i];
}
return sum;
}
// Linked list node
typedef struct Node {
int data;
struct Node* next;
} Node;
Node* create_linked_list(size_t size) {
if (size == 0) return NULL;
Node* head = (Node*)malloc(sizeof(Node));
head->data = 0;
head->next = NULL;
Node* current = head;
for (size_t i = 1; i < size; i++) {
current->next = (Node*)malloc(sizeof(Node));
current = current->next;
current->data = i % 1000;
current->next = NULL;
}
return head;
}
void free_linked_list(Node* head) {
while (head != NULL) {
Node* temp = head;
head = head->next;
free(temp);
}
}
long long traverse_linked_list(Node* head) {
long long sum = 0;
Node* current = head;
while (current != NULL) {
sum += current->data;
current = current->next;
}
return sum;
}
typedef long long (*TraverseFunc)(void*);
void benchmark(const char* name, TraverseFunc func, void* data, int iterations) {
clock_t start = clock();
long long result = 0;
for (int i = 0; i < iterations; i++) {
result += func(data);
}
clock_t end = clock();
double time_ms = (double)(end - start) / CLOCKS_PER_SEC * 1000;
printf("%-30s: %8.3f ms (sum=%lld)\n", name, time_ms, result);
}
int main() {
printf("╔════════════════════════════════════════════════════════╗\n");
printf("║ Array vs List: Cache Performance Benchmark ║\n");
printf("╚════════════════════════════════════════════════════════╝\n\n");
srand(time(NULL));
printf("Test size: %d elements\n\n", ARRAY_SIZE);
printf("Creating data structures...\n");
Array* array = create_array(ARRAY_SIZE);
Node* linked_list = create_linked_list(ARRAY_SIZE);
printf("✓ Data structures created\n\n");
int iterations = 100;
printf("=== Sequential Traversal Benchmark ===\n");
benchmark("Array (contiguous memory)", (TraverseFunc)traverse_array, array, iterations);
benchmark("List (scattered allocation)", (TraverseFunc)traverse_linked_list, linked_list, iterations);
printf("\n");
printf("Cleaning up...\n");
free_array(array);
free_linked_list(linked_list);
printf("✓ Cleanup complete\n\n");
return 0;
}
Results:
╔════════════════════════════════════════════════════════╗
║ Array vs List: Cache Performance Benchmark ║
╚════════════════════════════════════════════════════════╝
Test size: 1000000 elements
Creating data structures...
✓ Data structures created
=== Sequential Traversal Benchmark ===
Array (contiguous memory) : 28.274 ms (sum=49950000000)
List (scattered allocation): 340.064 ms (sum=49950000000)
Cleaning up...
✓ Cleanup complete
Over 10x difference — the root cause is that pointer chasing in the linked list leads to massive cache misses, while the array’s contiguous layout yields a much higher cache hit rate.
You can use perf to measure cache references and misses:
# Count cache references and misses during program execution
perf stat -e cache-references,cache-misses ./your_program
If a VM lacks access to physical hardware performance counters, use valgrind’s cachegrind tool to simulate cache behavior:
root@slpc:~# valgrind --tool=cachegrind --cache-sim=yes ./array
# ...
==10443== D refs: 101,055,817 (100,040,083 rd + 1,015,734 wr)
==10443== D1 misses: 6,314,563 ( 6,251,637 rd + 62,926 wr)
==10443== D1 miss rate: 6.2%
root@slpc:~# valgrind --tool=cachegrind --cache-sim=yes ./list
# ...
==11420== D refs: 306,061,821 (270,043,788 rd + 36,018,033 wr)
==11420== D1 misses: 51,016,462 ( 50,515,889 rd + 500,573 wr)
==11420== D1 miss rate: 16.7%
Comparing the two:
==10443== D1 miss rate: 6.2% # array D1 miss rate
==11420== D1 miss rate: 16.7% # linked list D1 miss rate
The MESI Protocol
MESI is the core mechanism for maintaining cache coherence in modern multi-core CPUs. Each core has its own private L1/L2 caches, while L3 is shared. This creates a coherence problem, solved by MESI, which tags each cache line with one of four states:
| State | Name | Meaning (plain-language) | Coherence |
|---|---|---|---|
| M | Modified | ”Dirty data, I own it.” Data has been modified and differs from main memory. Only this core has it. | Out of sync |
| E | Exclusive | ”Clean data, I own it.” Matches main memory, and only this core holds it. Can be modified freely. | In sync |
| S | Shared | ”Clean data, we all have it.” Matches main memory, and other cores may also have cached it. Must invalidate others before writing. | In sync |
| I | Invalid | ”Garbage.” Data is stale (someone else modified it). Cannot be used — must re-read. | Invalid |
MESI relies on bus snooping. Think of the system bus as a broadcast channel:
- Action: When core A wants to read or write memory, it signals on the bus.
- Snoop: Cores B, C, D all “listen” on this channel.
- Response: If core B sees core A about to modify data that B also holds (in S state), B marks its copy as I (Invalid).
Example: variable x = 10 in memory, cores A and B both operate on it.
Phase 1: Initial Read (E state)
- Action: Core A reads x.
- Result: The bus detects no other core has cached x.
- State: Core A’s cache line → E (Exclusive).
- Meaning: A has the latest data, uniquely, and it is clean.
Phase 2: Shared Read (S state)
- Action: Core B also reads x.
- Process: Core B issues a read request. Core A snoops this and says “I have that data too.” Core A tells the bus: “I’ve got it, here’s a copy.”
- State: Both core A and core B’s cache lines → S (Shared).
- Meaning: Everyone has the data; no one may modify it arbitrarily.
Phase 3: Write (S → M state)
- Action: Core A wants to execute x = 20.
- Process: Core A broadcasts an invalidation request: “I’m modifying x — invalidate your copies!” Core B snoops this and immediately marks its x as I (Invalid). Core A, on confirmation, modifies its cached data.
- State: Core A → M (Modified), Core B → I (Invalid).
- Meaning: Core A now has the latest data (20), but main memory still holds the old value (10). The write-back to memory happens only when core A evicts this cache line.
False Sharing
False sharing is a performance killer in multi-core programming, caused by the MESI protocol.
The scenario:
- Variables a and b are completely independent.
- But they happen to be close enough to land in the same cache line (typically 64 bytes).
- Core 1 frequently modifies a; core 2 frequently modifies b.
From MESI’s perspective:
- The cache coherence protocol operates at cache line granularity — it doesn’t know a and b are independent.
- Core 1 modifies a → entire cache line is invalidated → core 2’s line becomes I.
- Core 2 modifies b → entire cache line is invalidated → core 1’s line becomes I.
The consequence:
- The two cores, despite operating on entirely different variables, are furiously “kicking” each other’s cache lines.
- The cache line bounces between cores (the ping-pong effect), tanking performance.
Solution: Cache line padding — insert unused bytes between variables to force them into separate cache lines.
// ❌ Wrong: false sharing
struct Counter {
long long val1; // written by thread 1
long long val2; // written by thread 2
// adjacent val1 and val2 likely share the same 64-byte line
};
// ✅ Correct: isolate with padding
struct Counter {
long long val1;
char pad[64 - sizeof(long long)]; // pad to push val2 into the next line
long long val2;
};
// C++ standard approach (alignas)
struct AlignedCounter {
alignas(64) long long val1;
alignas(64) long long val2;
};
Cache Line Alignment
If a data structure (e.g., a lock or a hot counter) straddles two cache lines, accessing it may require loading two lines or trigger extra coherence traffic.
Best practice: align frequently accessed small objects (spinlocks, atomic counters) to 64-byte boundaries.
struct SpinLock {
std::atomic<int> flag;
// ensure the entire struct fills one line and is aligned to avoid sharing
} __attribute__((aligned(64)));
Observing Cache Behavior
We cannot directly read the contents of L1/L2/L3 caches, but tools can provide indirect insight into cache hits and misses.
Modern CPUs have dedicated hardware counters that track cache behavior:
# Count cache references and misses during program execution
perf stat -e cache-references,cache-misses ./your_program
Cache Line Optimizations in PostgreSQL
In foundational software like databases, where performance demands are extreme, cache line optimization is a mandatory consideration in kernel design.
PostgreSQL sets its cache line size to 128 rather than 64. This accommodates diverse hardware platforms — while x86 uses 64-byte lines, some ARM architectures (e.g., Kunpeng 920 with 128-byte cache lines) already use 128, and the conservative choice future-proofs against hardware evolution.
/*
* Assumed cache line size. This doesn't affect correctness, but can be used
* for low-level optimizations. Currently, this is used to pad some data
* structures in xlog.c, to ensure that highly-contended fields are on
* different cache lines. Too small a value can hurt performance due to false
* sharing, while the only downside of too large a value is a few bytes of
* wasted memory. The default is 128, which should be large enough for all
* supported platforms.
*/
#define PG_CACHE_LINE_SIZE 128
Locks heavily impact performance, and PG optimizes for this. Taking the lightweight lock LWLock as an example — it protects shared memory variables. Since PostgreSQL uses a multi-process architecture, LWLocks are accessed extremely frequently and their implementation is critical. In C, a union’s size is determined by its largest member. Defining LWLockPadded as a union ensures that no matter how compact LWLock is internally, once placed inside LWLockPadded it is padded to 128 bytes.
typedef struct LWLock
{
uint16 tranche; /* tranche ID */
pg_atomic_uint32 state; /* state of the exclusive/nonexclusive lockers */
proclist_head waiters; /* list of waiting PGPROCs */
} LWLock;
/*
* It is generally desirable to align each tranche of LWLocks on cache line
* boundaries and make the array stride a power of 2.
*
* This saves a few cycles in indexing, but more importantly ensures that
* individual LWLocks don't cross cache line boundaries. This reduces cache
* contention problems, especially on AMD Opterons.
*
* In some cases it's also useful to add more padding so that each LWLock
* takes up an entire cache line; this is useful, for example, in the main
* LWLock array when the overall number of locks is small but some are very
* heavily contended.
*/
#define LWLOCK_PADDED_SIZE PG_CACHE_LINE_SIZE
/* LWLock, padded to a full cache line size */
typedef union LWLockPadded
{
LWLock lock;
char pad[LWLOCK_PADDED_SIZE];
} LWLockPadded;
WAL insert locks are also cache-line-aligned to prevent false sharing:
typedef struct
{
LWLock lock;
XLogRecPtr insertingAt;
XLogRecPtr lastImportantAt;
} WALInsertLock;
typedef union WALInsertLockPadded
{
WALInsertLock l;
char pad[PG_CACHE_LINE_SIZE];
} WALInsertLockPadded;