Skip to main content

subetha_cxc/
shared_deque_fcl.rs

1//! `SharedDequeFcl` - Fat Chase-Lev: counter-only Chase-Lev with
2//! `K_inner = 3` items per slot.
3//!
4//! This primitive answers the design-cube question "is there middle
5//! ground between Chase-Lev (`K_inner=1`, counter-only) and KHL
6//! (`K_inner=3`, per-slot atomic)?". The answer is **yes**: Chase-
7//! Lev's safety proof never required `K_inner = 1`; it only required
8//! that the producer's bottom store be Release-fenced after the slot
9//! bytes are written. With `K_inner = 3` the protocol becomes:
10//!
11//! 1. Producer loads `bottom` (Relaxed) + `top` (Acquire).
12//! 2. Capacity check: `(bottom - top) + n_slots <= capacity`.
13//! 3. For each of the `n_slots` slots: write 64 bytes (sequence-
14//!    number-free) carrying 3 [`LineItem`] payloads + a count.
15//! 4. ONE Release fence orders all slot writes.
16//! 5. ONE Relaxed store on owner-private `bottom` advances bottom by
17//!    `n_slots`, atomically publishing all slots from the thieves'
18//!    perspective.
19//!
20//! No per-slot atomic. No per-slot Acquire-Release pair. Per K=64
21//! items the producer pays one `top` load + 22 cache-line writes +
22//! one Release fence + one `bottom` store - 24 atomic ops total, of
23//! which 22 are just memory writes.
24//!
25//! ## Cost-model comparison (K=64 producer-fast)
26//!
27//! | Primitive | Producer atomics |
28//! |---|---:|
29//! | `SharedDeque<u64>` (Chase-Lev `K_inner=1`) | 64 Release fences + 64 Relaxed bottom stores + 64 top loads |
30//! | `SharedDequeKhpd::publish_batch` | 22 slot Release-stores on state + 1 `fetch_add` (LOCK XADD) |
31//! | `SharedDequeLoh::publish_batch` | 64 slot Release-stores on sequence + 1 LOCK XADD |
32//! | `SharedDequeKhl::publish_batch` | 22 slot Acquire-loads on sequence + 22 slot Release-stores on sequence + 1 Release-store on owner-private tail |
33//! | **`SharedDequeFcl::publish_batch`** | **1 top Acquire-load + 22 cache-line writes + 1 Release fence + 1 Relaxed bottom store** |
34//!
35//! Fcl's producer side has the **fewest atomic operations** of any
36//! batched deque-family primitive on this substrate. The trade-off
37//! is on the thief side: Chase-Lev's steal protocol does a
38//! speculative slot read BEFORE the head CAS, so a thief that loses
39//! the CAS has read a 64-byte slot for nothing. Under heavy
40//! contention this wastes cache bandwidth; under producer-fast
41//! single-thief (the workload-shape Fcl targets) the speculative
42//! reads never get wasted because the CAS never loses.
43//!
44//! ## Why this is novel
45//!
46//! The Chase-Lev literature treats `K_inner = 1` as a fixed feature
47//! of the protocol, but inspecting the safety proof shows it never
48//! depended on the slot size. SubEtha's byte-oriented [`LineItem`]
49//! decoupling makes the natural fat-slot extension trivial: three
50//! [`LineItem`] payloads (16 B each = 48 B) plus an 8 B count word
51//! plus 8 B of tail padding fit exactly in 64 B. The slot becomes
52//! cache-line aligned by construction; sequential slot writes are
53//! sequential cache-line writes. This is the counter-only end's
54//! analogue of the `K_inner = 3` lever that KHPD pulled on the
55//! per-slot end.
56//!
57//! ## When to use this
58//!
59//! - **Producer-fast single-thief batched workloads**: this is the
60//!   win zone. Fcl's per-batch cost is dominated by 22 cache-line
61//!   writes; everything else is essentially free.
62//! - **NOT for multi-thief contention**: the speculative slot read
63//!   before head CAS wastes cache when the CAS races. Use
64//!   [`SharedDequeUrd`](crate::SharedDequeUrd) instead.
65//! - **NOT for per-item dispatch with K = 1**: just use plain
66//!   [`SharedDeque`]; Fcl's K_inner = 3 wastes slot bytes if the
67//!   caller has nothing to fill them with.
68
69#![allow(clippy::missing_errors_doc)]
70
71use std::io;
72use std::path::Path;
73
74use crate::shared_deque::{DequeError, SharedDeque};
75use crate::shared_deque_khpd::{FatLineItem, LineItem, PushError, LINE_ITEMS};
76
77/// MMF-backed Fat Chase-Lev deque. Counter-only Chase-Lev protocol
78/// with `K_inner = 3` items per slot, single owner, N thieves.
79///
80/// Wraps [`SharedDeque<FatLineItem>`](crate::SharedDeque) with a
81/// caller-facing [`publish_batch`](Self::publish_batch) API that
82/// packs [`LineItem`] payloads into 64-byte fat slots.
83pub struct SharedDequeFcl {
84    inner: SharedDeque<FatLineItem>,
85}
86
87impl SharedDequeFcl {
88    /// Create a fresh Fcl file. `capacity_slots` rounds up to the
89    /// next power of two. Total item capacity is
90    /// `capacity_slots * LINE_ITEMS`.
91    pub fn create<P: AsRef<Path>>(path: P, capacity_slots: usize) -> io::Result<Self> {
92        let inner = SharedDeque::<FatLineItem>::create(path, capacity_slots)
93            .map_err(|e| io::Error::other(format!("Fcl create: {e:?}")))?;
94        Ok(Self { inner })
95    }
96
97    /// Open an existing Fcl file as a thief (read-side).
98    pub fn open<P: AsRef<Path>>(path: P) -> io::Result<Self> {
99        let inner = SharedDeque::<FatLineItem>::open_as_thief(path)
100            .map_err(|e| io::Error::other(format!("Fcl open: {e:?}")))?;
101        Ok(Self { inner })
102    }
103
104    /// Capacity in slots (power of two). Total item capacity is
105    /// `capacity_slots() * LINE_ITEMS`.
106    pub fn capacity_slots(&self) -> usize {
107        self.inner.capacity()
108    }
109
110    /// Snapshot the current ring fill in slots.
111    pub fn approx_len_slots(&self) -> usize {
112        self.inner.approx_len()
113    }
114
115    /// Owner-side batched publish. Packs `items` into
116    /// `ceil(items.len() / LINE_ITEMS)` fat slots, then publishes
117    /// them with ONE top load + ONE Release fence + ONE Relaxed
118    /// bottom store via [`SharedDeque::push_batch`].
119    ///
120    /// Cost: 1 top load + `ceil(K/3)` cache-line writes + 1 Release
121    /// fence + 1 bottom store. No per-slot atomic.
122    ///
123    /// Returns the number of items published. Returns
124    /// `Err(DequeError::Full)` if the batch would overflow the ring.
125    pub fn publish_batch(&self, items: &[LineItem]) -> Result<usize, DequeError> {
126        if items.is_empty() {
127            return Ok(0);
128        }
129        let n_slots = items.len().div_ceil(LINE_ITEMS);
130        // SubEtha-style raw-pointer hot path: cast the slot's mapped
131        // bytes to `*mut FatLineItem` and write each field directly
132        // through the pointer. No intermediate `T` buffer, no
133        // `Marshal::marshal` byte copy, no slice bounds checks on
134        // the hot path. The slot is already 64-byte aligned (the
135        // header is `repr(align(64))` and `slot_bytes` = 64 for
136        // `FatLineItem`), so the cast is sound.
137        self.inner.push_batch_with(n_slots, |slot_i, slot_bytes| {
138            let start = slot_i * LINE_ITEMS;
139            let end = (start + LINE_ITEMS).min(items.len());
140            let chunk = &items[start..end];
141            let n = chunk.len();
142            // SAFETY: `slot_bytes` is a `slot_bytes_for::<FatLineItem>()`
143            // = 64-byte mapped region aligned to 64; cast to
144            // `*mut FatLineItem` is sound. Producer holds the
145            // reservation for this slot via the outer push_batch_with
146            // capacity check; no concurrent access until the Release
147            // fence + bottom store.
148            unsafe {
149                let dst = slot_bytes.as_mut_ptr() as *mut FatLineItem;
150                std::ptr::addr_of_mut!((*dst).n_items).write(n as u32);
151                std::ptr::addr_of_mut!((*dst).reserved).write(0);
152                // Each `(*dst).items[i] = *item` lowers to a single
153                // 16-byte SIMD store on x86_64.
154                let items_ptr = std::ptr::addr_of_mut!((*dst).items) as *mut LineItem;
155                for i in 0..n {
156                    items_ptr.add(i).write(*chunk.get_unchecked(i));
157                }
158                // Zero the unused tail of the items array so the
159                // consumer's `live_items()` decode does not return
160                // stale bytes from a prior round.
161                for i in n..LINE_ITEMS {
162                    items_ptr.add(i).write(LineItem::default());
163                }
164                std::ptr::addr_of_mut!((*dst)._pad).write([0u8; 8]);
165            }
166        })?;
167        Ok(items.len())
168    }
169
170    /// Thief-side steal. Returns one fat slot (1..=LINE_ITEMS items)
171    /// or `None` if the ring is empty / CAS lost.
172    pub fn steal_slot(&self) -> Option<FatLineItem> {
173        self.inner.steal()
174    }
175}
176
177impl From<PushError> for DequeError {
178    fn from(e: PushError) -> Self {
179        DequeError::Io(format!("Fcl pack: {e:?}"))
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::sync::Arc;
187    use std::sync::atomic::{AtomicUsize, Ordering as O};
188    use std::thread;
189
190    fn tmp(name: &str) -> std::path::PathBuf {
191        let mut p = std::env::temp_dir();
192        let pid = std::process::id();
193        let nonce = std::time::SystemTime::now()
194            .duration_since(std::time::UNIX_EPOCH)
195            .map(|d| d.as_nanos())
196            .unwrap_or(0);
197        p.push(format!("subetha_fcl_{pid}_{nonce}_{name}.bin"));
198        p
199    }
200
201    fn u32_item(id: u32) -> LineItem {
202        LineItem::new(&id.to_le_bytes()).expect("item")
203    }
204
205    fn item_id(item: &LineItem) -> u32 {
206        u32::from_le_bytes(item.payload[..4].try_into().unwrap())
207    }
208
209    #[test]
210    fn publish_batch_packs_three_items_per_slot() {
211        let path = tmp("packs");
212        let d = SharedDequeFcl::create(&path, 64).expect("create");
213        let items: Vec<LineItem> = (1..=7u32).map(u32_item).collect();
214        let n = d.publish_batch(&items).expect("publish");
215        assert_eq!(n, 7);
216        // 7 items = ceil(7/3) = 3 slots.
217        assert_eq!(d.approx_len_slots(), 3);
218        std::fs::remove_file(&path).ok();
219    }
220
221    #[test]
222    fn publish_batch_empty_is_noop() {
223        let path = tmp("empty");
224        let d = SharedDequeFcl::create(&path, 4).expect("create");
225        assert_eq!(d.publish_batch(&[]).expect("noop"), 0);
226        assert_eq!(d.approx_len_slots(), 0);
227        std::fs::remove_file(&path).ok();
228    }
229
230    #[test]
231    fn publish_batch_full_returns_full() {
232        let path = tmp("full");
233        let d = SharedDequeFcl::create(&path, 2).expect("create");
234        // Capacity is 2 slots = 6 items.
235        let first: Vec<LineItem> = (1..=6u32).map(u32_item).collect();
236        d.publish_batch(&first).expect("first batch");
237        let err = d
238            .publish_batch(&[u32_item(99)])
239            .expect_err("publish past capacity");
240        assert_eq!(err, DequeError::Full);
241        std::fs::remove_file(&path).ok();
242    }
243
244    #[test]
245    fn steal_drains_in_publication_order() {
246        let path = tmp("order");
247        let d = SharedDequeFcl::create(&path, 8).expect("create");
248        let items: Vec<LineItem> = (1..=7u32).map(u32_item).collect();
249        d.publish_batch(&items).expect("publish");
250        let mut drained = Vec::new();
251        while let Some(fat) = d.steal_slot() {
252            for item in fat.live_items() {
253                drained.push(item_id(item));
254            }
255        }
256        assert_eq!(drained, vec![1, 2, 3, 4, 5, 6, 7]);
257        std::fs::remove_file(&path).ok();
258    }
259
260    #[test]
261    fn concurrent_thieves_no_double_take() {
262        let path = tmp("stress");
263        let d = Arc::new(SharedDequeFcl::create(&path, 256).expect("create"));
264        let n: usize = 5_000;
265        let consumed = Arc::new(AtomicUsize::new(0));
266        let sum = Arc::new(AtomicUsize::new(0));
267
268        let mut thieves = Vec::new();
269        for _ in 0..2 {
270            let d = Arc::clone(&d);
271            let consumed = Arc::clone(&consumed);
272            let sum = Arc::clone(&sum);
273            thieves.push(thread::spawn(move || {
274                while consumed.load(O::Relaxed) < n {
275                    match d.steal_slot() {
276                        Some(fat) => {
277                            for item in fat.live_items() {
278                                consumed.fetch_add(1, O::Relaxed);
279                                sum.fetch_add(item_id(item) as usize, O::Relaxed);
280                            }
281                        }
282                        None => std::thread::yield_now(),
283                    }
284                }
285            }));
286        }
287
288        let burst = 64usize;
289        let mut pushed = 0usize;
290        while pushed < n {
291            let want = burst.min(n - pushed);
292            let batch: Vec<LineItem> =
293                (0..want).map(|j| u32_item((pushed + j) as u32)).collect();
294            loop {
295                match d.publish_batch(&batch) {
296                    Ok(_) => break,
297                    Err(DequeError::Full) => std::thread::yield_now(),
298                    Err(other) => panic!("publish_batch: {other:?}"),
299                }
300            }
301            pushed += want;
302        }
303
304        for t in thieves {
305            t.join().expect("thief");
306        }
307        let expected: usize = (0..n).sum();
308        assert_eq!(
309            sum.load(O::Relaxed),
310            expected,
311            "every item consumed exactly once"
312        );
313        std::fs::remove_file(&path).ok();
314    }
315}