← cosmin bararu

The benchmark said zero retries_

One benchmark reported zero retries while the same queue slowed from 16.6 ns/op with one producer to 99.28 ns/op with eight. Both numbers came from the same run.

The queue reserved slots through a compare-and-swap loop, but the counter labeled "retries" measured full-queue returns at the caller. Failed reservations stayed inside the queue and never reached it.

The hardware counters showed branch-recovery pressure, while the disassembly exposed the retry loop that the caller-level counter had missed. I replaced conditional reservation with one ticket increment and measured both versions.

COST PER OP · RETRIES · SAME RUN 100 75 50 25 0 ns / op 1 2 4 8 producers 16.60 99.28 cost per op → 6x caller counter → flat at 0
the same run: cost climbs 6x while the caller sees no full-queue returns

The ring in Astral

The ring lives in Astral's concurrency layer beside SPSC, ticket-based MPSC, fixed-lane fan-in, and MPMC structures. A separate epoch manager handles conversation lifetime. The rings do not depend on it for slot reuse.

Astral uses bounded queues for worker fan-in and token delivery. Their synchronization cost lands directly on runtime work, so I measured how each queue scaled with producer count.

The old push path

The original MPSC push had the shape every lock-free queue tutorial teaches. This is a self-contained illustration, compiled fresh for this post rather than lifted from the source, but the shape is exactly what I had:

// standalone illustration, compiled fresh for this post, not the astral source
uint64_t cas_reserve(CasRing& r) {
    for (;;) {
        uint64_t t    = r.tail.load(std::memory_order_relaxed); // observe the tail
        uint64_t want = t + 1;                                  // what I want it to become
        if (r.tail.compare_exchange_strong(t, want,
                                            std::memory_order_acq_rel,
                                            std::memory_order_relaxed))
            return t;                                           // won: slot t is mine
        // lost the race: another producer's increment landed between the
        // load and the compare, so go around against the new tail.
    }
}

The loop loads the shared tail, computes its replacement, and attempts a compare-exchange. Success reserves slot t. Failure means another producer changed the tail after the load, so the loop retries with the new value.

Clang -O2 emits this loop for the standalone reproduction:

; clang++ -O2 -std=c++17 -S -masm=intel (x86-64), cas_reserve
LBB0_1:
    mov       rcx, qword ptr [rdi]        ; observe the current tail
    lea       rdx, [rcx + 1]              ; the value I want it to become
    mov       rax, rcx                    ; comparand for cmpxchg
    lock cmpxchg qword ptr [rdi], rdx     ; needs the line exclusive first
    jne       LBB0_1                      ; taken exactly when the compare lost
verified: clang 20 / gcc 15 / msvc x64 · x86-64 · locked compare-exchange + conditional retry · open in compiler explorer

The lock cmpxchg needs exclusive ownership of the cache line holding tail. Under contention, that line moves between producer cores before the comparison can run.

The following jne branches when another core wins the reservation first. Its outcome depends on a race a few nanoseconds wide.

With a single producer the loop is fine. The line stays resident in one core's cache, the CAS always succeeds, the branch always falls through, and the cost sits at about 17 ns/op. Add producers and it degrades, and by eight producers the same push cost just under 100 ns/op.

Zero retries, 6x slower

The harness labeled its counter "retries," but incremented it when try_push returned false. In this ring that means the queue was full. A failed compare-and-swap retries inside try_push and never reaches the caller. Under contention the counter therefore reported zero.

The zero count did not include failed CAS reservations, so it could not explain the sixfold slowdown around the shared tail and ring slots.

I checked a third source: the CPU performance monitoring unit, or PMU. It counts hardware events independently of the queue's own instrumentation.

What the hardware counters could show

perf multiplexed the requested events because the hybrid processor did not have enough PMU slots to count them all at once. At four producers, the core events were active for about 78% of the interval and the efficiency-core events for about 53%.

