← cosmin bararu

The weakest ordering that is correct_

One memory-order change in a reference-count decrement made two ARM machines 37% and 39% faster. The same source change made x86 exactly 0% faster because the compiler emitted the same binary before and after.

The code came from a cross-platform primitives library used across game-runtime targets. Those machines do not pay the same price for acquire, release, and sequential consistency, even when the C++ operation looks identical.

This article builds the ordering model from relaxed through seq_cst, measures the cost ladder, and applies the same reasoning to the epoch handshake Astral uses today. The question throughout is simple: what is the weakest ordering that still proves this operation correct?

What the ordering argument governs

The ordering argument is not about the atomic variable. The variable itself is always consistent: every atomic object has a single modification order, every thread agrees on it, and no ordering argument weakens that. Let ten threads increment a relaxed counter a million times each and the counter reads exactly ten million at the end, every run; relaxed weakens nothing about the count itself.

What the argument governs is everything around the atomic: the plain loads and stores the same thread issued before and after it, and when other threads become entitled to see them. Relaxed is the floor. The operation is atomic, the variable's own history stays consistent, and that is the whole promise.

Two separate reorderers are at work around a relaxed operation, with different motives. The compiler is free to move surrounding work across the operation, because moving work keeps registers busy. The core is free to let stores drain late and loads start early, and to let its effects reach other cores in whatever order is convenient, because that keeps its pipeline fed. Both are doing exactly what relaxed permits, and on x86, neither shows itself often enough to build intuition against.

There is a large class of code for which the floor is exactly right: values read for their own sake, with nothing inferred from them about other memory. A counter of how many times a slow path fired, read once at the end after every thread has joined, is the clean case. No thread looks at that number and concludes "therefore the buffer is ready" or "therefore the object is dead". The number is the entire payload, and atomicity alone keeps it exact. So the counter takes relaxed, and on the ARM core above, one million relaxed increments cost 42 ms, which works out to 42 nanoseconds per increment. That is the baseline for every other number in this post.

The trouble starts the moment a thread does infer something from a value. The classic shape: one thread fills a buffer, then sets a flag; another thread sees the flag true and reads the buffer. The reading thread is inferring "the data is ready" from "the flag is set", and relaxed refuses to back that inference, because nothing keeps the flag store from becoming visible to the other core before the buffer stores do. The flag write comes last in the source, but the order another core observes is a separate fact, and making the two line up takes a stronger ordering than relaxed.

Publish and subscribe

A release store publishes. Every write the thread performed before it, plain or atomic, becomes visible to any thread that reads the stored value with an acquire load. An acquire load subscribes: once it reads that value, everything published before the matching release store is visible after it. Together they are a contract between exactly two threads about one value:

// producer                            // consumer
payload = build();                     while (!ready.load(acquire)) { }
ready.store(true, release);            use(payload);

The consumer leaves its loop only after reading true. The true it read was written by the release store, so the two operations synchronize: everything before the release store, the payload included, is visible to everything after the acquire load. The inference the previous section could not back is now a guarantee, and it cost two ordering arguments.

RELEASE PUBLISHES · ACQUIRE SUBSCRIBES producer payload = build(); ready.store(true, release); consumer while(!ready.load(acquire)){} use(payload); synchronizes-with the plain payload write is carried across the edge: visible after the acquire load the pairing is per read: acquire synchronizes only with the release whose value it read it cost two ordering arguments, one on each side, and nothing more
release publishes, acquire subscribes, one edge carries the payload

Two boundaries of that guarantee matter later, so they are worth stating exactly. First, the pairing happens per read, not per variable: an acquire load synchronizes only with the release operation whose value it actually read. An acquire load that happens to read a value written by a relaxed store subscribes to nothing, and a release store synchronizes with nobody until some thread reads its value with acquire. Second, release constrains order, not timing. It does not flush anything or push the data anywhere; it only promises that if the flag becomes visible, the payload is visible too. How soon the flag becomes visible is not part of the promise.

