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}