← 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 padding separates two groups of writers so they stop invalidating the same cache line. The smaller semaphore has the same API and passes the same functional tests, but loses a double-digit percentage of throughput under contention.

The semaphore and event measurements below come from cross-platform game-runtime primitives. The ring measurement comes from Astral in May 2026.

The cost of a cache line

Cores exchange memory in cache lines, usually 64 bytes. Before a core writes to a line, it must hold that line exclusively. Taking ownership invalidates every other core's copy. The next access from another core can then cost many times more than a hit in its own cache.

Reads are the cheap direction. Several cores can hold read-only copies of the same line at once without invalidating one another. Those reads can still pay for the first fetch, eviction, migration, and movement through the cache hierarchy. A writer adds coherence traffic because it must obtain exclusive ownership and invalidate the other copies.

The cost belongs to the line, not the variable. Put a counter and an unrelated flag in the same 64 bytes. One core increments the counter while another polls the flag. Each write to the counter invalidates the polling core's copy, even though that core never reads the counter. The line can move back and forth on every iteration.

This is false sharing. The source-level operations are independent, but the layout is not. A profiler reports the loads that stalled, not the struct definition that put the fields together.

The direct fix is to align each field to its own line. A four-byte flag then occupies 64 bytes, but it no longer incurs a cross-core miss on every poll.

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 transfers exclusive ownership and stalls the other core fix: pad the two fields onto separate lines so the writes stop colliding
two writers, one line, and the false-sharing ping-pong

To choose a layout, list who writes each line and which other cores touch it. The cache misses often appear in code that never mentions the field causing them.

A semaphore in two halves

A semaphore has a counter that acquire decrements and release increments. Acquire may block, and release may wake a blocked thread.

Under contention, every acquirer modifies the same counter and needs its cache line exclusively. The line moves between cores. That traffic is required because all acquirers genuinely share the counter.

Release also needs waiter bookkeeping. If that state shares the counter's line, the release path invalidates every acquirer's copy while updating data those acquirers never read. Their next attempt starts with another cross-core miss.

So the library splits the semaphore's counter from its waiter and wake bookkeeping. Acquire and release both modify the counter. The second line is touched only when the operation has to enter the waiting protocol. The split does not remove the contention required by the counter. It keeps slow-path wake bookkeeping from sharing that counter's line.

An uncontended release still modifies the same counter as acquire. Only the sleeping and waking state moves to the second line.

SPLIT THE SEMAPHORE ACROSS TWO LINES ACQUIRE LINE · 64 B count (waiters spin) atomic decrement / increment ... padded to fill the line, no wake word here ... counter traffic stays separate from slow-path waiter bookkeeping WAKE LINE · 64 B wake word / flag used by the waiting protocol ... padded away from the shared counter ... before: waiter bookkeeping shared the line modified by every acquire and release after: the same counter traffic remains, but waiting state uses another line the split isolates bookkeeping; it does not remove required counter contention
the semaphore counter separated from slow-path waiter bookkeeping

Under contention, the split improved throughput by a double-digit percentage in this benchmark. It removed the extra sharing from waiter bookkeeping. Contention on the counter remained.

I compiled the uncontended acquire and release on both test machines. Acquire is one atomic decrement. Release is one atomic add. Neither reads the wake word, and the operand places the counter 64 bytes into the struct 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')
semaphore acquire/release and three-state lock acquire/release · clang 20 x86-64 and arm64 plus MSVC x64 · open in compiler explorer

On x86, both operations use lock xadd. The locked instruction already provides the requested ordering. On ARM, the ordering appears in the opcode: ldadda for acquire and ldaddl for release. Both fast paths perform one atomic operation on the counter. They touch the wake word only when a thread must sleep.

Making the layout fail the build

The split works only while the fields stay on separate lines. A later refactor can add or reorder members and move both hot words onto the same line. The semaphore still counts correctly and functional tests still pass. Only throughput regresses.

Compile-time assertions pin the struct to exactly two cache lines and check each hot field's byte offset. Size alone is insufficient because fields can move while the total remains 128 bytes. Any layout drift is a compile error.

The destructor checks a separate rule. Destroying a semaphore while a thread waits on it causes a use-after-free when that waiter wakes. The destructor reads the wait state and asserts that no waiter remains, so the failure occurs at destruction.

Destruction requires external quiescence: no thread may begin or continue a wait once destruction starts. Under that lifecycle rule, the destructor checks the final waiter state. A seq_cst read cannot make destruction safe if waiter creation is still racing with it.

The unlock that skips the kernel