A release-acquire pair, then, is a handoff between exactly two threads, per store, per read, and only when the read lands on that store's value; nothing is armed in advance. Most code never needs anything stronger, which raises the question of what the default everyone reaches for is actually adding.

What sequential consistency adds

Sequential consistency is acquire-release plus one property: a single total order over all the seq_cst operations in the program, one order, agreed by every thread.

Acquire-release does not provide that, and the gap shows in a four-line experiment. Two threads, two flags, both initially false. Each thread raises its own flag, then checks the other's:

// thread A                            // thread B
a.store(true, release);                b.store(true, release);
saw_b = b.load(acquire);               saw_a = a.load(acquire);

Under acquire-release, both threads can read false. Nothing forbids it: each load read the initial value, which no release store wrote, so nothing synchronizes, and neither thread's store is obliged to have reached the other core yet. Under seq_cst the same outcome is impossible. The four operations take positions in one agreed order, each store sits before its own thread's load, and there is no way to arrange the four so that each load also comes before the store it failed to see; trying to closes a cycle. So under seq_cst, at least one thread sees the other's flag.

That property, everyone agreeing on the order of writes to two different variables, is what flag-based mutual exclusion protocols stand on, and almost nothing else consumes it. Most synchronization is pairwise, one thread handing state to another through one value, and publish and subscribe already covers that completely. Queues, flags, refcounts, published pointers: each is a writer and a reader agreeing about one value, which is exactly the contract the pair provides. If no place in the code needs every thread to agree on the order of independent writes, seq_cst gains nothing over acquire-release, and what it costs is something we could measure.

The ladder, measured

One million atomic increments on one 64-bit ARM core, the same loop every time, only the ordering argument changed. The increment is the right operation for this experiment because it is a read-modify-write, so it can take every rung:

orderingone million increments
relaxed42 ms, baseline
acquire+14%
release+17%
acquire-release+50%
sequential consistency+86%
COST LADDER · 1M INCREMENTS, ONE ARM CORE relaxed 42 ms · baseline acquire +14% release +17% acq-rel +50% seq_cst +86% one core's answer; the shape, each rung adding real time over the one below, is what holds
each rung of ordering adds real time, measured on one ARM core

On this core, every guarantee adds measured time. Acquire alone adds 14% and release alone adds 17%, so neither half is much cheaper than the other. Ask for both and the cost lands near the sum, +50%. Ask for the total order on top and the same increment runs 86% over baseline, close to twice the relaxed cost. In absolute terms the same work grew from 42 ms to about 78 ms (arithmetic from the table, not a separate run), for a loop that produced the same final count. Nothing in the loop changed except how much reordering freedom the core kept. A weakly ordered core is fast because of that freedom, overlapping loads and retiring stores when convenient, and every guarantee in the argument takes part of the overlap away. The exact percentages are one core's answer and I would not quote them for another chip; the shape, each rung adding real time over the one below, is the part I would expect to hold elsewhere.

x86 answers the same experiment with a different shape entirely, and the difference decides where the main trick of this post saves real time and where it vanishes, and the x86 answer comes first, because it decides whether the change compiles to anything at all.

The machine with the guarantees bundled in

On x86, the hardware's own memory model is strong. Every plain store already behaves like a release store and every plain load like an acquire load: the architecture keeps stores in issue order and loads in issue order on its own. So when the compiler meets an acquire load or a release store on x86, it emits a plain mov. The guarantee was going to be provided anyway, there is nothing to add, and plain atomic loads and stores land at 1 to 3 cycles. On x86 the ordering argument on a load or store changes nothing in the generated code; it records what the code relies on.

The same three operations, compiled for each machine, show the two cost models directly. On x86 the release and acquire are plain moves and only seq_cst pays, with a locked exchange:

; x86-64 (clang-18 -O2): the ordering rides in the instruction choice
store_release:  mov   dword ptr [g], edi   ; plain store; TSO already orders it
load_acquire:   mov   eax, dword ptr [g]   ; plain load
store_seqcst:   xchg  dword ptr [g], edi   ; locked exchange, the seq_cst tax

