subetha_cxc/shared_deque_loh.rs
1//! `SharedDequeLoh` - LCRQ-on-LIFO Hybrid deque, MMF-backed.
2//!
3//! Sibling to [`SharedDeque`](crate::SharedDeque) (Chase-Lev) and
4//! [`SharedDequeKhpd`](crate::SharedDequeKhpd) (publication-line). LOH
5//! targets the producer-fast burst shape: owner-side push goes into a
6//! process-private LIFO with *no atomic*; the migration step drains a
7//! batch into a Vyukov-sequence-number ring with one
8//! `tail.fetch_add(N)` plus N Release-stores. Thieves race on `head`
9//! via CAS with a sequence-number check that pre-validates the slot.
10//!
11//! ## Why this shape
12//!
13//! Chase-Lev pays one Release-store on `bottom` per item and the
14//! steal-side CAS on `top` per claimed item. For per-item request-
15//! reply that matches the cost of one cache-line bounce; for a
16//! workload that publishes many items per coherence interval
17//! (parallel-for fan-out, fork-join leaves) the per-item bookkeeping
18//! is unamortized. LOH amortizes by letting the owner stage many
19//! items in a private heap (no atomic) and pay one ring-tail update
20//! per migration batch.
21//!
22//! Trade-offs vs Chase-Lev MMF:
23//!
24//! - **Owner push** drops from one Release-store on `bottom` per item
25//! to a plain `Vec::push` (~3 ns).
26//! - **Migration** is one `tail.fetch_add(batch)` plus `batch`
27//! Release-stores on per-slot sequence numbers.
28//! - **Thief steal** is one CAS on `head` (same shape as Chase-Lev's
29//! `top` CAS) plus a sequence-number check on the slot. The
30//! wasted-ticket race that pure-XADD LCRQ exhibits is avoided by
31//! gating the CAS on `head < tail`.
32//!
33//! Where LOH wins per the cost model: bursty dispatch where the
34//! per-burst migration amortizes over many items per cache-line
35//! bounce. Where LOH does NOT win: single-item request-reply,
36//! because there's no batching to amortize against.
37//!
38//! ## Hot path API: [`publish_batch`](SharedDequeLoh::publish_batch)
39//!
40//! The canonical producer-fast API takes a slice of [`LineItem`] and
41//! migrates the whole batch in one shot. It bypasses the local LIFO
42//! entirely, paying exactly one Mutex acquire + one
43//! `tail.fetch_add(items.len())` + `items.len()` Release-stores for
44//! the call. This is the path that exercises the amortization lever
45//! and is the shape benchmarks measure.
46//!
47//! The [`push`](SharedDequeLoh::push) /
48//! [`flush`](SharedDequeLoh::flush) pair is still exposed for callers
49//! that want to stage items incrementally and migrate later
50//! (autoflushes at a configurable threshold). Per-item `push` does
51//! NOT exercise the amortization lever; it pays the same Mutex on
52//! every staged item.
53//!
54//! ## Layout
55//!
56//! ```text
57//! +-----------------------------+
58//! | LohHeader (128B) | magic, capacity, owner_pid,
59//! | | epoch, tail on its own cache
60//! | | line, head on its own cache line
61//! +-----------------------------+
62//! | LcrqJobSlot[0] (64B) | sequence (8B) + LineItem (16B)
63//! | LcrqJobSlot[1] | + 40B trailing padding
64//! | ... |
65//! | LcrqJobSlot[capacity-1] |
66//! +-----------------------------+
67//! ```
68//!
69//! Each slot is exactly one cache line so adjacent slots never share
70//! coherence-traffic lines. The `LineItem` payload is the same
71//! byte-oriented 16-byte struct
72//! [`SharedDequeKhpd`](crate::SharedDequeKhpd) and
73//! [`SharedDequeUrd`](crate::SharedDequeUrd) use, re-exported via
74//! [`crate::LineItem`] so consumers can ferry the same byte pattern
75//! across all three primitives without re-marshalling.
76//!
77//! ## When to use this vs `SharedDeque` / `SharedDequeKhpd`
78//!
79//! - **`SharedDeque<T>` (Chase-Lev)**: per-item dispatch and steal,
80//! strict LIFO at the owner; lowest constant when there is no
81//! batching.
82//! - **`SharedDequeKhpd`**: producer packs `LINE_ITEMS = 3` items per
83//! publication line and publishes them with one Release-store on
84//! `state`. The win zone is "K items per call where K is a small
85//! multiple of 3."
86//! - **`SharedDequeLoh` (this primitive)**: producer batches K items
87//! per call and pays one `tail.fetch_add(K)` plus K Release-stores.
88//! The win zone is "K items per call where the producer wants to
89//! amortize the producer-counter atomic over an arbitrary batch
90//! size."
91
92#![allow(clippy::missing_errors_doc)]
93
94use std::fs::{File, OpenOptions};
95use std::io;
96use std::path::Path;
97use std::sync::atomic::{AtomicI64, AtomicU64, Ordering, fence};
98
99use memmap2::{MmapMut, MmapOptions};
100use parking_lot::Mutex;
101
102use crate::shared_deque_khpd::LineItem;
103
104/// Prefetch the cache line at `slot` with write-intent (the M-state
105/// hint). Emits `PREFETCHW` directly via inline asm on x86_64
106/// because Rust's stable `_mm_prefetch` only exposes the T0/T1/T2/
107/// NTA hints (S-state targets), which force a publisher write to
108/// pay an RFO coherence upgrade. `PREFETCHW` brings the line to
109/// M-state directly so the publisher's payload Release-store costs
110/// one cycle instead of a cross-core RFO. The instruction is a NOP
111/// on x86_64 CPUs without the `PRFCHW` feature flag (3DNow-era
112/// AMD has it natively; Intel since Broadwell), so it is safe to
113/// unconditionally emit on x86_64.
114///
115/// On non-x86_64 architectures this compiles to a no-op.
116#[inline(always)]
117fn prefetch_slot(slot: *const LcrqJobSlot) {
118 #[cfg(target_arch = "x86_64")]
119 {
120 // SAFETY: `prefetchw` is a hardware hint and never faults on
121 // unmapped memory; the CPU silently ignores invalid
122 // addresses. `nostack` + `preserves_flags` lets the
123 // optimizer schedule freely around the asm.
124 unsafe {
125 core::arch::asm!(
126 "prefetchw [{ptr}]",
127 ptr = in(reg) slot,
128 options(nostack, preserves_flags),
129 );
130 }
131 }
132 #[cfg(not(target_arch = "x86_64"))]
133 {
134 _ = slot;
135 }
136}
137
138/// Magic byte sequence marking a valid LOH file. Reads as ASCII
139/// "WLOH" + version. Distinct from the Chase-Lev / KHPD / URD magics
140/// so a file-confusion is rejected at open time.
141pub const LOH_MAGIC: u64 = 0x574C_4F48_0000_0001;
142
143/// One slot is exactly one cache line.
144pub const LOH_SLOT_SIZE: usize = 64;
145
146/// Default LIFO soft cap. Push past this returns
147/// [`PushError::LifoFull`]; caller must `flush()` or back off.
148pub const DEFAULT_LIFO_CAP: usize = 256;
149
150/// File header. Cache-line aligned. `head` and `tail` each get their
151/// own cache line so the producer-side `tail.fetch_add` does not
152/// invalidate the consumer-side `head` line.
153#[repr(C, align(64))]
154pub struct LohHeader {
155 /// Magic constant.
156 pub magic: u64,
157 /// Number of ring slots; always a power of two.
158 pub capacity: u64,
159 /// Pid of the owner process; informational. Cleared on
160 /// `close_owner()`.
161 pub owner_pid: AtomicU64,
162 /// Epoch counter advanced by the owner on shutdown.
163 pub epoch: AtomicU64,
164 /// Padding to push `tail` to its own cache line.
165 pub _pad_meta: [u8; 24],
166 /// Producer counter. Owner `fetch_add(batch_size)` during
167 /// migration to claim a contiguous block of slots.
168 pub tail: AtomicI64,
169 /// Padding to push `head` to its own cache line.
170 pub _pad_tail: [u8; 56],
171 /// Consumer counter. Thieves CAS this to claim a slot.
172 pub head: AtomicI64,
173 /// Padding round to two whole cache lines after `head`.
174 pub _pad_head: [u8; 56],
175}
176
177/// Ring slot: Vyukov sequence + byte-oriented [`LineItem`] payload.
178/// Fixed shape, 64 bytes, process-portable.
179#[repr(C, align(64))]
180pub struct LcrqJobSlot {
181 /// Vyukov-style sequence number gating payload access:
182 /// - On creation: `seq == idx` (slot empty, ready to publish).
183 /// - After producer Release-store: `seq == idx + 1` (published,
184 /// consumer may read).
185 /// - After consumer Release-store: `seq == idx + capacity`
186 /// (consumed, ready for next round at `idx + capacity`).
187 pub sequence: AtomicI64,
188 /// Caller's byte-oriented payload.
189 pub item: LineItem,
190 /// Trailing padding rounding the slot to 64 bytes.
191 pub _pad: [u8; 40],
192}
193
194/// Total file size for a ring with `capacity` slots, including the
195/// header.
196pub const fn loh_file_size(capacity: usize) -> usize {
197 std::mem::size_of::<LohHeader>() + capacity * LOH_SLOT_SIZE
198}
199
200/// Outcome of [`SharedDequeLoh::push`] / [`SharedDequeLoh::flush`] /
201/// [`SharedDequeLoh::publish_batch`].
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum PushError {
204 /// Ring at capacity; consumer hasn't caught up. Caller may spin,
205 /// back off, or report upstream pressure.
206 Full,
207 /// Owner-side LIFO at its soft cap; caller must `flush()` or
208 /// back off before pushing more.
209 LifoFull,
210}
211
212/// Outcome of [`SharedDequeLoh::steal`].
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum Steal {
215 /// Got a slot's payload.
216 Success(StealResult),
217 /// Ring empty (no published item past `head`).
218 Empty,
219 /// CAS lost to a competing thief, or the publisher's Release on
220 /// the sequence number is missing from the slot snapshot; outer
221 /// loop should retry.
222 Retry,
223}
224
225/// Payload returned by a successful steal.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub struct StealResult {
228 /// The slot's 16-byte byte-oriented payload.
229 pub item: LineItem,
230}
231
232/// MMF-backed LOH deque. Single owner (the process that created the
233/// file); arbitrarily many thieves across processes.
234pub struct SharedDequeLoh {
235 _file: File,
236 mmap: MmapMut,
237 capacity: usize,
238 capacity_mask: i64,
239 flush_threshold: usize,
240 lifo_cap: usize,
241 /// Owner-side LIFO. `parking_lot::Mutex` is uncontended on the
242 /// hot path because, by protocol, only the originator thread
243 /// pushes; the Mutex exists so [`SharedDequeLoh`] can be shared
244 /// as `Arc<SharedDequeLoh>` between the originator and a
245 /// flush-trigger thread without losing `Sync`. The
246 /// [`Self::publish_batch`] hot path bypasses this Mutex entirely
247 /// (it does not touch the LIFO), so the batched-publish
248 /// throughput is set by `tail.fetch_add` cost only.
249 local_lifo: Mutex<Vec<LineItem>>,
250}
251
252// SAFETY: All fields are Send. Mmap handle is Send + Sync per
253// memmap2; every ring access goes through the LCRQ sequence-number
254// protocol (per-slot Acquire / Release pair) so concurrent producers
255// and consumers see a consistent view. The Mutex around the LIFO
256// linearizes owner-side accesses across any thread the originator
257// happens to schedule the push on.
258unsafe impl Send for SharedDequeLoh {}
259// SAFETY: Same justification as the `Send` impl directly above.
260unsafe impl Sync for SharedDequeLoh {}
261
262impl SharedDequeLoh {
263 /// Create a fresh LOH file. `capacity` rounds up to the next
264 /// power of two (min 2). `flush_threshold` is the LIFO length at
265 /// which an automatic [`Self::flush`] fires on the next push.
266 pub fn create<P: AsRef<Path>>(
267 path: P,
268 capacity: usize,
269 flush_threshold: usize,
270 ) -> io::Result<Self> {
271 let capacity = capacity.max(2).next_power_of_two();
272 let size = loh_file_size(capacity);
273
274 let file = OpenOptions::new()
275 .read(true)
276 .write(true)
277 .create(true)
278 .truncate(true)
279 .open(path.as_ref())?;
280 file.set_len(size as u64)?;
281
282 // SAFETY: `map_mut` is unsafe because the kernel cannot
283 // prevent another process from truncating or mutating the
284 // backing file in ways that violate Rust's aliasing rules.
285 // This call site upholds the soundness contract by writing
286 // only through the LCRQ per-slot sequence-number protocol;
287 // the file size is fixed by `file.set_len` immediately above
288 // and never shrunk for the lifetime of any mapping.
289 let mut mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
290
291 let header_ptr = mmap.as_mut_ptr() as *mut LohHeader;
292 // SAFETY: mmap is page-aligned (well above the 64-byte
293 // alignment LohHeader requires); the map covers
294 // `loh_file_size(capacity)` bytes by construction.
295 unsafe {
296 (*header_ptr).magic = LOH_MAGIC;
297 (*header_ptr).capacity = capacity as u64;
298 (*header_ptr).owner_pid = AtomicU64::new(std::process::id() as u64);
299 (*header_ptr).epoch = AtomicU64::new(0);
300 std::ptr::write_bytes((*header_ptr)._pad_meta.as_mut_ptr(), 0, 24);
301 (*header_ptr).tail = AtomicI64::new(0);
302 std::ptr::write_bytes((*header_ptr)._pad_tail.as_mut_ptr(), 0, 56);
303 (*header_ptr).head = AtomicI64::new(0);
304 std::ptr::write_bytes((*header_ptr)._pad_head.as_mut_ptr(), 0, 56);
305 }
306
307 // Initialise each slot's sequence to its index. On first
308 // producer touch, `sequence == idx`, so the publisher knows
309 // the slot is ready to publish (write payload, then
310 // Release-store `idx + 1`).
311 let slots_start = std::mem::size_of::<LohHeader>();
312 for i in 0..capacity {
313 let off = slots_start + i * LOH_SLOT_SIZE;
314 // SAFETY: `off + LOH_SLOT_SIZE <= loh_file_size(capacity)`
315 // by construction; the cast to `*mut LcrqJobSlot` is sound
316 // because the slot is `repr(C, align(64))` and `off` is a
317 // multiple of 64.
318 let slot_ptr = unsafe { mmap.as_mut_ptr().add(off) as *mut LcrqJobSlot };
319 // SAFETY: `slot_ptr` is in-bounds and aligned; payload
320 // bytes are valid for any bit pattern.
321 unsafe {
322 (*slot_ptr).sequence = AtomicI64::new(i as i64);
323 (*slot_ptr).item = LineItem::default();
324 std::ptr::write_bytes((*slot_ptr)._pad.as_mut_ptr(), 0, 40);
325 }
326 }
327
328 mmap.flush()?;
329
330 let flush_threshold = flush_threshold.max(1);
331 Ok(Self {
332 _file: file,
333 mmap,
334 capacity,
335 capacity_mask: (capacity as i64) - 1,
336 flush_threshold,
337 lifo_cap: DEFAULT_LIFO_CAP,
338 local_lifo: Mutex::new(Vec::with_capacity(DEFAULT_LIFO_CAP)),
339 })
340 }
341
342 /// Open an existing LOH file. Validates magic and capacity.
343 pub fn open<P: AsRef<Path>>(path: P, flush_threshold: usize) -> io::Result<Self> {
344 let file = OpenOptions::new()
345 .read(true)
346 .write(true)
347 .open(path.as_ref())?;
348 let size = file.metadata()?.len() as usize;
349 if size < std::mem::size_of::<LohHeader>() {
350 return Err(io::Error::new(
351 io::ErrorKind::InvalidData,
352 "loh file too small to contain header",
353 ));
354 }
355
356 // SAFETY: Same justification as `create` - protocol-only
357 // access through the per-slot sequence number.
358 let mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
359
360 let header_ptr = mmap.as_ptr() as *const LohHeader;
361 // SAFETY: map size verified to cover header; mmap alignment
362 // exceeds header alignment.
363 let (magic, capacity) =
364 unsafe { ((*header_ptr).magic, (*header_ptr).capacity as usize) };
365 if magic != LOH_MAGIC {
366 return Err(io::Error::new(
367 io::ErrorKind::InvalidData,
368 format!("loh magic mismatch: got {magic:#x}, want {LOH_MAGIC:#x}"),
369 ));
370 }
371 if !capacity.is_power_of_two() || capacity < 2 {
372 return Err(io::Error::new(
373 io::ErrorKind::InvalidData,
374 format!("loh capacity {capacity} is not a power of two >= 2"),
375 ));
376 }
377 if size < loh_file_size(capacity) {
378 return Err(io::Error::new(
379 io::ErrorKind::InvalidData,
380 format!(
381 "loh file size {size} below expected {}",
382 loh_file_size(capacity)
383 ),
384 ));
385 }
386
387 let flush_threshold = flush_threshold.max(1);
388 Ok(Self {
389 _file: file,
390 mmap,
391 capacity,
392 capacity_mask: (capacity as i64) - 1,
393 flush_threshold,
394 lifo_cap: DEFAULT_LIFO_CAP,
395 local_lifo: Mutex::new(Vec::with_capacity(DEFAULT_LIFO_CAP)),
396 })
397 }
398
399 /// Slot count of the ring (always a power of two).
400 pub fn capacity(&self) -> usize {
401 self.capacity
402 }
403
404 /// Configured auto-flush threshold (LIFO length that triggers a
405 /// flush on the next push).
406 pub fn flush_threshold(&self) -> usize {
407 self.flush_threshold
408 }
409
410 /// Pid of the owner process at create time, or 0 if cleared.
411 pub fn owner_pid(&self) -> u64 {
412 self.header().owner_pid.load(Ordering::Acquire)
413 }
414
415 /// Owner shutdown: zero pid + advance epoch.
416 pub fn close_owner(&self) {
417 self.header().owner_pid.store(0, Ordering::Release);
418 self.header().epoch.fetch_add(1, Ordering::Release);
419 }
420
421 fn header(&self) -> &LohHeader {
422 // SAFETY: map covers the header; alignment is page-aligned.
423 unsafe { &*(self.mmap.as_ptr() as *const LohHeader) }
424 }
425
426 fn slot_ptr(&self, idx: i64) -> *mut LcrqJobSlot {
427 let slot_idx = (idx & self.capacity_mask) as usize;
428 let off = std::mem::size_of::<LohHeader>() + slot_idx * LOH_SLOT_SIZE;
429 // SAFETY: `slot_idx` is in [0, capacity); `off` is within the
430 // mapped region and 64-byte aligned.
431 unsafe { self.mmap.as_ptr().add(off) as *mut LcrqJobSlot }
432 }
433
434 /// Snapshot the current `(head, tail, ring_size, lifo_len)`.
435 /// Loads are independent; the tuple is not a linearizable
436 /// snapshot - useful for debug / introspection only.
437 pub fn snapshot_size(&self) -> (i64, i64, i64, usize) {
438 let h = self.header();
439 let head = h.head.load(Ordering::Acquire);
440 let tail = h.tail.load(Ordering::Acquire);
441 let lifo_len = self.local_lifo.try_lock().map(|g| g.len()).unwrap_or(0);
442 (head, tail, tail - head, lifo_len)
443 }
444
445 /// Owner-side push. Stages the item in the local LIFO; when the
446 /// LIFO reaches `flush_threshold` an automatic [`Self::flush`]
447 /// fires that drains the LIFO into the ring tail.
448 ///
449 /// **Only the owner process may call this.**
450 pub fn push(&self, item: LineItem) -> Result<(), PushError> {
451 let mut lifo = self.local_lifo.lock();
452 if lifo.len() >= self.lifo_cap {
453 return Err(PushError::LifoFull);
454 }
455 lifo.push(item);
456 if lifo.len() >= self.flush_threshold {
457 // Flush from inside the lock to keep the LIFO consistent
458 // with the migration count. If the flush fails (ring at
459 // capacity), undo the push so the caller can retry with
460 // a clean LIFO state.
461 if let Err(e) = self.flush_locked(&mut lifo) {
462 lifo.pop();
463 return Err(e);
464 }
465 }
466 Ok(())
467 }
468
469 /// Owner-side explicit flush. Drains the local LIFO into the
470 /// ring's tail in one batch (one `tail.fetch_add(N)` + N
471 /// Release-stores). Returns the number of items migrated.
472 pub fn flush(&self) -> Result<usize, PushError> {
473 let mut lifo = self.local_lifo.lock();
474 self.flush_locked(&mut lifo)
475 }
476
477 /// Owner-side single-call batch publish. **Holds zero locks.**
478 /// The LIFO-bypass property is the SubEtha-native lever: the
479 /// upstream LCRQ-on-LIFO design held a Mutex during batch
480 /// publish to satisfy a separate dispatch-backend `&self`
481 /// contract; SubEtha's owner-only protocol makes that Mutex
482 /// gratuitous on the batch path. `tail.fetch_add(N)` atomically
483 /// reserves a disjoint slot range; the per-slot sequence-number
484 /// protocol gates the writes. A sibling `flush()` or `push()`
485 /// touching the LIFO is independent: it competes only on
486 /// `tail.fetch_add`, not on the LIFO Vec.
487 ///
488 /// Cost per call: one `tail.fetch_add(items.len())` plus
489 /// `items.len()` per-slot Release-stores on the sequence number.
490 ///
491 /// Returns the number of items migrated.
492 pub fn publish_batch(&self, items: &[LineItem]) -> Result<usize, PushError> {
493 if items.is_empty() {
494 return Ok(0);
495 }
496 let n = items.len();
497 let h = self.header();
498 let head_snapshot = h.head.load(Ordering::Acquire);
499 let tail_snapshot = h.tail.load(Ordering::Relaxed);
500 if (tail_snapshot - head_snapshot + n as i64) > self.capacity as i64 {
501 return Err(PushError::Full);
502 }
503 let base = h.tail.fetch_add(n as i64, Ordering::AcqRel);
504
505 // Prefetch the first slot before entering the publish loop so
506 // the producer's sequence Acquire-load hits a warm line.
507 prefetch_slot(self.slot_ptr(base));
508
509 for (i, item) in items.iter().enumerate() {
510 let idx = base + i as i64;
511 // Warm the NEXT slot's cache line while we publish this
512 // one. The `i + 1 < n` guard avoids prefetching past the
513 // reserved range.
514 if i + 1 < n {
515 prefetch_slot(self.slot_ptr(idx + 1));
516 }
517 // SAFETY: slot_ptr returns an in-bounds aligned pointer.
518 unsafe {
519 self.publish_at(idx, *item);
520 }
521 }
522 Ok(n)
523 }
524
525 fn flush_locked(&self, lifo: &mut Vec<LineItem>) -> Result<usize, PushError> {
526 let n = lifo.len();
527 if n == 0 {
528 return Ok(0);
529 }
530 let h = self.header();
531 let head_snapshot = h.head.load(Ordering::Acquire);
532 let tail_snapshot = h.tail.load(Ordering::Relaxed);
533 if (tail_snapshot - head_snapshot + n as i64) > self.capacity as i64 {
534 // Ring would overflow; report Full so caller can back
535 // off. Items remain in the LIFO for the next flush
536 // attempt.
537 return Err(PushError::Full);
538 }
539 let base = h.tail.fetch_add(n as i64, Ordering::AcqRel);
540
541 // Drain LIFO in FIFO order (oldest first) so the ring sees
542 // items in their original push order. `drain()` avoids the
543 // O(N) shift cost of pop()-into-reverse.
544 for (i, item) in lifo.drain(..).enumerate() {
545 let idx = base + i as i64;
546 // SAFETY: slot_ptr returns an in-bounds aligned pointer.
547 unsafe {
548 self.publish_at(idx, item);
549 }
550 }
551 Ok(n)
552 }
553
554 /// Migrate one item into the slot at ring index `idx` under the
555 /// Vyukov sequence-number protocol.
556 ///
557 /// # Safety
558 ///
559 /// Caller must have reserved the slot by holding the producer
560 /// lock and having `idx` in `[base, base + N)` of a successful
561 /// `tail.fetch_add(N)`.
562 unsafe fn publish_at(&self, idx: i64, item: LineItem) {
563 let slot = self.slot_ptr(idx);
564 // Spin-wait until the slot is publishable (sequence == idx).
565 // For the owner path this should usually already be true:
566 // head <= tail always and slot.sequence advances past idx
567 // only when a consumer has taken it.
568 loop {
569 // SAFETY: `slot` is the in-bounds aligned pointer returned
570 // by `slot_ptr`; the LCRQ sequence-number protocol ensures
571 // no other writer touches this slot between our reservation
572 // (caller-held `tail.fetch_add`) and the Release-store at
573 // the bottom of this function.
574 let seq = unsafe { (*slot).sequence.load(Ordering::Acquire) };
575 let diff = seq - idx;
576 if diff == 0 {
577 // Slot ready: consumer released the prior round (or
578 // this is the first publish, where init set
579 // sequence == idx).
580 break;
581 }
582 if diff < 0 {
583 // Prior round's consumer still owns the slot. Spin.
584 std::hint::spin_loop();
585 continue;
586 }
587 // diff > 0: the slot's sequence is for a future round.
588 // With a single producer and the capacity-check guard
589 // this is unreachable; loud panic so the cause can be
590 // diagnosed instead of silently overwriting a slot.
591 panic!(
592 "LOH producer protocol violation: slot[{}] seq={} ahead of idx={}",
593 idx & self.capacity_mask,
594 seq,
595 idx
596 );
597 }
598 // SAFETY: same as the Acquire-load above; we own the slot for
599 // this round per the caller's reservation in `tail.fetch_add`.
600 unsafe {
601 (*slot).item = item;
602 (*slot).sequence.store(idx + 1, Ordering::Release);
603 }
604 }
605
606 /// Owner-side pop from the local LIFO. Items still in the LIFO
607 /// (unmigrated) may be retrieved locally without round-tripping
608 /// through the ring.
609 pub fn pop_local(&self) -> Option<LineItem> {
610 let mut lifo = self.local_lifo.lock();
611 lifo.pop()
612 }
613
614 /// Thief-side steal. Race-free CAS-on-head with sequence-number
615 /// validation on the slot. Returns [`Steal::Retry`] when a
616 /// competing thief beat us on the head CAS, or when the
617 /// publisher's Release on the sequence number is missing from
618 /// the slot snapshot; outer loop should retry.
619 pub fn steal(&self) -> Steal {
620 let h = self.header();
621 let head = h.head.load(Ordering::Acquire);
622 fence(Ordering::SeqCst);
623 let tail = h.tail.load(Ordering::Acquire);
624 if head >= tail {
625 return Steal::Empty;
626 }
627 let slot = self.slot_ptr(head);
628 // Check the sequence ahead of the CAS. The producer Release-
629 // stores `head + 1` after writing the slot bytes; a value
630 // less than that means the publisher's Release on the slot
631 // is missing from our snapshot, and a value greater than
632 // that means the ring has wrapped and the producer has
633 // re-published this slot for a future round (the head we
634 // loaded is stale).
635 //
636 // SAFETY: slot is in-bounds + aligned.
637 let seq = unsafe { (*slot).sequence.load(Ordering::Acquire) };
638 if seq != head + 1 {
639 return Steal::Retry;
640 }
641 // Try to claim head. Once we win the CAS we own slot[head &
642 // mask] for this round: the producer cannot re-publish the
643 // slot until we release the sequence to `head + capacity`,
644 // and the seq-check above already confirmed the publisher
645 // released `head + 1`. The slot bytes we read below are the
646 // bytes the producer wrote for this round.
647 let won = h
648 .head
649 .compare_exchange(head, head + 1, Ordering::SeqCst, Ordering::Relaxed)
650 .is_ok();
651 if !won {
652 return Steal::Retry;
653 }
654 // SAFETY: same as above; head is now ours and the producer's
655 // Release on slot.sequence happens-before our Acquire load
656 // of slot.sequence above.
657 let result = unsafe {
658 StealResult {
659 item: (*slot).item,
660 }
661 };
662 // Release the slot for the next round at `head + capacity`.
663 //
664 // SAFETY: still our slot; the Release synchronises with the
665 // next producer's Acquire-spin in `publish_at`.
666 unsafe {
667 (*slot)
668 .sequence
669 .store(head + self.capacity as i64, Ordering::Release);
670 }
671 Steal::Success(result)
672 }
673
674 /// Force any dirty pages to disk.
675 pub fn flush_to_disk(&self) -> io::Result<()> {
676 self.mmap.flush()
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use std::sync::Arc;
684 use std::sync::atomic::{AtomicUsize, Ordering as O};
685 use std::thread;
686
687 fn temp_path(name: &str) -> std::path::PathBuf {
688 let mut p = std::env::temp_dir();
689 let pid = std::process::id();
690 let nonce = std::time::SystemTime::now()
691 .duration_since(std::time::UNIX_EPOCH)
692 .map(|d| d.as_nanos())
693 .unwrap_or(0);
694 p.push(format!("subetha_loh_{pid}_{nonce}_{name}.bin"));
695 p
696 }
697
698 fn u32_item(id: u32) -> LineItem {
699 LineItem::new(&id.to_le_bytes()).expect("build item")
700 }
701
702 fn item_id(item: &LineItem) -> u32 {
703 u32::from_le_bytes(item.payload[..4].try_into().unwrap())
704 }
705
706 #[test]
707 fn create_then_open_round_trips_header() {
708 let path = temp_path("create_open");
709 let _d = SharedDequeLoh::create(&path, 8, 4).expect("create");
710 let o = SharedDequeLoh::open(&path, 4).expect("open");
711 assert_eq!(o.capacity(), 8);
712 assert_eq!(o.owner_pid(), std::process::id() as u64);
713 std::fs::remove_file(&path).ok();
714 }
715
716 #[test]
717 fn open_rejects_bad_magic() {
718 let path = temp_path("bad_magic");
719 std::fs::write(&path, vec![0xCDu8; 8192]).expect("seed");
720 let r = SharedDequeLoh::open(&path, 4);
721 assert!(r.is_err());
722 std::fs::remove_file(&path).ok();
723 }
724
725 #[test]
726 fn push_and_explicit_flush_migrates() {
727 let path = temp_path("flush");
728 // flush_threshold = usize::MAX so auto-flush never fires;
729 // the explicit `flush()` is the only path to the ring.
730 let d = SharedDequeLoh::create(&path, 8, usize::MAX).expect("create");
731 for i in 0..3u32 {
732 d.push(u32_item(i)).expect("push");
733 }
734 // Ring is still empty before flush.
735 let (head, tail, sz, lifo_len) = d.snapshot_size();
736 assert_eq!(head, 0);
737 assert_eq!(tail, 0);
738 assert_eq!(sz, 0);
739 assert_eq!(lifo_len, 3);
740 // Flush: 3 items migrate.
741 let n = d.flush().expect("flush");
742 assert_eq!(n, 3);
743 let (_, tail, sz, lifo_len) = d.snapshot_size();
744 assert_eq!(tail, 3);
745 assert_eq!(sz, 3);
746 assert_eq!(lifo_len, 0);
747 std::fs::remove_file(&path).ok();
748 }
749
750 #[test]
751 fn push_auto_flushes_at_threshold() {
752 let path = temp_path("autoflush");
753 let d = SharedDequeLoh::create(&path, 8, 4).expect("create");
754 for i in 0..4u32 {
755 d.push(u32_item(i)).expect("push");
756 }
757 // The 4th push triggers auto-flush.
758 let (_, tail, sz, lifo_len) = d.snapshot_size();
759 assert_eq!(tail, 4);
760 assert_eq!(sz, 4);
761 assert_eq!(lifo_len, 0);
762 std::fs::remove_file(&path).ok();
763 }
764
765 #[test]
766 fn publish_batch_migrates_in_fifo_order() {
767 let path = temp_path("publish_batch");
768 let d = SharedDequeLoh::create(&path, 64, usize::MAX).expect("create");
769 let items: Vec<LineItem> = (1..=5u32).map(u32_item).collect();
770 let n = d.publish_batch(&items).expect("publish_batch");
771 assert_eq!(n, 5);
772 let (_, tail, sz, lifo_len) = d.snapshot_size();
773 assert_eq!(tail, 5);
774 assert_eq!(sz, 5);
775 // publish_batch bypasses the LIFO entirely.
776 assert_eq!(lifo_len, 0);
777 for expected in 1..=5u32 {
778 loop {
779 match d.steal() {
780 Steal::Success(r) => {
781 assert_eq!(item_id(&r.item), expected);
782 break;
783 }
784 Steal::Empty | Steal::Retry => std::thread::yield_now(),
785 }
786 }
787 }
788 assert!(matches!(d.steal(), Steal::Empty));
789 std::fs::remove_file(&path).ok();
790 }
791
792 #[test]
793 fn publish_batch_empty_is_noop() {
794 let path = temp_path("publish_batch_empty");
795 let d = SharedDequeLoh::create(&path, 4, usize::MAX).expect("create");
796 let n = d.publish_batch(&[]).expect("publish_batch empty");
797 assert_eq!(n, 0);
798 let (_, tail, sz, _) = d.snapshot_size();
799 assert_eq!(tail, 0);
800 assert_eq!(sz, 0);
801 std::fs::remove_file(&path).ok();
802 }
803
804 #[test]
805 fn publish_batch_full_returns_full() {
806 let path = temp_path("publish_batch_full");
807 let d = SharedDequeLoh::create(&path, 4, usize::MAX).expect("create");
808 let items: Vec<LineItem> = (1..=4u32).map(u32_item).collect();
809 d.publish_batch(&items).expect("publish first batch");
810 // Ring at capacity; the follow-up publish_batch reports Full.
811 let err = d
812 .publish_batch(&[u32_item(99)])
813 .expect_err("publish past capacity");
814 assert_eq!(err, PushError::Full);
815 std::fs::remove_file(&path).ok();
816 }
817
818 #[test]
819 fn steal_drains_in_fifo_order_after_flush() {
820 let path = temp_path("fifo");
821 let d = SharedDequeLoh::create(&path, 8, usize::MAX).expect("create");
822 for i in 1..=3u32 {
823 d.push(u32_item(i)).expect("push");
824 }
825 d.flush().expect("flush");
826 for expected in 1..=3u32 {
827 loop {
828 match d.steal() {
829 Steal::Success(slot) => {
830 assert_eq!(item_id(&slot.item), expected);
831 break;
832 }
833 Steal::Empty | Steal::Retry => std::thread::yield_now(),
834 }
835 }
836 }
837 assert!(matches!(d.steal(), Steal::Empty));
838 std::fs::remove_file(&path).ok();
839 }
840
841 #[test]
842 fn pop_local_drains_lifo_in_lifo_order() {
843 let path = temp_path("pop_local_lifo");
844 let d = SharedDequeLoh::create(&path, 4, usize::MAX).expect("create");
845 for i in 1..=3u32 {
846 d.push(u32_item(i)).expect("push");
847 }
848 // Owner pops in LIFO order (newest first).
849 for expected in (1..=3u32).rev() {
850 let e = d.pop_local().expect("pop_local");
851 assert_eq!(item_id(&e), expected);
852 }
853 assert!(d.pop_local().is_none());
854 std::fs::remove_file(&path).ok();
855 }
856
857 #[test]
858 fn ring_full_at_capacity() {
859 let path = temp_path("full");
860 let d = SharedDequeLoh::create(&path, 2, usize::MAX).expect("create");
861 d.push(u32_item(1)).expect("push");
862 d.push(u32_item(2)).expect("push");
863 let n = d.flush().expect("flush");
864 assert_eq!(n, 2);
865 // Ring is at capacity; pushing more + flushing reports Full.
866 d.push(u32_item(3)).expect("push to lifo");
867 let err = d.flush().expect_err("flush past capacity");
868 assert_eq!(err, PushError::Full);
869 std::fs::remove_file(&path).ok();
870 }
871
872 #[test]
873 fn close_owner_zeros_pid_and_advances_epoch() {
874 let path = temp_path("close");
875 let d = SharedDequeLoh::create(&path, 2, 1).expect("create");
876 assert_eq!(d.owner_pid(), std::process::id() as u64);
877 let h = d.header();
878 let before = h.epoch.load(O::Acquire);
879 d.close_owner();
880 assert_eq!(d.owner_pid(), 0);
881 assert_eq!(h.epoch.load(O::Acquire), before + 1);
882 std::fs::remove_file(&path).ok();
883 }
884
885 #[test]
886 fn concurrent_thieves_no_double_take() {
887 // Stress: owner pushes + auto-flushes; two thief threads
888 // race to drain. Every slot must be consumed exactly once.
889 let path = temp_path("stress");
890 let d = Arc::new(SharedDequeLoh::create(&path, 128, 8).expect("create"));
891 let n = 5_000usize;
892
893 let consumed = Arc::new(AtomicUsize::new(0));
894 let sum = Arc::new(AtomicUsize::new(0));
895
896 let mut thieves = Vec::new();
897 for _ in 0..2 {
898 let d = Arc::clone(&d);
899 let consumed = Arc::clone(&consumed);
900 let sum = Arc::clone(&sum);
901 thieves.push(thread::spawn(move || {
902 while consumed.load(O::Relaxed) < n {
903 match d.steal() {
904 Steal::Success(slot) => {
905 consumed.fetch_add(1, O::Relaxed);
906 sum.fetch_add(item_id(&slot.item) as usize, O::Relaxed);
907 }
908 Steal::Empty | Steal::Retry => std::thread::yield_now(),
909 }
910 }
911 }));
912 }
913
914 for i in 0..n {
915 loop {
916 match d.push(u32_item(i as u32)) {
917 Ok(()) => break,
918 Err(PushError::LifoFull) | Err(PushError::Full) => {
919 std::thread::yield_now();
920 // Opportunistic: a Full flush here just means
921 // the ring is congested; the outer loop keeps
922 // retrying the push.
923 d.flush().ok();
924 }
925 }
926 }
927 }
928 // The TERMINAL flush must succeed or the tail of the run
929 // (up to flush_threshold - 1 items) stays stranded in the
930 // process-local LIFO and the thieves spin on `consumed < n`
931 // forever - flush() returning Full leaves items staged by
932 // contract ("items remain in the LIFO for the next flush
933 // attempt"). Retry until the thieves free ring space.
934 loop {
935 match d.flush() {
936 Ok(_) => break,
937 Err(PushError::Full) => std::thread::yield_now(),
938 Err(e) => panic!("terminal flush: {e:?}"),
939 }
940 }
941 for h in thieves {
942 h.join().expect("thief");
943 }
944 let expected: usize = (0..n).sum();
945 assert_eq!(
946 sum.load(O::Relaxed),
947 expected,
948 "every slot consumed once"
949 );
950 std::fs::remove_file(&path).ok();
951 }
952
953 #[test]
954 fn publish_batch_stress_two_thieves() {
955 // Stress the canonical hot path: publish_batch fires N=64
956 // items per call; two thieves race to drain.
957 let path = temp_path("publish_batch_stress");
958 let d = Arc::new(
959 SharedDequeLoh::create(&path, 256, usize::MAX).expect("create"),
960 );
961 let n = 5_000usize;
962
963 let consumed = Arc::new(AtomicUsize::new(0));
964 let sum = Arc::new(AtomicUsize::new(0));
965
966 let mut thieves = Vec::new();
967 for _ in 0..2 {
968 let d = Arc::clone(&d);
969 let consumed = Arc::clone(&consumed);
970 let sum = Arc::clone(&sum);
971 thieves.push(thread::spawn(move || {
972 while consumed.load(O::Relaxed) < n {
973 match d.steal() {
974 Steal::Success(slot) => {
975 consumed.fetch_add(1, O::Relaxed);
976 sum.fetch_add(item_id(&slot.item) as usize, O::Relaxed);
977 }
978 Steal::Empty | Steal::Retry => std::thread::yield_now(),
979 }
980 }
981 }));
982 }
983
984 let mut pushed = 0usize;
985 let burst = 64usize;
986 while pushed < n {
987 let want = burst.min(n - pushed);
988 let batch: Vec<LineItem> = (0..want)
989 .map(|j| u32_item((pushed + j) as u32))
990 .collect();
991 loop {
992 match d.publish_batch(&batch) {
993 Ok(_) => break,
994 Err(PushError::Full) => std::thread::yield_now(),
995 Err(other) => panic!("publish_batch: {other:?}"),
996 }
997 }
998 pushed += want;
999 }
1000
1001 for t in thieves {
1002 t.join().expect("thief");
1003 }
1004 let expected: usize = (0..n).sum();
1005 assert_eq!(
1006 sum.load(O::Relaxed),
1007 expected,
1008 "publish_batch stress: every item consumed once"
1009 );
1010 std::fs::remove_file(&path).ok();
1011 }
1012}