Skip to main content

perf_features/
perf_features.rs

1//! Feature classification bench. Each feature's representative op is swept
2//! across three RING CAPACITIES, `classify_feature` DECIDES the category from
3//! the shape of that sweep, and the decision plus a measured `p99ByStage` is
4//! merge-written into `.subms/features/rust.json`.
5//!
6//! Capacity is the sweep axis because it is the only thing that sets a ring's
7//! size. 1024 / 16384 / 262144 slots of `u64` is 8 KiB / 128 KiB / 2 MiB, so the
8//! span crosses out of L1 and out of L2. Push and pop are O(1) by construction,
9//! so a flat curve is the EXPECTED answer here and a rising one would be the
10//! finding; what the sweep guards against is a feature whose bookkeeping walks
11//! the ring.
12//!
13//! Two measurement units, deliberately:
14//!
15//! - The SWEEP times a sample of `ITEMS_PER_SAMPLE` round trips, not one op. A
16//!   push costs a few ns and the platform clock this bench is developed on ticks
17//!   at 100 ns, so every single-op p50 reads exactly 100 ns and every curve is
18//!   flat by quantisation rather than by physics. Folding a thousand round trips
19//!   into one sample puts the sample microseconds above the tick and lets the
20//!   sweep see a slope at all.
21//! - `p99ByStage` times ONE op, the way every other recipe's manifest does, so
22//!   the numbers stay comparable across the cookbook. Those figures are only
23//!   published from a fleet capture, where the clock resolves single ns.
24//!
25//! A sample covers the same ITEM count in every feature, including `bulk`, whose
26//! calls are `BULK_BATCH` items wide. Comparing a 32-item bulk call against a
27//! 1-item push would compare batch sizes, not features.
28//!
29//! Every ring is pre-filled to half capacity and every measured op is a round
30//! trip, so occupancy is a fixed fraction at every sweep point and neither side
31//! ever takes its full / empty branch.
32//!
33//! Every op returns a `u64` that the timed loop accumulates and blackboxes once
34//! per sample. Blackboxing each pop's `Option` instead forces a 16-byte value
35//! through memory on every iteration, and it lands only on the features whose
36//! pop returns an `Option` - which made the BusySpin wrapper measure ~20% FASTER
37//! than the base ring it wraps.
38//!
39//! Each sweep point is measured `ROUNDS` times, size-interleaved, and the
40//! MINIMUM is kept. A single pass put the LARGEST ring fastest on three of five
41//! features, and the raw rounds show why: every measurement on this box lands on
42//! one of exactly two levels a constant 1.31x apart, the same ratio for the base
43//! ring and for every feature, which is a clock or core-class landing rather than
44//! anything about the ring. A median mixes the two levels, so the sweep column
45//! then moves by which level each point drew - `mpsc-fan-in` read as a 1.3x rise
46//! with size that way, and reads flat once the levels are separated. The minimum
47//! draws every point from the same level.
48//!
49//! `wait-strategies` is measured ONLY on the non-full, non-empty fast path. A
50//! strategy's `wait()` is a scheduler measurement - `ParkStrategy` sleeps until
51//! the other end unparks it, which is milliseconds - and publishing that as the
52//! feature's per-op cost would be a category error. What IS measured is what the
53//! wrapper costs when the ring is ready, which is where a caller spends its time.
54//!
55//! The multi-producer features (`mpsc-fan-in`, `mpmc-disruptor`) are measured
56//! SINGLE-THREADED at a fixed producer / consumer count. That isolates the
57//! indirection from the contention it exists to relieve; a contended number here
58//! would say more about the thread count than about the feature.
59//!
60//! These p99 figures describe THIS machine. They are published only when the
61//! manifest is stamped `p99_source: fleet`; a local run leaves the category,
62//! which is machine independent, and no published number.
63//!
64//! Run:
65//!   cargo run --release --example perf_features \
66//!       --features "harness bulk wait-strategies mpsc-fan-in mpmc-disruptor metrics"
67
68use std::collections::BTreeMap;
69use std::hint::black_box;
70use std::io::{self, Write};
71use std::path::PathBuf;
72
73use subms::{SubMsFeatureManifest, SubMsP99Source, SubMsPerfHarness, classify_feature, summarize};
74use subms_spsc_ring_buffer::{Consumer, Producer, SpscRingBuffer};
75
76/// Slot counts the sweep walks. A 256x span, already powers of two.
77const SIZES: [usize; 3] = [1_024, 16_384, 262_144];
78const CANON: usize = SIZES[SIZES.len() - 1];
79/// Items moved inside ONE timed sample. Fixed across every feature so the
80/// base-delta test compares cost per item moved.
81const ITEMS_PER_SAMPLE: usize = 1_024;
82/// Timed samples per sweep point.
83const SAMPLES: usize = 256;
84/// Size-interleaved repeats of the whole sweep; the per-size minimum is kept.
85const ROUNDS: usize = 7;
86/// Items per bulk call. FIXED across the sweep - varying it would sweep the
87/// batch size rather than the ring.
88const BULK_BATCH: usize = 32;
89/// Single-op reps behind each `p99ByStage` figure.
90const OPS: usize = 50_000;
91/// Warmup is TIME-BOXED, not a fixed rep count. A cold first sweep point lands
92/// its page-fault and branch-predictor ramp on whichever size runs first, which
93/// reads as a curve that FALLS with size - as wrong as a fake rise, and harder
94/// to spot.
95const WARM_NANOS: u64 = 300_000_000;
96const WARM_MAX_SAMPLES: usize = 1_000;
97
98fn stat(h: &SubMsPerfHarness, median: bool) -> u64 {
99    summarize(h)
100        .stages
101        .iter()
102        .find(|s| s.name == "op")
103        .map_or(0, |s| if median { s.p50_ns } else { s.p99_ns })
104}
105
106/// p50 ns of one timed sample covering `reps` calls of `op`.
107fn batched(reps: usize, mut op: impl FnMut(usize) -> u64) -> u64 {
108    let mut i = 0usize;
109    let start = std::time::Instant::now();
110    for _ in 0..WARM_MAX_SAMPLES {
111        let mut acc = 0u64;
112        for _ in 0..reps {
113            acc = acc.wrapping_add(op(i));
114            i += 1;
115        }
116        black_box(acc);
117        if start.elapsed().as_nanos() as u64 >= WARM_NANOS {
118            break;
119        }
120    }
121    let mut h = SubMsPerfHarness::new("spsc-feature", "rust");
122    let st = h.stage("op", SAMPLES);
123    for _ in 0..SAMPLES {
124        st.time(|| {
125            let mut acc = 0u64;
126            for _ in 0..reps {
127                acc = acc.wrapping_add(op(i));
128                i += 1;
129            }
130            black_box(acc);
131        });
132    }
133    stat(&h, true)
134}
135
136/// p99 ns of a single `timed` call. `untimed` runs outside the timed region and
137/// restores the ring's depth, so a 50k-op enqueue pass cannot fill the ring and
138/// start measuring the full branch instead of the fast path.
139fn single(mut timed: impl FnMut(usize) -> u64, mut untimed: impl FnMut(usize) -> u64) -> u64 {
140    for i in 0..OPS {
141        black_box(timed(i));
142        black_box(untimed(i));
143    }
144    let mut h = SubMsPerfHarness::new("spsc-feature", "rust");
145    let st = h.stage("op", OPS);
146    for i in 0..OPS {
147        st.time(|| black_box(timed(i)));
148        black_box(untimed(i));
149    }
150    stat(&h, false)
151}
152
153/// Sweeps and PRINTS the curve, raw rounds included in ROUND ORDER. A
154/// ratio-compressed or non-monotonic curve classifies flat, and the rows are the
155/// only place that shows up - and the raw rounds are the only place a bimodal
156/// clock shows up.
157fn sweep(label: &str, mut at: impl FnMut(usize) -> u64) -> Vec<(usize, u64)> {
158    let mut runs: Vec<Vec<u64>> = vec![Vec::new(); SIZES.len()];
159    for _ in 0..ROUNDS {
160        for (k, &n) in SIZES.iter().enumerate() {
161            runs[k].push(at(n));
162        }
163    }
164    let rows: Vec<(usize, u64)> = SIZES
165        .iter()
166        .enumerate()
167        .map(|(k, &n)| (n, runs[k].iter().copied().min().unwrap_or(0)))
168        .collect();
169    eprintln!("sweep {label}: {rows:?} raw {runs:?}");
170    rows
171}
172
173/// Lowest of `ROUNDS` repeats, for a figure that is printed rather than swept.
174fn best(mut f: impl FnMut() -> u64) -> u64 {
175    (0..ROUNDS).map(|_| f()).min().unwrap_or(0)
176}
177
178/// A ring pre-filled to half capacity. Occupancy is a constant fraction at every
179/// sweep point, so a slope has one cause; and both sides stay off their full /
180/// empty branch for the whole measurement.
181fn pair(cap: usize) -> (Producer<u64>, Consumer<u64>) {
182    let (mut tx, rx) = SpscRingBuffer::with_capacity::<u64>(cap);
183    for i in 0..cap / 2 {
184        let _ = tx.try_push(i as u64);
185    }
186    (tx, rx)
187}
188
189fn main() -> io::Result<()> {
190    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
191        .join("..")
192        .join(".subms")
193        .join("features")
194        .join("rust.json");
195    let existing = std::fs::read_to_string(&path).unwrap_or_default();
196    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
197    // Stamp the box these numbers came from. The bench runs wherever it is
198    // invoked, so an unstamped manifest is indistinguishable from a fleet
199    // capture; the renderer will not publish one it cannot attribute.
200    let (source, instance) = SubMsP99Source::from_env();
201    manifest.set_p99_source(source, instance.as_deref());
202
203    // The baseline: the base wait-free push + pop round trip. Swept as well as
204    // sampled, because whether ring capacity moves the BASE op is the context
205    // every feature curve is read against.
206    let base_sweep = sweep("base/push+pop", |cap| {
207        let (mut tx, mut rx) = pair(cap);
208        batched(ITEMS_PER_SAMPLE, |i| {
209            let _ = tx.try_push(i as u64);
210            rx.try_pop().unwrap_or(0)
211        })
212    });
213    let base_p50 = base_sweep
214        .iter()
215        .find(|(n, _)| *n == CANON)
216        .map_or(0, |(_, v)| *v);
217    eprintln!("base push+pop p50 per {ITEMS_PER_SAMPLE}-item sample: {base_p50}ns");
218
219    // ---------- bulk: one fence per BULK_BATCH items ----------
220    #[cfg(feature = "bulk")]
221    {
222        // A sample moves ITEMS_PER_SAMPLE items either way; only the call width
223        // differs. That is the comparison the feature exists to win, and it is
224        // why the reps count is divided rather than the batch grown.
225        let sw = sweep("bulk/enqueue+dequeue", |cap| {
226            let (mut tx, mut rx) = pair(cap);
227            let batch = [0u64; BULK_BATCH];
228            let mut out = [0u64; BULK_BATCH];
229            batched(ITEMS_PER_SAMPLE / BULK_BATCH, |_| {
230                let n = tx.try_enqueue_bulk(&batch);
231                (n + rx.try_dequeue_bulk(&mut out)) as u64
232            })
233        });
234        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
235
236        let batch = [0u64; BULK_BATCH];
237        let mut out = [0u64; BULK_BATCH];
238        let (mut tx, mut rx) = pair(CANON);
239        let enq = single(
240            |_| tx.try_enqueue_bulk(&batch) as u64,
241            |_| rx.try_dequeue_bulk(&mut out) as u64,
242        );
243        let (mut tx, mut rx) = pair(CANON);
244        let deq = single(
245            |_| rx.try_dequeue_bulk(&mut out) as u64,
246            |_| tx.try_enqueue_bulk(&batch) as u64,
247        );
248        let mut p99 = BTreeMap::new();
249        p99.insert("enqueue_bulk".to_string(), enq);
250        p99.insert("dequeue_bulk".to_string(), deq);
251        manifest.set_feature("bulk", cat, &p99, &reason);
252    }
253
254    // ---------- wait-strategies: blocking wrappers, fast path only ----------
255    #[cfg(feature = "wait-strategies")]
256    {
257        use subms_spsc_ring_buffer::{
258            BlockingSpscConsumer, BlockingSpscProducer, BusySpin, ParkStrategy,
259        };
260        // Swept on BusySpin, whose `signal()` is a no-op, so the curve is the
261        // wrapper's own overhead over the base ring and nothing else. The ring
262        // is never full or empty, so `wait()` is never called; the parked-wakeup
263        // path is a scheduler latency, not a per-op cost, and is not measured.
264        let sw = sweep("wait-strategies/push+pop", |cap| {
265            let (tx, rx) = pair(cap);
266            let mut p = BlockingSpscProducer::new(tx, BusySpin);
267            let mut c = BlockingSpscConsumer::new(rx, BusySpin);
268            batched(ITEMS_PER_SAMPLE, |i| {
269                p.push(i as u64);
270                c.pop()
271            })
272        });
273        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
274
275        // ParkStrategy on the SAME ready ring, printed rather than classified.
276        // Its `signal()` fires on every successful op even when nothing is
277        // parked, and it takes a lock to do it, so the strategy choice shows up
278        // here on the fast path - not in `wait()`, which this never enters.
279        let park_batched = best(|| {
280            let (ps, cs) = ParkStrategy::pair();
281            let (tx, rx) = pair(CANON);
282            let mut p = BlockingSpscProducer::new(tx, ps);
283            let mut c = BlockingSpscConsumer::new(rx, cs);
284            batched(ITEMS_PER_SAMPLE, |i| {
285                p.push(i as u64);
286                c.pop()
287            })
288        });
289        eprintln!(
290            "wait-strategies park fast path at {CANON}: {park_batched}ns per \
291             {ITEMS_PER_SAMPLE}-item sample (spin {}ns) - signal() takes a lock per op",
292            sw.iter().find(|(n, _)| *n == CANON).map_or(0, |(_, v)| *v)
293        );
294
295        let (tx, rx) = pair(CANON);
296        let mut p = BlockingSpscProducer::new(tx, BusySpin);
297        let mut c = BlockingSpscConsumer::new(rx, BusySpin);
298        let push_spin = single(
299            |i| {
300                p.push(i as u64);
301                0
302            },
303            |_| c.pop(),
304        );
305        let (tx, rx) = pair(CANON);
306        let mut p = BlockingSpscProducer::new(tx, BusySpin);
307        let mut c = BlockingSpscConsumer::new(rx, BusySpin);
308        let pop_spin = single(
309            |_| c.pop(),
310            |i| {
311                p.push(i as u64);
312                0
313            },
314        );
315
316        let (ps, cs) = ParkStrategy::pair();
317        let (tx, rx) = pair(CANON);
318        let mut p = BlockingSpscProducer::new(tx, ps);
319        let mut c = BlockingSpscConsumer::new(rx, cs);
320        let push_park = single(
321            |i| {
322                p.push(i as u64);
323                0
324            },
325            |_| c.pop(),
326        );
327        let (ps, cs) = ParkStrategy::pair();
328        let (tx, rx) = pair(CANON);
329        let mut p = BlockingSpscProducer::new(tx, ps);
330        let mut c = BlockingSpscConsumer::new(rx, cs);
331        let pop_park = single(
332            |_| c.pop(),
333            |i| {
334                p.push(i as u64);
335                0
336            },
337        );
338
339        let mut p99 = BTreeMap::new();
340        p99.insert("push_spin".to_string(), push_spin);
341        p99.insert("pop_spin".to_string(), pop_spin);
342        p99.insert("push_park".to_string(), push_park);
343        p99.insert("pop_park".to_string(), pop_park);
344        manifest.set_feature("wait-strategies", cat, &p99, &reason);
345    }
346
347    // ---------- mpsc-fan-in: N rings, one round-robin consumer ----------
348    #[cfg(feature = "mpsc-fan-in")]
349    {
350        use subms_spsc_ring_buffer::{MpscFanIn, MpscFanInConsumer, MpscFanInProducer};
351        /// Producer count is held FIXED across the sweep. Moving it would sweep
352        /// the fan-in width, which sets the consumer's probe loop, not the ring.
353        const PRODUCERS: usize = 4;
354
355        fn fanin(cap: usize) -> (Vec<MpscFanInProducer<u64>>, MpscFanInConsumer<u64>) {
356            let (mut ps, c) = MpscFanIn::with_capacity::<u64>(PRODUCERS, cap);
357            for p in ps.iter_mut() {
358                for i in 0..cap / 2 {
359                    let _ = p.try_push(i as u64);
360                }
361            }
362            (ps, c)
363        }
364
365        // Pushes round-robin and the consumer cursor advances one ring per pop,
366        // so the two stay in step and every ring holds a constant half load.
367        // With every ring non-empty the consumer's probe hits on its first try,
368        // which is the steady-state shape; a starved fan-in probes all N and that
369        // is a different measurement.
370        let sw = sweep("mpsc-fan-in/push+pop", |cap| {
371            let (mut ps, mut c) = fanin(cap);
372            batched(ITEMS_PER_SAMPLE, |i| {
373                let _ = ps[i % PRODUCERS].try_push(i as u64);
374                c.try_pop().unwrap_or(0)
375            })
376        });
377        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
378
379        let (mut ps, mut c) = fanin(CANON);
380        let enq = single(
381            |i| {
382                let _ = ps[i % PRODUCERS].try_push(i as u64);
383                0
384            },
385            |_| c.try_pop().unwrap_or(0),
386        );
387        let (mut ps, mut c) = fanin(CANON);
388        let deq = single(
389            |_| c.try_pop().unwrap_or(0),
390            |i| {
391                let _ = ps[i % PRODUCERS].try_push(i as u64);
392                0
393            },
394        );
395        let mut p99 = BTreeMap::new();
396        p99.insert("fanin_enqueue".to_string(), enq);
397        p99.insert("fanin_dequeue".to_string(), deq);
398        manifest.set_feature("mpsc-fan-in", cat, &p99, &reason);
399    }
400
401    // ---------- mpmc-disruptor: CAS claim + sequence barrier ----------
402    #[cfg(feature = "mpmc-disruptor")]
403    {
404        use subms_spsc_ring_buffer::{DisruptorConsumer, DisruptorProducer, MpmcDisruptor};
405        /// One consumer, held fixed. `try_publish` scans every consumer cursor
406        /// before claiming, so the consumer count - not the capacity - is what
407        /// that loop is O(). Sweeping it would answer a different question.
408        const CONSUMERS: usize = 1;
409
410        fn disruptor(cap: usize) -> (DisruptorProducer<u64>, DisruptorConsumer<u64>) {
411            let (p, mut cs) = MpmcDisruptor::with_consumers::<u64>(cap, CONSUMERS);
412            let c = cs.remove(0);
413            for i in 0..cap / 2 {
414                let _ = p.try_publish(i as u64);
415            }
416            (p, c)
417        }
418
419        // Half a ring of published-but-unconsumed items keeps the producer clear
420        // of the gating spin (it only fires within one capacity of the slowest
421        // consumer) and the consumer clear of the unpublished early return.
422        let sw = sweep("mpmc-disruptor/publish+consume", |cap| {
423            let (p, mut c) = disruptor(cap);
424            batched(ITEMS_PER_SAMPLE, |i| {
425                let _ = p.try_publish(i as u64);
426                c.try_consume().unwrap_or(0)
427            })
428        });
429        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
430
431        let (p, mut c) = disruptor(CANON);
432        let published = single(
433            |i| {
434                let _ = p.try_publish(i as u64);
435                0
436            },
437            |_| c.try_consume().unwrap_or(0),
438        );
439        let (p, mut c) = disruptor(CANON);
440        let consumed = single(
441            |_| c.try_consume().unwrap_or(0),
442            |i| {
443                let _ = p.try_publish(i as u64);
444                0
445            },
446        );
447        let mut p99 = BTreeMap::new();
448        p99.insert("publish".to_string(), published);
449        p99.insert("consume".to_string(), consumed);
450        manifest.set_feature("mpmc-disruptor", cat, &p99, &reason);
451    }
452
453    // ---------- metrics: counters on the push / pop path ----------
454    #[cfg(feature = "metrics")]
455    {
456        use subms_spsc_ring_buffer::InstrumentedSpsc;
457
458        // The wrapper adds a `fetch_add` per op plus, on the producer side, a
459        // high-water-mark update. `local_depth` only ever increments - a producer
460        // cannot observe pops - so that update takes its CAS on every push rather
461        // than settling once the mark stops moving. The counter cost on the push
462        // path is two read-modify-writes, not one.
463        let sw = sweep("metrics/push+pop", |cap| {
464            let (tx, rx) = pair(cap);
465            let (mut tx, mut rx, _m) = InstrumentedSpsc::wrap(tx, rx);
466            batched(ITEMS_PER_SAMPLE, |i| {
467                let _ = tx.try_push(i as u64);
468                rx.try_pop().unwrap_or(0)
469            })
470        });
471        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
472
473        let (tx, rx) = pair(CANON);
474        let (mut tx, mut rx, _m) = InstrumentedSpsc::wrap(tx, rx);
475        let enq = single(
476            |i| {
477                let _ = tx.try_push(i as u64);
478                0
479            },
480            |_| rx.try_pop().unwrap_or(0),
481        );
482        let (tx, rx) = pair(CANON);
483        let (mut tx, mut rx, _m) = InstrumentedSpsc::wrap(tx, rx);
484        let deq = single(
485            |_| rx.try_pop().unwrap_or(0),
486            |i| {
487                let _ = tx.try_push(i as u64);
488                0
489            },
490        );
491        let mut p99 = BTreeMap::new();
492        p99.insert("metrics_enqueue".to_string(), enq);
493        p99.insert("metrics_dequeue".to_string(), deq);
494        manifest.set_feature("metrics", cat, &p99, &reason);
495    }
496
497    std::fs::create_dir_all(path.parent().unwrap())?;
498    std::fs::write(&path, manifest.to_json())?;
499    io::stdout().write_all(manifest.to_json().as_bytes())?;
500    Ok(())
501}