← cosmin bararu

Cache lines are the API_

One semaphore counter fits in four bytes. The library in this article requires the full semaphore to occupy two cache lines, 128 bytes on a target with 64-byte lines, and fails the build if that layout changes.

// two hot counters that two cores write, forced onto separate lines
struct Padded {
    alignas(64) std::atomic<uint64_t> a;   // core 0's counter, its own line
    alignas(64) std::atomic<uint64_t> b;   // core 1's flag, its own line
};
static_assert(offsetof(Padded, b) == 64, "b must sit on its own cache line");

The extra space separates two groups of writers. Put their state on one line and the cores repeatedly take that line from each other; split it and each group stops invalidating the other's bookkeeping. The smaller semaphore exposes the same API and passes the same functional tests, but loses a double-digit percentage of throughput under contention.

This article derives the 128 bytes from one two-core trace, then follows the same reasoning into an event that makes the opposite choice and an Astral ring that keeps remote cursor reads off its common path. The semaphore and event measurements come from cross-platform game-runtime primitives; the ring measurement comes from Astral in May 2026.

What a line costs

A cache line is the unit cores exchange, 64 bytes on most hardware. Lines exist because memory is slow and bulk transfers amortize it, so the hardware fetches 64 bytes at a time and tracks ownership at the same granularity. The rule that matters is about writes: before a core writes to a line, it must hold that line exclusively, and taking exclusive ownership invalidates every other core's copy. The next core to touch that line, for any reason, pays many times the cost of a hit in its own cache while the line travels back across the chip.

Reads are the cheap direction. Any number of cores can hold read-only copies of the same line at once, and they all hit locally; the invalidation happens when somebody writes, because one writer forces every other copy out. So a line that everybody reads and nobody writes costs nothing after the first fetch, and a line that even one core writes keeps costing every other core that touches it.

So cost attaches to lines, not to variables. Take two variables with nothing in common: a counter one thread increments in a tight loop, and a flag another thread polls. Lay them out next to each other in a struct, the way anyone lays out a struct, and they usually land in the same 64 bytes. Now run the two threads on two cores. The first increment pulls the line into the incrementing core exclusively. The polling thread's next read misses, because its copy of the line was invalidated by a write to a counter it never reads. The line travels over, the next increment yanks it back, and the two threads settle into a rhythm where the poller misses on every single read, even though no line of source touches the other thread's variable.

This is false sharing: the sharing exists only in the layout, and part of what makes it expensive is that nothing points at it. Neither thread's code looks wrong, and a profiler charges the misses to whichever loads happened to stall, not to the struct definition that caused them.

The repair in this simple case is mechanical: pad the struct so the flag moves out of the counter's 64 bytes, usually by aligning each variable to its own line. A four-byte flag grows a 64-byte footprint, which looks absurd in a struct definition, but the alternative is a cross-core miss on every poll for as long as the polling loop runs.

FALSE SHARING: ONE LINE, TWO WRITERS core 0 core 1 counter += 1 flag = 1 cache line ping-pong SHARED CACHE LINE · 64 B counter flag core 0 writes here core 1 writes here core 0 touches counter, core 1 touches flag → logically independent writes but they share one 64 B line, so each store steals the line back and stalls the other fix: pad the two fields onto separate lines so the writes stop colliding
two writers, one line, and the false-sharing ping-pong

A write by one core costs every other core that touches the same line, and the cost arrives as cache misses in code that never mentions the shared data. Everything the library does about layout follows from working out who writes each line and who else touches it.

A semaphore in two halves

A semaphore is a counter with two operations: acquire decrements it and blocks when it hits zero, release increments it and wakes a blocked thread if one exists. A write costs every other core touching the line; now put that counter under real contention.

The textbook layout is one counter on one line. Acquirers arrive on several cores at once, and each acquire is a read-modify-write: read the counter, check it, write it back changed, atomically. A read-modify-write cannot run against a shared read-only copy, because it is a write, so every acquirer needs the line exclusively, over and over, and the acquirers pull the line back and forth between their cores. That part is the cost of the semantics: they genuinely share the counter, and no layout choice removes a fight over a value all of them must modify.

