spindle://

spindle_

A lock-free shared-memory IPC bus — and an honest cross-ISA study of queue tail latency. Header-only C++20 that doubles as the textbook that teaches it.

  • 120 nsSPSC one-way p50
  • 4.7×vs mutex baseline
  • p99.99measured — never means

GitHub → repo paper → spindle.pdf Repo is private until release — links go live with it. Numbers above: aarch64 Neoverse-N1, paced 100 kHz, 5×1M samples, committed in bench/results/. x86 numbers pending.

The spindle ring buffer A ring of 56 dotted slots between a producer process pinned to core 2 and a consumer pinned to core 3. Messages flow from the producer around the ring to the consumer; the tail and head indices advance monotonically and never wrap. tail → ← head spsc_ring<T> pow2 slots · u64 monotonic indices acquire / release · no locks, no CAS PRODUCER core 2 · pinned tail 18 240 733 CONSUMER core 3 · pinned head 18 240 712

§01Measure the ruler first

Before trusting any latency number, calibrate the thing that produces it. On this Neoverse-N1 the generic counter (cntvct_el0) runs at 25 MHz — a 40 ns tick, coarser than the queue operation being measured. After two sysctls, the PMU cycle counter reads from userspace at ~0.34 ns per tick. Then comes the harder trap: coordinated omission. Time each op back-to-back and every stall politely pauses your load generator — the histogram never sees the queue at its worst. spindle measures against an intended schedule at a fixed offered rate instead.

Naive versus paced latency percentiles Log-scale percentile chart. Naive back-to-back timing and paced intended-schedule timing agree at p50 and p90 near one microsecond, then diverge: at p99.9 naive reports 2.7 microseconds while the paced truth is 605 microseconds — a 222 times understatement in this committed run. 1 µs 10 µs 100 µs 1 ms p50 p90 p99 p99.9 p99.99 paced — intended schedule (the truth) naive — back-to-back timing naive p99 = 1.08 µs naive p99.9 = 2.72 µs naive p99.99 = 632 µs paced p99 = 12.1 µs paced p99.9 = 605 µs paced p99.99 = 2.35 ms 222× understated at p99.9 — this run naive: flat, flattering paced: the tail it hid
data — bench/results/co-demo-*-2026-08-27.json
seriesp50p90p99p99.9p99.99max
naive1.08 µs1.08 µs1.08 µs2.72 µs632 µs1.0 ms
paced1.08 µs1.08 µs12.1 µs605 µs2.35 ms3.17 ms

Synthetic op: 1 µs spin with a 1 ms stall every 10k ops, offered at 10 kHz (bench/co_demo.cpp, n = 200,000). The understatement is run-dependent — 38× on another committed run, 222× on this one — which is itself the point: the naive number is not just wrong, it is unstably wrong. Both runs are committed.

Paper figure 1: percentile curves and histograms for the coordinated-omission demo, naive versus paced versus slip-corrected.
Fig. 1 from the paper — generated by paper/figures/make_figures.py from the committed raw JSON, never drawn by hand.

§02Architecture

Two processes, one /dev/shm segment, zero locks on the data path. The header is release-published and validated field-by-field on attach; every contended index lives alone on its own padded cache line. Hover the parts for the why.

spindle shared-memory transport /dev/shm/spindle-<name> 448 B header — validated field-by-field on attach The magic word is the LAST thing create() publishes — a release store. An attacher acquire-loads it: seeing magic guarantees every other header field is visible. Same publish idiom as the ring itself. magic + abi version — release-stored last Stale segments (creator died) are unlinked and re-created with epoch+1, so a process still holding the old mapping can detect it is orphaned. epoch — bumped on every re-create PID reuse is defeated by pairing the creator PID with its /proc start time. Heartbeats are the cheap runtime signal; this pair is the arbiter of liveness. creator pid + start-time — liveness arbiter Each side stamps a cntvct tick onto its own cache line — a relaxed store on a self-owned line, so keeping the heartbeat costs no coherence traffic. heartbeat A · heartbeat B — cntvct ticks The consumer parks here via FUTEX_WAIT; a push into an armed doorbell (and close()) wakes it. Cost: the producer pays one seq_cst doorbell load per push — measured and reported, not hidden. futex doorbell — 32-bit, 4-aligned index lines — alignas(64), each alone on its cache line tail — the u64 monotonic write index, alone on its padded cache line. PadBytes is a template parameter: the Claim 1 padding sweep varies exactly this, 0 to 256 bytes. tail pad → 64 B head — the u64 monotonic read index on its own padded line. Indices never wrap: slot = index & (capacity − 1), and full/empty fall out of tail − head. head pad → 64 B slots[] — pow2 capacity · raw storage + placement new Only trivially-copyable types cross the boundary — elements move by memcpy, and geometry (capacity, mask, offsets) is recomputed on attach, never trusted from the segment. The producer keeps a PRIVATE copy of head and only re-reads the shared line when the ring looks full — the fast path generates no coherence traffic at all. producer core 2 · SCHED_FIFO 50 cached_head (private) The consumer mirrors it: a private cached_tail, refreshed with one load-acquire only when the ring looks empty. Waiting is a policy — spin, backoff, futex park, or the adaptive budget of Claim 3. consumer core 3 · SCHED_FIFO 50 cached_tail (private)

