Photon Ring banner

Photon Ring

Seqlock-stamped inter-thread messaging for Rust. Zero-allocation broadcast channels at near-hardware latency.

48 ns
p50 one-way latency
2.8 ns
publish cost (Intel)
300M
msg/s sustained
0 alloc
on hot path

Overview

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.

Slot layout

                    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
Near-hardware latency
48 ns p50 one-way latency on Intel i7-10700KF — within 20% of the bare L3 snoop floor, leaving almost no software overhead.
📡
True broadcast
Every subscriber sees every message. Fanout to 10 independent subscribers costs 17 ns total (Intel) — 1.7 ns per subscriber.
🧰
Zero allocation on the hot path
The ring is pre-allocated at construction. publish and try_recv never touch the allocator — no GC pauses, no malloc jitter.
🧪
Pod payload safety
Requires T: Pod (every bit pattern valid), making speculative torn reads safe to discard. Compile-time proof, not a runtime check.
SPMC and MPMC
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.
🌐
no_std + alloc
Works on bare metal, WASM, and embedded targets with alloc. Pipeline topology builder, hugepages, and CPU affinity are available on supported desktop/server platforms.

Benchmarks

Criterion (100 samples, --release, no custom RUSTFLAGS) on two machines. Numbers are medians unless stated.

Hardware

Intel i7-10700KF — Primary
CPUIntel Core i7-10700KF
MicroarchComet Lake (14 nm)
Cores / Threads8C / 16T (SMT on)
Base / Turbo3.80 GHz / 5.10 GHz
L1d / L2 / L332 KB / 256 KB / 16 MB
OSLinux 6.8 (Ubuntu)
Rust1.93.1 stable
Apple M1 Pro — Secondary
CPUApple M1 Pro
Architectureaarch64 (ARMv8.5-A)
Cores8 (6P + 2E)
L1d (P-core)128 KB
L212 MB (P-cluster)
OSmacOS 26.3
Rust1.92.0 stable

Core operations

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
Cross-thread roundtrip latency distribution
100,000,000 samples per library — Intel i7-10700KF, no core pinning
Publish Latency Comparison
Publish-only cost in nanoseconds, same Criterion run
Cross-Thread Roundtrip
Publisher → subscriber → signal-back, two machines
One-way Latency Percentiles (RDTSC)
p50, p90, p99, p99.9 on Intel i7-10700KF (x86_64)

Throughput

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

Payload scaling

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.

Payload scaling chart: Photon Ring same-thread and cross-thread latency across 8B-4KiB payloads
Reproducibility: Numbers use 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.

Where this fits

What inter-thread messaging designs cost, and which problem each one solves

Approaches

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.

ApproachWrite costRead costAllocationSolves
Locked queueLock acquisitionLock acquisition Dynamic growthGeneral handoff, any payload
Lock-free queueCAS on headCAS on tail NonePoint-to-point: one receiver owns each message
Shared sequence barrierSequence claim, then a fold over every consumer's position Barrier spinNoneOrdered pipelines with dependent stages
Stamped slots (this crate)Stamp + payload write Stamp check, private cursorNone 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.

Fanout scaling

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 consumers12481632Marginal
Photon Ring 3.95.98.5 14.925.950.2 1.5 ns
tokio::sync::broadcast 1.53 47.765.599.2167.3306.9579.617.2 ns
crossbeam-channel 0.5, one per consumer 22.344.387.8176.5351.3701.921.9 ns
flume 0.11, one per consumer 27.453.9108.7217.6428.4859.326.8 ns
Reading this honestly: the marginal column is what matters — a subscriber here costs about one cursor read and one stamp check, because subscribers share no state, so the producer's work does not grow with the audience. A point-to-point queue is not broadcast at all: fanning out means the producer sends once per consumer, so that row shows what broadcast costs on a queue that does not do broadcast, rather than a race those libraries lost. They solve the different and equally real problem of exactly one receiver owning each message. Reproduce with cargo bench --bench fanout_scaling.

Cross-thread fanout

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.

LibraryDeliveryN=1N=2N=4N=8
Photon Ringlossless 0.490.520.582.62
disruptor 4.0lossless 0.452.946.9912.15
crossbeam-channel 0.5, one per consumerlossless 1.3914.9027.9246.79
tokio::sync::broadcast 1.53lossy 8.238.6133.86119.47
Where this crate loses, and what is not established: at a single consumer 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.

Capabilities

CapabilityPhoton Ring
DeliveryBroadcast — every subscriber sees every message
PayloadsPod on the seqlock ring; any Send type on an event ring
Per-consumer contractsGating and non-gating subscribers on one ring
Consumer failureA dead consumer releases the publisher
Hot attachSubscribe to and detach from a running ring
BackpressureOptional, per subscriber
TopologiesPipelines, fan-out, managed terminal consumers
Topic busNamed topics, and a typed bus for per-topic payload types
Multi-producerYes
no_stdYes, with alloc
VerificationMiri and loom gated in CI; a TLA+ model of the seqlock checked by hand

Design constraints

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.
When to choose crossbeam-channel instead: If each message should be consumed by exactly one receiver (point-to-point ownership transfer), use crossbeam-channel. Photon Ring is optimised for broadcast: every subscriber sees the same stream with independent cursors and no contention.

API Overview

Channels, buses, pipelines, and wait strategies — all composable. See docs.rs for the full reference.

SPMC channel

Channel basics rust
// 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

Named-topic bus

Photon<T> bus rust
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));

Pipeline topology

Multi-stage pipeline builder rust
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();

Wait strategies

Blocking vs. spinning rust
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 support

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

Get Started

From zero to a working channel in under a minute

1. Add to Cargo.toml

Cargo.toml toml
[dependencies]
photon-ring = "2"

# Optional features
# photon-ring = { version = "2", features = ["derive", "hugepages"] }

2. Quick start

src/main.rs rust
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));
}

3. Cross-thread usage

Cross-thread example rust
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();

Resources