← cosmin bararu

The atomics you don't have_

A 64-bit load can tear on a 32-bit CPU. A reader can load one half before a concurrent write and the other half after it, returning a value no thread ever stored.

TIME reader load OLD.low load NEW.high writer store NEW word returned OLD.low NEW.high
a two-instruction window can manufacture a value that never existed in memory

The failure window was tiny, which made it worse. A test machine almost never hit it. A deployed fleet eventually would, and the report would contain a corrupt number with no reproducer. Atomic means every other core observes an operation entirely or not at all. On this target, an ordinary 64-bit integer load did not meet that definition.

The atomics layer still promised one interface across compilers and architectures, including targets without native 64-bit loads or every read-modify-write callers expected. The missing operations had to be synthesized without weakening that contract.

This article follows three examples: a CAS retry loop, an atomic load built from a failed compare, and a reservation primitive whose alignment rule is part of correctness.

One primitive to build the rest

One 32-bit target had, for 64-bit values, exactly one useful atomic primitive: compare-and-swap. CAS takes an expected value and a desired value, installs the desired value only if the location still holds the expected one, and tells you whether it did, and on failure it hands back what it found instead. The calling convention carries more weight than it looks: expected goes in by reference, and a failed CAS overwrites it with what the location actually held. That one primitive is enough to build all the missing ones, because any read-modify-write can be phrased as: read the current value, compute what you want, attempt the swap, and go around again if someone beat you to it.

uint64_t fetch_add_64(atomic64* p, uint64_t delta) {
    uint64_t old = atomic_load_64(p);
    while (!cas_64(p, &old, old + delta)) {
        // the failed CAS refreshed 'old' with what it found;
        // recompute and try again
    }
    return old;
}

The costs sit right on the surface. A native atomic add is one instruction that cannot fail. The loop is a load, an add, and a CAS at minimum, and under contention it retries: every time another core slips a write in between your read and your attempt, you go around again. The loop is correct on a quiet location and correct on a loud one; what changes is how many laps it runs, because the operation happens at the instant one CAS finally succeeds, and everything computed before that instant was provisional. The layer paid that price, because the alternative was not having 64-bit atomics on that target at all.

The load that tears

The torn read from the top of this post was the same target's second surprise: beyond the missing read-modify-writes, a plain 64-bit load or store was not atomic either. It looks fine in testing, and that is the failure mechanism: the window is two instructions wide, so it tears at whatever rate two cores actually collide there, which on a test box is close to never; a deployed fleet collides there eventually, and the report arrives as a support ticket with no reproducer.

The fix that shipped is a strange one. On that target there was one 64-bit type the compiler did move with a single instruction: the 64-bit floating-point type, because it travels through the FP and vector registers rather than the integer pair. So the value was stored in a 64-bit float slot and the bits were reinterpreted on the way in and out. No arithmetic ever touched it as a float.

The fix leans on the compiler's codegen for one type on one target, which is a narrower promise than an architectural one. A lock around every 64-bit access would also have been correct. But this layer is the bottom of everything, and a mutex inside its cheapest load gets inherited by every structure built on top, on its worst day, under contention. The float representation keeps the load to one instruction.

Double width and the ABA counter

One level up, the same shape repeated at double width. Compilers provide a double-width compare-exchange, two machine words in one atomic operation, and nothing else at that width: no double-width load, store, or exchange.

The usual reason to need that width is a pointer and a counter that must change together. If a location goes from A to B and back to A while you are computing, a CAS expecting A succeeds even though the location changed twice. That is the ABA problem. Pair the pointer with a counter that increments on every change and the recycled A returns with a different counter, so the stale CAS fails.

The layer synthesized the three missing operations from the CAS. Exchange is the retry loop with a constant desired value. Store is exchange with the returned value discarded. Load is the surprising one:

// double-width atomic load, built from CAS alone
pair expected = {0, 0};
cas_2w(p, &expected, {0, 0});
return expected;  // either it really was zero, or the failed
                  // CAS just wrote the current value here

