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}