Skip to main content

bench_tuple_generic_update/
bench_tuple_generic_update.rs

1//! Throughput harness for `TupleSketch::update_*` — the generic (type-erased)
2//! Tuple path, where C++ calls back into Rust to clone and combine summaries.
3//!
4//! Separate from `bench_tuple_update.rs` because the two have genuinely
5//! different cost structures. ArrayOfDoubles binds a concrete C++
6//! instantiation and its summary is a plain `f64` array; the generic path
7//! wraps every summary in a `rust::Box<RustSummary>` and reaches Rust through
8//! a trampoline for each clone and combine. There is no native C++ reference
9//! for this one — the callback design has no C++ equivalent to compare
10//! against, so the number to watch is this file against itself across changes.
11//!
12//! Run with (release matters — a debug build measures nothing useful):
13//!   cargo run --release --example bench_tuple_generic_update --features tuple
14//!   cargo run --release --example bench_tuple_generic_update --features tuple -- 100000000
15//!   cargo run --release --example bench_tuple_generic_update --features tuple -- --ladder
16//!
17//! Accepts `[ITEMS] [--reps N] [--ladder]`. Every figure printed is the lower
18//! median of `--reps` passes (default 3), with the spread alongside it, so a
19//! single noisy pass cannot become a published number. `--ladder` sweeps a
20//! range of item counts instead of one, because a family's per-update cost is
21//! not constant as the sketch fills.
22//!
23//! Fixed parameters so runs are comparable: `lg_k = 12`, defaults elsewhere,
24//! item count defaults to 10M.
25//!
26//! Same three scenarios as the ArrayOfDoubles harness, for the same reason —
27//! they exercise different halves of upstream's `update_tuple_sketch::update`:
28//!
29//! - `distinct` — every key is new, so once theta drops most keys are rejected
30//!   by `hash_and_screen`, which returns *before* the update value is read.
31//!   Per-call work performed ahead of that screen is pure waste here.
32//! - `hot` — keys drawn from a space small enough to stay fully retained, so
33//!   every call reaches `union_combine`.
34//!
35//! The summary below is deliberately the cheapest possible: a single `u64`,
36//! with `create`/`union_combine` doing one add. That is the point — it makes
37//! the harness measure binding overhead rather than the user's own work.
38
39use apache_datasketches::tuple::generic::{TupleSketch, TupleSketchBuilder, TupleSummary};
40use std::time::{Duration, Instant};
41
42const LG_K: u8 = 12;
43const HOT_KEY_SPACE: u64 = 1 << 10;
44
45/// Size of the pre-built string-key pool. See `string_keys`.
46const STR_KEY_SPACE: u64 = 1 << 16;
47
48/// Built once, outside every timed region: formatting a key costs more than
49/// the update does, and it costs a different amount in each language, so
50/// including it would swamp the per-call delta this harness exists to show.
51/// Keep the format identical to the C++ counterpart or the estimates diverge.
52fn string_keys() -> Vec<String> {
53    (0..STR_KEY_SPACE).map(|i| format!("key_{i:010}")).collect()
54}
55
56#[derive(Clone)]
57struct Count(u64);
58
59impl TupleSummary for Count {
60    type Update = ();
61    fn create(_: &()) -> Self {
62        Count(1)
63    }
64    fn union_combine(&mut self, other: &Self) {
65        self.0 += other.0;
66    }
67    fn intersection_combine(&mut self, other: &Self) {
68        self.0 += other.0;
69    }
70}
71
72/// Item counts for `--ladder`, which exists because a single item count hides
73/// the shape: a family's per-update cost is not constant as the sketch fills.
74///
75/// Starts at 1M rather than lower. Below that the cheap families are still in
76/// a warm-up regime -- HLL's coupon list, CPC's flavour transitions -- so the
77/// printed ns/op would be an average taken across a regime change rather than
78/// a steady-state cost, which is precisely the kind of number the ladder
79/// exists to stop people quoting.
80const LADDER: [u64; 3] = [1_000_000, 10_000_000, 100_000_000];
81const DEFAULT_ITEMS: u64 = 10_000_000;
82const DEFAULT_REPS: usize = 3;
83
84/// Parses `[ITEMS] [--reps N] [--ladder]`. Hand-rolled: three flags do not
85/// justify pulling an argument crate into a bench example.
86fn parse_args() -> (Vec<u64>, usize) {
87    let mut items = None;
88    let mut reps = DEFAULT_REPS;
89    let mut ladder = false;
90    let mut args = std::env::args().skip(1);
91    while let Some(arg) = args.next() {
92        match arg.as_str() {
93            "--ladder" => ladder = true,
94            "--reps" => {
95                reps = args
96                    .next()
97                    .and_then(|v| v.parse().ok())
98                    .filter(|&n| n > 0)
99                    .expect("--reps needs a positive integer")
100            }
101            other => {
102                let n = other
103                    .parse()
104                    .expect("item count must be a positive integer");
105                assert!(n > 0, "item count must be a positive integer");
106                items = Some(n);
107            }
108        }
109    }
110    // Rejected rather than resolved by precedence: silently ignoring an
111    // explicit item count would make a mis-typed invocation look like it
112    // measured what was asked for.
113    assert!(
114        !(ladder && items.is_some()),
115        "pass an item count or --ladder, not both"
116    );
117    let counts = if ladder {
118        LADDER.to_vec()
119    } else {
120        vec![items.unwrap_or(DEFAULT_ITEMS)]
121    };
122    (counts, reps)
123}
124
125/// Prints the lower median of the passes plus the spread, so a published
126/// figure is never a single noisy point -- the AGENTS.md rule that a
127/// performance claim rest on a median of at least three runs is enforced here
128/// rather than left to whoever happens to be running it.
129///
130/// Lower median (`sorted[(n - 1) / 2]`), not the average of the two middle
131/// values: every number printed is then one that an actual pass produced. At
132/// the default `reps = 3` the two definitions agree; this only matters for an
133/// even `--reps`.
134///
135/// The estimates are asserted equal across reps rather than merely reported.
136/// These workloads are deterministic, so a disagreement means the reps are not
137/// running the same thing -- most likely a sketch reused across reps instead
138/// of rebuilt, which would quietly lower the ns/op of every rep after the
139/// first.
140///
141/// `ns/op`, `reps` and `estimate` are printed as labelled values rather than
142/// as bare numbers in fixed columns, so that reading them back does not mean
143/// counting awk fields that shift whenever a column is added.
144fn report(label: &str, items: u64, passes: &[Pass]) {
145    for (i, pass) in passes.iter().enumerate() {
146        assert_eq!(
147            pass.estimate, passes[0].estimate,
148            "rep {i} estimated {} but rep 0 estimated {}: the reps are not running \
149             the same workload",
150            pass.estimate, passes[0].estimate
151        );
152    }
153    let mut ns_per_op: Vec<f64> = passes
154        .iter()
155        .map(|p| p.elapsed.as_secs_f64() * 1e9 / items as f64)
156        .collect();
157    ns_per_op.sort_by(f64::total_cmp);
158    let median = ns_per_op[(ns_per_op.len() - 1) / 2];
159    let (min, max) = (ns_per_op[0], ns_per_op[ns_per_op.len() - 1]);
160    let rate = 1000.0 / median;
161    let (reps, estimate) = (passes.len(), passes[0].estimate);
162    println!(
163        "{label:9} {items:>12} items  {median:>7.2} ns/op  min {min:>7.2}  max {max:>7.2}  \
164         {rate:>8.1} M/s  reps={reps} estimate={estimate:.0}"
165    );
166}
167
168/// One timed pass over `items` updates, and the estimate the sketch held
169/// afterwards. Reading the estimate also keeps the update loop from being
170/// optimised out.
171struct Pass {
172    elapsed: Duration,
173    estimate: f64,
174}
175
176fn build() -> TupleSketch<Count> {
177    TupleSketchBuilder::new()
178        .lg_k(LG_K)
179        .build()
180        .expect("builder rejected fixed valid parameters")
181}
182
183/// Each rep rebuilds the sketch: a reused one would already be full, so every
184/// rep after the first would measure a different workload. `report` asserts
185/// the per-rep estimates agree, which is what catches that if it regresses.
186fn bench_distinct(items: u64, reps: usize) {
187    let mut passes = Vec::with_capacity(reps);
188    for _ in 0..reps {
189        let mut sketch = build();
190        let start = Instant::now();
191        for key in 0..items {
192            sketch.update_u64(key, &());
193        }
194        let elapsed = start.elapsed();
195        passes.push(Pass {
196            elapsed,
197            estimate: sketch.get_estimate(),
198        });
199    }
200    report("distinct", items, &passes);
201}
202
203fn bench_hot(items: u64, reps: usize) {
204    let mut passes = Vec::with_capacity(reps);
205    for _ in 0..reps {
206        let mut sketch = build();
207        let start = Instant::now();
208        for i in 0..items {
209            sketch.update_u64(i % HOT_KEY_SPACE, &());
210        }
211        let elapsed = start.elapsed();
212        passes.push(Pass {
213            elapsed,
214            estimate: sketch.get_estimate(),
215        });
216    }
217    report("hot", items, &passes);
218}
219
220fn bench_str(items: u64, reps: usize) {
221    let keys = string_keys();
222    let mut passes = Vec::with_capacity(reps);
223    for _ in 0..reps {
224        let mut sketch = build();
225        let start = Instant::now();
226        for i in 0..items {
227            sketch.update_str(&keys[(i % STR_KEY_SPACE) as usize], &());
228        }
229        let elapsed = start.elapsed();
230        passes.push(Pass {
231            elapsed,
232            estimate: sketch.get_estimate(),
233        });
234    }
235    report("str", items, &passes);
236}
237
238fn main() {
239    let (counts, reps) = parse_args();
240    for items in counts {
241        println!("lg_k={LG_K} items={items} reps={reps}");
242        bench_distinct(items, reps);
243        bench_hot(items, reps);
244        bench_str(items, reps);
245    }
246}