On the ARM core the ordering is welded into the opcode instead. (These are the instructions this compiler picks for Apple Silicon; a generic AArch64 target often shows ldar for the acquire load, and older cores without the atomics extension use load-linked / store-conditional loops.)

; ARM64 (clang, Apple Silicon: LSE + LRCPC): the ordering rides in the opcode
store_release:  stlr   w0, [x8]            ; store-release
load_acquire:   ldapr  w0, [x8]            ; load-acquire (RCpc form)
store_seqcst:   stlr   w0, [x8]            ; same opcode as release on this target

The one liberty the architecture keeps for itself is the store buffer: a store can sit in it, waiting to drain, while younger loads complete around it. That liberty is precisely the one the two-flags experiment exercises, both stores parked in their buffers while both loads run, so it is the one thing seq_cst still has to shut off here. Shutting it off is the full fence, at about 100 cycles.

Between the movs and the fence sits the locked read-modify-write, the instruction under an atomic increment, at about 15 cycles: the core takes the cache line exclusive and holds it through the operation.

So the two machines run the same source under two different cost models. On the ARM core each guarantee adds its own time, which is the ladder. x86 bundles the load and store guarantees into every load and store, and its costs come in three tiers: mov, locked operation, fence. An ordering argument on x86 mostly selects a tier. On the ARM core the same argument moves the cost inside one. This also makes x86 comfortable and slightly misleading as a development machine, because code can be underspecified about ordering and still run correctly there for years; the hardware is quietly supplying guarantees the source never asked for. Both halves of that, the comfort and the misleading part, are about to matter, because the clearest case of a design tuned against the ARM ladder was the library's reference count.

Splitting the refcount barrier

A refcounted object carries one atomic counter. Every holder decrements it on the way out, and the thread whose decrement takes the counter to zero runs the destructor. The decrement is the hot side of the design: it runs once per reference per lifetime, on every thread, all over a codebase. The increment side has an argument of its own and it is not this story; the story is the decrement.

A decrement is a read-modify-write: it reads and writes in one indivisible step, so it can carry a guarantee on either half or on both, acquire on its read side, release on its write side, acquire-release for the pair. The reflexive way to write the decrement is acquire-release, the ordering you reach for when nobody wants to think about halves, and on the ladder that is the +50% rung.

The library's version splits it, and the split falls out of asking the weakest-correct question separately for each path of the decrement, because the two paths need different things.

A thread that decrements from five to four is finished with the object. Dropping a reference is a promise never to touch the object again, so this thread will read nothing through it, and a subscription to other threads' writes would protect reads that are never going to happen. What it owes runs the other way: its own writes into the object must be published before the count drops, because the moment the count drops, some other thread's decrement may hit zero and destroy the object, and the destroyer has to see what this thread wrote. So the common path takes release and nothing more: it publishes its own writes and subscribes to nothing.

The thread that decrements from one to zero is the destroyer. It is about to run a destructor that reads fields every other holder may have written, then frees the memory under them. It is the one reader that must see everyone's writes, which means subscribing to every earlier holder's publication, and every earlier holder published through its release decrement. So the zero path needs an acquire, and it is the only path that does, because only the destroyer must observe every other thread's writes.

The code follows the two paragraphs literally, with one tool this post has not needed yet. An ordering does not have to ride on an operation: C++ also has standalone fences, and atomic_thread_fence(acquire) makes the atomic reads before it count as acquire for everything after it. So the decrement itself is release-only, and the acquire runs as a fence, only after a decrement has come back saying zero, between that answer and the destructor:

if (count.fetch_sub(1, release) == 1) {
    atomic_thread_fence(acquire);
    destroy(object);
}

The acquire now runs on one thread per object, the one about to read. The common-path decrements skip it, because they read nothing through the object.

RELEASE ON EVERY DECREMENT · ACQUIRE ON ONE PATH count.fetch_sub(1, release) result != 1 common path return, nothing more release-only, subscribes to nothing result == 1 atomic_thread_fence(acquire) the barrier stands between zero and destroy destroy(object); only the destroyer must see every other thread's writes, so only the zero path acquires on x86 the split is exactly 0% (lock is a full barrier); on weak cores it saves 37-39%
release on every decrement, acquire on only the zero path