Release is where layout starts to matter, because release has a second job. Besides updating state, a release must find out whether some thread went to sleep waiting, because that thread needs waking. That means waiter bookkeeping, and the bookkeeping has to live somewhere. Put it on the same line as the counter, and under load a release needs the line exclusively to touch its bookkeeping: it steals the line from whichever acquirer holds it and invalidates every other acquirer's copy on the way. Each acquirer's next attempt starts with a cross-core miss it would not otherwise have paid. The acquirers were already paying for each other; now they pay for the release path too, for bytes they never read.

So the library splits the semaphore's state into two variables on two separate cache lines: one touched only by acquirers, and one touched only when a waiter has to be woken. A release never steals the line the acquirers are hammering. The traffic comes in two populations, so each population gets a line of its own, and that is where the assert's two cache lines come from.

After the split, the same release writes the wake-side line, which no acquirer reads, so no acquirer's copy gets invalidated and nobody's next attempt starts with a miss. The acquirers keep trading their own line among themselves exactly as before, no better and no worse, and the release path has stopped being their problem.

SPLIT THE SEMAPHORE ACROSS TWO LINES ACQUIRE LINE · 64 B count (waiters spin) atomic dec on acquire ... padded to fill the line, no wake word here ... different 64 B lines → a store on one never invalidates the other WAKE LINE · 64 B wake word / flag poster stores here ... padded, waiters never touch this line ... before: both words shared one line, so acquire spin and post fought for it after: split onto two lines, so the hot spin path and the wake path stay apart the counter waiters hammer no longer shares a line with the word the poster writes
the semaphore split across two lines so the sides never collide

We measured the split under contention, and it is worth a double-digit percentage of throughput. To be precise about what it does not buy: it does not make acquirers faster against each other, because their fight over the counter is real. It removes only the release traffic from their line, and removing just that is worth double digits, which tells you what one extra writer on a hot line costs.

Work out who writes what, and give writers that must not slow each other down separate lines. Whatever contention survives that exercise is contention the semantics actually require, and no layout was ever going to remove it.

The split is visible in the instructions, so I compiled the uncontended acquire and release for both machines I run on. Acquire is one atomic decrement of the counter; release is one atomic add. The fast path never reads the wake word, and the operand shows why the layout is the way it is: the counter sits 64 bytes in, on its own line.

; x86-64 (clang-18 -O2), Intel Core Ultra 7 265
sem_acquire:  mov   eax, -1
              lock  xadd dword ptr [rdi + 64], eax  ; counter is +64, on its own line
              test  eax, eax
              setg  al                               ; got a token iff previous count > 0
sem_release:  lock  xadd dword ptr [rdi + 64], eax  ; release: the SAME lock xadd
; arm64 (Apple M5 Max, LSE): the ordering is one letter of the opcode
sem_acquire:  ldadda w9, w8, [x8]   ; atomic add, ACQUIRE  (the 'a')
sem_release:  ldaddl w1, w8, [x8]   ; atomic add, RELEASE  (the 'l')

The two machines disagree about how much the memory ordering shows. On x86 the atomic is the same lock xadd for both: the acquire and release orderings are ones the hardware already guarantees for a locked op, so neither costs an instruction the other does not, and the ordering never shows in the opcode. The surrounding code still differs, since the two calls return different things. On the ARM core they are different opcodes outright, ldadda for the acquiring decrement and ldaddl for the releasing add, the ordering welded into a single letter. Either way the uncontended call is one atomic on the counter's own line, and the wake word on the other line is never touched unless a thread actually has to sleep.

Making the layout fail the build

The split only works while the two variables stay on separate lines, and nothing in the language keeps them there. You can align the fields when you first write the struct, but a refactor two years later can add a member, reorder members, or tidy away what looks like wasted padding, and the wake-side word slides onto the acquire-side line. Nothing observable breaks. The semaphore counts correctly, every test stays green, the binary ships.

