Skip to main content

bench_tuple_update/
bench_tuple_update.rs

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