On two different ARM cores, the split decrement measured 37% and 39% faster than the acquire-release version. On x86 it measured exactly 0%, and the zero is exact rather than approximately zero: the decrement compiles to the same locked instruction whichever of those orderings the source names, because a locked read-modify-write on x86 carries the stronger guarantee whether it is requested or not, and the standalone acquire fence constrains the compiler while emitting no instruction at all. The source changed, the binary did not, so there was nothing for a benchmark to find. The previous section said a design tuned against the ARM ladder can compile to nothing on x86, and the split decrement turned out to be that case.

You can read the exact 0% straight out of the instruction set. On x86 the two orderings compile to the same locked decrement, so choosing the weaker one costs nothing:

; x86-64: the ordering is free on the RMW; identical instruction either way
dec_release:  lock dec dword ptr [count]   ; release
dec_acqrel:   lock dec dword ptr [count]   ; acq_rel: byte-for-byte the same

On the weakly ordered core the acquire is a real, separate bit of the opcode, one a between the release-only form and the acquire-release form, which is exactly the guarantee the common path is dropping:

; ARM64 (Apple Silicon, LSE): the acquire is one letter of real work
dec_release:  ldaddl   w9, w8, [count]     ; load-add, release
dec_acqrel:   ldaddal  w9, w8, [count]     ; load-add, acquire + release
verified: clang 20 / gcc 15 / msvc x64 · x86-64 and arm64 · release decrement, acquire fence only on zero · open in compiler explorermsvc arm64 lowers the decrement through an out-of-line intrinsic

Those are the numbers from the top of the post. The cost of the change is real too; it just never shows up on a benchmark.

If the proof is wrong

The split decrement is correct only by a proof about which path needs which guarantee. The proof is the two paragraphs above: the common path owes one publication and needs no subscription; the zero path needs exactly one subscription. There is no stronger backing available for it, and that is unusual for an optimization, because most of them fail in ways a test can see.

Suppose the proof is wrong. Suppose a refactor quietly adds a path that reads the object after its decrement, and nobody re-derives the argument, because the argument lives in review and refactors do not reopen reviews. On x86 nothing happens, and the reason nothing happens is the important part: the hardware bundles the acquire into the locked decrement whether the source asks or not, so the missing guarantee is silently supplied, and every test on every x86 machine passes and keeps passing.

On a weakly ordered core, the missing acquire is really missing, and the failure is the destructor racing another holder's last writes: a use-after-free that needs a narrow reordering window to open, on the zero path, under contention. For a library shipping across many operating systems and toolchains, that is an unpleasant distribution of risk, because the bug clusters on the targets that see the least development and the least stress-testing.

Ordinary testing cannot catch it, and not because coverage is thin: a stress test samples interleavings and hopes the schedule visits a bad one. It has no lever that forces the core to exercise the one reordering the proof forgot, and on x86 the reordering does not exist to be exercised. The library shipped the split anyway, and I think that was right, because the proof is short enough to hold in review and the decrement is hot enough to justify holding it. But the episode set the terms for everything that follows: with orderings, the argument is the only check that works, because the test suite cannot exercise the failure. The shape of the API started reflecting that, and the clearest example is a combination of orderings the library refuses to accept.

The combination the API refuses

Compare-exchange is the primitive under most lock-free structures, and it needs a one-paragraph introduction before the refusal makes sense. It takes an expected value and a desired value. If the variable still holds expected, it writes desired and reports success. If not, it loads what the variable holds now into expected and reports failure. Retry loops are built on exactly that: read the current value, compute the replacement, try to install it, and on failure go around again with the fresher value already in hand.

The loop is worth one concrete pass. To push a node onto a lock-free list, you read the current head, write it into your node's next pointer, and compare-exchange the head from that value to your node. Success means your node is installed and the list is intact behind it. Failure means someone else pushed first, and the fresh head the operation handed back is exactly what the next attempt needs.