perf stat -e cycles,instructions,branches,branch-misses,cache-references,cache-misses \
  ./bench_ring --producers 4

Branches and branch misses on each PMU shared the same active fraction, so their ratio was still useful for deciding what to inspect. The queue timings came from the benchmark's own clock.

The two trends

Branch mispredictions rose with producer count across the complete benchmark. That made the compare-exchange retry branch worth inspecting, though the event cannot identify one source branch by itself. Generic cache misses are incomplete too. A hot line can move between cores without reaching the last-level cache or DRAM.

Branch misses reached 16.9% at two producers and 19.5% at four while generic cache misses stayed below 1%. At eight producers, branch misses fell to 12.4% even as cost rose. This supports branch cost and argues against increasing DRAM misses. Direct retry instrumentation or sampled branch records would be needed to attribute those misses to the compare-exchange loop.

BRANCH MISS vs CACHE MISS · PMU TREND 20% 15% 10% 5% 0 miss rate 1 2 4 8 producers 19.5% 12.4% < 1% branch-misses → front end guessing wrong cache-misses → no growing DRAM-miss story
multiplexed counter sweep: branch misses climb while generic cache misses stay flat

The flat cache-miss line rules out increasing misses at the measured cache level. Coherence can still move a hot line directly between cores, and the locked compare still needs exclusive ownership of the tail line.

Cache-line arbitration

A locked read-modify-write needs its cache line in exclusive state. When another core owns the line, the coherence protocol has to move it: a read-for-ownership goes out, the owner gives the line up, ownership transfers, and only then does the compare execute. With producers on several cores all hammering the same tail counter, the line is in constant motion.

CAS succeeds only if no other core increments the tail between the load and locked compare. Which producer wins depends on cache-line ownership and the order in which the interconnect services requests. The program sees only success or failure.

ONE TAIL LINE, FOUR CORES IN MOTION core 0 core 1 core 2 core 3 producer producer producer producer tail line (excl.) read-for-ownership before each locked compare → the line never rests ONE PRODUCER'S LOAD-TO-COMPARE WINDOW load tail lock cmpxchg a few ns · another core's increment can land here held the line all window → compare succeeds, the jne falls through line stolen mid-window → compare fails, the jne is taken, go around the winner is decided by interconnect timing, not by anything the program computes
the tail line in constant motion, and one load-to-compare window

That arbitration timing becomes the input to the jne.

An unpredictable retry branch

A branch predictor looks for correlation in past outcomes and surrounding control flow. A compare-and-swap retry depends partly on when other cores modify the same word, information the local branch history does not fully encode. The measured miss rate shows that this branch was difficult to predict on this workload.

The branch-miss rate is not a direct CAS-failure counter, so 19.5% is not a retry rate. It shows that the complete workload spent real front-end work recovering from mispredictions, while the source and assembly identify the retry branch as the race-dependent branch worth removing.

The ticket reservation

The old reservation can execute extra locked compares and an unpredictable branch before one push succeeds. The ticket redesign makes reservation unconditional: one atomic increment produces one ticket, with no failed reservation branch. The same standalone illustration, the ticket path this time:

// reservation: one unconditional atomic, no failure case
uint64_t ticket_reserve(TicketRing& r) {
    return r.tail.fetch_add(1, std::memory_order_relaxed);
}

// publish: wait on this slot's own sequence, then store and release
void ticket_publish(Slot* ring, uint64_t capacity,
                    uint64_t ticket, void* item) {
    Slot& s = ring[ticket & (capacity - 1)];    // capacity is a power of two
    while (s.sequence.load(std::memory_order_acquire) != ticket)
        cpu_pause();                            // spin on this slot only
    s.value = item;
    s.sequence.store(ticket + 1, std::memory_order_release);
}

One unconditional atomic increment returns the previous tail value as a ticket. Concurrent increments are serialized by hardware, and every producer receives a distinct number.

The ticket selects a slot. The producer waits until that slot's sequence equals the ticket, writes the item, and publishes ticket + 1 with release ordering. The consumer advances the same sequence when it releases the slot for the next lap.

