subetha_cxc/shared_deque_khpd.rs
1//! `SharedDequeKhpd` - K-axis Hierarchical Publication Deque, MMF-backed.
2//!
3//! Companion primitive to [`SharedDeque`](crate::SharedDeque) (the
4//! Chase-Lev work-stealing deque) for workloads where the producer
5//! batches and the per-line transfer cost dominates the round-trip.
6//!
7//! ## The amortization lever
8//!
9//! Chase-Lev pays one cache-line bounce per single-item handoff
10//! between owner and thief. KHPD packs `LINE_ITEMS = 3` items into
11//! one 64-byte cache line and atomically publishes them with a
12//! single Release-store on the line's state word. A thief takes the
13//! whole line in one CAS, reading all three items in a single
14//! cache-line transfer. The architectural saving is one Release-
15//! store per item amortized over three items - measured at 1.16x
16//! producer-side throughput vs Chase-Lev on a Zen+ R7 2700 when the
17//! workload uses the batch publish API.
18//!
19//! ## Layout
20//!
21//! ```text
22//! +-----------------------------+
23//! | KhpdHeader (128B) | magic, capacity, owner_pid,
24//! | | tail on its own line,
25//! | | head on its own line
26//! +-----------------------------+
27//! | PublicationLine[0] (64B) | state (8B) + 3 LineItems (48B)
28//! | PublicationLine[1] | + 8B padding
29//! | ... |
30//! | PublicationLine[capacity-1] |
31//! +-----------------------------+
32//! ```
33//!
34//! Each `PublicationLine` is exactly one cache line so adjacent
35//! lines never share coherence-traffic lines. `state` is an
36//! `AtomicU64` packed as `(epoch: u32 << 32) | (n_items: u16 << 16)
37//! | claim: u16`; the publisher writes the line items in place and
38//! issues one Release-store on `state` with `claim = CLAIM_BIT` and
39//! `n_items` set. The claimer reads `state` Acquire, validates the
40//! epoch matches its head, CAS-takes the head, then reads the line
41//! items and releases the slot by storing `STATE_EMPTY` for the next
42//! round's producer.
43//!
44//! Each `LineItem` is a 16-byte byte-oriented payload. Callers
45//! marshal their own value into the payload at publish time and
46//! unmarshal it at steal time. SubEtha's [`Marshal`](subetha_core::Marshal)
47//! trait is the recommended packing contract.
48//!
49//! ## When to use this vs `SharedDeque`
50//!
51//! - **`SharedDeque<T>` (Chase-Lev)** - per-item dispatch and steal,
52//! strict LIFO at the owner, optimal at low per-item batch size.
53//! - **`SharedDequeKhpd` (this primitive)** - producer batches
54//! multiple items per publication line. Beats Chase-Lev by ~16%
55//! on producer-side throughput when the workload calls
56//! [`publish`](SharedDequeKhpd::publish) with several staged items.
57//! On per-item dispatch (one stage + one publish per call), KHPD
58//! gives back the amortization win and may underperform.
59
60#![allow(clippy::missing_errors_doc)]
61
62use std::fs::{File, OpenOptions};
63use std::io;
64use std::path::Path;
65use std::sync::Mutex;
66use std::sync::atomic::{AtomicI64, AtomicU64, Ordering, fence};
67
68use memmap2::{MmapMut, MmapOptions};
69
70/// Magic byte sequence marking a valid KHPD file. ASCII 'WKHP' + ver.
71pub const KHPD_MAGIC: u64 = 0x574B_4850_0000_0001;
72
73/// Cache-line size; one publication line per cache line.
74pub const KHPD_LINE_SIZE: usize = 64;
75
76/// Items per publication line. State (8 B) + 3 * 16 = 56 B; 8 B
77/// trailing padding rounds the line to 64.
78pub const LINE_ITEMS: usize = 3;
79
80/// Bytes per [`LineItem`] payload. Callers marshal their value into
81/// these 16 bytes (and unmarshal at steal time).
82pub const KHPD_ITEM_BYTES: usize = 16;
83
84/// `state` packed-bit-field layout: epoch in the top 32 bits,
85/// `n_items` in the next 16, `claim` in the bottom 16.
86const STATE_EMPTY: u64 = 0;
87const CLAIM_BIT: u64 = 1;
88
89/// File header. Cache-line aligned. `head` and `tail` each get
90/// their own cache line to prevent producer and consumer counters
91/// from invalidating each other.
92#[repr(C, align(64))]
93pub struct KhpdHeader {
94 /// Magic constant.
95 pub magic: u64,
96 /// Number of publication lines; always a power of two.
97 pub capacity: u64,
98 /// Pid of the owner process; informational. Cleared on
99 /// `close_owner()`.
100 pub owner_pid: AtomicU64,
101 /// Epoch counter advanced on owner shutdown.
102 pub epoch: AtomicU64,
103 /// Padding to push `tail` to its own cache line.
104 pub _pad_meta: [u8; 24],
105 /// Producer counter. Owner `fetch_add(1)` per published line.
106 pub tail: AtomicI64,
107 /// Padding to push `head` to its own line.
108 pub _pad_tail: [u8; 56],
109 /// Consumer counter. Thieves CAS this to claim a line.
110 pub head: AtomicI64,
111 /// Padding to round the header to two whole cache lines after
112 /// `head`.
113 pub _pad_head: [u8; 56],
114}
115
116/// One item carried in a publication line. 16 bytes.
117#[repr(C, align(8))]
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub struct LineItem {
120 /// Byte-oriented payload. Callers marshal in / unmarshal out;
121 /// the KHPD layer treats this as an opaque 16-byte slot.
122 pub payload: [u8; KHPD_ITEM_BYTES],
123}
124
125impl LineItem {
126 /// Build a line item from a caller-supplied byte slice. The
127 /// slice must be at most [`KHPD_ITEM_BYTES`] bytes; shorter
128 /// slices are zero-padded on the right.
129 pub fn new(bytes: &[u8]) -> Result<Self, PushError> {
130 if bytes.len() > KHPD_ITEM_BYTES {
131 return Err(PushError::PayloadTooLarge);
132 }
133 let mut item = Self::default();
134 item.payload[..bytes.len()].copy_from_slice(bytes);
135 Ok(item)
136 }
137
138 /// Borrow the 16-byte payload.
139 pub fn bytes(&self) -> &[u8; KHPD_ITEM_BYTES] {
140 &self.payload
141 }
142}
143
144/// 64-byte cache-line-sized payload carrying up to [`LINE_ITEMS`] =
145/// 3 [`LineItem`] payloads plus a count. The deque-family hybrid
146/// [`SharedDequeFcl`](crate::SharedDequeFcl) uses this as the slot
147/// type for counter-only Chase-Lev with `K_inner = 3`: each push
148/// publishes 3 items in one cache-line write, with NO per-slot
149/// atomic and ONE owner-private `bottom` store amortized across the
150/// whole batch.
151///
152/// Layout:
153/// - `n_items` (4 B): count of valid items in `items` (1..=3)
154/// - `reserved` (4 B): caller-use tag / cache-line alignment
155/// - `items` (48 B): the [`LineItem`] payloads
156/// - `_pad` (8 B): tail padding to round to exactly 64 B
157#[repr(C, align(64))]
158#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
159pub struct FatLineItem {
160 /// Number of valid items in `items` (1..=[`LINE_ITEMS`]).
161 pub n_items: u32,
162 /// Reserved for caller use (variant tag / numa hint / etc.).
163 pub reserved: u32,
164 /// Up to [`LINE_ITEMS`] caller payloads.
165 pub items: [LineItem; LINE_ITEMS],
166 /// Trailing padding to round the struct to exactly 64 B.
167 pub _pad: [u8; 8],
168}
169
170const _: () = assert!(std::mem::size_of::<FatLineItem>() == 64);
171
172impl FatLineItem {
173 /// Build a fat item from a slice of up to [`LINE_ITEMS`]
174 /// [`LineItem`] values. Returns [`PushError::TooManyItems`] if
175 /// the slice has more than [`LINE_ITEMS`] elements.
176 pub fn from_items(items: &[LineItem]) -> Result<Self, PushError> {
177 if items.len() > LINE_ITEMS {
178 return Err(PushError::TooManyItems);
179 }
180 let mut fat = Self {
181 n_items: items.len() as u32,
182 ..Self::default()
183 };
184 fat.items[..items.len()].copy_from_slice(items);
185 Ok(fat)
186 }
187
188 /// Borrow the valid items (`&items[..n_items]`).
189 pub fn live_items(&self) -> &[LineItem] {
190 let n = (self.n_items as usize).min(LINE_ITEMS);
191 &self.items[..n]
192 }
193}
194
195// SAFETY: `FatLineItem` is `#[repr(C, align(64))]` with explicitly
196// laid out fields (n_items: u32 + reserved: u32 + items: [LineItem;
197// 3] + _pad: [u8; 8] = 64 bytes), no padding holes, every field is
198// itself byte-portable. The bytes are position-independent across
199// address spaces; round-trip is a memcpy.
200unsafe impl subetha_core::Marshal for FatLineItem {
201 const PAYLOAD_BYTES: usize = 64;
202
203 fn marshal(&self, dst: &mut [u8]) {
204 // SAFETY: `Self` has size 64, layout is repr(C) with no
205 // padding holes (asserted via the const above).
206 let bytes = unsafe {
207 std::slice::from_raw_parts(self as *const Self as *const u8, 64)
208 };
209 dst[..64].copy_from_slice(bytes);
210 }
211
212 fn unmarshal(src: &[u8]) -> Result<Self, subetha_core::MarshalError> {
213 if src.len() < 64 {
214 return Err(subetha_core::MarshalError::ShortBuffer {
215 expected: 64,
216 got: src.len(),
217 });
218 }
219 let mut out = Self::default();
220 // SAFETY: same layout justification as `marshal`.
221 let dst_bytes = unsafe {
222 std::slice::from_raw_parts_mut(&mut out as *mut Self as *mut u8, 64)
223 };
224 dst_bytes.copy_from_slice(&src[..64]);
225 Ok(out)
226 }
227}
228
229// SAFETY: `LineItem` is `#[repr(C, align(8))]` over a single
230// `[u8; KHPD_ITEM_BYTES]` payload field. The bytes are position-
231// independent across address spaces; round-trip is a memcpy.
232unsafe impl subetha_core::Marshal for LineItem {
233 const PAYLOAD_BYTES: usize = KHPD_ITEM_BYTES;
234
235 fn marshal(&self, dst: &mut [u8]) {
236 dst[..KHPD_ITEM_BYTES].copy_from_slice(&self.payload);
237 }
238
239 fn unmarshal(src: &[u8]) -> Result<Self, subetha_core::MarshalError> {
240 if src.len() < KHPD_ITEM_BYTES {
241 return Err(subetha_core::MarshalError::ShortBuffer {
242 expected: KHPD_ITEM_BYTES,
243 got: src.len(),
244 });
245 }
246 let mut payload = [0u8; KHPD_ITEM_BYTES];
247 payload.copy_from_slice(&src[..KHPD_ITEM_BYTES]);
248 Ok(Self { payload })
249 }
250}
251
252/// One publication line: state + `LINE_ITEMS` items + padding.
253#[repr(C, align(64))]
254pub struct PublicationLine {
255 /// `(epoch:32) << 32 | (n_items:16) << 16 | claim:16`.
256 /// `claim` = 0 (empty), 1 (READY for claim).
257 pub state: AtomicU64,
258 /// Inline items.
259 pub items: [LineItem; LINE_ITEMS],
260 /// Trailing padding to round to 64 bytes.
261 pub _pad: [u8; 8],
262}
263
264/// Total file size for a KHPD with `capacity` publication lines.
265pub const fn khpd_file_size(capacity: usize) -> usize {
266 std::mem::size_of::<KhpdHeader>() + capacity * KHPD_LINE_SIZE
267}
268
269/// Outcome of [`SharedDequeKhpd::publish`].
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum PushError {
272 /// Ring at capacity; consumer has not caught up.
273 Full,
274 /// Items count exceeds [`LINE_ITEMS`].
275 TooManyItems,
276 /// Payload exceeds [`KHPD_ITEM_BYTES`].
277 PayloadTooLarge,
278}
279
280/// Outcome of [`SharedDequeKhpd::steal_line`].
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum Steal {
283 /// Got a publication line; carries up to [`LINE_ITEMS`] items.
284 Success(StealResult),
285 /// Ring was empty (head >= tail).
286 Empty,
287 /// Lost the CAS race on `head` to a competing thief, or the
288 /// publisher has not finished writing this line yet.
289 Retry,
290}
291
292/// Result of a successful steal: the publication line's items.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub struct StealResult {
295 /// How many of the `items` slots are filled.
296 pub n_items: usize,
297 /// The items (only `items[..n_items]` are valid).
298 pub items: [LineItem; LINE_ITEMS],
299}
300
301/// MMF-backed K-axis Hierarchical Publication Deque. Single owner,
302/// arbitrarily many thieves.
303pub struct SharedDequeKhpd {
304 _file: File,
305 mmap: MmapMut,
306 capacity: usize,
307 capacity_mask: i64,
308 /// Owner-side staging buffer. Items accumulate here until the
309 /// caller calls [`publish`](Self::publish) to flush the buffer
310 /// into one or more publication lines. `Mutex` is uncontended
311 /// on the hot path (only the owner stages).
312 pending: Mutex<Vec<LineItem>>,
313}
314
315// SAFETY: all fields are Send. Mmap handle is Send + Sync per
316// memmap2. Every line access goes through the per-line state-atomic
317// protocol; the `pending` Mutex linearises owner-side accesses.
318unsafe impl Send for SharedDequeKhpd {}
319// SAFETY: same justification as the Send impl directly above.
320unsafe impl Sync for SharedDequeKhpd {}
321
322impl SharedDequeKhpd {
323 /// Create a fresh KHPD file. `capacity` rounds up to the next
324 /// power of two; minimum 2.
325 pub fn create<P: AsRef<Path>>(path: P, capacity: usize) -> io::Result<Self> {
326 let capacity = capacity.max(2).next_power_of_two();
327 let size = khpd_file_size(capacity);
328
329 let file = OpenOptions::new()
330 .read(true)
331 .write(true)
332 .create(true)
333 .truncate(true)
334 .open(path.as_ref())?;
335 file.set_len(size as u64)?;
336
337 // SAFETY: `map_mut` is unsafe because the kernel cannot
338 // prevent another process from truncating the file. This
339 // call site upholds the soundness contract by writing only
340 // through the KHPD per-line state-atomic protocol; file
341 // size is fixed by `set_len` above and never shrunk for the
342 // lifetime of any mapping.
343 let mut mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
344
345 let header_ptr = mmap.as_mut_ptr() as *mut KhpdHeader;
346 // SAFETY: mmap is page-aligned (well above the 64-byte
347 // alignment KhpdHeader requires); the map covers
348 // `khpd_file_size(capacity)` bytes by construction.
349 unsafe {
350 (*header_ptr).magic = KHPD_MAGIC;
351 (*header_ptr).capacity = capacity as u64;
352 (*header_ptr).owner_pid = AtomicU64::new(std::process::id() as u64);
353 (*header_ptr).epoch = AtomicU64::new(0);
354 std::ptr::write_bytes((*header_ptr)._pad_meta.as_mut_ptr(), 0, 24);
355 (*header_ptr).tail = AtomicI64::new(0);
356 std::ptr::write_bytes((*header_ptr)._pad_tail.as_mut_ptr(), 0, 56);
357 (*header_ptr).head = AtomicI64::new(0);
358 std::ptr::write_bytes((*header_ptr)._pad_head.as_mut_ptr(), 0, 56);
359 }
360
361 // Zero the lines (state == 0 == STATE_EMPTY).
362 let lines_start = std::mem::size_of::<KhpdHeader>();
363 // SAFETY: lines_start..lines_start + capacity*KHPD_LINE_SIZE
364 // is the unwritten tail of the map.
365 unsafe {
366 std::ptr::write_bytes(
367 mmap.as_mut_ptr().add(lines_start),
368 0,
369 capacity * KHPD_LINE_SIZE,
370 );
371 }
372
373 mmap.flush()?;
374
375 Ok(Self {
376 _file: file,
377 mmap,
378 capacity,
379 capacity_mask: (capacity as i64) - 1,
380 pending: Mutex::new(Vec::with_capacity(LINE_ITEMS)),
381 })
382 }
383
384 /// Open an existing KHPD file.
385 pub fn open<P: AsRef<Path>>(path: P) -> io::Result<Self> {
386 let file = OpenOptions::new()
387 .read(true)
388 .write(true)
389 .open(path.as_ref())?;
390 let size = file.metadata()?.len() as usize;
391 if size < std::mem::size_of::<KhpdHeader>() {
392 return Err(io::Error::new(
393 io::ErrorKind::InvalidData,
394 "khpd file too small",
395 ));
396 }
397 // SAFETY: same protocol-only-access justification as
398 // `create`.
399 let mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
400 let header_ptr = mmap.as_ptr() as *const KhpdHeader;
401 // SAFETY: map size verified to cover header.
402 let (magic, capacity) =
403 unsafe { ((*header_ptr).magic, (*header_ptr).capacity as usize) };
404 if magic != KHPD_MAGIC {
405 return Err(io::Error::new(
406 io::ErrorKind::InvalidData,
407 format!("khpd magic mismatch {magic:#x}"),
408 ));
409 }
410 if !capacity.is_power_of_two() || capacity < 2 {
411 return Err(io::Error::new(
412 io::ErrorKind::InvalidData,
413 format!("khpd capacity {capacity} not pow2 >= 2"),
414 ));
415 }
416 if size < khpd_file_size(capacity) {
417 return Err(io::Error::new(
418 io::ErrorKind::InvalidData,
419 format!(
420 "khpd file size {size} below expected {}",
421 khpd_file_size(capacity)
422 ),
423 ));
424 }
425 Ok(Self {
426 _file: file,
427 mmap,
428 capacity,
429 capacity_mask: (capacity as i64) - 1,
430 pending: Mutex::new(Vec::with_capacity(LINE_ITEMS)),
431 })
432 }
433
434 /// Capacity in publication lines (always a power of two).
435 pub fn capacity(&self) -> usize {
436 self.capacity
437 }
438
439 /// Owner pid at create time, or 0 after `close_owner()`.
440 pub fn owner_pid(&self) -> u64 {
441 self.header().owner_pid.load(Ordering::Acquire)
442 }
443
444 /// Advance epoch + zero the owner pid on shutdown.
445 pub fn close_owner(&self) {
446 self.header().owner_pid.store(0, Ordering::Release);
447 self.header().epoch.fetch_add(1, Ordering::Release);
448 }
449
450 fn header(&self) -> &KhpdHeader {
451 // SAFETY: header is at the start of the map; mmap is
452 // page-aligned.
453 unsafe { &*(self.mmap.as_ptr() as *const KhpdHeader) }
454 }
455
456 fn line_ptr(&self, idx: i64) -> *mut PublicationLine {
457 let line_idx = (idx & self.capacity_mask) as usize;
458 let off = std::mem::size_of::<KhpdHeader>() + line_idx * KHPD_LINE_SIZE;
459 // SAFETY: `line_idx` < capacity; `off` is in-bounds + 64-byte aligned.
460 unsafe { self.mmap.as_ptr().add(off) as *mut PublicationLine }
461 }
462
463 /// Snapshot `(head, tail, ring_size_lines, pending_items)`.
464 pub fn snapshot_size(&self) -> (i64, i64, i64, usize) {
465 let h = self.header();
466 let head = h.head.load(Ordering::Acquire);
467 let tail = h.tail.load(Ordering::Acquire);
468 let pending = self
469 .pending
470 .try_lock()
471 .map(|g| g.len())
472 .unwrap_or(0);
473 (head, tail, tail - head, pending)
474 }
475
476 /// Owner-side stage. Adds one item to the pending buffer.
477 /// Returns the running pending count (so the caller can decide
478 /// to flush at [`LINE_ITEMS`]). **Only the owner process may
479 /// stage.**
480 pub fn stage(&self, item: LineItem) -> Result<usize, PushError> {
481 let mut p = self.pending.lock().expect("KHPD pending poisoned");
482 p.push(item);
483 Ok(p.len())
484 }
485
486 /// Owner-side single-call batch publish. Bypasses the
487 /// [`stage`](Self::stage)/[`publish`](Self::publish) pair so the
488 /// caller pays only ONE Mutex acquire per batch instead of one
489 /// per staged item. This is the canonical hot-path API: the
490 /// caller hands in a slice of [`LineItem`] values and the method
491 /// publishes them into `ceil(items.len() / LINE_ITEMS)`
492 /// publication lines with one `tail.fetch_add(n_lines)` plus
493 /// one Release-store per line.
494 ///
495 /// Returns the number of LINES published.
496 pub fn publish_batch(&self, items: &[LineItem]) -> Result<usize, PushError> {
497 if items.is_empty() {
498 return Ok(0);
499 }
500 // Hold migration_lock-equivalent: serialise against other
501 // owner-side publishes by going through the same Mutex the
502 // staged path uses.
503 let _g = self.pending.lock().expect("KHPD pending poisoned");
504 let n_lines = items.len().div_ceil(LINE_ITEMS);
505 let h = self.header();
506 let head_snap = h.head.load(Ordering::Acquire);
507 let tail_snap = h.tail.load(Ordering::Relaxed);
508 if (tail_snap - head_snap + n_lines as i64) > self.capacity as i64 {
509 return Err(PushError::Full);
510 }
511 let base = h.tail.fetch_add(n_lines as i64, Ordering::AcqRel);
512
513 // No PREFETCHW here: empirical 30-second criterion bench on
514 // Zen+ R7 2700 measured a 12% regression vs the unprefetched
515 // path (p = 0.01). KHPD's publication lines are L1d-warm from
516 // the prior iteration of `publish_batch`; explicit prefetch
517 // pollutes the prefetch queue without payoff. The architectural
518 // lever is preserved for Chase-Lev and LOH where the slot line
519 // is cold per push.
520
521 let mut it = items.iter();
522 for line_i in 0..n_lines {
523 let idx = base + line_i as i64;
524 let line = self.line_ptr(idx);
525 // SAFETY: line is in-bounds + aligned.
526 unsafe {
527 loop {
528 let st = (*line).state.load(Ordering::Acquire);
529 if st == STATE_EMPTY { break; }
530 std::hint::spin_loop();
531 }
532 let mut n_filled = 0usize;
533 for slot in 0..LINE_ITEMS {
534 match it.next() {
535 Some(item) => {
536 (*line).items[slot] = *item;
537 n_filled += 1;
538 }
539 None => break,
540 }
541 }
542 let new_state =
543 ((idx as u64) << 32) | ((n_filled as u64) << 16) | CLAIM_BIT;
544 (*line).state.store(new_state, Ordering::Release);
545 }
546 }
547 Ok(n_lines)
548 }
549
550 /// Owner-side publish. Drains the pending buffer into one or
551 /// more publication lines ([`LINE_ITEMS`] items per line). Each
552 /// line takes one `tail.fetch_add(1)` plus one Release-store on
553 /// the line's state. Returns the number of LINES published.
554 pub fn publish(&self) -> Result<usize, PushError> {
555 let mut p = self.pending.lock().expect("KHPD pending poisoned");
556 if p.is_empty() {
557 return Ok(0);
558 }
559 let total = p.len();
560 let n_lines = total.div_ceil(LINE_ITEMS);
561 let h = self.header();
562 let head_snap = h.head.load(Ordering::Acquire);
563 let tail_snap = h.tail.load(Ordering::Relaxed);
564 if (tail_snap - head_snap + n_lines as i64) > self.capacity as i64 {
565 return Err(PushError::Full);
566 }
567 let base = h.tail.fetch_add(n_lines as i64, Ordering::AcqRel);
568
569 let mut item_iter = p.drain(..);
570 for line_i in 0..n_lines {
571 let idx = base + line_i as i64;
572 let line = self.line_ptr(idx);
573 // Spin-wait for the slot to be reusable. The consumer's
574 // release stores `STATE_EMPTY` (= 0); the producer at
575 // `idx` spins until state == 0 for this slot's next
576 // round.
577 //
578 // SAFETY: line is in-bounds + aligned.
579 unsafe {
580 loop {
581 let st = (*line).state.load(Ordering::Acquire);
582 if st == STATE_EMPTY {
583 break;
584 }
585 std::hint::spin_loop();
586 }
587
588 // Fill items.
589 let mut n_filled = 0usize;
590 for i in 0..LINE_ITEMS {
591 match item_iter.next() {
592 Some(item) => {
593 (*line).items[i] = item;
594 n_filled += 1;
595 }
596 None => break,
597 }
598 }
599 // Pack state: (epoch:32 from idx) | (n_filled:16) |
600 // claim:16 == CLAIM_BIT (READY).
601 let new_state =
602 ((idx as u64) << 32) | ((n_filled as u64) << 16) | CLAIM_BIT;
603 (*line).state.store(new_state, Ordering::Release);
604 }
605 }
606 Ok(n_lines)
607 }
608
609 /// Thief-side. Claim one publication line.
610 pub fn steal_line(&self) -> Steal {
611 let h = self.header();
612 let head = h.head.load(Ordering::Acquire);
613 fence(Ordering::SeqCst);
614 let tail = h.tail.load(Ordering::Acquire);
615 if head >= tail {
616 return Steal::Empty;
617 }
618 let line = self.line_ptr(head);
619 // SAFETY: line is in-bounds + aligned.
620 let state = unsafe { (*line).state.load(Ordering::Acquire) };
621 // Validate: state's epoch matches our head and CLAIM_BIT is
622 // set.
623 let expected_epoch = (head as u64) << 32;
624 if state & 0xFFFF_FFFF_0000_0000 != expected_epoch {
625 // Publisher has not written this line for our round yet.
626 return Steal::Retry;
627 }
628 if state & CLAIM_BIT == 0 {
629 // Line empty (no items for this round). Skip.
630 return Steal::Retry;
631 }
632 // CAS head to claim.
633 let won = h
634 .head
635 .compare_exchange(head, head + 1, Ordering::SeqCst, Ordering::Relaxed)
636 .is_ok();
637 if !won {
638 return Steal::Retry;
639 }
640 // We own the line; read items and release the slot for the
641 // next round.
642 //
643 // SAFETY: line is in-bounds + aligned; the CAS established
644 // exclusive read access for this round.
645 let result = unsafe {
646 let n_items = ((state >> 16) & 0xFFFF) as usize;
647 let n_items = n_items.min(LINE_ITEMS);
648 StealResult {
649 n_items,
650 items: (*line).items,
651 }
652 };
653 // Release the slot: store STATE_EMPTY so the next round's
654 // producer (at idx = head + capacity) sees the slot ready.
655 //
656 // SAFETY: still our slot; the Release synchronises with the
657 // next producer's Acquire-spin in `publish`.
658 unsafe {
659 (*line).state.store(STATE_EMPTY, Ordering::Release);
660 }
661 Steal::Success(result)
662 }
663
664 /// Force any dirty pages to disk.
665 pub fn flush_to_disk(&self) -> io::Result<()> {
666 self.mmap.flush()
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use std::sync::Arc;
674 use std::sync::atomic::{AtomicUsize, Ordering as O};
675 use std::thread;
676
677 fn temp_path(name: &str) -> std::path::PathBuf {
678 let mut p = std::env::temp_dir();
679 let pid = std::process::id();
680 let nonce = std::time::SystemTime::now()
681 .duration_since(std::time::UNIX_EPOCH)
682 .map(|d| d.as_nanos())
683 .unwrap_or(0);
684 p.push(format!("subetha_khpd_{pid}_{nonce}_{name}.bin"));
685 p
686 }
687
688 fn u32_item(id: u32) -> LineItem {
689 LineItem::new(&id.to_le_bytes()).expect("item")
690 }
691
692 fn item_id(item: &LineItem) -> u32 {
693 u32::from_le_bytes(item.payload[..4].try_into().unwrap())
694 }
695
696 #[test]
697 fn create_open_round_trips_header() {
698 let path = temp_path("create_open");
699 let _d = SharedDequeKhpd::create(&path, 8).expect("create");
700 let o = SharedDequeKhpd::open(&path).expect("open");
701 assert_eq!(o.capacity(), 8);
702 assert_eq!(o.owner_pid(), std::process::id() as u64);
703 std::fs::remove_file(&path).ok();
704 }
705
706 #[test]
707 fn open_rejects_bad_magic() {
708 let path = temp_path("badmagic");
709 std::fs::write(&path, vec![0u8; 8192]).expect("seed");
710 assert!(SharedDequeKhpd::open(&path).is_err());
711 std::fs::remove_file(&path).ok();
712 }
713
714 #[test]
715 fn stage_then_publish_writes_one_line() {
716 let path = temp_path("stage_publish");
717 let d = SharedDequeKhpd::create(&path, 4).expect("create");
718 d.stage(u32_item(1)).expect("stage 1");
719 d.stage(u32_item(2)).expect("stage 2");
720 let lines = d.publish().expect("publish");
721 assert_eq!(lines, 1);
722 let (_, tail, sz, pending) = d.snapshot_size();
723 assert_eq!(tail, 1);
724 assert_eq!(sz, 1);
725 assert_eq!(pending, 0);
726 std::fs::remove_file(&path).ok();
727 }
728
729 #[test]
730 fn publish_spans_multiple_lines() {
731 let path = temp_path("multi_line");
732 let d = SharedDequeKhpd::create(&path, 4).expect("create");
733 // 7 items: 3 + 3 + 1 = 3 lines.
734 for i in 1..=7u32 {
735 d.stage(u32_item(i)).expect("stage");
736 }
737 let lines = d.publish().expect("publish");
738 assert_eq!(lines, 3);
739 let (_, tail, sz, _) = d.snapshot_size();
740 assert_eq!(tail, 3);
741 assert_eq!(sz, 3);
742 std::fs::remove_file(&path).ok();
743 }
744
745 #[test]
746 fn steal_returns_items_in_publication_order() {
747 let path = temp_path("fifo");
748 let d = SharedDequeKhpd::create(&path, 4).expect("create");
749 for i in 1..=5u32 {
750 d.stage(u32_item(i)).expect("stage");
751 }
752 d.publish().expect("publish");
753 // Line 0 carries (1, 2, 3); line 1 carries (4, 5).
754 loop {
755 match d.steal_line() {
756 Steal::Success(r) => {
757 assert_eq!(r.n_items, 3);
758 assert_eq!(item_id(&r.items[0]), 1);
759 assert_eq!(item_id(&r.items[1]), 2);
760 assert_eq!(item_id(&r.items[2]), 3);
761 break;
762 }
763 Steal::Empty | Steal::Retry => std::thread::yield_now(),
764 }
765 }
766 loop {
767 match d.steal_line() {
768 Steal::Success(r) => {
769 assert_eq!(r.n_items, 2);
770 assert_eq!(item_id(&r.items[0]), 4);
771 assert_eq!(item_id(&r.items[1]), 5);
772 break;
773 }
774 Steal::Empty | Steal::Retry => std::thread::yield_now(),
775 }
776 }
777 loop {
778 match d.steal_line() {
779 Steal::Empty => break,
780 Steal::Retry => continue,
781 Steal::Success(_) => panic!("unexpected success after drain"),
782 }
783 }
784 std::fs::remove_file(&path).ok();
785 }
786
787 #[test]
788 fn oversize_payload_rejected() {
789 let big = vec![0u8; KHPD_ITEM_BYTES + 1];
790 let err = LineItem::new(&big).expect_err("oversize");
791 assert_eq!(err, PushError::PayloadTooLarge);
792 }
793
794 #[test]
795 fn ring_full_at_capacity_returns_full() {
796 let path = temp_path("full");
797 let d = SharedDequeKhpd::create(&path, 2).expect("create");
798 // Fill the ring (2 publication lines * 3 items = 6 items).
799 for i in 1..=6u32 {
800 d.stage(u32_item(i)).expect("stage");
801 }
802 d.publish().expect("publish 2 lines");
803 // Stage more + publish; ring is full.
804 d.stage(u32_item(7)).expect("stage 7");
805 let err = d.publish().expect_err("publish past capacity");
806 assert_eq!(err, PushError::Full);
807 std::fs::remove_file(&path).ok();
808 }
809
810 #[test]
811 fn close_owner_zeros_pid_and_advances_epoch() {
812 let path = temp_path("close");
813 let d = SharedDequeKhpd::create(&path, 2).expect("create");
814 let before = d.header().epoch.load(O::Acquire);
815 d.close_owner();
816 assert_eq!(d.owner_pid(), 0);
817 assert_eq!(d.header().epoch.load(O::Acquire), before + 1);
818 std::fs::remove_file(&path).ok();
819 }
820
821 #[test]
822 fn concurrent_thieves_no_double_take() {
823 // Stress: 5000 items via repeated stage + publish; 2 thieves
824 // race to drain. Every item must be consumed exactly once.
825 let path = temp_path("stress");
826 let d = Arc::new(SharedDequeKhpd::create(&path, 64).expect("create"));
827 let n = 5_000usize;
828 let consumed = Arc::new(AtomicUsize::new(0));
829 let sum = Arc::new(AtomicUsize::new(0));
830
831 let mut thieves = Vec::new();
832 for _ in 0..2 {
833 let d = Arc::clone(&d);
834 let consumed = Arc::clone(&consumed);
835 let sum = Arc::clone(&sum);
836 thieves.push(thread::spawn(move || {
837 while consumed.load(O::Relaxed) < n {
838 match d.steal_line() {
839 Steal::Success(r) => {
840 for i in 0..r.n_items {
841 consumed.fetch_add(1, O::Relaxed);
842 sum.fetch_add(item_id(&r.items[i]) as usize, O::Relaxed);
843 }
844 }
845 Steal::Empty | Steal::Retry => std::thread::yield_now(),
846 }
847 }
848 }));
849 }
850
851 // Publisher: stage LINE_ITEMS, publish, repeat until n items.
852 let mut pushed = 0usize;
853 while pushed < n {
854 let want = LINE_ITEMS.min(n - pushed);
855 for _ in 0..want {
856 d.stage(u32_item(pushed as u32)).expect("stage");
857 pushed += 1;
858 }
859 loop {
860 match d.publish() {
861 Ok(_) => break,
862 Err(PushError::Full) => {
863 std::thread::yield_now();
864 }
865 Err(other) => panic!("publish: {other:?}"),
866 }
867 }
868 }
869
870 for t in thieves {
871 t.join().expect("thief");
872 }
873 let expected: usize = (0..n).sum();
874 assert_eq!(
875 sum.load(O::Relaxed),
876 expected,
877 "stress sum mismatch (expected every item consumed exactly once)"
878 );
879 std::fs::remove_file(&path).ok();
880 }
881}