Skip to main content

bench_theta_update/
bench_theta_update.rs

1//! Throughput harness for `ThetaSketch::update_u64`.
2//!
3//! Run with (release matters -- a debug build measures nothing useful):
4//!   cargo run --release --example bench_theta_update --features theta
5//!   cargo run --release --example bench_theta_update --features theta -- 100000000
6//!   cargo run --release --example bench_theta_update --features theta -- --ladder
7//!
8//! Accepts `[ITEMS] [--reps N] [--ladder]`. Every figure printed is the lower
9//! median of `--reps` passes (default 3), with the spread alongside it, so a
10//! single noisy pass cannot become a published number. `--ladder` sweeps a
11//! range of item counts instead of one, because a family's per-update cost is
12//! not constant as the sketch fills.
13//!
14//! Pair with the native C++ counterpart on the same item count to get the
15//! binding's overhead, which is the number worth tracking:
16//!
17//!   ./benches/cpp_reference/run.sh 10000000
18//!
19//! That harness takes the same arguments and prints the same format. Keep the
20//! two in step -- in particular the output format, the median definition and
21//! the string-key format; the mechanism lives in its `bench_common.h`.
22//!
23//! Fixed parameters so runs are comparable: `lg_k = 12`, defaults elsewhere.
24//! Keep them in sync with `benches/cpp_reference/theta_update.cc`; both print
25//! the estimate, and the estimates must match.
26//!
27//! Theta *does* have a theta screen, so as with the Tuple harnesses `distinct`
28//! and `hot` exercise different halves of the update: once theta drops, most
29//! distinct keys are rejected early, while hot keys always reach the table.
30//!
31//! `str` covers the string update path, which crosses the boundary as a
32//! borrowed `(pointer, length)` pair rather than an integer. Its C++
33//! counterpart calls the same `(data, length)` overload the shim does, so the
34//! difference between them is binding overhead and not a choice of overload.
35
36use apache_datasketches::theta::{
37    jaccard_similarity, CompactThetaSketch, ThetaIntersection, ThetaSketch, ThetaSketchBuilder,
38    ThetaUnionBuilder,
39};
40use std::hint::black_box;
41use std::time::{Duration, Instant};
42
43const LG_K: u8 = 12;
44const HOT_KEY_SPACE: u64 = 1 << 10;
45
46/// The `ser` and `deser` scenarios are the one place where the item count is
47/// not the divisor: serialization cost tracks the serialized *size*, and at
48/// this harness's `lg_k` the sketch saturates well below the ladder's bottom
49/// rung, so the same buffer is produced at 1M items as at 100M. The printed
50/// `ns/op` is therefore per serialize call, over a call count fixed here
51/// rather than taken from the command line -- otherwise the number would
52/// silently mean something different at each rung.
53///
54/// Keep in step with `bench_common.h`.
55const SER_CALLS: u64 = 20_000;
56const DESER_CALLS: u64 = 5_000;
57
58/// Union, intersection and Jaccard cost tracks the retained-entries table (at
59/// most `2^lg_k` entries), not the item count -- like `ser`/`deser`, the same
60/// operand sketches produce the same cost at every ladder rung, so this is a
61/// fixed call count rather than one taken from the command line.
62///
63/// Keep in step with `bench_common.h`.
64const OP_CALLS: u64 = 5_000;
65
66/// Size of the pre-built string-key pool. See `string_keys`.
67const STR_KEY_SPACE: u64 = 1 << 16;
68
69/// Built once, outside every timed region: formatting a key costs more than
70/// the update does, and it costs a different amount in each language, so
71/// including it would swamp the per-call delta this harness exists to show.
72/// Keep the format identical to the C++ counterpart or the estimates diverge.
73fn string_keys() -> Vec<String> {
74    (0..STR_KEY_SPACE).map(|i| format!("key_{i:010}")).collect()
75}
76
77/// Item counts for `--ladder`, which exists because a single item count hides
78/// the shape: a family's per-update cost is not constant as the sketch fills.
79///
80/// Starts at 1M rather than lower. Below that the cheap families are still in
81/// a warm-up regime -- HLL's coupon list, CPC's flavour transitions -- so the
82/// printed ns/op would be an average taken across a regime change rather than
83/// a steady-state cost, which is precisely the kind of number the ladder
84/// exists to stop people quoting.
85const LADDER: [u64; 3] = [1_000_000, 10_000_000, 100_000_000];
86const DEFAULT_ITEMS: u64 = 10_000_000;
87const DEFAULT_REPS: usize = 3;
88
89/// Parses `[ITEMS] [--reps N] [--ladder]`. Hand-rolled: three flags do not
90/// justify pulling an argument crate into a bench example.
91fn parse_args() -> (Vec<u64>, usize) {
92    let mut items = None;
93    let mut reps = DEFAULT_REPS;
94    let mut ladder = false;
95    let mut args = std::env::args().skip(1);
96    while let Some(arg) = args.next() {
97        match arg.as_str() {
98            "--ladder" => ladder = true,
99            "--reps" => {
100                reps = args
101                    .next()
102                    .and_then(|v| v.parse().ok())
103                    .filter(|&n| n > 0)
104                    .expect("--reps needs a positive integer")
105            }
106            other => {
107                let n = other
108                    .parse()
109                    .expect("item count must be a positive integer");
110                assert!(n > 0, "item count must be a positive integer");
111                items = Some(n);
112            }
113        }
114    }
115    // Rejected rather than resolved by precedence: silently ignoring an
116    // explicit item count would make a mis-typed invocation look like it
117    // measured what was asked for.
118    assert!(
119        !(ladder && items.is_some()),
120        "pass an item count or --ladder, not both"
121    );
122    let counts = if ladder {
123        LADDER.to_vec()
124    } else {
125        vec![items.unwrap_or(DEFAULT_ITEMS)]
126    };
127    (counts, reps)
128}
129
130/// Prints the lower median of the passes plus the spread, so a published
131/// figure is never a single noisy point -- the AGENTS.md rule that a
132/// performance claim rest on a median of at least three runs is enforced here
133/// rather than left to whoever happens to be running it.
134///
135/// Lower median (`sorted[(n - 1) / 2]`), not the average of the two middle
136/// values: every number printed is then one that an actual pass produced. At
137/// the default `reps = 3` the two definitions agree; this only matters for an
138/// even `--reps`.
139///
140/// The estimates are asserted equal across reps rather than merely reported.
141/// These workloads are deterministic, so a disagreement means the reps are not
142/// running the same thing -- most likely a sketch reused across reps instead
143/// of rebuilt, which would quietly lower the ns/op of every rep after the
144/// first.
145///
146/// `ns/op`, `reps` and `estimate` are printed as labelled values rather than
147/// as bare numbers in fixed columns, so that reading them back does not mean
148/// counting awk fields that shift whenever a column is added.
149fn report(label: &str, items: u64, passes: &[Pass]) {
150    report_line(label, items, items, passes, String::new());
151}
152
153/// As [`report`], plus the serialized size, and dividing by an explicit `ops`
154/// rather than by the item count.
155///
156/// The size is worth printing for its own sake -- it is the quantity a `ser`
157/// or `deser` `ns/op` is proportional to, so without it the timing cannot be
158/// interpreted -- but it is also a check the estimate cannot make. Two sides
159/// can agree exactly on the estimate while one compacts ordered and the other
160/// does not, or serializes a different format; the byte count differs the
161/// moment they do.
162fn report_bytes(label: &str, items: u64, ops: u64, passes: &[Pass], bytes: usize) {
163    report_line(label, items, ops, passes, format!(" bytes={bytes}"));
164}
165
166fn report_line(label: &str, items: u64, ops: u64, passes: &[Pass], suffix: String) {
167    for (i, pass) in passes.iter().enumerate() {
168        assert_eq!(
169            pass.estimate, passes[0].estimate,
170            "rep {i} estimated {} but rep 0 estimated {}: the reps are not running \
171             the same workload",
172            pass.estimate, passes[0].estimate
173        );
174    }
175    let mut ns_per_op: Vec<f64> = passes
176        .iter()
177        .map(|p| p.elapsed.as_secs_f64() * 1e9 / ops as f64)
178        .collect();
179    ns_per_op.sort_by(f64::total_cmp);
180    let median = ns_per_op[(ns_per_op.len() - 1) / 2];
181    let (min, max) = (ns_per_op[0], ns_per_op[ns_per_op.len() - 1]);
182    let rate = 1000.0 / median;
183    let (reps, estimate) = (passes.len(), passes[0].estimate);
184    println!(
185        "{label:9} {items:>12} items  {median:>7.2} ns/op  min {min:>7.2}  max {max:>7.2}  \
186         {rate:>8.1} M/s  reps={reps} estimate={estimate:.0}{suffix}"
187    );
188}
189
190/// As [`report`], but for Jaccard: the result is a confidence interval in
191/// `[0.0, 1.0]`, not a scale-free count, so `report`'s fixed `{:.0}` precision
192/// would round every printed value to 0 and make the parity check vacuous.
193/// Nine decimal digits instead -- both sides call into the exact same
194/// vendored jaccard implementation, so the bits already agree and only the
195/// print format needs to.
196fn report_jaccard(label: &str, items: u64, ops: u64, passes: &[JaccardPass]) {
197    for (i, pass) in passes.iter().enumerate() {
198        assert_eq!(
199            (pass.lower_bound, pass.estimate, pass.upper_bound),
200            (
201                passes[0].lower_bound,
202                passes[0].estimate,
203                passes[0].upper_bound
204            ),
205            "rep {i} did not reproduce rep 0's bounds: the reps are not running the same workload"
206        );
207    }
208    let mut ns_per_op: Vec<f64> = passes
209        .iter()
210        .map(|p| p.elapsed.as_secs_f64() * 1e9 / ops as f64)
211        .collect();
212    ns_per_op.sort_by(f64::total_cmp);
213    let median = ns_per_op[(ns_per_op.len() - 1) / 2];
214    let (min, max) = (ns_per_op[0], ns_per_op[ns_per_op.len() - 1]);
215    let rate = 1000.0 / median;
216    let reps = passes.len();
217    let (lower_bound, estimate, upper_bound) = (
218        passes[0].lower_bound,
219        passes[0].estimate,
220        passes[0].upper_bound,
221    );
222    println!(
223        "{label:9} {items:>12} items  {median:>7.2} ns/op  min {min:>7.2}  max {max:>7.2}  \
224         {rate:>8.1} M/s  reps={reps} lower={lower_bound:.9} estimate={estimate:.9} \
225         upper={upper_bound:.9}"
226    );
227}
228
229/// One timed pass over `items` updates, and the estimate the sketch held
230/// afterwards. Reading the estimate also keeps the update loop from being
231/// optimised out.
232struct Pass {
233    elapsed: Duration,
234    estimate: f64,
235}
236
237/// As [`Pass`], but for the three-field Jaccard result.
238struct JaccardPass {
239    elapsed: Duration,
240    lower_bound: f64,
241    estimate: f64,
242    upper_bound: f64,
243}
244
245fn build() -> ThetaSketch {
246    ThetaSketchBuilder::new()
247        .lg_k(LG_K)
248        .build()
249        .expect("fixed valid parameters were rejected")
250}
251
252/// Each rep rebuilds the sketch: a reused one would already be full, so every
253/// rep after the first would measure a different workload. `report` asserts
254/// the per-rep estimates agree, which is what catches that if it regresses.
255fn bench_distinct(items: u64, reps: usize) {
256    let mut passes = Vec::with_capacity(reps);
257    for _ in 0..reps {
258        let mut sketch = build();
259        let start = Instant::now();
260        for key in 0..items {
261            sketch.update_u64(key);
262        }
263        let elapsed = start.elapsed();
264        passes.push(Pass {
265            elapsed,
266            estimate: sketch.get_estimate(),
267        });
268    }
269    report("distinct", items, &passes);
270}
271
272fn bench_hot(items: u64, reps: usize) {
273    let mut passes = Vec::with_capacity(reps);
274    for _ in 0..reps {
275        let mut sketch = build();
276        let start = Instant::now();
277        for i in 0..items {
278            sketch.update_u64(i % HOT_KEY_SPACE);
279        }
280        let elapsed = start.elapsed();
281        passes.push(Pass {
282            elapsed,
283            estimate: sketch.get_estimate(),
284        });
285    }
286    report("hot", items, &passes);
287}
288
289fn bench_str(items: u64, reps: usize) {
290    let keys = string_keys();
291    let mut passes = Vec::with_capacity(reps);
292    for _ in 0..reps {
293        let mut sketch = build();
294        let start = Instant::now();
295        for i in 0..items {
296            sketch.update_str(&keys[(i % STR_KEY_SPACE) as usize]);
297        }
298        let elapsed = start.elapsed();
299        passes.push(Pass {
300            elapsed,
301            estimate: sketch.get_estimate(),
302        });
303    }
304    report("str", items, &passes);
305}
306
307/// Serialization, measured per call rather than per item: its cost tracks the
308/// serialized size, which at `lg_k = 12` is the same at every ladder rung.
309///
310/// The sketch is built once and shared by both directions and every rep.
311/// Serializing does not mutate it, so unlike the update scenarios there is no
312/// state that a second rep would find already dirtied -- and rebuilding at the
313/// 100M rung would cost more than the measurement itself.
314fn bench_serde(items: u64, reps: usize) {
315    let mut update_sketch = build();
316    for key in 0..items {
317        update_sketch.update_u64(key);
318    }
319    let sketch = update_sketch.compact(true);
320    let reference = sketch.serialize_compact();
321
322    let mut passes = Vec::with_capacity(reps);
323    for _ in 0..reps {
324        let start = Instant::now();
325        let mut total = 0usize;
326        for _ in 0..SER_CALLS {
327            total += black_box(sketch.serialize_compact()).len();
328        }
329        let elapsed = start.elapsed();
330        black_box(total);
331        passes.push(Pass {
332            elapsed,
333            estimate: sketch.get_estimate(),
334        });
335    }
336    report_bytes("ser", items, SER_CALLS, &passes, reference.len());
337
338    let deserialize =
339        || CompactThetaSketch::deserialize(&reference).expect("the bytes came from serialize");
340    let mut passes = Vec::with_capacity(reps);
341    for _ in 0..reps {
342        let start = Instant::now();
343        let mut total = 0.0;
344        for _ in 0..DESER_CALLS {
345            total += deserialize().get_estimate();
346        }
347        let elapsed = start.elapsed();
348        black_box(total);
349        passes.push(Pass {
350            elapsed,
351            estimate: deserialize().get_estimate(),
352        });
353    }
354    report_bytes("deser", items, DESER_CALLS, &passes, reference.len());
355}
356
357/// Two operands with 50% overlap, built once outside every timed region: the
358/// operand-construction cost belongs to the setup, not to the union/
359/// intersection/jaccard call being measured.
360fn build_operands(items: u64) -> (CompactThetaSketch, CompactThetaSketch) {
361    let mut a = build();
362    for key in 0..items {
363        a.update_u64(key);
364    }
365    let mut b = build();
366    for key in (items / 2)..(items + items / 2) {
367        b.update_u64(key);
368    }
369    (a.compact(true), b.compact(true))
370}
371
372/// A fresh union is built inside the timed loop, so the figure is
373/// construct + two updates + get_result, not the merge alone -- reusing one
374/// accumulator across `OP_CALLS` iterations would have each iteration merge
375/// into an ever-growing result, measuring a different workload every time.
376fn bench_union(items: u64, reps: usize) {
377    let (a, b) = build_operands(items);
378    let mut passes = Vec::with_capacity(reps);
379    for _ in 0..reps {
380        let start = Instant::now();
381        let mut total = 0.0;
382        let mut estimate = 0.0;
383        for _ in 0..OP_CALLS {
384            let mut union = ThetaUnionBuilder::new()
385                .lg_k(LG_K)
386                .build()
387                .expect("fixed valid parameters were rejected");
388            union.update(&a);
389            union.update(&b);
390            estimate = union.get_result(true).get_estimate();
391            total += estimate;
392        }
393        let elapsed = start.elapsed();
394        black_box(total);
395        passes.push(Pass { elapsed, estimate });
396    }
397    report_line("union", items, OP_CALLS, &passes, String::new());
398}
399
400/// As [`bench_union`]: a fresh intersection per iteration, so the figure is
401/// construct + two updates + get_result.
402fn bench_intersect(items: u64, reps: usize) {
403    let (a, b) = build_operands(items);
404    let mut passes = Vec::with_capacity(reps);
405    for _ in 0..reps {
406        let start = Instant::now();
407        let mut total = 0.0;
408        let mut estimate = 0.0;
409        for _ in 0..OP_CALLS {
410            let mut intersection = ThetaIntersection::new();
411            intersection.update(&a);
412            intersection.update(&b);
413            estimate = intersection
414                .get_result(true)
415                .expect("both operands were non-empty")
416                .get_estimate();
417            total += estimate;
418        }
419        let elapsed = start.elapsed();
420        black_box(total);
421        passes.push(Pass { elapsed, estimate });
422    }
423    report_line("intersect", items, OP_CALLS, &passes, String::new());
424}
425
426/// `jaccard_similarity` is a pure function of its two operands -- no
427/// accumulator to rebuild, unlike union and intersection.
428fn bench_jaccard(items: u64, reps: usize) {
429    let (a, b) = build_operands(items);
430    let mut passes = Vec::with_capacity(reps);
431    for _ in 0..reps {
432        let start = Instant::now();
433        let mut total = 0.0;
434        let mut bounds = jaccard_similarity(&a, &b);
435        for _ in 0..OP_CALLS {
436            bounds = jaccard_similarity(&a, &b);
437            total += bounds.estimate;
438        }
439        let elapsed = start.elapsed();
440        black_box(total);
441        passes.push(JaccardPass {
442            elapsed,
443            lower_bound: bounds.lower_bound,
444            estimate: bounds.estimate,
445            upper_bound: bounds.upper_bound,
446        });
447    }
448    report_jaccard("jaccard", items, OP_CALLS, &passes);
449}
450
451fn main() {
452    let (counts, reps) = parse_args();
453    for items in counts {
454        println!("lg_k={LG_K} items={items} reps={reps}");
455        bench_distinct(items, reps);
456        bench_hot(items, reps);
457        bench_str(items, reps);
458        bench_serde(items, reps);
459        bench_union(items, reps);
460        bench_intersect(items, reps);
461        bench_jaccard(items, reps);
462    }
463}