Putting a thread to sleep or waking it requires a system call and scheduler work. That costs far more than a cache miss, so the uncontended path must stay out of the kernel.

The place this bites is unlock. This lock is optimized for its intended common case: acquisition finds it free, and unlock has nobody to wake. A lock word with only two states, locked and unlocked, leaves unlock guessing about that case. It can skip the wake and risk a lost wakeup. It can wake every time and pay a syscall when no waiter exists. Or it can keep waiter state somewhere else, which creates another coordination problem.

Uncontended acquire and release each need one atomic operation. A contender marks the word as locked with waiters before sleeping, so release enters the kernel only when that state was present.

The arriving waiter pays for the third state just before it sleeps. The fast path remains one swap to acquire and one swap to release.

Unlock learns whether anyone is waiting from the word it already writes. The common case is one atomic operation 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?
same source as the semaphore panels above · compare-exchange acquire plus exchange release · open in compiler explorer

The final compare controls the futex wake. It runs only when the displaced value is 2, meaning locked with waiters. Otherwise release returns after the single xchg or swpl.

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 semaphore separates fields that different threads update. The event goes the other way because the kernel must re-check the complete sleep decision in one atomic comparison. Threads sleep until someone sets it, and a broadcast wakes them all. Two flag bits and a 30-bit generation counter therefore share one 32-bit word.

The source layout uses the high two bits for state and the low thirty bits for the generation. A set that observes waiting threads increments that low field and publishes the new generation with the set state in one compare-exchange. When nobody is waiting, set changes only the state and skips both the generation increment and the kernel notification.

// one 32-bit word: high 2 bits = state, low 30 bits = generation
constexpr uint32_t SET       = 1u << 30;
constexpr uint32_t RESET     = 2u << 30;
constexpr uint32_t GEN_MASK  = (1u << 30) - 1;

uint32_t generation(uint32_t w) { return w & GEN_MASK; }

// waiting path: advance the generation and set the event together
uint32_t next_set_state(uint32_t current) {
    return ((generation(current) + 1) & GEN_MASK) | SET;
}

A waiter reads the state, finds the event clear, and prepares to sleep. A broadcast can occur between that read and the sleep. Without another check, the waiter could sleep immediately after its wakeup. Wait-on-address avoids this by sleeping only if the word still matches the value the waiter read. If a broadcast changed it, the comparison fails and the waiter checks again.

The layout follows from the wait protocol. The state used to decide whether this wait should sleep must fit in the word the kernel compares. State elsewhere may exist, but it cannot participate in that decision unless another synchronization step covers it. Two flags and a 30-bit generation counter share 32 bits so this decision is captured and re-checked in one atomic snapshot.

The flag bits alone are insufficient. Set and then reset the event, and they return to the value a waiter may have read before the cycle. The generation changes during that cycle, so the full word no longer matches the stale snapshot.

The counter is finite and wraps after 230 generation changes. The argument assumes that no waiter can retain one snapshot across a complete wrap. The code enforces that lifetime separately from the bit layout.

Suppose a waiter reads clear at generation five. A broadcast changes the word before the sleep begins. The kernel compares the stale snapshot with the live word, refuses to sleep, and makes the waiter recheck. It then sees the set flag and returns.

With no waiters, set and reset need one atomic write and no kernel call. The generation advances only when waiters need to observe the transition.

Broadcasting to 8 waiters took 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. The comparison includes each implementation's mutex work and wake behavior, so it measures the complete broadcast rather than one isolated kernel operation.

The comparison measures a broadcast with a full set of waiters, when kernel work is highest. The one-word design still ran in about half the time of the condition variable. The no-waiter case does not appear because it is only one atomic write with no kernel call.

ONE WORD: 30-BIT GENERATION + 2 FLAG BITS state · bits [31:30] generation · bits [29:0] · low 30 bits state set / reset generation counter incremented when waiters must observe a transition 31 30 29 ··· 0 with waiters: increment the low 30-bit generation and publish SET in one CAS without waiters: change the state only; no generation increment and no futex notify state and generation share the exact word passed to the kernel's compare-and-wait
one event word: high state bits over a low 30-bit generation

Both layouts are pinned by compile-time assertions. The semaphore must stay at exactly two lines, while the event state must remain in the one word passed to the kernel.

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.

The memory cost

The cost is mostly memory. Two cache lines per semaphore is 128 bytes on a target with 64-byte lines, for state a textbook fits in four bytes. A thousand semaphores uses 128 KB, most of it padding. The double-digit gain appeared under contention. An uncontended semaphore gains nothing from the separation, so callers have to measure whether the extra memory is worthwhile.

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.