You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: README.md
+64-8Lines changed: 64 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -14,7 +14,7 @@
14
14
15
15
Photon Ring is a zero-allocation pub/sub crate for Rust built around pre-allocated ring buffers, per-slot stamp validation, and `T: Pod` payloads. 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 should see every message.
16
16
17
-
By default, slots use a volatile-based seqlock for maximum performance. With the `atomic-slots` feature, the same stamp protocol operates over `AtomicU64` stripes — **formally sound under the Rust abstract machine** with zero performance regression on x86-64.
17
+
By default, slots use a volatile-based seqlock for maximum performance. With the `atomic-slots` feature, the same stamp protocol operates over `AtomicU64` stripes — **free of data races under the Rust abstract machine**for padding-free payloads, with zero performance regression on x86-64, and verified under Miri in CI.
18
18
19
19
It is `no_std` compatible with `alloc`, supports named-topic buses and typed buses, and includes a pipeline builder for multi-stage thread topologies on supported desktop/server platforms.
20
20
@@ -57,7 +57,7 @@ Optional features:
57
57
58
58
-`derive`: enables `#[derive(photon_ring::DerivePod)]` for user-defined `Pod` types.
59
59
-`hugepages`: enables Linux memory controls such as `mlock`, `prefault`, and NUMA helpers.
60
-
-`atomic-slots`: enables a data-race-free slot implementation that decomposes the payload into `AtomicU64` stripes (stepping down through `AtomicU32`/`U16`/`U8` for a trailing partial stripe) instead of `write_volatile`/`read_volatile`. Zero performance cost on x86-64; ~5-10ns reader overhead on ARM64 due to acquire fence. Eliminates the formal undefined behavior the default path carries under the Rust abstract machine, **for payloads with no padding bytes**. Its multi-threaded tests run under Miri in CI (`miri (atomic-slots)`), so the claim is machine-checked rather than asserted. Padding is the remaining gap: a type such as `(u8, u64)` has 7 uninitialized bytes, and reading those as part of an atomic word is itself undefined. Use `#[repr(C)]` with explicit padding fields (as the examples do) so every byte is initialized.
60
+
-`atomic-slots`: enables a data-race-free slot implementation that decomposes the payload into `AtomicU64` stripes (stepping down through `AtomicU32`/`U16`/`U8` for a trailing partial stripe) instead of `write_volatile`/`read_volatile`. Zero performance cost on x86-64. On ARM64 both paths pay the same reader-side acquire fence, so `atomic-slots` costs nothing extra there either. Eliminates the formal undefined behavior the default path carries under the Rust abstract machine, **for payloads with no padding bytes**. Its multi-threaded tests run under Miri in CI (`miri (atomic-slots)`), so the claim is machine-checked rather than asserted. Padding is the remaining gap: a type such as `(u8, u64)` has 7 uninitialized bytes, and reading those as part of an atomic word is itself undefined. Use `#[repr(C)]` with explicit padding fields (as the examples do) so every byte is initialized.
61
61
62
62
Rust 1.94+ is supported. For best performance, compile with `-C target-cpu=native` to enable `PREFETCHW` and other CPU-specific optimizations.
63
63
@@ -106,9 +106,10 @@ Photon Ring moves synchronization into each slot. Every slot carries its own seq
106
106
3. if s1 < expected -> Empty
107
107
4. if s1 > expected -> Lagged
108
108
5. value = read_volatile(slot) (direct read, T: Pod)
109
-
6. s2 = stamp.load(Acquire)
110
-
7. if s1 == s2 -> return
111
-
8. else -> retry
109
+
6. fence(Acquire) (payload read completes before re-check)
110
+
7. s2 = stamp.load(Relaxed)
111
+
8. if s1 == s2 -> return
112
+
9. else -> retry
112
113
```
113
114
114
115
## Why this is fast
@@ -128,7 +129,7 @@ Measured with Criterion on an **Intel i7-10700KF** (8C/16T, 3.80 GHz, Linux 6.8,
128
129
129
130
### Against `disruptor-rs`
130
131
131
-
-**Publish:**2.8 ns (Intel) / 2.4 ns (M1 Pro), versus 30.6 ns / 15.3 ns for `disruptor-rs`
132
+
-**Publish:**not directly comparable as measured here — photon's publish-only benchmark ran with no consumer attached, while `disruptor-rs` always runs one, so the gap partly measures cache-coherence traffic that photon never paid. Use the `publish, live consumer` benchmarks for a like-for-like figure.
132
133
-**Cross-thread roundtrip:** 95 ns (Intel) / 130 ns (M1 Pro), versus 138 ns / 186 ns for `disruptor-rs`
133
134
134
135
### Core operations
@@ -153,6 +154,47 @@ Measured with Criterion on an **Intel i7-10700KF** (8C/16T, 3.80 GHz, Linux 6.8,
153
154
-**Sustained throughput:** about 300M msg/s on Intel and 88M msg/s on M1 Pro
154
155
-**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 [`docs/payload-scaling.md`](docs/payload-scaling.md)
155
156
157
+
## Degradation, not deadlock
158
+
159
+
Backpressure exists so a consumer that must not lose messages can stop the
160
+
publisher. But two things should never stop the world: a consumer that is only
161
+
*observing*, and a consumer that has *died*.
162
+
163
+
Because each slot carries its own stamp, subscribers need no shared barrier —
164
+
so a single ring can carry **different delivery contracts per consumer**:
165
+
166
+
```rust
167
+
let (mutpub_, subs) =channel_bounded::<Order>(1024, 0);
168
+
169
+
letmutrisk=subs.subscribe(); // gates the publisher, loses nothing
170
+
letmuttelemetry=subs.subscribe_lossy(); // never gates it, drops when behind
171
+
```
172
+
173
+
`risk` keeps its no-loss guarantee. `telemetry` is invisible to the publisher's
174
+
backpressure scan, so however slow it gets it cannot stall order flow; when it
175
+
falls behind it observes `Lagged { skipped }` with an exact count, and
176
+
`receive_ratio()` reports what it sampled. Both read the same sequence numbers,
177
+
so an observation can be correlated with the message the risk engine processed.
178
+
179
+
A consumer that *dies* also releases the ring, **provided its `Subscriber` drops
180
+
with it** — which is automatic when the consumer thread owns the subscriber, as
181
+
the thread unwinds and `Drop` removes it from the backpressure set. A subscriber
182
+
parked in long-lived shared state (an `Arc`'d registry, a supervisor struct)
183
+
outlives its consumer and keeps gating the publisher, so don't do that with a
184
+
tracked subscriber. A merely *wedged* consumer still applies backpressure — that
185
+
is the guarantee working as intended.
186
+
187
+
The no-loss guarantee is a property of each tracked subscriber's lifetime, not
188
+
of the ring: if the last tracked subscriber goes away while lossy ones remain,
189
+
nothing gates the publisher any more and the bounded ring behaves like a lossy
190
+
one.
191
+
192
+
Subscribers can also attach to a ring that is already running, so a lossy
193
+
debug tap can be added and removed on a live system without perturbing it.
194
+
195
+
`cargo run --release --example degradation` demonstrates both scenarios;
196
+
`tests/degradation.rs` asserts them.
197
+
156
198
## Comparison
157
199
158
200
|| Photon Ring | disruptor-rs (v4) | crossbeam-channel | bus |
@@ -242,11 +284,25 @@ Photon Ring offers two slot implementations, selectable at compile time:
242
284
|**Formal status**| Data race under Rust abstract machine (practical UB) |**Formally sound** — no data races |
243
285
|**Miri**| Flags multi-threaded tests |**Passes, enforced in CI**|
|**ARM64 cost**|One `DMB ISHLD`reader fence (both paths) | Same fence — no additional cost|
246
288
|**Precedent**| Same pattern as Linux kernel seqlocks (20+ years) | Per-word atomic decomposition, as in `atomic-memcpy`|
247
289
248
290
> [!NOTE]
249
-
> The default volatile-based implementation is **correct on all real hardware** (x86, ARM). The "UB" is purely under Rust's abstract machine — no compiler has ever miscompiled this pattern, and the Linux kernel relies on identical semantics. Enable `atomic-slots` if you need formal soundness, Miri compliance, or defense against hypothetical future compiler optimizations.
291
+
> Both implementations place an `Acquire` fence between the payload read and the
292
+
> stamp re-check — the same barrier the Linux kernel's `read_seqcount_retry()`
293
+
> carries as `smp_rmb()`. Without it an acquire *load* is one-way and a weakly
294
+
> ordered CPU may satisfy the payload read after the re-check has validated,
295
+
> which would return data from a later overwrite. On x86 the fence emits no
296
+
> instruction — TSO already orders load-load — but it is still a compiler
297
+
> barrier, and measured at roughly +1.2 ns on the same-thread roundtrip
298
+
> microbenchmark because it forbids reordering the optimiser was otherwise
299
+
> free to do. That is the price of the guarantee, on every architecture.
300
+
>
301
+
> With that fence in place, the default volatile implementation produces correct
302
+
> results on real hardware; what remains is that it is a data race under Rust's
303
+
> abstract machine, which Miri reports and no compiler has yet exploited. Enable
304
+
> `atomic-slots` for a build free of that race — machine-checked in CI, and free
305
+
> on x86-64.
250
306
251
307
> [!TIP]
252
308
> Keep rich domain types at the edges and publish compact `Pod` messages in the middle. Convert enums, `Option`, booleans, and strings into explicit numeric fields or fixed-size buffers before calling `publish`.
0 commit comments