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}