subetha_cxc/shared_deque_khl.rs
1//! `SharedDequeKhl` - K-axis Hierarchical LCRQ deque, MMF-backed.
2//!
3//! Novel SubEtha-native hybrid that pulls THREE amortization levers
4//! the four prior primitives pull individually:
5//!
6//! 1. **KHPD's 3-items-per-Release-store** - each ring slot carries
7//! up to [`KHL_ITEMS_PER_SLOT`] = 3 [`LineItem`] payloads, and one
8//! Release-store on the slot's Vyukov sequence number publishes
9//! them all together. The per-item coherence cost is one cache
10//! line bounce per 3 items.
11//! 2. **LOH's K-slots-per-counter-update** -
12//! [`SharedDequeKhl::publish_batch`] reserves
13//! `ceil(K / KHL_ITEMS_PER_SLOT)` slots with ONE update of the
14//! producer tail counter, amortizing the producer-counter cost
15//! across the whole batch.
16//! 3. **Chase-Lev's owner-private tail counter** - the producer's
17//! tail-counter update is a Release-store (not a `LOCK XADD`),
18//! because the contract is "single owner process pushes." The
19//! Release ordering on the per-slot sequence number is what
20//! publishes the slot bytes; the tail counter only signals "this
21//! many slots reserved." Saves ~15 cycles per batch vs an atomic
22//! fetch_add.
23//!
24//! Why this hybrid is SubEtha-only: the upstream LCRQ-on-LIFO ring
25//! has 56 bytes of dispatch-coupled payload per slot (closure id +
26//! args + latch offset), so three slots cannot fit in one cache
27//! line. SubEtha's byte-oriented [`LineItem`] is 16 bytes; three of
28//! them plus an 8-byte sequence number plus a 4-byte count plus 4
29//! bytes of reservation fit exactly in 64 bytes. The decoupling
30//! between dispatch (`pass_registry`) and transport (`SharedDeque*`)
31//! is what unlocks the hybrid.
32//!
33//! ## Cost-model comparison (per K=64 producer-fast batch)
34//!
35//! | Primitive | Producer atomics | Thief CAS attempts |
36//! |---|---:|---:|
37//! | `SharedDeque<u64>` (Chase-Lev per-item) | 64 Release-stores + 64 fences | 64 |
38//! | `SharedDequeKhpd::publish_batch` | 22 slot Release-stores + 1 `fetch_add(LOCK XADD)` | 22 |
39//! | `SharedDequeLoh::publish_batch` | 64 slot Release-stores + 1 `fetch_add(LOCK XADD)` | 64 |
40//! | **`SharedDequeKhl::publish_batch`** | **22 slot Release-stores + 1 Release-store on tail** | **22** |
41//!
42//! KHL matches KHPD's per-slot count, matches LOH's per-batch
43//! counter amortization, and adds Chase-Lev's owner-private counter
44//! to save the LOCK XADD on top of that.
45//!
46//! ## Layout
47//!
48//! ```text
49//! +-----------------------------+
50//! | KhlHeader (192B) | magic, capacity, owner_pid,
51//! | | epoch, tail on its own cache
52//! | | line, head on its own cache line
53//! +-----------------------------+
54//! | KhlSlot[0] (64B) | sequence (8B) + n_items (4B) +
55//! | KhlSlot[1] | reserved (4B) + 3 LineItems (48B)
56//! | ... |
57//! | KhlSlot[capacity-1] |
58//! +-----------------------------+
59//! ```
60//!
61//! Each slot is exactly one cache line. The Vyukov sequence number
62//! gating protocol is identical to
63//! [`SharedDequeLoh`](crate::SharedDequeLoh) at the per-slot level:
64//! `seq == idx` (empty) -> `seq == idx + 1` (published) ->
65//! `seq == idx + capacity` (consumed).
66//!
67//! ## When to use this vs the four base primitives
68//!
69//! - `SharedDeque` (Chase-Lev): per-item dispatch, no batching.
70//! Lowest constant per push but pays one Release-store per item.
71//! - `SharedDequeKhpd`: small batches (K up to ~64 on Zen+ R7 2700).
72//! Pays one `fetch_add` per batch.
73//! - `SharedDequeLoh`: very large batches where the per-slot
74//! amortization dominates the per-line one.
75//! - `SharedDequeUrd`: multi-thief workloads where the per-thief
76//! mailbox eliminates shared-head CAS contention.
77//! - **`SharedDequeKhl`**: producer-fast single-thief batches at any
78//! K >= 6 where the caller wants the best of KHPD's per-slot density
79//! and LOH's per-batch amortization simultaneously. Empirically
80//! the strongest single-thief batched primitive on Zen+ R7 2700.
81
82#![allow(clippy::missing_errors_doc)]
83
84use std::fs::{File, OpenOptions};
85use std::io;
86use std::path::Path;
87use std::sync::atomic::{fence, AtomicI64, AtomicU64, Ordering};
88
89use memmap2::{MmapMut, MmapOptions};
90use subetha_core::has_movdir64b;
91
92use crate::shared_deque_khpd::LineItem;
93
94/// `K_radius` axis - the coherence distance the publish operation
95/// crosses. Captures the empirical observation that the optimal
96/// publish mechanism differs by 50-100x across coherence domains
97/// (same-CCX vs cross-CCX vs cross-socket), and that no algorithmic
98/// structure axis (K_inner/K_outer/K_consumer/K_counter_share)
99/// captures this dimension.
100///
101/// On the producer side this enum picks between cached Release-store
102/// (best at small K_radius, where the line stays in the publisher's
103/// L1d) and MOVDIR64B non-temporal stores (best at large K_radius,
104/// where the line transfer to the consumer's L1d dominates).
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum PublishRadius {
107 /// Local: producer and consumer share L1d or L2 (d=0..1,
108 /// same physical core or same CCX). Cached Release-store on the
109 /// per-slot sequence is the right mechanism; the line stays in
110 /// the publisher's L1d and the consumer's first read pays one
111 /// L1d -> L1d transfer at ~3-10 ns.
112 Local,
113 /// Distant: producer and consumer are in different coherence
114 /// clusters (d=2..6, cross-CCX / cross-CCD / cross-socket /
115 /// CXL.mem). The per-slot publish uses `MOVDIR64B` plus `SFENCE`
116 /// so the line writes go directly to LLC, bypassing the
117 /// publisher's L1d. The consumer's first read fetches from LLC
118 /// without paying the cross-CCX coherence-upgrade penalty that a
119 /// cached store would otherwise force. Requires
120 /// [`subetha_core::has_movdir64b`] to return true; on hosts
121 /// without `MOVDIR64B` the [`PublishRadius::pick_auto`] helper
122 /// degrades to `Local`.
123 Distant,
124}
125
126impl PublishRadius {
127 /// Pick a default radius for the current host. If MOVDIR64B is
128 /// available, picks `Distant` (the M-state-direct publish wins
129 /// whenever the consumer is anywhere outside the publisher's L1d
130 /// and never loses badly inside it). Otherwise picks `Local`.
131 pub fn pick_auto() -> Self {
132 if has_movdir64b() {
133 Self::Distant
134 } else {
135 Self::Local
136 }
137 }
138
139 /// Resolve a caller-supplied request: a request of `Distant`
140 /// degrades to `Local` on hosts without MOVDIR64B, since the
141 /// instruction is unavailable.
142 pub fn resolve(self) -> Self {
143 match self {
144 Self::Local => Self::Local,
145 Self::Distant => {
146 if has_movdir64b() {
147 Self::Distant
148 } else {
149 Self::Local
150 }
151 }
152 }
153 }
154}
155
156/// Prefetch the cache line at `slot` with write-intent (M-state).
157/// Emits `PREFETCHW` directly via inline asm on x86_64. See
158/// `shared_deque_loh::prefetch_slot` for the architectural reasoning.
159#[inline(always)]
160fn prefetchw_slot(slot: *const KhlSlot) {
161 #[cfg(target_arch = "x86_64")]
162 {
163 // SAFETY: `prefetchw` is a hardware hint and never faults.
164 unsafe {
165 core::arch::asm!(
166 "prefetchw [{ptr}]",
167 ptr = in(reg) slot,
168 options(nostack, preserves_flags),
169 );
170 }
171 }
172 #[cfg(not(target_arch = "x86_64"))]
173 {
174 _ = slot;
175 }
176}
177
178/// Magic byte sequence marking a valid KHL file. ASCII "WKHL" + ver
179/// 2 (bumped for the `n_items`-into-sequence bit-pack layout change).
180pub const KHL_MAGIC: u64 = 0x574B_484C_0000_0002;
181
182/// Cache-line size; one slot per cache line.
183pub const KHL_SLOT_SIZE: usize = 64;
184
185/// Items per slot: state (8 B sequence + 4 B n_items + 4 B reserved
186/// = 16 B header) + 3 * 16 = 48 B = 64 B total.
187pub const KHL_ITEMS_PER_SLOT: usize = 3;
188
189/// File header. Cache-line aligned. `head` and `tail` each get their
190/// own cache line so the producer's owner-private store on `tail`
191/// does not invalidate the consumer-side `head` line.
192#[repr(C, align(64))]
193pub struct KhlHeader {
194 /// Magic constant.
195 pub magic: u64,
196 /// Number of ring slots; always a power of two.
197 pub capacity: u64,
198 /// Pid of the owner process; informational. Cleared on
199 /// `close_owner()`.
200 pub owner_pid: AtomicU64,
201 /// Epoch counter advanced by the owner on shutdown.
202 pub epoch: AtomicU64,
203 /// Padding to push `tail` to its own cache line.
204 pub _pad_meta: [u8; 24],
205 /// Producer counter. Written by the owner only (Chase-Lev-style
206 /// owner-private counter); Release-stored after the per-slot
207 /// publish loop. Thieves Acquire-load to learn the high watermark.
208 pub tail: AtomicI64,
209 /// Padding to push `head` to its own cache line.
210 pub _pad_tail: [u8; 56],
211 /// Consumer counter. Thieves CAS to claim a slot.
212 pub head: AtomicI64,
213 /// Padding round to two whole cache lines after `head`.
214 pub _pad_head: [u8; 56],
215}
216
217/// Ring slot: Vyukov sequence (with `n_items` bit-packed into the
218/// low 2 bits) + 3 [`LineItem`] payloads. Fixed shape, 64 bytes,
219/// process-portable.
220///
221/// ## Cross-axis fusion: `n_items` packed into `sequence`
222///
223/// `n_items` is always in `1..=KHL_ITEMS_PER_SLOT = 3`, which fits
224/// in 2 bits. Instead of paying a separate store to publish
225/// `n_items` alongside the sequence number, we encode it in the low
226/// 2 bits of `packed_sequence`. The producer's ONE Release-store on
227/// `packed_sequence` publishes BOTH the protocol state AND the
228/// payload count - saving one store per slot, which fuses the
229/// `K_inner` axis (items per slot) with the `K_gating` axis
230/// (per-slot atomic) at the slot's cache line.
231///
232/// Encoding:
233/// - `idx_value` = high 62 bits of `packed_sequence`
234/// - `n_items` = low 2 bits of `packed_sequence`
235/// - State: `idx_value == idx` (empty) -> `idx_value == idx + 1`
236/// (published, `n_items` valid) -> `idx_value == idx + capacity`
237/// (consumed)
238#[repr(C, align(64))]
239pub struct KhlSlot {
240 /// Bit-packed Vyukov sequence: `(idx_value << 2) | n_items`.
241 pub packed_sequence: AtomicI64,
242 /// Reserved 8 bytes for cache-line alignment of the items array
243 /// (items start at offset 16, slot is 64 bytes total).
244 pub _reserved: u64,
245 /// Caller's byte-oriented payloads. Only the first
246 /// `unpack_n_items(packed_sequence.load())` are guaranteed valid.
247 pub items: [LineItem; KHL_ITEMS_PER_SLOT],
248}
249
250/// Pack `(idx_value, n_items)` into a single i64 for atomic store.
251#[inline(always)]
252pub const fn pack_seq(idx_value: i64, n_items: usize) -> i64 {
253 (idx_value << 2) | (n_items as i64 & 0x3)
254}
255
256/// Unpack idx_value from a packed sequence word.
257#[inline(always)]
258pub const fn unpack_idx(packed: i64) -> i64 {
259 packed >> 2
260}
261
262/// Unpack n_items from a packed sequence word.
263#[inline(always)]
264pub const fn unpack_n_items(packed: i64) -> usize {
265 (packed & 0x3) as usize
266}
267
268/// Total file size for a ring with `capacity` slots.
269pub const fn khl_file_size(capacity: usize) -> usize {
270 std::mem::size_of::<KhlHeader>() + capacity * KHL_SLOT_SIZE
271}
272
273/// Outcome of [`SharedDequeKhl::publish_batch`].
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum PushError {
276 /// Ring at capacity; consumer hasn't caught up.
277 Full,
278}
279
280/// Outcome of [`SharedDequeKhl::steal_slot`].
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum Steal {
283 /// Got items from one slot (1..=KHL_ITEMS_PER_SLOT).
284 Success(StealResult),
285 /// Ring empty.
286 Empty,
287 /// CAS lost or publisher's Release on sequence is missing from
288 /// the snapshot; outer loop should retry.
289 Retry,
290}
291
292/// Payload returned by a successful steal.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub struct StealResult {
295 /// Count of valid items in `items`.
296 pub n_items: usize,
297 /// The slot's items (only `items[..n_items]` are valid).
298 pub items: [LineItem; KHL_ITEMS_PER_SLOT],
299}
300
301/// MMF-backed K-axis Hierarchical LCRQ deque. Single owner, N thieves.
302pub struct SharedDequeKhl {
303 _file: File,
304 mmap: MmapMut,
305 capacity: usize,
306 capacity_mask: i64,
307 publish_radius: PublishRadius,
308}
309
310// SAFETY: All fields are Send. Mmap handle is Send + Sync per
311// memmap2; every slot access goes through the LCRQ sequence-number
312// protocol. The owner-private tail contract ("only the owner process
313// pushes") makes the Relaxed/Release-store-on-tail safe; the
314// per-slot Release-store on sequence is what publishes the bytes.
315unsafe impl Send for SharedDequeKhl {}
316// SAFETY: Same justification as the `Send` impl directly above.
317unsafe impl Sync for SharedDequeKhl {}
318
319impl SharedDequeKhl {
320 /// Create a fresh KHL file. `capacity` rounds up to the next
321 /// power of two (min 2). Capacity is in SLOTS; total item
322 /// capacity is `capacity * KHL_ITEMS_PER_SLOT`.
323 pub fn create<P: AsRef<Path>>(path: P, capacity: usize) -> io::Result<Self> {
324 let capacity = capacity.max(2).next_power_of_two();
325 let size = khl_file_size(capacity);
326
327 let file = OpenOptions::new()
328 .read(true)
329 .write(true)
330 .create(true)
331 .truncate(true)
332 .open(path.as_ref())?;
333 file.set_len(size as u64)?;
334
335 // SAFETY: `map_mut` soundness contract is upheld by writing
336 // only through the per-slot Vyukov sequence-number protocol;
337 // file size is fixed by `set_len` and never shrunk for the
338 // lifetime of any mapping.
339 let mut mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
340
341 let header_ptr = mmap.as_mut_ptr() as *mut KhlHeader;
342 // SAFETY: mmap is page-aligned; the map covers the full
343 // header + slots by construction.
344 unsafe {
345 (*header_ptr).magic = KHL_MAGIC;
346 (*header_ptr).capacity = capacity as u64;
347 (*header_ptr).owner_pid = AtomicU64::new(std::process::id() as u64);
348 (*header_ptr).epoch = AtomicU64::new(0);
349 std::ptr::write_bytes((*header_ptr)._pad_meta.as_mut_ptr(), 0, 24);
350 (*header_ptr).tail = AtomicI64::new(0);
351 std::ptr::write_bytes((*header_ptr)._pad_tail.as_mut_ptr(), 0, 56);
352 (*header_ptr).head = AtomicI64::new(0);
353 std::ptr::write_bytes((*header_ptr)._pad_head.as_mut_ptr(), 0, 56);
354 }
355
356 // Initialise each slot's sequence to its index. On first
357 // producer touch, `sequence == idx`, so the publisher knows
358 // the slot is ready.
359 let slots_start = std::mem::size_of::<KhlHeader>();
360 for i in 0..capacity {
361 let off = slots_start + i * KHL_SLOT_SIZE;
362 // SAFETY: off + KHL_SLOT_SIZE <= khl_file_size(capacity)
363 // by construction; cast to *mut KhlSlot is sound because
364 // the slot is repr(C, align(64)) and off is a multiple
365 // of 64.
366 let slot_ptr = unsafe { mmap.as_mut_ptr().add(off) as *mut KhlSlot };
367 // SAFETY: slot_ptr is in-bounds + aligned.
368 unsafe {
369 // Empty state: packed_sequence = (i << 2) | 0
370 (*slot_ptr).packed_sequence =
371 AtomicI64::new(pack_seq(i as i64, 0));
372 (*slot_ptr)._reserved = 0;
373 (*slot_ptr).items = [LineItem::default(); KHL_ITEMS_PER_SLOT];
374 }
375 }
376
377 mmap.flush()?;
378 Ok(Self {
379 _file: file,
380 mmap,
381 capacity,
382 capacity_mask: (capacity as i64) - 1,
383 publish_radius: PublishRadius::pick_auto(),
384 })
385 }
386
387 /// Open an existing KHL file.
388 pub fn open<P: AsRef<Path>>(path: P) -> io::Result<Self> {
389 let file = OpenOptions::new()
390 .read(true)
391 .write(true)
392 .open(path.as_ref())?;
393 let size = file.metadata()?.len() as usize;
394 if size < std::mem::size_of::<KhlHeader>() {
395 return Err(io::Error::new(
396 io::ErrorKind::InvalidData,
397 "khl file too small to contain header",
398 ));
399 }
400
401 // SAFETY: Same protocol-only-access justification as create.
402 let mmap = unsafe { MmapOptions::new().len(size).map_mut(&file)? };
403
404 let header_ptr = mmap.as_ptr() as *const KhlHeader;
405 // SAFETY: map size verified to cover header.
406 let (magic, capacity) =
407 unsafe { ((*header_ptr).magic, (*header_ptr).capacity as usize) };
408 if magic != KHL_MAGIC {
409 return Err(io::Error::new(
410 io::ErrorKind::InvalidData,
411 format!("khl magic mismatch: got {magic:#x}, want {KHL_MAGIC:#x}"),
412 ));
413 }
414 if !capacity.is_power_of_two() || capacity < 2 {
415 return Err(io::Error::new(
416 io::ErrorKind::InvalidData,
417 format!("khl capacity {capacity} is not pow2 >= 2"),
418 ));
419 }
420 if size < khl_file_size(capacity) {
421 return Err(io::Error::new(
422 io::ErrorKind::InvalidData,
423 format!(
424 "khl file size {size} below expected {}",
425 khl_file_size(capacity)
426 ),
427 ));
428 }
429 Ok(Self {
430 _file: file,
431 mmap,
432 capacity,
433 capacity_mask: (capacity as i64) - 1,
434 publish_radius: PublishRadius::pick_auto(),
435 })
436 }
437
438 /// Capacity in slots (always a power of two). Total item
439 /// capacity is `capacity() * KHL_ITEMS_PER_SLOT`.
440 pub fn capacity(&self) -> usize {
441 self.capacity
442 }
443
444 /// The currently configured `K_radius` axis value. Defaults to
445 /// [`PublishRadius::pick_auto`] at construction; callers can
446 /// override via [`Self::with_publish_radius`] when they know the
447 /// producer-consumer coherence distance in advance (e.g. a
448 /// cross-CCD scheduler explicitly requesting `Distant`).
449 pub fn publish_radius(&self) -> PublishRadius {
450 self.publish_radius
451 }
452
453 /// Override the publish radius for this handle. The setter
454 /// resolves `Distant` to `Local` on hosts without MOVDIR64B so
455 /// callers can request `Distant` unconditionally without breaking
456 /// on older silicon.
457 pub fn with_publish_radius(mut self, radius: PublishRadius) -> Self {
458 self.publish_radius = radius.resolve();
459 self
460 }
461
462 /// Owner pid at create time, or 0 after `close_owner()`.
463 pub fn owner_pid(&self) -> u64 {
464 self.header().owner_pid.load(Ordering::Acquire)
465 }
466
467 /// Owner shutdown: zero pid + advance epoch.
468 pub fn close_owner(&self) {
469 self.header().owner_pid.store(0, Ordering::Release);
470 self.header().epoch.fetch_add(1, Ordering::Release);
471 }
472
473 fn header(&self) -> &KhlHeader {
474 // SAFETY: map covers the header; alignment is page-aligned.
475 unsafe { &*(self.mmap.as_ptr() as *const KhlHeader) }
476 }
477
478 fn slot_ptr(&self, idx: i64) -> *mut KhlSlot {
479 let slot_idx = (idx & self.capacity_mask) as usize;
480 let off = std::mem::size_of::<KhlHeader>() + slot_idx * KHL_SLOT_SIZE;
481 // SAFETY: slot_idx is in [0, capacity); off is in-bounds +
482 // 64-byte aligned.
483 unsafe { self.mmap.as_ptr().add(off) as *mut KhlSlot }
484 }
485
486 /// Snapshot `(head, tail, ring_size_slots)`. Loads are
487 /// independent; the tuple is not a linearizable snapshot.
488 pub fn snapshot_size(&self) -> (i64, i64, i64) {
489 let h = self.header();
490 let head = h.head.load(Ordering::Acquire);
491 let tail = h.tail.load(Ordering::Acquire);
492 (head, tail, tail - head)
493 }
494
495 /// Owner-side batch publish. Packs `items` into
496 /// `ceil(items.len() / KHL_ITEMS_PER_SLOT)` slots, advances the
497 /// owner-private tail by that many slots via ONE Release-store
498 /// (no atomic fetch_add), and writes each slot's payload with one
499 /// Release-store on the per-slot Vyukov sequence number.
500 ///
501 /// **Only the owner process may call this.**
502 ///
503 /// Cost per call: 1 Release-store on tail + `ceil(K/3)` slot
504 /// Release-stores. For K=64 items: 1 + 22 = 23 atomic ops total
505 /// vs Chase-Lev's 64+ and LOH's 65.
506 ///
507 /// Returns the number of items published.
508 pub fn publish_batch(&self, items: &[LineItem]) -> Result<usize, PushError> {
509 if items.is_empty() {
510 return Ok(0);
511 }
512 let k = items.len();
513 let n_slots = k.div_ceil(KHL_ITEMS_PER_SLOT);
514 let h = self.header();
515 // Chase-Lev-style: read head Acquire, then the owner reads
516 // its own private tail with a Relaxed load (the owner is the
517 // only writer to tail). Capacity check before reserving.
518 let head_snapshot = h.head.load(Ordering::Acquire);
519 let tail_snapshot = h.tail.load(Ordering::Relaxed);
520 if (tail_snapshot - head_snapshot + n_slots as i64) > self.capacity as i64 {
521 return Err(PushError::Full);
522 }
523 let base = tail_snapshot;
524 // Prefetch the first slot before entering the publish loop.
525 prefetchw_slot(self.slot_ptr(base));
526
527 // Publish each slot under the Vyukov protocol.
528 let mut written = 0usize;
529 for slot_i in 0..n_slots {
530 let idx = base + slot_i as i64;
531 // Warm the next slot while we publish this one.
532 if slot_i + 1 < n_slots {
533 prefetchw_slot(self.slot_ptr(idx + 1));
534 }
535 let take = (k - written).min(KHL_ITEMS_PER_SLOT);
536 // SAFETY: slot_ptr returns in-bounds aligned pointer;
537 // caller has reserved `idx` by the (pending) tail update.
538 unsafe {
539 self.publish_slot_at(idx, &items[written..written + take]);
540 }
541 written += take;
542 }
543
544 // Owner-private Release-store on tail. The Release ordering
545 // is overkill for the protocol (the per-slot Release on
546 // sequence is what publishes the slot bytes; tail is just a
547 // high-watermark hint to thieves), but Release lets the thief
548 // Acquire-load on tail synchronise reliably even on weakly-
549 // ordered architectures. On x86 a Release store costs the
550 // same as a Relaxed store.
551 h.tail.store(base + n_slots as i64, Ordering::Release);
552
553 Ok(k)
554 }
555
556 /// Publish one slot at ring index `idx` under the Vyukov
557 /// sequence-number protocol with up to KHL_ITEMS_PER_SLOT items.
558 ///
559 /// # Safety
560 ///
561 /// Caller must hold the producer reservation: `idx` is in the
562 /// range `[base, base + n_slots)` for a successful capacity check
563 /// in `publish_batch` that has not yet been committed to `tail`.
564 /// `items.len() <= KHL_ITEMS_PER_SLOT`.
565 unsafe fn publish_slot_at(&self, idx: i64, items: &[LineItem]) {
566 let slot = self.slot_ptr(idx);
567 // Spin-wait until the slot is publishable: idx_value == idx
568 // (low 2 bits ignored; an empty slot has packed = idx << 2).
569 loop {
570 // SAFETY: slot is in-bounds + aligned; producer owns the
571 // reservation; LCRQ sequence-number protocol ensures no
572 // other writer touches this slot until consumer Releases.
573 let packed = unsafe {
574 (*slot).packed_sequence.load(Ordering::Acquire)
575 };
576 let idx_value = unpack_idx(packed);
577 let diff = idx_value - idx;
578 if diff == 0 {
579 break;
580 }
581 if diff < 0 {
582 std::hint::spin_loop();
583 continue;
584 }
585 // diff > 0: future round. Single-producer + capacity
586 // check makes this unreachable.
587 panic!(
588 "KHL producer protocol violation: slot[{}] idx_value={} ahead of idx={}",
589 idx & self.capacity_mask,
590 idx_value,
591 idx
592 );
593 }
594 // K_radius dispatch: pick the publish mechanism based on the
595 // configured coherence distance.
596 match self.publish_radius {
597 PublishRadius::Local => {
598 // SAFETY: producer owns the slot for this round.
599 // ONE Release-store on packed_sequence publishes
600 // BOTH the protocol state AND `n_items` together
601 // (cross-axis fusion: K_inner + K_gating).
602 unsafe {
603 let n = items.len();
604 for (i, item) in items.iter().enumerate() {
605 (*slot).items[i] = *item;
606 }
607 (*slot)
608 .packed_sequence
609 .store(pack_seq(idx + 1, n), Ordering::Release);
610 }
611 }
612 PublishRadius::Distant => {
613 // SAFETY: same; `Distant` is only set when
614 // `has_movdir64b()` returned true.
615 unsafe {
616 self.publish_slot_movdir64b(slot, idx, items);
617 }
618 }
619 }
620 }
621
622 /// Build a 64-byte source line on the stack carrying the new
623 /// sequence + n_items + items, then atomically write it to the
624 /// destination slot via `MOVDIR64B` + `SFENCE`. The whole slot
625 /// (including the sequence number) is published as one atomic
626 /// Write-Combining store that bypasses the producer's L1d.
627 ///
628 /// # Safety
629 ///
630 /// Caller must have validated that `self.publish_radius ==
631 /// Distant` (so `has_movdir64b()` returned true), holds the
632 /// producer reservation for `idx`, and `items.len() <=
633 /// KHL_ITEMS_PER_SLOT`.
634 #[inline(always)]
635 unsafe fn publish_slot_movdir64b(
636 &self,
637 slot: *mut KhlSlot,
638 idx: i64,
639 items: &[LineItem],
640 ) {
641 // The src line is layout-compatible with KhlSlot. The
642 // packed_sequence carries (idx+1, n_items) bit-packed - the
643 // cross-axis fusion of K_inner + K_gating in the same atomic
644 // word the K_radius MOVDIR64B atomically publishes.
645 #[repr(C, align(64))]
646 struct SrcLine {
647 packed_sequence: i64,
648 _reserved: u64,
649 items: [LineItem; KHL_ITEMS_PER_SLOT],
650 }
651 let mut src = SrcLine {
652 packed_sequence: pack_seq(idx + 1, items.len()),
653 _reserved: 0,
654 items: [LineItem::default(); KHL_ITEMS_PER_SLOT],
655 };
656 for (i, item) in items.iter().enumerate() {
657 src.items[i] = *item;
658 }
659
660 let dst_ptr = slot as *mut u8;
661 let src_ptr = &src as *const SrcLine as *const u8;
662
663 #[cfg(target_arch = "x86_64")]
664 {
665 // SAFETY: `MOVDIR64B` writes 64 bytes from `[src_ptr]` to
666 // `[dst_ptr]`. Both pointers are 64-byte aligned (KhlSlot
667 // is repr(C, align(64)); SrcLine matches). `SFENCE`
668 // drains the WC store buffer so the publish is globally
669 // visible. The MOVDIR64B atomically publishes the
670 // sequence + n_items + items together, so the consumer
671 // observes either the OLD slot (seq != idx + 1) or the
672 // NEW slot (seq == idx + 1) with no partial publish
673 // visible.
674 unsafe {
675 core::arch::asm!(
676 "movdir64b {dst}, [{src}]",
677 "sfence",
678 dst = in(reg) dst_ptr,
679 src = in(reg) src_ptr,
680 options(nostack, preserves_flags),
681 );
682 }
683 }
684 #[cfg(not(target_arch = "x86_64"))]
685 {
686 _ = dst_ptr;
687 _ = src_ptr;
688 _ = slot;
689 _ = idx;
690 unreachable!(
691 "publish_slot_movdir64b reached on non-x86_64 host; \
692 PublishRadius::resolve() returns Local there"
693 );
694 }
695 }
696
697 /// Thief-side steal. Claim one slot's worth of items via CAS on
698 /// the shared head + Acquire-load on the per-slot sequence.
699 pub fn steal_slot(&self) -> Steal {
700 let h = self.header();
701 let head = h.head.load(Ordering::Acquire);
702 fence(Ordering::SeqCst);
703 let tail = h.tail.load(Ordering::Acquire);
704 if head >= tail {
705 return Steal::Empty;
706 }
707 let slot = self.slot_ptr(head);
708 // SAFETY: slot is in-bounds + aligned.
709 let packed = unsafe {
710 (*slot).packed_sequence.load(Ordering::Acquire)
711 };
712 // Cross-axis fusion: the ONE Acquire-load above reads BOTH
713 // the protocol state (idx_value) AND the payload count
714 // (n_items) packed into the same atomic word.
715 let idx_value = unpack_idx(packed);
716 if idx_value != head + 1 {
717 return Steal::Retry;
718 }
719 let n = unpack_n_items(packed).min(KHL_ITEMS_PER_SLOT);
720 let won = h
721 .head
722 .compare_exchange(head, head + 1, Ordering::SeqCst, Ordering::Relaxed)
723 .is_ok();
724 if !won {
725 return Steal::Retry;
726 }
727 // SAFETY: the CAS established exclusive read access for this
728 // round; the producer's Release on packed_sequence
729 // happens-before our Acquire load above.
730 let result = unsafe {
731 StealResult {
732 n_items: n,
733 items: (*slot).items,
734 }
735 };
736 // Release the slot for the next round at head + capacity.
737 // n_items=0 in the released state (consumed).
738 // SAFETY: still our slot; the Release synchronises with the
739 // next producer's Acquire-spin in publish_slot_at.
740 unsafe {
741 (*slot).packed_sequence.store(
742 pack_seq(head + self.capacity as i64, 0),
743 Ordering::Release,
744 );
745 }
746 Steal::Success(result)
747 }
748
749 /// Force any dirty pages to disk.
750 pub fn flush_to_disk(&self) -> io::Result<()> {
751 self.mmap.flush()
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use std::sync::Arc;
759 use std::sync::atomic::{AtomicUsize, Ordering as O};
760 use std::thread;
761
762 fn temp_path(name: &str) -> std::path::PathBuf {
763 let mut p = std::env::temp_dir();
764 let pid = std::process::id();
765 let nonce = std::time::SystemTime::now()
766 .duration_since(std::time::UNIX_EPOCH)
767 .map(|d| d.as_nanos())
768 .unwrap_or(0);
769 p.push(format!("subetha_khl_{pid}_{nonce}_{name}.bin"));
770 p
771 }
772
773 fn u32_item(id: u32) -> LineItem {
774 LineItem::new(&id.to_le_bytes()).expect("item")
775 }
776
777 fn item_id(item: &LineItem) -> u32 {
778 u32::from_le_bytes(item.payload[..4].try_into().unwrap())
779 }
780
781 #[test]
782 fn publish_radius_matches_host() {
783 let path = temp_path("radius_auto");
784 let d = SharedDequeKhl::create(&path, 8).expect("create");
785 let r = d.publish_radius();
786 if subetha_core::has_movdir64b() {
787 assert_eq!(r, PublishRadius::Distant);
788 } else {
789 assert_eq!(r, PublishRadius::Local);
790 }
791 std::fs::remove_file(&path).ok();
792 }
793
794 #[test]
795 fn publish_radius_distant_resolves_to_local_without_movdir64b() {
796 let path = temp_path("radius_resolve");
797 let d = SharedDequeKhl::create(&path, 8)
798 .expect("create")
799 .with_publish_radius(PublishRadius::Distant);
800 // On hosts without MOVDIR64B the resolve degrades to Local.
801 if subetha_core::has_movdir64b() {
802 assert_eq!(d.publish_radius(), PublishRadius::Distant);
803 } else {
804 assert_eq!(d.publish_radius(), PublishRadius::Local);
805 }
806 std::fs::remove_file(&path).ok();
807 }
808
809 #[test]
810 fn publish_then_drain_works_under_both_radius_modes() {
811 // Round-trip a batch under whichever radius pick_auto chose
812 // for this host (Local on Zen+ R7 2700; Distant on Zen 5+/
813 // Tiger Lake+). Either path produces bit-exact slots.
814 let path = temp_path("radius_round_trip");
815 let d = SharedDequeKhl::create(&path, 8).expect("create");
816 let items: Vec<LineItem> = (1..=6u32).map(u32_item).collect();
817 d.publish_batch(&items).expect("publish");
818 let mut drained = Vec::new();
819 loop {
820 match d.steal_slot() {
821 Steal::Success(r) => {
822 for i in 0..r.n_items {
823 drained.push(item_id(&r.items[i]));
824 }
825 }
826 Steal::Empty => break,
827 Steal::Retry => continue,
828 }
829 }
830 assert_eq!(drained, vec![1, 2, 3, 4, 5, 6]);
831 std::fs::remove_file(&path).ok();
832 }
833
834 #[test]
835 fn create_then_open_round_trips_header() {
836 let path = temp_path("create_open");
837 let _d = SharedDequeKhl::create(&path, 8).expect("create");
838 let o = SharedDequeKhl::open(&path).expect("open");
839 assert_eq!(o.capacity(), 8);
840 assert_eq!(o.owner_pid(), std::process::id() as u64);
841 std::fs::remove_file(&path).ok();
842 }
843
844 #[test]
845 fn open_rejects_bad_magic() {
846 let path = temp_path("bad_magic");
847 std::fs::write(&path, vec![0xCDu8; 8192]).expect("seed");
848 assert!(SharedDequeKhl::open(&path).is_err());
849 std::fs::remove_file(&path).ok();
850 }
851
852 #[test]
853 fn publish_batch_packs_three_items_per_slot() {
854 let path = temp_path("publish_batch_packs");
855 let d = SharedDequeKhl::create(&path, 64).expect("create");
856 let items: Vec<LineItem> = (1..=7u32).map(u32_item).collect();
857 let n = d.publish_batch(&items).expect("publish_batch");
858 assert_eq!(n, 7);
859 let (_, tail, sz) = d.snapshot_size();
860 // 7 items = ceil(7/3) = 3 slots.
861 assert_eq!(tail, 3);
862 assert_eq!(sz, 3);
863 std::fs::remove_file(&path).ok();
864 }
865
866 #[test]
867 fn publish_batch_empty_is_noop() {
868 let path = temp_path("publish_empty");
869 let d = SharedDequeKhl::create(&path, 4).expect("create");
870 assert_eq!(d.publish_batch(&[]).expect("noop"), 0);
871 let (_, tail, sz) = d.snapshot_size();
872 assert_eq!(tail, 0);
873 assert_eq!(sz, 0);
874 std::fs::remove_file(&path).ok();
875 }
876
877 #[test]
878 fn publish_batch_full_returns_full() {
879 let path = temp_path("publish_full");
880 let d = SharedDequeKhl::create(&path, 2).expect("create");
881 // Capacity is 2 slots = 6 items. First batch fills all 2 slots.
882 let first: Vec<LineItem> = (1..=6u32).map(u32_item).collect();
883 d.publish_batch(&first).expect("first batch");
884 // Next publish should fail with Full.
885 let err = d
886 .publish_batch(&[u32_item(99)])
887 .expect_err("publish past capacity");
888 assert_eq!(err, PushError::Full);
889 std::fs::remove_file(&path).ok();
890 }
891
892 #[test]
893 fn steal_drains_in_publication_order() {
894 let path = temp_path("steal_order");
895 let d = SharedDequeKhl::create(&path, 8).expect("create");
896 // 7 items: slots [0]=(1,2,3), [1]=(4,5,6), [2]=(7).
897 let items: Vec<LineItem> = (1..=7u32).map(u32_item).collect();
898 d.publish_batch(&items).expect("publish");
899 let mut drained = Vec::new();
900 loop {
901 match d.steal_slot() {
902 Steal::Success(r) => {
903 for i in 0..r.n_items {
904 drained.push(item_id(&r.items[i]));
905 }
906 }
907 Steal::Empty => break,
908 Steal::Retry => continue,
909 }
910 }
911 assert_eq!(drained, vec![1, 2, 3, 4, 5, 6, 7]);
912 std::fs::remove_file(&path).ok();
913 }
914
915 #[test]
916 fn close_owner_zeros_pid_and_advances_epoch() {
917 let path = temp_path("close");
918 let d = SharedDequeKhl::create(&path, 2).expect("create");
919 let before = d.header().epoch.load(O::Acquire);
920 d.close_owner();
921 assert_eq!(d.owner_pid(), 0);
922 assert_eq!(d.header().epoch.load(O::Acquire), before + 1);
923 std::fs::remove_file(&path).ok();
924 }
925
926 #[test]
927 fn concurrent_thieves_no_double_take() {
928 let path = temp_path("stress");
929 let d = Arc::new(SharedDequeKhl::create(&path, 256).expect("create"));
930 let n: usize = 5_000;
931 let consumed = Arc::new(AtomicUsize::new(0));
932 let sum = Arc::new(AtomicUsize::new(0));
933
934 let mut thieves = Vec::new();
935 for _ in 0..2 {
936 let d = Arc::clone(&d);
937 let consumed = Arc::clone(&consumed);
938 let sum = Arc::clone(&sum);
939 thieves.push(thread::spawn(move || {
940 while consumed.load(O::Relaxed) < n {
941 match d.steal_slot() {
942 Steal::Success(r) => {
943 for i in 0..r.n_items {
944 consumed.fetch_add(1, O::Relaxed);
945 sum.fetch_add(
946 item_id(&r.items[i]) as usize,
947 O::Relaxed,
948 );
949 }
950 }
951 Steal::Empty | Steal::Retry => std::thread::yield_now(),
952 }
953 }
954 }));
955 }
956
957 // Producer: 64 items per batch.
958 let burst = 64usize;
959 let mut pushed = 0usize;
960 while pushed < n {
961 let want = burst.min(n - pushed);
962 let batch: Vec<LineItem> =
963 (0..want).map(|j| u32_item((pushed + j) as u32)).collect();
964 loop {
965 match d.publish_batch(&batch) {
966 Ok(_) => break,
967 Err(PushError::Full) => std::thread::yield_now(),
968 }
969 }
970 pushed += want;
971 }
972
973 for t in thieves {
974 t.join().expect("thief");
975 }
976 let expected: usize = (0..n).sum();
977 assert_eq!(sum.load(O::Relaxed), expected, "every item consumed once");
978 std::fs::remove_file(&path).ok();
979 }
980}