FETCH_ADD HANDS OUT CONSECUTIVE TICKETS producer A producer B producer C tail counter lock xadd · serialized ticket 41 ticket 42 ticket 43 distinct, no retry EACH WAITS ON ITS OWN SLOT · ticket mod capacity slot 0 slot 1 slot 2 slot 3 slot 4 slot 5 slot 6 slot 7 seq==41 seq==42 seq==43 A waits on slot 1, B on slot 2, C on slot 3: three private handshakes, one shared counter the wait is an acquire load on the slot's own sequence: a learnable branch, not a race
fetch_add hands out consecutive tickets, then each waits on its own slot

After claiming a ticket, the producer no longer reports the ring as full. It waits on the sequence counter for its assigned slot, which only that ticket owner and the consumer advance. It can still wait indefinitely if the consumer or an earlier ticket owner stops.

At the instruction level, the same clang -O2 output:

; clang++ -O2 (x86-64), ticket_reserve
    mov       eax, 1
    lock xadd qword ptr [rdi], rax        ; unconditional; rax is my ticket
    ret                                   ; no jne, nothing to retry
verified: clang 20 / gcc 15 / msvc x64 · x86-64 · locked add, no conditional retry · open in compiler explorer

Each xadd still needs exclusive ownership of the shared tail line, so producers serialize through one contended atomic increment. The ticket path performs exactly one locked operation per reservation and has no branch on success.

The wait loop remains, but it polls one slot's sequence counter after reservation. In the common case the slot is ready. Under backpressure the producer waits for the consumer to release that specific slot. The benchmark measures the combined result rather than assuming that branch is free.

The shared ticket ring still performs one locked read-modify-write because every producer reserves from the same tail. That is a property of this ownership model, not a rule for every MPSC-facing queue.

When I know the producer set ahead of time, I can give each producer its own SPSC lane and let the consumer poll the lanes. Astral's SpscFanIn takes that route, so producers do not share a reservation counter in the hot path. The cost moves into fixed producer ownership, memory for every lane, and the consumer's lane scan.

I kept the ticket ring for the runtime work queue because submissions can arrive from arbitrary application threads. Under that constraint, one fetch_add is the shared reservation I chose.

The measured result

Contention levelCAS retry loopTicket reservation
Low (1 producer)17.74 ns/op4.37 ns/op
High (8 producers)97.18 ns/op17.82 ns/op

The ticket path remains between roughly 18 and 21 ns/op from two through eight producers, where the CAS path climbed 5.5x over the same sweep. At eight producers the ticket path came out 5.5x faster, and the contended ticket cost, 17.82 ns/op, is about what the CAS path cost with no contention at all.

The two rings were measured back to back on the same host. This comparison is separate from the opening measurement, which is why the eight-producer CAS value is 97.18 here and 99.28 in the first figure. The difference between those sessions does not change the decision: the ticket ring stayed near 18 to 21 ns/op while the CAS loop stayed near 100.

The ticket commits the producer

The redesign changes behavior when the ring is full. The CAS version can return busy, letting the caller back off, drop the work, or try another queue. After fetch_add, the ticket version owns a future slot and must wait for it.

Changing the reservation also changed the contract. After fetch_add, a producer owns a future slot. In this design, it cannot return busy without leaving a hole for the consumer, so it has to wait for that slot.

I use the ticket ring only on internal paths where every work item must eventually get through. I would not put it behind a non-blocking public API, where the caller may need to back off or drop the work. I put that rule beside the type because it is part of the queue's behavior, not a benchmark footnote.

One unresolved result

The counter now names the full-queue returns it actually measures. The ticket ring still serializes producers through one atomic tail increment, but it removes failed reservations and their branch.

One number remains unexplained: the branch-miss rate fell from 19.5% at four producers to 12.4% at eight even while cost rose. I kept the point in the results and do not yet have a mechanism for it.