Crossing the process boundary costs nothing the coherence protocol wasn't already charging: the shm transport's one-way p50 is the same 120 ns as the in-process ring. The cache line is the unit of truth, and it does not care about your address space.

Paper figure 2: latency distributions of the mutex baseline versus the lock-free ring, one-way and round-trip, on Neoverse-N1.
Fig. 2 from the paper — mutex+condvar baseline vs lock-free ring: one-way p50 560 → 120 ns (4.7×), ping-pong RTT p50 892 → 232 ns. Deep tails (≥ p99.9) on this shared VM are environment-dominated and affect both queues equally — stated in the paper's threats-to-validity, not footnoted away.

§03Three claims, honestly tested

Each claim ships with its experiment, its committed results, and its failure modes. Predictions were registered in the ADRs before the measurements — including the ones that could come back null. And the ring is not judged against its own strawman: it ties rigtorp at p50/p99, 235 ns RTT — same window, committed JSONs (boost +37 ns, moodycamel +83 ns; fairness audit in ADR-0011).

1 · 64 B is not enoughx86 pending

064 128256 B PENDING x86 — predicted cliff 64→128 N1 — predicted flat

Intel's adjacent-line prefetcher drags 128-byte pairs, so hardware_destructive_interference_size (64 on both ISAs) is folklore, not physics. spsc_ring<T, PadBytes> makes padding a template parameter; the sweep maps p99.9 vs 0–256 B on both ISAs.

Registered before measuring: cliff at 64→128 on x86 (an Ice Lake null result exists — claim is microarchitecture-conditional); no cliff on N1, whose TRM documents no adjacent-line prefetcher.

2 · Ordering is ISA-priced

