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}