What actually happens is slower and worse. The throughput quietly gives back its double-digit gain, and the only way anyone notices is a benchmark: the benchmark has to run, and someone has to read it and believe the regression enough to bisect a performance drift back to a struct cleanup that changed no behavior. There is no crash anywhere in that chain, only a benchmark three months later that somebody may or may not chase.

The library refuses the whole chain with compile-time asserts that pin the exact size of the struct (a semaphore must be exactly two cache lines) and the exact offsets of the hot fields. Size alone would not catch everything, because a struct can keep its total size while the fields inside it trade places, and two lines still add up to two lines with both hot words sitting on the first one. So the asserts also name the exact byte offset each hot field must occupy, and any drift, deliberate or accidental, is a compile error. A refactor that co-locates the acquire-side and wake-side words fails the build. The invariant moves out of a benchmark somebody has to remember to run and into the build, and the build runs on every change, for every target. The failure also lands on the right person: the one making the change, while the change is still open, instead of whoever notices a benchmark drift a quarter later and has to walk it back.

That covers the bytes. The destructor carries a second assert, for a different bug.

Destroying a semaphore while a thread still waits on it is a use-after-free: the waiter eventually wakes and touches freed memory, and the default failure is corruption diagnosed far from its cause. So the destructor reads the wait state and asserts that no thread still waits. The failure moves to the destruction site, where the assert names the semaphore and the invariant that broke, instead of surfacing weeks later as a crash in unrelated code that a debugging session has to walk back to its cause.

That read is seq_cst, the most expensive ordering the machine offers. Destruction happens once, off every hot path, so the expense lands where nothing is timed, and a cheaper read would not do the job: an assert built on a stale view of the wait state could pass while a waiter still exists.

The unlock that skips the kernel

Everything so far costs cache misses. There is a second, steeper tier: the kernel. Putting a thread to sleep and waking it again goes through a system call, and a system call costs more than any cache miss, because the caller has to switch into kernel mode and the scheduler has to run before the other thread's context comes back. All of that dwarfs a line bouncing between cores, so a primitive that calls into the kernel when it does not need to loses orders of magnitude, where the layout work so far fought over percentages.

The place this bites is unlock. Most acquisitions of most locks find the lock free, because critical sections are short and a collision needs two threads to want the same lock in the same narrow window. So the case that deserves the engineering is the empty one, where nobody is waiting and there is nobody to wake. A lock word with only two states, locked and unlocked, leaves unlock guessing about that case. It can guess that nobody waits and skip the wake, which loses wakeups and hangs threads. It can wake every time, which is correct and pays a syscall on the common case that needed nothing: the kernel gets asked to find a waiter, finds none, and returns, over and over on a busy lock. Or it can keep a waiter count somewhere else, and the semaphore section already showed what carelessly placed bookkeeping does to a hot line.

The library's lock removes the guess by encoding the answer in the lock word itself, as three states: unlocked, locked with no waiters, and locked with waiters. A thread finds the word unlocked and swaps in locked-no-waiters: one atomic operation, lock held. It unlocks while nobody else has arrived: it swaps unlocked back in, sees that the old value was locked-no-waiters, and returns. No kernel call happened in either direction.

Now a second thread acquires, and a third arrives while the lock is held. The third cannot proceed, so before sleeping it sets the state to locked-with-waiters, then sleeps. When the holder unlocks, the old value it swaps out says locked-with-waiters, and only now does unlock pay for the kernel call, because only now is there a waiter to wake.

The bookkeeping cost lands in the right place too. The thread that pays for the third state is the one that arrives at a held lock and marks it locked-with-waiters, and that thread is about to sleep in the kernel, the most expensive thing a lock ever does to a caller. Next to the sleep, one extra atomic write disappears. So the accounting rides the path that was already slow, and the fast path stays one swap to acquire and one swap to release.

The question unlock must answer, is anyone waiting, is answered by the same word unlock already has to write, on a line the unlocking thread usually still owns because it wrote that word when it acquired. The common case is one atomic write and no kernel call.

I compiled both operations on both machines to check the description against the instructions. Acquire is one compare-exchange from unlocked to locked. Release is one exchange back to unlocked, then a test of the value it displaced.

