Skip to main content

perf_features/
perf_features.rs

1//! Feature classification bench. Each feature's representative op is swept
2//! across three PRECISIONS, `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//! Significant digits is the sweep axis because it is what sets a histogram's
7//! size: 3, 4 and 5 digits give 2048, 32768 and 262144 sub-buckets. A
8//! `record` writes one bucket regardless, so it should read flat; anything that
9//! folds the bucket array should climb. `decay`'s record does the second while
10//! looking like the first.
11//!
12//! Run:
13//!   cargo run --release --example perf_features \
14//!       --features "harness dual-recorder concurrent-writes merge decay value-tagging iterators"
15
16use std::collections::BTreeMap;
17use std::io::{self, Write};
18use std::path::PathBuf;
19
20use subms::{SubMsFeatureManifest, SubMsP99Source, SubMsPerfHarness, classify_feature, summarize};
21use subms_hdr_histogram::HdrHistogram;
22
23/// 2048 / 32768 / 262144 sub-buckets, a 128x span. NOT 1/2/3 digits: that gives
24/// 32 / 256 / 2048, and at 32 buckets a fold is entirely fixed per-call cost, so
25/// three genuinely O(buckets) ops - the interval read, the decaying record, the
26/// percentile walk - all measured flat and classified hot-path. Sweeping from a
27/// size where the fold already dominates measures the fold.
28const DIGITS: [u32; 3] = [3, 4, 5];
29const CANON_D: u32 = DIGITS[DIGITS.len() - 1];
30/// Recorded ops per measurement. Fixed across the sweep so a slope has one cause.
31const OPS: usize = 20_000;
32/// Samples per bulk op. A whole-structure call is far above the per-key budget,
33/// so a distribution needs repeats rather than one shot. 256 is a FLOOR, not a
34/// preference: the harness takes p99 as `sorted[floor(0.99 * n)]`, so at n <= 100
35/// that index IS `n - 1` and the "p99" is the single worst sample. A structural
36/// verdict then turns on whichever rep caught a page fault. 256 puts two samples
37/// above the index and makes it a real percentile. Do not lower it.
38const BULK_REPS: usize = 256;
39/// Bulk warmup is TIME-BOXED, not a fixed rep count - the same fix the Java port
40/// needs, for a different reason. Rust has no JIT, but an op that ALLOCATES has
41/// an allocator and a page-fault ramp, and 8 reps do not settle either: the
42/// interval read's smallest sweep point moved 7000 -> 30800 -> 44800 ns across
43/// three runs and flipped the feature between structural and hot-path. A budget
44/// gives cheap sizes thousands of reps and expensive ones enough.
45const BULK_WARM_NANOS: u64 = 300_000_000;
46const BULK_WARM_MAX_REPS: usize = 5_000;
47
48const MAX_VALUE: u64 = 10_000_000;
49
50/// Values spread over seven orders of magnitude, so buckets across the whole
51/// range carry counts and a fold cannot skip most of the array.
52fn value_at(i: usize) -> u64 {
53    1 + ((i as u64).wrapping_mul(2_654_435_761) % MAX_VALUE)
54}
55
56fn sub_count(d: u32) -> usize {
57    HdrHistogram::new(d).sub_count() as usize
58}
59
60fn stat(h: &SubMsPerfHarness, median: bool) -> u64 {
61    summarize(h)
62        .stages
63        .iter()
64        .find(|s| s.name == "op")
65        .map_or(0, |s| if median { s.p50_ns } else { s.p99_ns })
66}
67
68fn keyed(mut op: impl FnMut(usize), median: bool) -> u64 {
69    let mut h = SubMsPerfHarness::new("hdr-feature", "rust");
70    let st = h.stage("op", OPS);
71    for i in 0..OPS {
72        st.time(|| op(i));
73    }
74    stat(&h, median)
75}
76
77/// A whole-array op. `setup` runs OUTSIDE the timed region and the first
78/// `BULK_WARM` reps are discarded: measured cold, a bulk op lands its
79/// first-touch cost on whichever sweep point runs first, which reads as a curve
80/// that FALLS with size - the opposite of the structural signal.
81fn bulk<T>(mut setup: impl FnMut() -> T, mut op: impl FnMut(&mut T), median: bool) -> u64 {
82    let mut input = setup();
83    let start = std::time::Instant::now();
84    for _ in 0..BULK_WARM_MAX_REPS {
85        op(&mut input);
86        if start.elapsed().as_nanos() as u64 >= BULK_WARM_NANOS {
87            break;
88        }
89    }
90    let mut h = SubMsPerfHarness::new("hdr-feature", "rust");
91    let st = h.stage("op", BULK_REPS);
92    for _ in 0..BULK_REPS {
93        st.time(|| op(&mut input));
94    }
95    stat(&h, median)
96}
97
98/// Sweeps and PRINTS the curve, indexed by SUB-BUCKET COUNT rather than by
99/// digits - the classifier reads the size column as a magnitude.
100fn sweep(label: &str, mut at: impl FnMut(u32) -> u64) -> Vec<(usize, u64)> {
101    let rows: Vec<(usize, u64)> = DIGITS.iter().map(|&d| (sub_count(d), at(d))).collect();
102    eprintln!("sweep {label}: {rows:?}");
103    rows
104}
105
106/// Filled to OCCUPANCY, not to a fixed record count. `merge` and the iterators
107/// visit NON-EMPTY buckets, so a fixed 20k values leaves 20k of them occupied
108/// whether the array holds 2048 buckets or 262144 - the op stops scaling and
109/// both features read flat. Recording `sub_count` values keeps the occupied
110/// fraction roughly constant, which is what makes the sweep a size sweep.
111fn filled(d: u32) -> HdrHistogram {
112    let mut h = HdrHistogram::new(d);
113    for i in 0..sub_count(d) {
114        h.record(value_at(i));
115    }
116    h
117}
118
119fn main() -> io::Result<()> {
120    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
121        .join("..")
122        .join(".subms")
123        .join("features")
124        .join("rust.json");
125    let existing = std::fs::read_to_string(&path).unwrap_or_default();
126    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
127    // Stamp the box these numbers came from. The bench runs wherever it is
128    // invoked, so an unstamped manifest is indistinguishable from a fleet
129    // capture; the renderer will not publish one it cannot attribute.
130    let (source, instance) = SubMsP99Source::from_env();
131    manifest.set_p99_source(source, instance.as_deref());
132
133    // The baseline: base `record`, the per-op write path. Every feature is
134    // classified against the cost of the write it is decorating.
135    let mut base = HdrHistogram::new(CANON_D);
136    let base_p50 = keyed(|i| base.record(value_at(i)), true);
137    eprintln!("base record p50: {base_p50}ns");
138
139    // ---------- concurrent-writes: atomic buckets, &self record ----------
140    #[cfg(feature = "concurrent-writes")]
141    {
142        use subms_hdr_histogram::ConcurrentHdrHistogram;
143        let sw = sweep("concurrent-writes/record", |d| {
144            let c = ConcurrentHdrHistogram::new(d);
145            keyed(|i| c.record(value_at(i)), true)
146        });
147        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
148
149        let c = ConcurrentHdrHistogram::new(CANON_D);
150        let mut p99 = BTreeMap::new();
151        p99.insert(
152            "record".to_string(),
153            keyed(|i| c.record(value_at(i)), false),
154        );
155        p99.insert(
156            "percentile".to_string(),
157            keyed(|_| _ = c.value_at_percentile(99.0), false),
158        );
159        manifest.set_feature("concurrent-writes", cat, &p99, &reason);
160    }
161
162    // ---------- dual-recorder: hot/stable pair, swap and drain ----------
163    #[cfg(feature = "dual-recorder")]
164    {
165        use subms_hdr_histogram::DualRecorder;
166        // Swept on the interval read, not on `record`. The record is the same
167        // atomic write the concurrent feature already covers; the swap-and-drain
168        // is what a dual recorder is FOR, and it is O(buckets).
169        let sw = sweep("dual-recorder/interval", |d| {
170            let r = DualRecorder::new(d);
171            for i in 0..sub_count(d) {
172                r.record(value_at(i));
173            }
174            bulk(|| (), |()| _ = r.get_interval_histogram(), true)
175        });
176        // PINNED structural. The drain is O(buckets) from the source in BOTH
177        // ports, but it is DESTRUCTIVE, so repeating it measures an already-empty
178        // histogram rather than the drain: after the first rep each side has
179        // nothing left to copy. Refilling between reps would put the refill
180        // inside the timed region, which is the bug the ART port shipped. The
181        // two ports disagree precisely here for that reason - Rust copies the
182        // counter array unconditionally (468us at 262144 buckets) while Java
183        // collapses a high-water index and reads 100ns - and neither number is
184        // the operation a caller performs. Recording either as hot-path would
185        // say a whole-array drain is safe per-op.
186        let (cat, reason) = classify_feature(
187            &sw,
188            Some(base_p50),
189            Some(subms::SubMsFeatureCategory::Structural),
190        );
191
192        let r = DualRecorder::new(CANON_D);
193        for i in 0..OPS {
194            r.record(value_at(i));
195        }
196        let mut p99 = BTreeMap::new();
197        p99.insert(
198            "record".to_string(),
199            keyed(|i| r.record(value_at(i)), false),
200        );
201        p99.insert(
202            "interval_read".to_string(),
203            bulk(|| (), |()| _ = r.get_interval_histogram(), false),
204        );
205        manifest.set_feature("dual-recorder", cat, &p99, &reason);
206    }
207
208    // ---------- merge: element-wise add over the bucket arrays ----------
209    #[cfg(feature = "merge")]
210    {
211        use subms_hdr_histogram::merge;
212        // Both histograms are built by `setup`, outside the timed region.
213        // Repeating the merge grows the destination's counts but does identical
214        // work each rep, so the figure is the merge and nothing else.
215        let sw = sweep("merge/merge", |d| {
216            bulk(
217                || (filled(d), filled(d)),
218                |(dst, src)| merge(dst, src).expect("same precision"),
219                true,
220            )
221        });
222        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
223
224        let mut p99 = BTreeMap::new();
225        p99.insert(
226            "merge".to_string(),
227            bulk(
228                || (filled(CANON_D), filled(CANON_D)),
229                |(dst, src)| merge(dst, src).expect("same precision"),
230                false,
231            ),
232        );
233        manifest.set_feature("merge", cat, &p99, &reason);
234    }
235
236    // ---------- decay: exponentially-weighted counts ----------
237    #[cfg(feature = "decay")]
238    {
239        use subms_hdr_histogram::{Clock, DecayingHdrHistogram};
240        // The clock ADVANCES on every read. A frozen clock lets `decay_to_now`
241        // early-return and the feature measures as a plain record, which is the
242        // opposite of the truth: with time moving, every record first brings the
243        // whole counter array up to date, so the write is O(buckets). Freezing
244        // the clock here would have published that as hot-path.
245        struct TickingClock(std::cell::Cell<u64>);
246        impl Clock for TickingClock {
247            fn now_ns(&self) -> u64 {
248                self.0.set(self.0.get() + 1_000_000);
249                self.0.get()
250            }
251        }
252        let halflife = 1_000_000_000;
253        let sw = sweep("decay/record", |d| {
254            let mut x =
255                DecayingHdrHistogram::new(d, halflife, TickingClock(std::cell::Cell::new(0)));
256            keyed(|i| x.record(value_at(i)), true)
257        });
258        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
259
260        let mut x =
261            DecayingHdrHistogram::new(CANON_D, halflife, TickingClock(std::cell::Cell::new(0)));
262        let mut p99 = BTreeMap::new();
263        p99.insert(
264            "record".to_string(),
265            keyed(|i| x.record(value_at(i)), false),
266        );
267        p99.insert(
268            "percentile".to_string(),
269            keyed(|_| _ = x.value_at_percentile(99.0), false),
270        );
271        manifest.set_feature("decay", cat, &p99, &reason);
272    }
273
274    // ---------- value-tagging: a parallel histogram per tag ----------
275    #[cfg(feature = "value-tagging")]
276    {
277        use subms_hdr_histogram::TaggedHdrHistogram;
278        let sw = sweep("value-tagging/record", |d| {
279            let mut t = TaggedHdrHistogram::new(d);
280            keyed(|i| t.record(value_at(i), (i % 4) as u8), true)
281        });
282        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
283
284        let mut t = TaggedHdrHistogram::new(CANON_D);
285        let mut p99 = BTreeMap::new();
286        p99.insert(
287            "record".to_string(),
288            keyed(|i| t.record(value_at(i), (i % 4) as u8), false),
289        );
290        p99.insert(
291            "percentile_for_tag".to_string(),
292            keyed(|_| _ = t.value_at_percentile_for_tag(99.0, 0), false),
293        );
294        manifest.set_feature("value-tagging", cat, &p99, &reason);
295    }
296
297    // ---------- iterators: linear / logarithmic / percentile walks ----------
298    #[cfg(feature = "iterators")]
299    {
300        // A percentile walk accumulates counts across the bucket array, so it is
301        // O(buckets), and the sweep is monotonic and strongly rising. It is not
302        // 64x: the walk emits a BOUNDED number of entries (1% steps, ~100 of
303        // them) however large the array is, so the per-bucket work amortises
304        // better at the top and it measures ~57x over a 128x span - under the
305        // classifier's 0.5 guard.
306        //
307        // `iter_linear` was tried as the swept op instead, on the reasoning that
308        // it visits every bucket. It does not: it steps by VALUE unit, so over a
309        // 10^7 value range it emits millions of entries and the walk never
310        // finished. Wrong op, not a slower one.
311        //
312        // PINNED structural rather than published as hot-path, which would tell
313        // a reader a full percentile walk is safe per-operation. It is not.
314        let sw = sweep("iterators/percentiles", |d| {
315            let h = filled(d);
316            bulk(|| (), |()| _ = h.iter_percentiles(1.0).count(), true)
317        });
318        let (cat, reason) = classify_feature(
319            &sw,
320            Some(base_p50),
321            Some(subms::SubMsFeatureCategory::Structural),
322        );
323
324        let h = filled(CANON_D);
325        let mut p99 = BTreeMap::new();
326        p99.insert(
327            "iter_percentiles".to_string(),
328            bulk(|| (), |()| _ = h.iter_percentiles(1.0).count(), false),
329        );
330        p99.insert(
331            "iter_logarithmic".to_string(),
332            bulk(|| (), |()| _ = h.iter_logarithmic().count(), false),
333        );
334        manifest.set_feature("iterators", cat, &p99, &reason);
335    }
336
337    std::fs::create_dir_all(path.parent().unwrap())?;
338    std::fs::write(&path, manifest.to_json())?;
339    io::stdout().write_all(manifest.to_json().as_bytes())?;
340    Ok(())
341}