How Photon Ring achieves near-hardware latency
Photon Ring is a zero-allocation pub/sub crate for Rust built around pre-allocated ring buffers with per-slot seqlock stamps. It targets the part of concurrent systems where queueing overhead dominates: market data, telemetry fanout, staged pipelines, and other hot-path broadcast workloads where every subscriber must observe every message.
The central insight is stamp-in-slot co-location:
by embedding the seqlock sequence stamp directly alongside the payload in one
#[repr(C, align(64))] struct, both ownership metadata and data reside
within a single 64-byte cache line for payloads up to 56 bytes. Consumers validate
and read in one L3 snoop instead of two, cutting coherence traffic in half.
64 bytes (one cache line)
+-----------------------------------------------------------+
| stamp: AtomicU64 | value: T |
| (seqlock) | (Pod — all bit patterns valid) |
+-----------------------------------------------------------+
For T <= 56 bytes: stamp and value share one cache line.
Larger T spills to additional lines (still correct, slightly slower).
Write protocol: Read protocol:
stamp = seq*2 + 1 (odd) s1 = stamp.load(Acquire)
fence(Release) if odd → spin
memcpy(slot.value, data) if s1 < expected → Empty
stamp = seq*2 + 2 (even) if s1 > expected → Lagged
cursor = seq (Release) value = memcpy(slot)
s2 = stamp.load(Acquire)
if s1 == s2 → return value
else → retry
publish and
try_recv never touch the allocator —
no GC pauses, no malloc jitter.
T: Pod (every bit pattern valid), making
speculative torn reads safe to discard. Compile-time proof,
not a runtime check.
Publisher is single-producer via &mut self
(no CAS on write). MpPublisher adds a lock-free multi-producer
path. Named-topic Photon<T> and heterogeneous
TypedBus included.
alloc. Pipeline topology builder, hugepages, and CPU
affinity are available on supported desktop/server platforms.
Criterion (100 samples, --release, no custom RUSTFLAGS) on two machines. Numbers are medians unless stated.
BusySpin wait strategy, 4096-slot ring, Criterion, 100 samples. Machine A is the Intel i7-10700KF, machine B the Apple M1 Pro.
| Operation | Machine A | Machine B | ||
|---|---|---|---|---|
| Publish only | 2.8 ns | 2.4 ns | ||
| Cross-thread roundtrip | 95 ns | 130 ns | ||
| Same-thread roundtrip (1 sub) | 2.7 ns | 8.8 ns | — | |
| Fanout (10 subscribers) | 17.0 ns | 27.7 ns | — | |
| MPMC (1 pub + 1 sub) | 12.1 ns | 10.6 ns | — | |
| Empty poll | 0.85 ns | 1.1 ns | — | |
| Batch publish 64 + drain | 158 ns | 282 ns | — | |
| Struct roundtrip (24-byte Pod) | 4.8 ns | 9.3 ns | — | |
| One-way latency p50 (RDTSC) | 48 ns | — | — | — |
| Sustained throughput | ~300M msg/s | ~88M msg/s | — |
Sustained message rate, single publisher, single subscriber, BusySpin.
| Machine | Throughput | Notes |
|---|---|---|
| Intel i7-10700KF (Intel i7-10700KF) | ~300M msg/s | BusySpin, 4096 slots, u64 payload |
| Apple M1 Pro (Apple M1 Pro) | ~88M msg/s | BusySpin, 4096 slots, u64 payload |
At cache-line-sized payloads the copy is a few percent of latency — cross-core cache-coherence transfer dominates. The copy only becomes co-dominant in the KiB range. See full payload scaling analysis.
T: Pod payloads and no custom RUSTFLAGS.
CPU governor, Turbo Boost, SMT, and core pinning are not controlled in the Criterion suite.
Run cargo bench on your own hardware and treat published figures as indicative snapshots.
What inter-thread messaging designs cost, and which problem each one solves
Inter-thread messaging designs usually pay for at least one expensive property on the hot path. Which one you can afford is the real choice.
| Approach | Write cost | Read cost | Allocation | Solves |
|---|---|---|---|---|
| Locked queue | Lock acquisition | Lock acquisition | Dynamic growth | General handoff, any payload |
| Lock-free queue | CAS on head | CAS on tail | None | Point-to-point: one receiver owns each message |
| Shared sequence barrier | Sequence claim, then a fold over every consumer's position | Barrier spin | None | Ordered pipelines with dependent stages |
| Stamped slots (this crate) | Stamp + payload write | Stamp check, private cursor | None | Broadcast: every subscriber sees every message, uncoordinated |
Pre-allocated rings are what removed allocator overhead from this problem, and the shared sequence barrier is the classic way to order consumers on one: every consumer publishes its position, and the producer folds them into a minimum before it may overwrite a slot. That is cheap with one consumer and grows with the audience. Moving validation into each slot trades the ordering for independence — subscribers keep private cursors and never consult one another — which is also what makes per-consumer delivery contracts, dead-consumer recovery and hot attach possible, since there is no shared minimum for a stalled consumer to poison.
Delivering one message to N consumers is O(N) work somewhere — the question is where it lands. Time to publish one message and have all N consumers observe it, in nanoseconds, single-threaded and in-cache. This measures protocol overhead, not real cross-core fanout latency.
| N consumers | 1 | 2 | 4 | 8 | 16 | 32 | Marginal |
|---|---|---|---|---|---|---|---|
| Photon Ring | 3.9 | 5.9 | 8.5 | 14.9 | 25.9 | 50.2 | 1.5 ns |
tokio::sync::broadcast 1.53 |
47.7 | 65.5 | 99.2 | 167.3 | 306.9 | 579.6 | 17.2 ns |
crossbeam-channel 0.5, one per consumer |
22.3 | 44.3 | 87.8 | 176.5 | 351.3 | 701.9 | 21.9 ns |
flume 0.11, one per consumer |
27.4 | 53.9 | 108.7 | 217.6 | 428.4 | 859.3 | 26.8 ns |
cargo bench --bench fanout_scaling.
The table above isolates protocol cost in cache. This is the deployed shape: one producer, N consumer threads on their own cores, 100k messages, every consumer accounting for all of them. Total milliseconds, lower is better.
| Library | Delivery | N=1 | N=2 | N=4 | N=8 |
|---|---|---|---|---|---|
| Photon Ring | lossless | 0.49 | 0.52 | 0.58 | 2.62 |
disruptor 4.0 | lossless | 0.45 | 2.94 | 6.99 | 12.15 |
crossbeam-channel 0.5, one per consumer | lossless | 1.39 | 14.90 | 27.92 | 46.79 |
tokio::sync::broadcast 1.53 | lossy | 8.23 | 8.61 | 33.86 | 119.47 |
disruptor is faster. Its consumers coordinate through
a shared sequence barrier, and with one consumer there is nothing to coordinate, so the
barrier costs nothing while this crate still pays for its per-slot stamps. From two
consumers onward the producer must fold every consumer's position into a minimum before
it may publish, and that cost grows with the audience.
Separately, the jump in this crate's own N=8 figure is not established as architectural:
that run put nine threads on a sixteen-thread machine that was not otherwise idle, and it
needs a pinned rerun on a quiet box. And the lossy row is not comparable to the three
lossless ones — it drops under pressure instead of applying backpressure, so compare
it against this crate's lossy channel() rather than channel_bounded().
Reproduce with cargo bench --bench fanout_threaded.
| Capability | Photon Ring |
|---|---|
| Delivery | Broadcast — every subscriber sees every message |
| Payloads | Pod on the seqlock ring; any Send type on an event ring |
| Per-consumer contracts | Gating and non-gating subscribers on one ring |
| Consumer failure | A dead consumer releases the publisher |
| Hot attach | Subscribe to and detach from a running ring |
| Backpressure | Optional, per subscriber |
| Topologies | Pipelines, fan-out, managed terminal consumers |
| Topic bus | Named topics, and a typed bus for per-topic payload types |
| Multi-producer | Yes |
no_std | Yes, with alloc |
| Verification | Miri and loom gated in CI; a TLA+ model of the seqlock checked by hand |
| Constraint | Rationale |
|---|---|
| T: Pod | Every bit pattern must be valid. Torn reads from speculative seqlock copies are safe to reject without UB. |
| Power-of-two capacity | Indexing uses seq & mask instead of %, avoiding division on the hot path. |
| Single producer by default | &mut self enforces one writer at the type level. No CAS on the write path. |
| Lossy overflow by default | Publisher never blocks. Slow subscribers detect drops via TryRecvError::Lagged. |
| 64-bit atomics required | The seqlock stamp is a u64. Platforms without atomic 64-bit operations are not supported. |
Channels, buses, pipelines, and wait strategies — all composable. See docs.rs for the full reference.
// Single producer, multiple consumers — the fastest path let (mut pub_, subs) = channel::<u64>(1024); let mut sub = subs.subscribe(); pub_.publish(42); assert_eq!(sub.try_recv(), Ok(42)); // Bounded backpressure (publisher blocks instead of overwriting) let (mut pub_, subs) = channel_bounded::<u64>(1024, 512); // Multiple producers let (mp_pub, subs) = channel_mpmc::<u64>(1024); let mp_pub2 = mp_pub.clone(); // MpPublisher: Clone + Send + Sync
let bus = Photon::<u64>::new(1024); let mut prices = bus.publisher("prices"); let mut trades = bus.publisher("trades"); let mut sub = bus.subscribe("prices"); prices.publish(100); assert_eq!(sub.try_recv(), Ok(100));
let (input, pipeline) = Pipeline::builder() .capacity(4096) .input::<u64>() .then(|x| x * 2) // stage 1: dedicated thread .then(|x| x + 1) // stage 2: dedicated thread .build(); input.publish(21); // Fan-out: diamond topology let (input, _pipeline) = Pipeline::builder() .capacity(1024) .input::<u64>() .fan_out(|x| x * 2, |x| x + 100) // two parallel branches .build();
use photon_ring::WaitStrategy; // Absolute lowest wakeup latency sub.recv_with(WaitStrategy::BusySpin); // Cooperative spinning (yields CPU between spins) sub.recv_with(WaitStrategy::YieldSpin); // Exponential backoff (good for mixed loads) sub.recv_with(WaitStrategy::BackoffSpin); // Automatically tunes based on observed latency sub.recv_with(WaitStrategy::Adaptive);
| Platform | Core ring | Affinity | Topology | Hugepages |
|---|---|---|---|---|
| x86_64 Linux | Yes | Yes | Yes | Yes |
| x86_64 macOS / Windows | Yes | Yes | Yes | No |
| aarch64 Linux | Yes | Yes | Yes | Yes |
| aarch64 macOS (Apple Silicon) | Yes | Yes | Yes | No |
| wasm32 | Yes | No | No | No |
| FreeBSD / NetBSD / Android | Yes | Yes | Yes | No |
| 32-bit ARM (Cortex-M) | No | No | No | No |
From zero to a working channel in under a minute
[dependencies] photon-ring = "2" # Optional features # photon-ring = { version = "2", features = ["derive", "hugepages"] }
use photon_ring::{channel, Photon}; fn main() { // SPMC: one publisher, multiple independent subscribers let (mut pub_, subs) = channel::<u64>(1024); let mut sub_a = subs.subscribe(); let mut sub_b = subs.subscribe(); pub_.publish(42); // Both subscribers see the same message assert_eq!(sub_a.try_recv(), Ok(42)); assert_eq!(sub_b.try_recv(), Ok(42)); // Named-topic bus let bus = Photon::<u64>::new(1024); let mut p = bus.publisher("prices"); let mut s = bus.subscribe("prices"); p.publish(100); assert_eq!(s.try_recv(), Ok(100)); }
use photon_ring::{channel, WaitStrategy}; use std::thread; let (mut pub_, subs) = channel::<u64>(4096); let mut sub = subs.subscribe(); let consumer = thread::spawn(move || { loop { match sub.try_recv() { Ok(v) => { /* process v */ } Err(_) => break, } } }); for i in 0..1_000_000 { pub_.publish(i); } consumer.join().unwrap();