; x86-64 (clang-18 -O2), Intel Core Ultra 7 265
lock_acquire: mov   ecx, 1
              xor   eax, eax                     ; expected = unlocked (0)
              lock  cmpxchg dword ptr [rdi], ecx ; one CAS: unlocked -> locked
              sete  al
lock_release: xor   eax, eax
              xchg  dword ptr [rdi], eax         ; one exchange back to unlocked
              cmp   eax, 2                        ; was it locked-with-waiters?
              sete  al                            ; wake a thread only then
; arm64 (Apple M5 Max, LSE)
lock_acquire: casa  w8, w9, [x0]   ; compare-and-swap, acquire: unlocked -> locked
              cmp   w8, #0
lock_release: swpl  wzr, w8, [x0]  ; swap-release: store unlocked (wzr), old -> w8
              cmp   w8, #2           ; locked-with-waiters?

The kernel call hides behind that last compare. The futex wake runs only when the displaced value was locked-with-waiters, the 2 the cmp looks for. On the common path the release is the single xchg, or the single swpl, the compare comes up short, and the function returns without ever entering the kernel.

THREE STATES, ONE WAKING EDGE acquire 0 → 1 release no waiters · no syscall contend: a waiter blocks state 1 → 2 contended release WAKE SYSCALL FREE 0 · unlocked LOCKED 1 · held LOCKED · WAITERS 2 · contended acquire and the uncontended release are plain atomic stores, no kernel entry only releasing a lock that has waiters pays the wake syscall, and that edge is rare
three states, and the wake syscall on only the contended edge

An event in one word

The rule so far: split state by who writes it, and spend bytes to do it. The broadcast event in the same library went the opposite way, everything packed into a single 32-bit word, and the reason it gets to override the layout rule is a race. An event is a flag threads can wait on: waiters sleep until somebody sets it, and a broadcast wakes all of them at once. The library's version packs the entire primitive into one 32-bit word, two flag bits plus a 30-bit generation counter, and the constraint that forces everything into one word is a race.

The whole state-and-version update is one compare-exchange, and the compiler turns the bit arithmetic into two instructions before it: clearing the flag bits and stepping the generation are an and and an add, because the generation lives in the high bits, so adding four to the raw word adds one to it.

// one 32-bit word: low 2 bits = state flags, high 30 bits = a generation
uint32_t generation(uint32_t w)  { return w >> 2; }   // bits [31:2]
uint32_t state_flags(uint32_t w) { return w & 0x3u; } // bits [1:0]

// release the lock and bump the generation in a single CAS
bool release_and_bump(std::atomic<uint32_t>& word) {
    uint32_t cur  = word.load(std::memory_order_relaxed);
    uint32_t next = ((generation(cur) + 1) << 2) | 0u;  // state FREE, gen + 1
    return word.compare_exchange_strong(cur, next, std::memory_order_acq_rel);
}
; clang-18 -O2 (x86-64), release_and_bump
    mov       eax, dword ptr [rdi]     ; load the word
    mov       ecx, eax
    and       ecx, -4                  ; clear the 2 flag bits: state -> FREE
    add       ecx, 4                   ; +4 raw == +1 in the [31:2] generation
    lock cmpxchg dword ptr [rdi], ecx  ; publish FREE + the new generation
    sete      al
    ret

A waiter on an event lives like this: read the state, see the event not set, go to sleep until it is set. Between the read and the sleep there is a gap. If the broadcast fires inside it, a naive waiter goes to sleep just after the wakeup it wanted, and sleeps forever. This is the classic lost wakeup, and the wait-on-address facilities that kernels provide exist to close exactly this gap: the kernel puts the thread to sleep only if the word still holds the value the waiter saw when it decided to sleep. A broadcast inside the gap changes the word, the sleep attempt fails the comparison, and the waiter re-checks instead of sleeping.

The layout falls out of that mechanism. The kernel compares one word, so everything the waiter's decision depends on must live in that one word. Any state in a second word is state the comparison cannot see change, and the gap reopens through it. Two flags and a 30-bit generation counter share 32 bits because the whole truth has to be readable in one atomic snapshot and re-checkable by the kernel in one comparison.

