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//! Precision is the sweep axis because it is the only size a HyperLogLog has:
7//! `p` fixes the register array at `2^p`, and the cardinality being counted
8//! changes nothing about the memory touched. `add` hashes and writes one
9//! register regardless of `p`, so it should read flat; anything that folds the
10//! whole register array - `estimate`, a union - should climb.
11//!
12//! The register count is capped at `2^18` by the constructor's clamp, so the
13//! sweep cannot be pushed an octave higher the way a sketch's width can. That
14//! caps the bulk sweep at a 64x span starting from a 4 KB array, where fixed
15//! per-call cost is still a visible share of the measurement.
16//!
17//! Run:
18//!   cargo run --release --example perf_features \
19//!       --features "harness sparse union-intersect"
20
21use std::collections::BTreeMap;
22use std::io::{self, Write};
23use std::path::PathBuf;
24
25use subms::{SubMsFeatureManifest, SubMsP99Source, SubMsPerfHarness, classify_feature, summarize};
26use subms_hyperloglog::HyperLogLog;
27
28/// 4096 / 32768 / 262144 registers. 18 is the constructor's ceiling.
29const PRECISIONS: [u32; 3] = [12, 15, 18];
30const CANON_P: u32 = PRECISIONS[PRECISIONS.len() - 1];
31/// Sparse-list lengths. `sparse` is swept over this rather than over precision;
32/// see the sparse block for why precision is the wrong axis for that one.
33const LIST_LENS: [usize; 3] = [4_096, 32_768, 262_144];
34/// Keyed ops per measurement. Fixed across the sweep so a slope has one cause.
35const OPS: usize = 20_000;
36const MAX_KEYS: usize = LIST_LENS[LIST_LENS.len() - 1];
37/// Samples per bulk op. A whole-structure call is far above the per-key budget,
38/// so a distribution needs repeats rather than one shot. 256 is a FLOOR, not a
39/// preference: the harness takes p99 as `sorted[floor(0.99 * n)]`, so at n <= 100
40/// that index IS `n - 1` and the "p99" is the single worst sample. A structural
41/// verdict then turns on whichever rep caught a page fault. 256 puts two samples
42/// above the index and makes it a real percentile. Do not lower it.
43const BULK_REPS: usize = 256;
44const BULK_WARM: usize = 8;
45
46fn keys() -> Vec<String> {
47    (0..MAX_KEYS).map(|i| format!("key-{i}")).collect()
48}
49
50fn regs(p: u32) -> usize {
51    1usize << p
52}
53
54fn keyed_p50<T>(f: &mut T, ks: &[String], mut op: impl FnMut(&mut T, &str)) -> u64 {
55    keyed(f, ks, &mut op, true)
56}
57
58fn keyed_p99<T>(f: &mut T, ks: &[String], mut op: impl FnMut(&mut T, &str)) -> u64 {
59    keyed(f, ks, &mut op, false)
60}
61
62fn keyed<T>(f: &mut T, ks: &[String], op: &mut impl FnMut(&mut T, &str), median: bool) -> u64 {
63    let mut h = SubMsPerfHarness::new("hll-feature", "rust");
64    let st = h.stage("op", ks.len());
65    for k in ks {
66        st.time(|| op(f, k));
67    }
68    stat(&h, median)
69}
70
71/// A whole-array op. `setup` runs OUTSIDE the timed region and the first
72/// `BULK_WARM` reps are discarded: measured cold, a bulk op lands its
73/// first-touch cost on whichever sweep point runs first, which reads as a curve
74/// that FALLS with size - the opposite of the structural signal.
75fn bulk<T>(mut setup: impl FnMut() -> T, mut op: impl FnMut(&mut T), median: bool) -> u64 {
76    let mut input = setup();
77    for _ in 0..BULK_WARM {
78        op(&mut input);
79    }
80    let mut h = SubMsPerfHarness::new("hll-feature", "rust");
81    let st = h.stage("op", BULK_REPS);
82    for _ in 0..BULK_REPS {
83        st.time(|| op(&mut input));
84    }
85    stat(&h, median)
86}
87
88fn stat(h: &SubMsPerfHarness, median: bool) -> u64 {
89    summarize(h)
90        .stages
91        .iter()
92        .find(|s| s.name == "op")
93        .map_or(0, |s| if median { s.p50_ns } else { s.p99_ns })
94}
95
96/// Sweeps and PRINTS the curve, indexed by REGISTER COUNT rather than by
97/// precision - the classifier reads the size column as a magnitude, and `p` is
98/// its logarithm.
99fn sweep(label: &str, mut at: impl FnMut(u32) -> u64) -> Vec<(usize, u64)> {
100    let rows: Vec<(usize, u64)> = PRECISIONS.iter().map(|&p| (regs(p), at(p))).collect();
101    eprintln!("sweep {label}: {rows:?}");
102    rows
103}
104
105/// Sweeps over an explicit size column rather than over precision.
106fn sweep_sizes(
107    label: &str,
108    sizes: &[usize],
109    mut at: impl FnMut(usize) -> u64,
110) -> Vec<(usize, u64)> {
111    let rows: Vec<(usize, u64)> = sizes.iter().map(|&n| (n, at(n))).collect();
112    eprintln!("sweep {label}: {rows:?}");
113    rows
114}
115
116fn main() -> io::Result<()> {
117    let ks = keys();
118
119    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
120        .join("..")
121        .join(".subms")
122        .join("features")
123        .join("rust.json");
124    let existing = std::fs::read_to_string(&path).unwrap_or_default();
125    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
126    // Stamp the box these numbers came from. The bench runs wherever it is
127    // invoked, so an unstamped manifest is indistinguishable from a fleet
128    // capture; the renderer will not publish one it cannot attribute.
129    let (source, instance) = SubMsP99Source::from_env();
130    manifest.set_p99_source(source, instance.as_deref());
131
132    // The baseline is base `add`, the per-op path. NOT base `estimate`: that
133    // folds all 2^p registers, so classifying a per-key feature against it would
134    // let anything look free.
135    let mut base = HyperLogLog::new(CANON_P);
136    let base_p50 = keyed_p50(&mut base, &ks[..OPS], |h, k| {
137        h.add(k);
138    });
139    eprintln!("base add p50: {base_p50}ns");
140
141    // ---------- sparse: a linear entry list until it earns the dense array ----------
142    #[cfg(feature = "sparse")]
143    {
144        use subms_hyperloglog::SparseHyperLogLog;
145        // Swept over SPARSE LIST LENGTH, not over precision. `add` linear-probes
146        // the list, so length is the cost driver; precision only sets it
147        // indirectly through the `m/4` promotion threshold, and swept that way
148        // the curve is a step rather than a slope. At p=12 and p=15 the
149        // structure promotes early, so BOTH low points measure the dense floor
150        // (100ns) rather than a small sparse probe, and at p=18 the list is
151        // capped by the key count instead of by the threshold. The resulting
152        // ratio landed either side of the classifier's guard - 40x in Rust,
153        // 23x in Java - which is a measurement artefact, not a real disagreement.
154        //
155        // `with_threshold` exists for exactly this: pin promotion out of reach
156        // and add n keys, and the swept axis IS the list length.
157        //
158        // The list is built to length n OUTSIDE the timed region, and the timed
159        // ops are re-adds of keys already in it - a fixed OPS of them at every
160        // size, so the op count is constant and the scan length is the only
161        // thing varying. Re-adding rather than adding fresh keys keeps the list
162        // from growing under measurement.
163        let sw = sweep_sizes("sparse/add(list-len)", &LIST_LENS, |n| {
164            let mut s = SparseHyperLogLog::with_threshold(CANON_P, n + 1);
165            for k in &ks[..n] {
166                s.add(k);
167            }
168            let mut h = SubMsPerfHarness::new("hll-feature", "rust");
169            let st = h.stage("op", OPS);
170            for i in 0..OPS {
171                let k = &ks[(i * 7919) % n];
172                st.time(|| s.add(k));
173            }
174            stat(&h, true)
175        });
176        // PINNED structural when the ratio test cannot carry it. `add`
177        // linear-probes the sparse list, so it is O(entries) from the source and
178        // the sweep above is monotonic and strongly rising. What it is not is
179        // 32x: a long scan runs ~0.34 ns/element against ~0.93 for a short one,
180        // so a true O(n) op measures ~23x over a 64x span and falls under the
181        // classifier's 0.5 guard. Publishing that as hot-path would tell a
182        // reader the probe is free at high precision. It is not, and the pin
183        // says a human decided rather than dressing the decision as measured.
184        let (cat, reason) = classify_feature(
185            &sw,
186            Some(base_p50),
187            Some(subms::SubMsFeatureCategory::Structural),
188        );
189
190        let mut s = SparseHyperLogLog::new(CANON_P);
191        let mut p99 = BTreeMap::new();
192        p99.insert(
193            "add".to_string(),
194            keyed_p99(&mut s, &ks[..OPS], |x, k| {
195                x.add(k);
196            }),
197        );
198        p99.insert(
199            "estimate".to_string(),
200            bulk(
201                || {
202                    let mut x = SparseHyperLogLog::new(CANON_P);
203                    for k in &ks[..OPS] {
204                        x.add(k);
205                    }
206                    x
207                },
208                |x| _ = x.estimate(),
209                false,
210            ),
211        );
212        manifest.set_feature("sparse", cat, &p99, &reason);
213    }
214
215    // ---------- union-intersect: pairwise folds over both register arrays ----------
216    #[cfg(feature = "union-intersect")]
217    {
218        use subms_hyperloglog::{estimate_intersect, estimate_union};
219        // Both HLLs are built by `setup`, outside the timed region. A union is a
220        // pure read of two register arrays, so repeating it does identical work.
221        // Filled with `m` keys, not a fixed count. OCCUPANCY has to be held
222        // constant or it, not size, is what the sweep measures: `estimate` costs
223        // `2f64.powi(-r)` per register and `powi(0)` takes a fast path, so a
224        // fixed key set against a growing array leaves 92% of registers zero at
225        // p=18 against 0% at p=12. That reads as a per-register cost falling
226        // with size, and it compressed a triple-O(m) op to 26x over 64x.
227        let build = |p: u32| {
228            let n = regs(p);
229            let mut a = HyperLogLog::new(p);
230            let mut b = HyperLogLog::new(p);
231            for (i, k) in ks[..n].iter().enumerate() {
232                a.add(k);
233                if i % 2 == 0 {
234                    b.add(k);
235                }
236            }
237            (a, b)
238        };
239        let sw = sweep("union-intersect/estimate_union", |p| {
240            bulk(
241                || build(p),
242                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
243                true,
244            )
245        });
246        let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
247
248        let mut p99 = BTreeMap::new();
249        p99.insert(
250            "union".to_string(),
251            bulk(
252                || build(CANON_P),
253                |(a, b)| _ = estimate_union(a, b).expect("same precision"),
254                false,
255            ),
256        );
257        p99.insert(
258            "intersect".to_string(),
259            bulk(
260                || build(CANON_P),
261                |(a, b)| _ = estimate_intersect(a, b).expect("same precision"),
262                false,
263            ),
264        );
265        manifest.set_feature("union-intersect", cat, &p99, &reason);
266    }
267
268    std::fs::create_dir_all(path.parent().unwrap())?;
269    std::fs::write(&path, manifest.to_json())?;
270    io::stdout().write_all(manifest.to_json().as_bytes())?;
271    Ok(())
272}