CAS the location against a zero comparand, desiring zero. If the location happens to hold zero, the CAS succeeds and swaps zero for zero, which changes nothing, and zero is the correct answer. If it holds anything else, the CAS fails, and failing is the useful outcome, because the failure path writes the value it found into expected. Either branch ends with an atomic snapshot in your hands. The load falls out as a free by-product of the compare's failure reporting, which was designed to restart retry loops and turns out to be a read primitive if you never retry.

cas(addr, expected=0, desired=0) held 0 held x swap succeeds 0 exchanged for 0 — no effect snapshot = 0 swap fails found value written into expected snapshot = x either way you now have the value an atomic load built from the compare's failure reporting
the zero-comparand CAS load, both branches ending in a snapshot

The library call in the hot path

Whether any of this is worth doing inline is a measurement question, because the alternative world already exists: ask the compiler for these atomics generically and on some targets it emits a call into a support library, a real function call in the hottest position the code has, with no inlining around it. Inlining is what pays for itself at this width: measured end to end, the inline double-width path ran about 3.3x faster than the compiler's library-call fallback on x86-64 and about 2.8x faster on a 64-bit ARM core. Those two numbers are measured, not derived, and they are the entire justification for the extra code.

The library call showed up once more, in a worse form. On one architecture, 128-bit atomics silently compiled to a non-inlined library call even though the hardware could do the operation directly, and the call was several times slower than the silicon underneath it. There was no diagnostic. The fix was handwritten inline assembly for that architecture. Past the asm boundary the compiler cannot inspect the block, and each port has to maintain its own version, so the rule was narrow: use assembly only when the intrinsic is missing, broken, or measurably worse. The 128-bit block had measurements. The rest of the library stayed in intrinsics. A new port began on the emulation and earned an assembly path only from its own result, not from the last architecture's result.

The reservation

Some architectures offer load-linked and store-conditional instead of compare-and-swap. Load-linked reads a location and opens a hardware reservation; store-conditional writes only if that reservation survived. Because the reservation tracks writes to the location rather than equality of its value, an A-to-B-to-A change kills it instead of fooling it. The library exposed the pair as a retrying macro: load, run a small caller-supplied transform, attempt the store, and repeat if the reservation died.

The alignment contract

The reservation is where alignment stops being a throughput question and becomes a correctness contract. The hardware tracks the reservation at a granule whose size differs across implementations, and any write inside the granule kills the reservation, including a write to a neighboring variable that happens to share the granule with your target, including your own.

The target is therefore cache-line aligned and cache-line sized. Reservation granules differ by implementation, but a target that owns a full line also owns every granule no larger than that line. Without the rule, a hot neighboring counter can invalidate every reservation and keep the retry loop from terminating. Every caller spends one cache line to remove that failure on the tested cores.

WHAT KILLS AN LL/SC RESERVATION 1. target shares the granule target hot neighbor load-linked target another core writes neighbor store-conditional target reservation lost: retry 2. target owns the granule aligned, line-sized target load-linked target neighboring writes stay outside store-conditional can succeed
a neighboring write kills the shared granule; an isolated target removes that failure

Only tested cores get the native path

The native path is also limited to tested silicon revisions because reservation-granule size is implementation-defined. An untested core with a wider granule could recreate the permanent-failure shape above. Other cores, including x86 where LL/SC does not exist, use a CAS-loop implementation of the same interface.

Then the measurements, which are the reason the macro survived code review. Native LL/SC beat the CAS-loop emulation by 22% on one 64-bit ARM core and by 30% on an older 32-bit core. Nothing above the layer changes to collect a win like that; every caller of the macro gets it at once. On x86-64, the emulation came within about 4% of a hand-written CAS loop. So the split holds on both sides: cores with real LL/SC get a real, measured win, and cores without it pay about 4% against the best loop a human would write there, which is cheap enough to keep one portable interface instead of two.

I still do not know a general way to choose that boundary before the port exists. The shipped interface hides four different implementations, and each implementation keeps its own reason: native LL/SC for the measured ARM win, CAS emulation elsewhere, a support call when the compiler cannot inline the operation, and assembly only where that call lost to the hardware. The cache-line alignment rule remains the largest caller-visible cost, because without it the native reservation loop may never terminate.