The generation counter is the part that moves when there are waiters who must not be missed, and one cycle shows why the flag bits alone would not be enough. Set the event and then reset it, and the two flag bits land back on exactly the value a waiter may have read before the cycle. A kernel comparison against flags alone would find the word unchanged and put that waiter to sleep, past a set it never saw. The generation counter moves through the cycle, so the word does not return to a value a waiter has already seen.

The lost-wakeup race now plays out safely. A waiter reads the word: event clear, generation five. It decides to sleep. Before the sleep lands, a broadcast fires and the word changes. The sleep hands the kernel the stale snapshot, the kernel compares it against the live word, the values differ, and the sleep refuses. The waiter re-reads, sees the set flag, and returns instead of sleeping through its own wakeup. The gap between deciding to sleep and sleeping never went away; the layout just leaves nothing that can change inside it without the kernel's comparison catching the change.

The payoff shows up when nobody is waiting: a set or reset with no waiters bumps nothing and makes no kernel call. Flipping a flag that nobody watches is one atomic write to one word on one line. A system that signals state changes far more often than anyone waits on them, which is how events tend to get used, pays almost nothing for all that signaling, and kernel time gets spent only when a waiter actually needs waking.

Measured, broadcasting to 8 waiters: about 4.2 microseconds for the futex-based version, 8.1 microseconds for the standard condition variable, and roughly 42 microseconds for a semaphore-based fallback used where no native wait-on-address facility exists. Both gaps have plain readings. The factor of two against the condition variable is plausible as overhead the packed word never carries, since a condition variable drags a mutex through every wake. The factor of ten against the fallback reads like the cost of waking eight waiters one at a time instead of all at once.

Notice what the comparison measures: the broadcast with a full set of waiters, the moment of maximum kernel involvement, where layout should matter least. The one-word design still runs it in half the time of the standard primitive. Its best case, set and reset with nobody waiting, does not appear in the table at all, because a case with no kernel call and one atomic write has nothing worth timing against a condition variable.

ONE WORD: 30-BIT GENERATION + 2 FLAG BITS generation · bits [31:2] · high 30 bits flags · bits [1:0] monotonic generation counter bumped once per release state flags 31 ··· 2 1 0 release & bump: clear the 2 flag bits and step the generation, all in one CAS machine level: (word & -4) clears [1:0], then + 4 increments the generation the flags ride in the same word, so lock state and version update atomically together
one 32-bit word: a 30-bit generation over 2 flag bits

The semaphore splits its state across two lines because its two writer populations would otherwise slow each other down. The event went the other way, everything in one word, because the sleep decision has to be re-checkable in one atomic comparison. Both layouts are pinned by the same kind of compile-time assert that holds the semaphore at exactly two lines.

The same layout in a ring

Astral's single-producer single-consumer ring applies the same split: each cursor has one writer and its own cache line. Each side also keeps a stale local copy of the other cursor so the common path does not read across cores. That post contains the safety argument, assembly, and measurements.

What it costs

The cost is mostly memory. Two cache lines per semaphore is 128 bytes on a 64-byte-line target, for state a textbook fits in four bytes. A thousand semaphores is 128 KB, most of it padding placed on purpose. It is the same cure as moving the flag out of the counter's line, applied deliberately at a larger size, and pinned by a build assert. Whether that trade is good depends entirely on the callers: the double-digit gain was measured under contention, and an uncontended semaphore does not care where its fields sit, so wherever contention never arrives, the padding buys nothing and the small semaphore was the better one. The trade stands or falls with the contention assumption, and only a measurement can say which side of it a given system is on.

The asserts also fail on intended changes. Cache-line size is a per-target constant, so a port with a different line size stays broken until someone re-derives the size and offsets for that target. Every port pays that review cost.

No rule chooses between those layouts in advance. The semaphore's split came from a contention measurement; the event's packed word came from the need to update the flag and counter as one state. A new primitive can need both properties, and its callers determine how much contention arrives. The asserts preserve these two answers. The next primitive still needs its own trace and benchmark.