Skip to main content

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}