Skip to main content

perf_features/
perf_features.rs

1//! Feature classification bench. Each feature's representative op is swept
2//! across three input sizes, `classify_feature` DECIDES the category from the
3//! shape of that sweep, and the decision plus a measured `p99ByStage` is
4//! merge-written into `.subms/features/rust.json`.
5//!
6//! The sweep axis is the TOTAL number of elements across the 16 merged streams.
7//! A k-way merge step is O(log k) in the number of streams and independent of
8//! how many elements sit behind them, so a per-element feature should read flat
9//! and the sweep is here to prove that rather than assume it.
10//!
11//! Two measurement decisions carry the whole file:
12//!
13//! - A measurement TIMES A FIXED NUMBER OF ELEMENTS however large the input is.
14//!   Timing a whole drain would report the size of the ANSWER: 64x the elements
15//!   take 64x as long at an unchanged per-element cost, and every feature would
16//!   classify structural. The drain still visits every element, so the working
17//!   set grows with the sweep, but only `SAMPLES` batches of it are timed.
18//! - Elements are timed in BATCHES of `BATCH` and the recorded figure is the
19//!   batch mean. A merge step costs ~15 ns here and this platform's clock ticks
20//!   at 100 ns, so an unbatched sample reads 0 or 100 ns: 41% of single-step
21//!   timings came back as zero and the p50 of every variant, base included, was
22//!   exactly one tick. Unbatched, this bench measures the clock.
23//!
24//! Run:
25//!   cargo run --release --example perf_features \
26//!       --features "harness seek-to reverse tombstones dedup priority"
27
28use std::collections::BTreeMap;
29use std::hint::black_box;
30use std::io::{self, Write};
31use std::path::PathBuf;
32use std::time::Instant;
33
34use subms::{
35    SubMsFeatureManifest, SubMsP99Source, SubMsPerfHarness, SubMsTimer, classify_feature, summarize,
36};
37
38use subms_merge_iterator::MergeIterator;
39#[cfg(feature = "reverse")]
40use subms_merge_iterator::ReverseMergeIterator;
41#[cfg(feature = "seek-to")]
42use subms_merge_iterator::SeekableMergeIterator;
43#[cfg(feature = "dedup")]
44use subms_merge_iterator::{DedupEntry, DedupMergeIterator};
45#[cfg(feature = "priority")]
46use subms_merge_iterator::{PriorityEntry, PriorityMergeIterator, PrioritySource};
47#[cfg(feature = "tombstones")]
48use subms_merge_iterator::{TombstoneEntry, TombstoneMergeIterator};
49
50/// Total elements across all streams: a 64x span. The bottom of the range is
51/// already past the point where the 16-entry heap fits in L1 with room to
52/// spare, so a per-element cost that still climbed would be a real cache
53/// effect rather than a fixed per-call cost being amortised away.
54const SIZES: [usize; 3] = [32_768, 262_144, 2_097_152];
55const CANON: usize = SIZES[SIZES.len() - 1];
56const STREAMS: usize = 16;
57
58/// Elements per timed sample. The recorded value is the batch mean, which is
59/// what lifts a ~15 ns step clear of the 100 ns clock tick.
60const BATCH: usize = 64;
61/// Timed batches per measurement. Fixed across the sweep, so the statistic is
62/// the cost of ONE element and not the length of the drain.
63const SAMPLES: usize = 512;
64/// A short input runs out of batches before it runs out of samples, so the
65/// drain repeats over a freshly built iterator until the sample count is met.
66/// Without it the smallest sweep point was decided by a quarter of the samples
67/// of the largest, and a single scheduling blip moved it by 30%.
68const MAX_PASSES: usize = 16;
69
70const WARM_NANOS: u64 = 300_000_000;
71const WARM_MAX_REPS: usize = 64;
72
73/// Keys skipped per `seek`. Held CONSTANT across the sweep for the same reason
74/// the timed element count is: `seek` walks each stream forward one entry at a
75/// time until it reaches the target, so its cost is set by the skip distance,
76/// not by how many elements sit beyond it. Spreading a fixed number of seeks
77/// over a growing key range would sweep the skip distance and call it size.
78#[cfg(any(feature = "seek-to", feature = "reverse"))]
79const SEEK_SKIP: u64 = 64;
80/// Seeks per pass, capped so a pass consumes at most half the smallest input.
81#[cfg(any(feature = "seek-to", feature = "reverse"))]
82const SEEK_ROUNDS: usize = 256;
83/// Seeks per timed sample. A seek over 64 keys costs a few hundred ns, which is
84/// three or four clock ticks, and the unbatched curve jittered by a full tick
85/// between sweep points. Batching buys the resolution back.
86#[cfg(any(feature = "seek-to", feature = "reverse"))]
87const SEEK_BATCH: usize = 2;
88#[cfg(feature = "seek-to")]
89const SEEK_NEXT_ROUNDS: usize = 128;
90/// Passes over a freshly built iterator. One pass cannot yield `SAMPLES`
91/// without running the skip distance up with it, and the skip distance is the
92/// one thing this measurement holds fixed. Kept as low as the sample count
93/// allows: a pass builds the whole n-element input to seek over the first
94/// 16k of it, and in the Java port that garbage is what a later measurement
95/// ends up collecting.
96#[cfg(any(feature = "seek-to", feature = "reverse"))]
97const SEEK_PASSES: usize = 4;
98
99/// Stream `s` carries values `s, s+STREAMS, s+2*STREAMS, ...` so the 16 streams
100/// interleave into the dense range `0..n` with no gaps.
101fn plain_streams(n: usize) -> Vec<std::vec::IntoIter<u64>> {
102    let per = n / STREAMS;
103    (0..STREAMS)
104        .map(|s| {
105            (0..per)
106                .map(move |i| (s + i * STREAMS) as u64)
107                .collect::<Vec<u64>>()
108                .into_iter()
109        })
110        .collect()
111}
112
113fn stat(h: &SubMsPerfHarness, median: bool) -> u64 {
114    summarize(h)
115        .stages
116        .iter()
117        .find(|s| s.name == "op")
118        .map_or(0, |s| if median { s.p50_ns } else { s.p99_ns })
119}
120
121fn harness() -> SubMsPerfHarness {
122    SubMsPerfHarness::new("merge-iterator-feature", "rust")
123}
124
125/// Runs `measure` into a throwaway harness until the budget expires, then once
126/// more into the harness whose numbers are kept.
127///
128/// Warming the WORK is not enough: warm has to run the identical TIMED path.
129/// With warm draining the iterator untimed, the first measurement a process
130/// made still read about 20% high, and it stayed 20% high with the sweep
131/// reversed - so it was the first entry into the timed region, not the size.
132/// Time-boxed rather than a fixed rep count, or a cheap size gets the same
133/// handful of passes as an expensive one.
134fn warmed(mut measure: impl FnMut(&mut SubMsPerfHarness), median: bool) -> u64 {
135    let start = Instant::now();
136    for _ in 0..WARM_MAX_REPS {
137        let mut scratch = harness();
138        measure(&mut scratch);
139        black_box(stat(&scratch, median));
140        if start.elapsed().as_nanos() as u64 >= WARM_NANOS {
141            break;
142        }
143    }
144    let mut h = harness();
145    measure(&mut h);
146    stat(&h, median)
147}
148
149/// Per-element cost of a merge step, in ns. `make` builds a fresh iterator
150/// OUTSIDE the timed region (a merge iterator is single-use, so the input has
151/// to be rebuilt per pass and building it is not the thing being measured).
152/// The drain consumes the whole input; every `stride`th batch is timed, which
153/// keeps the sample count fixed while the working set grows with `n`.
154fn per_element<It: Iterator>(
155    mut make: impl FnMut() -> It,
156    expected_out: usize,
157    median: bool,
158) -> u64 {
159    let stride = (expected_out / (BATCH * SAMPLES)).max(1);
160    warmed(
161        |h| {
162            let st = h.stage("op", SAMPLES + 1);
163            let mut recorded = 0usize;
164            for _ in 0..MAX_PASSES {
165                if recorded >= SAMPLES {
166                    break;
167                }
168                let mut it = make();
169                let mut batch = 0usize;
170                loop {
171                    let timed = batch % stride == 0;
172                    let mut taken = 0usize;
173                    if timed {
174                        let t0 = SubMsTimer::tick();
175                        while taken < BATCH && it.next().is_some() {
176                            taken += 1;
177                        }
178                        let ns = t0.elapsed_ns();
179                        if taken == BATCH {
180                            st.record(ns / BATCH as u64);
181                            recorded += 1;
182                        }
183                    } else {
184                        while taken < BATCH && it.next().is_some() {
185                            taken += 1;
186                        }
187                    }
188                    if taken < BATCH {
189                        break;
190                    }
191                    batch += 1;
192                }
193            }
194        },
195        median,
196    )
197}
198
199/// Sweeps and PRINTS the curve. A ratio-compressed or non-monotonic sweep
200/// classifies flat, and the only way to catch one is to read the rows.
201fn sweep(label: &str, mut at: impl FnMut(usize) -> u64) -> Vec<(usize, u64)> {
202    let rows: Vec<(usize, u64)> = SIZES.iter().map(|&n| (n, at(n))).collect();
203    eprintln!("sweep {label}: {rows:?}");
204    rows
205}
206
207#[cfg(feature = "seek-to")]
208fn seek_only(n: usize, median: bool) -> u64 {
209    warmed(
210        |h| {
211            let st = h.stage("op", SEEK_PASSES * SEEK_ROUNDS / SEEK_BATCH + 1);
212            for _ in 0..SEEK_PASSES {
213                let mut it = SeekableMergeIterator::new(plain_streams(n));
214                let mut r = 0usize;
215                while r < SEEK_ROUNDS {
216                    let t0 = SubMsTimer::tick();
217                    for _ in 0..SEEK_BATCH {
218                        r += 1;
219                        it.seek(&(r as u64 * SEEK_SKIP));
220                    }
221                    st.record(t0.elapsed_ns() / SEEK_BATCH as u64);
222                }
223            }
224        },
225        median,
226    )
227}
228
229/// Streaming cost of the `next` calls that follow a seek. Batched for the same
230/// clock-tick reason as every other per-element figure, so the first element of
231/// each batch is the one that lands right after the seek.
232#[cfg(feature = "seek-to")]
233fn seek_then_next(n: usize, median: bool) -> u64 {
234    let stride = (SEEK_SKIP as usize + BATCH) as u64;
235    warmed(
236        |h| {
237            let st = h.stage("op", SEEK_PASSES * SEEK_NEXT_ROUNDS + 1);
238            for _ in 0..SEEK_PASSES {
239                let mut it = SeekableMergeIterator::new(plain_streams(n));
240                for r in 0..SEEK_NEXT_ROUNDS {
241                    it.seek(&(r as u64 * stride));
242                    let t0 = SubMsTimer::tick();
243                    let mut taken = 0usize;
244                    while taken < BATCH && it.next().is_some() {
245                        taken += 1;
246                    }
247                    let ns = t0.elapsed_ns();
248                    if taken == BATCH {
249                        st.record(ns / BATCH as u64);
250                    }
251                }
252            }
253        },
254        median,
255    )
256}
257
258fn main() -> io::Result<()> {
259    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
260        .join("..")
261        .join(".subms")
262        .join("features")
263        .join("rust.json");
264    let existing = std::fs::read_to_string(&path).unwrap_or_default();
265    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
266    // Stamp the box these numbers came from. The bench runs wherever it is
267    // invoked, so an unstamped manifest is indistinguishable from a fleet
268    // capture; the renderer will not publish one it cannot attribute.
269    let (source, instance) = SubMsP99Source::from_env();
270    manifest.set_p99_source(source, instance.as_deref());
271
272    // The baseline: a plain merge step with no feature enabled. Every feature
273    // decorates this step, so it is what they are classified against. Swept as
274    // well as measured, because a base that itself drifted with size would make
275    // every feature's flat reading meaningless.
276    let base_sw = sweep("base/next", |n| {
277        per_element(|| MergeIterator::new(plain_streams(n)), n, true)
278    });
279    let base_p50 = base_sw[base_sw.len() - 1].1;
280    eprintln!("base next p50: {base_p50}ns/element");
281
282    // ---------- seek-to: skip forward past a key ----------
283    #[cfg(feature = "seek-to")]
284    {
285        let sw = sweep("seek-to/seek", |n| seek_only(n, true));
286        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
287
288        let mut p99 = BTreeMap::new();
289        p99.insert("seek".to_string(), seek_only(CANON, false));
290        p99.insert("next_after_seek".to_string(), seek_then_next(CANON, false));
291        manifest.set_feature("seek-to", cat, &p99, &reason);
292    }
293
294    // ---------- reverse: descending merge + seek_for_prev ----------
295    #[cfg(feature = "reverse")]
296    {
297        let sw = sweep("reverse/next", |n| {
298            per_element(|| ReverseMergeIterator::new(descending_streams(n)), n, true)
299        });
300        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
301
302        let mut p99 = BTreeMap::new();
303        p99.insert(
304            "reverse_next".to_string(),
305            per_element(
306                || ReverseMergeIterator::new(descending_streams(CANON)),
307                CANON,
308                false,
309            ),
310        );
311        p99.insert(
312            "seek_for_prev".to_string(),
313            seek_for_prev_only(CANON, false),
314        );
315        manifest.set_feature("reverse", cat, &p99, &reason);
316    }
317
318    // ---------- tombstones: delete markers mask same-key entries ----------
319    #[cfg(feature = "tombstones")]
320    {
321        // Every 8th key is a tombstone, so one next in eight pops twice and
322        // loops to find the next live key. The decoration is per element.
323        let sw = sweep("tombstones/next", |n| {
324            per_element(
325                || TombstoneMergeIterator::new(tombstone_streams(n)),
326                n / 8 * 7,
327                true,
328            )
329        });
330        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
331
332        let mut p99 = BTreeMap::new();
333        p99.insert(
334            "tombstones_next".to_string(),
335            per_element(
336                || TombstoneMergeIterator::new(tombstone_streams(CANON)),
337                CANON / 8 * 7,
338                false,
339            ),
340        );
341        manifest.set_feature("tombstones", cat, &p99, &reason);
342    }
343
344    // ---------- dedup: collapse equal keys, latest source wins ----------
345    #[cfg(feature = "dedup")]
346    {
347        // Halved key space, so every key is carried by two sources and every
348        // next pops twice: the collapse path runs on every element yielded.
349        let sw = sweep("dedup/next", |n| {
350            per_element(|| DedupMergeIterator::new(dedup_streams(n)), n / 2, true)
351        });
352        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
353
354        let mut p99 = BTreeMap::new();
355        p99.insert(
356            "dedup_next".to_string(),
357            per_element(
358                || DedupMergeIterator::new(dedup_streams(CANON)),
359                CANON / 2,
360                false,
361            ),
362        );
363        manifest.set_feature("dedup", cat, &p99, &reason);
364    }
365
366    // ---------- priority: explicit per-source precedence on key tie ----------
367    #[cfg(feature = "priority")]
368    {
369        // Same collide-on-halved-keys shape as dedup, plus a priority field in
370        // the heap comparison, so the two figures are directly comparable.
371        let sw = sweep("priority/next", |n| {
372            per_element(
373                || PriorityMergeIterator::new(priority_sources(n)),
374                n / 2,
375                true,
376            )
377        });
378        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
379
380        let mut p99 = BTreeMap::new();
381        p99.insert(
382            "priority_next".to_string(),
383            per_element(
384                || PriorityMergeIterator::new(priority_sources(CANON)),
385                CANON / 2,
386                false,
387            ),
388        );
389        manifest.set_feature("priority", cat, &p99, &reason);
390    }
391
392    std::fs::create_dir_all(path.parent().unwrap())?;
393    std::fs::write(&path, manifest.to_json())?;
394    io::stdout().write_all(manifest.to_json().as_bytes())?;
395    Ok(())
396}
397
398/// The `plain_streams` shape reversed: stream `s` counts DOWN, so the 16
399/// streams interleave into a dense descending `n..0`.
400#[cfg(feature = "reverse")]
401fn descending_streams(n: usize) -> Vec<std::vec::IntoIter<u64>> {
402    let per = n / STREAMS;
403    (0..STREAMS)
404        .map(|s| {
405            (0..per)
406                .map(move |i| (s + (per - 1 - i) * STREAMS) as u64)
407                .collect::<Vec<u64>>()
408                .into_iter()
409        })
410        .collect()
411}
412
413/// Mirror of `seek_only`, walking backward. Same fixed skip distance, so the
414/// two seek figures are directly comparable.
415#[cfg(feature = "reverse")]
416fn seek_for_prev_only(n: usize, median: bool) -> u64 {
417    warmed(
418        |h| {
419            let st = h.stage("op", SEEK_PASSES * SEEK_ROUNDS / SEEK_BATCH + 1);
420            for _ in 0..SEEK_PASSES {
421                let mut it = ReverseMergeIterator::new(descending_streams(n));
422                let top = (n - 1) as u64;
423                let mut r = 0usize;
424                while r < SEEK_ROUNDS {
425                    let t0 = SubMsTimer::tick();
426                    for _ in 0..SEEK_BATCH {
427                        r += 1;
428                        it.seek_for_prev(&top.saturating_sub(r as u64 * SEEK_SKIP));
429                    }
430                    st.record(t0.elapsed_ns() / SEEK_BATCH as u64);
431                }
432            }
433        },
434        median,
435    )
436}
437
438#[cfg(feature = "tombstones")]
439fn tombstone_streams(n: usize) -> Vec<std::vec::IntoIter<TombstoneEntry<u64, u64>>> {
440    let per = n / STREAMS;
441    (0..STREAMS)
442        .map(|s| {
443            (0..per)
444                .map(move |i| {
445                    let key = (s + i * STREAMS) as u64;
446                    if key % 8 == 0 {
447                        TombstoneEntry::tombstone(key)
448                    } else {
449                        TombstoneEntry::live(key, key)
450                    }
451                })
452                .collect::<Vec<_>>()
453                .into_iter()
454        })
455        .collect()
456}
457
458#[cfg(feature = "dedup")]
459fn dedup_streams(n: usize) -> Vec<std::vec::IntoIter<DedupEntry<u64, u64>>> {
460    let per = n / STREAMS;
461    (0..STREAMS)
462        .map(|s| {
463            (0..per)
464                .map(move |i| {
465                    let key = ((s + i * STREAMS) as u64) / 2;
466                    DedupEntry::new(key, key)
467                })
468                .collect::<Vec<_>>()
469                .into_iter()
470        })
471        .collect()
472}
473
474#[cfg(feature = "priority")]
475fn priority_sources(n: usize) -> Vec<PrioritySource<std::vec::IntoIter<PriorityEntry<u64, u64>>>> {
476    let per = n / STREAMS;
477    (0..STREAMS)
478        .map(|s| {
479            let stream = (0..per)
480                .map(move |i| {
481                    let key = ((s + i * STREAMS) as u64) / 2;
482                    PriorityEntry::new(key, key)
483                })
484                .collect::<Vec<_>>()
485                .into_iter();
486            PrioritySource::new((STREAMS - s) as i32, stream)
487        })
488        .collect()
489}