Success and failure do different amounts of work, one is a read-modify-write and the other is only a load, so the C++ API takes two ordering arguments, one per outcome. A failure ordering cannot be a release flavor, since a failed exchange stores nothing. The ordinary pairing is acquire-release on success and acquire on failure, and that pairing means the same thing on both paths: whatever value came back, if a release operation wrote it, you synchronized with that operation.

The combination the library forbids is legal by the standard: release on success, acquire on failure. The names invite a belief: when I win, I publish my work; when I lose, I synchronize with whoever beat me. The failure half of that belief is where the trap is. The boundary from the publish and subscribe section has not moved: an acquire load synchronizes only with a release operation whose value it actually read. The value a failing compare-exchange reads is whatever happens to be in the variable, written by whoever got there first, at whatever ordering that writer chose. If that writer stored relaxed, the acquire on the failure path subscribes to nothing, and the code walks away believing the failure path synchronized when it did not.

The success half does not rescue the belief either, because release on success carries no acquire: the winning path, the one about to act on what it displaced, has subscribed to nothing by construction.

So the library's compare-exchange does not accept the combination. Nothing is wrong with its letter; what is wrong is what the names invite a caller to believe about whether the failure path synchronizes, and an ordering bug born from that belief inherits everything the last section said: x86 cannot show it and ordinary testing cannot catch it on the weakly ordered cores. A caller who genuinely wants the asymmetry can still spell it out with relaxed operations and standalone fences, where the unusual thing is visible at the call site instead of encoded in two enum values. The point of the refusal is narrow: remove a legal footgun from the surface.

TWO OUTCOMES, TWO ORDERING ARGUMENTS compare_exchange(expected, desired, success_order, failure_order) success failure (stores nothing) ordinary pairing acq_rel acquire both paths synchronize with any release that wrote the value they read forbidden release acquire release on success carries no acquire: the winner subscribes to nothing legal by the standard, but a caller who truly wants the asymmetry spells it out with relaxed ops and standalone fences, where the unusual thing is visible at the call site
two outcomes, two orderings, and the pairing the library forbids

The house rule

The last piece of the same posture is one sentence, written in a header where everyone works: never sequential consistency unless justified in writing.

The language's default puts the burden on weakening. You start at seq_cst and argue downward, and since no review process demands an argument for a default, most code never argues at all and ships at the +86% rung on the weakly ordered cores. The house rule inverts the burden: acquire and release are the starting point, and seq_cst is the ordering you argue up to, on paper. The writing requirement is doing the real work, because of what seq_cst actually adds. It adds one property over acquire-release, the agreed total order from the two-flags experiment, so a real justification has to point at the place where the code consumes that property: the two independent writes whose order every thread must agree on. If no such place can be pointed at, the operation was not consuming the total order, and acquire-release was already correct.

When the code moves, the sentence naming the property is sitting next to the operation, and whoever touches it can check whether the place it points at still exists.

The rule outlived the library. Astral's production epoch manager uses a total-order handshake rather than one isolated strong operation. Reader entry loads the global epoch, stores that value into its participant slot, and reloads the global epoch; all three operations are seq_cst. The collector publishes the next global epoch with a seq_cst increment. If publication wins the race, the reader sees the changed epoch and retries before loading a protected pointer. Otherwise the collector's later scan must see the announcement or conservatively delay reclamation. A cold unregister also stores the inactive epoch with seq_cst before its slot can be reused. The retirement queues and ordinary participant scans stay on acquire and release.

What never got a good answer, there or since, is the proofs themselves. The measurements in this post are the sturdy part: the ladder can be rerun, the 37% and 39% can be rerun, even the exact 0% can be read straight out of the instruction set. The correctness is in the paragraphs: which path needs which guarantee, argued in review, and re-argued only if someone remembers to. If one of those paragraphs is wrong, the failure arrives on the weakly ordered cores, rarely, as a use-after-free, and ordinary testing cannot catch it.