; push hot path — the shipped queue
ldr   x2, [x0]      ; tail (own ctr: plain)
ldr   x3, [x0,#8]   ; cached head (private)
stp   q0, q1, [x2]  ; 32 B payload → slot
stlr  x3, [x0]      ; publish: store-RELEASE
; slow path — ring looks full:
ldar  x3, [x3]      ; refresh: load-ACQUIRE

census: ldar 1 · stlr 1 · dmb 0 corruption: 0 / 6 M msgs

; same queue, all orders relaxed
ldr   x2, [x0]      ; tail
ldr   x3, [x0,#8]   ; cached head
stp   q0, q1, [x2]  ; payload → slot
str   x3, [x0]      ; publish: PLAIN store —
                    ; nothing orders the bytes
; slow path:
ldr   x4, [x4]      ; refresh: plain load

census: ldar 0 · stlr 0 · dmb 0 corrupted: 42 8.5% in cap-4 stress

; relaxed ops + standalone fences
ldr   x3, [x0]      ; tail
stp   q0, q1, [x1]  ; payload → slot
dmb   ish           ; fence, THEN publish
str   x3, [x0]
; slow path:
ldr   x2, [x2]      ; refresh head
dmb   ishld         ; acquire fence

census: dmb 2 per op a full barrier where stlr/ldar order one address

Four disciplines, one queue, disassembled from the running binary (bench/results/asm/). g++-12 at default -march emits RCsc ldar/stlr, never ldapr — the weaker RCpc load N1 supports needs +rcpc and GCC ≥ 13.

broken_relaxed: 42 corrupted + 43 stale msgs at a paced 100 kHz; 8.5% of 6 M msgs in the capacity-4 stress. acq_rel at the same ~25 M msg/s: exactly 0. Zero-corruption runs stay possible — the deterministic verdict is TSan's.

3 · Adaptive spin-then-park

0% 50% 100% duty 1µs 100µs park-immediately: p50 21.3 µs at 0.3% duty adaptive: p50 0.16 µs at 50.4% duty busy-spin: p50 0.12 µs at 100% duty park 21.3 µs adaptive 0.16 µs busy 0.12 µs bursty 20 kHz · p50 · duty measured by PMU

The consumer estimates inter-arrival gaps online (EWMA + P² quantile) and buys spin time under an explicit CPU budget — Karlin's competitive spinning with a duty-cycle constraint, ancestry cited, not claimed novel.

Honest verdict: adaptive never beats busy-spin — nothing can. On bursty traffic it matches busy-spin's p50 (0.16 vs 0.12 µs) at ~50% duty; on steady traffic the budget binds and it degenerates to ≈ park. Pilot ran on a shared VM — far tails await the quiet full run.

§04The repo is the textbook

Written for a strong Java/Python engineer who is a genuine beginner in C++. Every non-obvious construct carries a // C++ NOTE: explaining the why and the closest Java/Python analogue — and docs/cpp-primer/ grows a chapter for each concept in the order the code introduces it.

  1. 01clocks & cycle counters
  2. 02templates, constexpr & bits
  3. 03threads, affinity & RAII
  4. 04mutexes, condvars & locks
  5. 05atomics & memory orders
  6. 06cache coherence & false sharing
  7. 07shared memory & object lifetime
  8. 08futexes & parking
  9. 09CAS, ABA & MPSC
  10. 10streaming estimators & waiting

Naive first, optimized second, both kept — the delta between spsc_naive and spsc_ring is the lesson, and the benchmark that compares them is the point.

// C++ NOTE: why not std::this_thread::yield() in a spin loop.
//   yield() is a SYSCALL asking the scheduler to run someone
//   else. On a pinned, otherwise idle core there is no one
//   else: you pay a kernel round trip (~µs) per iteration and
//   learn nothing. The right tool is a CPU *hint* instruction
//   that stays in userspace and merely de-aggresses the
//   pipeline for a few ns. On x86 that is `pause`. On aarch64
//   the architectural hint is `yield` (the instruction, not
//   the syscall) — but it exists for SMT siblings, and
//   Neoverse-N1 (this repo's ARM box) has no SMT, so `yield`
//   retires as a NOP and the loop hammers the load unit at
//   full rate. […]
asm volatile("isb sy" ::: "memory");  // measured, not folklore

From include/spindle/spsc_ring.hpp — one of the // C++ NOTE: blocks the code carries inline (abridged).

§05Reproduce it

Every number on this page traces to a committed JSON in bench/results/, produced by one serial script run, with the machine's state embedded in the file that carries the number.

01 · calibrate the ruler
$ ./scripts/setup_perf.sh
# PMU userspace access (2 sysctls — reset on reboot)
$ cmake --preset release && cmake --build --preset release
$ ./build/release/src/spindle-bench calibrate
pmu_cycle_clock: userspace · ~0.34 ns/tick
cntvct_el0: 25 MHz → 40 ns/tick (batch or PMU)
02 · the official session
$ sudo ./scripts/run_official_session.sh
# one serial run — no concurrent builds:
#   padding sweep → orderings → adaptive → shm → queues
# SCHED_FIFO 50, cores 2/3, RT throttling off
# (the throttle ON was a 40 ms p99 artifact — documented)
every result JSON carries its machine — excerpt, verbatim keys
"machine": {
  "cpu_model": "Neoverse-N1",  "arch": "aarch64",  "core_count": 4,
  "kernel": "6.8.0-1049-oracle",  "compiler": "g++-12 12.3.0",
  "aslr_randomize_va_space": 2,  "transparent_hugepages": "madvise",
  "perf_event_paranoid": 1,  "perf_user_access": 1,
  "sched_rt_runtime_us": -1,  "loadavg": [5.32, 6.23, 4.62], …
}

That loadavg is real — this is a shared 4-core VM, and the results say so instead of hiding it: p50/p99 are trustworthy here; deep tails (≥ p99.9) are environment-dominated and read accordingly. If a setup detail can flip a conclusion, it must be recorded.