bench_cpc_update/bench_cpc_update.rs
1//! Throughput harness for `CpcSketch::update_u64`.
2//!
3//! Run with (release matters -- a debug build measures nothing useful):
4//! cargo run --release --example bench_cpc_update --features cpc
5//! cargo run --release --example bench_cpc_update --features cpc -- 100000000
6//! cargo run --release --example bench_cpc_update --features cpc -- --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//! Fixed parameters so runs are comparable: `lg_k = 12`, defaults elsewhere.
20//! Keep them in sync with `benches/cpp_reference/cpc_update.cc`; both print the
21//! estimate, and the estimates must match.
22//!
23//! `cpc::init()` runs first, and the C++ counterpart does the equivalent. CPC
24//! builds global decompression tables lazily on first use, and letting that
25//! land inside a timed loop would charge one-time setup to whichever scenario
26//! ran first.
27//!
28//! Like HLL, CPC has no theta screen; `hot` differs from `distinct` only in
29//! staying cache-resident.
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::cpc::{init, CpcSketch, CpcSketchBuilder};
37use std::hint::black_box;
38use std::time::{Duration, Instant};
39
40const LG_K: u8 = 12;
41
42/// The `ser` and `deser` scenarios are the one place where the item count is
43/// not the divisor: serialization cost tracks the serialized *size*, and at
44/// this harness's `lg_k` the sketch saturates well below the ladder's bottom
45/// rung, so the same buffer is produced at 1M items as at 100M. The printed
46/// `ns/op` is therefore per serialize call, over a call count fixed here
47/// rather than taken from the command line -- otherwise the number would
48/// silently mean something different at each rung.
49///
50/// Keep in step with `bench_common.h`.
51const SER_CALLS: u64 = 20_000;
52const DESER_CALLS: u64 = 5_000;
53
54const HOT_KEY_SPACE: u64 = 1 << 10;
55
56/// Size of the pre-built string-key pool. See `string_keys`.
57const STR_KEY_SPACE: u64 = 1 << 16;
58
59/// Built once, outside every timed region: formatting a key costs more than
60/// the update does, and it costs a different amount in each language, so
61/// including it would swamp the per-call delta this harness exists to show.
62/// Keep the format identical to the C++ counterpart or the estimates diverge.
63fn string_keys() -> Vec<String> {
64 (0..STR_KEY_SPACE).map(|i| format!("key_{i:010}")).collect()
65}
66
67/// Item counts for `--ladder`, which exists because a single item count hides
68/// the shape: a family's per-update cost is not constant as the sketch fills.
69///
70/// Starts at 1M rather than lower. Below that the cheap families are still in
71/// a warm-up regime -- HLL's coupon list, CPC's flavour transitions -- so the
72/// printed ns/op would be an average taken across a regime change rather than
73/// a steady-state cost, which is precisely the kind of number the ladder
74/// exists to stop people quoting.
75const LADDER: [u64; 3] = [1_000_000, 10_000_000, 100_000_000];
76const DEFAULT_ITEMS: u64 = 10_000_000;
77const DEFAULT_REPS: usize = 3;
78
79/// Parses `[ITEMS] [--reps N] [--ladder]`. Hand-rolled: three flags do not
80/// justify pulling an argument crate into a bench example.
81fn parse_args() -> (Vec<u64>, usize) {
82 let mut items = None;
83 let mut reps = DEFAULT_REPS;
84 let mut ladder = false;
85 let mut args = std::env::args().skip(1);
86 while let Some(arg) = args.next() {
87 match arg.as_str() {
88 "--ladder" => ladder = true,
89 "--reps" => {
90 reps = args
91 .next()
92 .and_then(|v| v.parse().ok())
93 .filter(|&n| n > 0)
94 .expect("--reps needs a positive integer")
95 }
96 other => {
97 let n = other
98 .parse()
99 .expect("item count must be a positive integer");
100 assert!(n > 0, "item count must be a positive integer");
101 items = Some(n);
102 }
103 }
104 }
105 // Rejected rather than resolved by precedence: silently ignoring an
106 // explicit item count would make a mis-typed invocation look like it
107 // measured what was asked for.
108 assert!(
109 !(ladder && items.is_some()),
110 "pass an item count or --ladder, not both"
111 );
112 let counts = if ladder {
113 LADDER.to_vec()
114 } else {
115 vec![items.unwrap_or(DEFAULT_ITEMS)]
116 };
117 (counts, reps)
118}
119
120/// Prints the lower median of the passes plus the spread, so a published
121/// figure is never a single noisy point -- the AGENTS.md rule that a
122/// performance claim rest on a median of at least three runs is enforced here
123/// rather than left to whoever happens to be running it.
124///
125/// Lower median (`sorted[(n - 1) / 2]`), not the average of the two middle
126/// values: every number printed is then one that an actual pass produced. At
127/// the default `reps = 3` the two definitions agree; this only matters for an
128/// even `--reps`.
129///
130/// The estimates are asserted equal across reps rather than merely reported.
131/// These workloads are deterministic, so a disagreement means the reps are not
132/// running the same thing -- most likely a sketch reused across reps instead
133/// of rebuilt, which would quietly lower the ns/op of every rep after the
134/// first.
135///
136/// `ns/op`, `reps` and `estimate` are printed as labelled values rather than
137/// as bare numbers in fixed columns, so that reading them back does not mean
138/// counting awk fields that shift whenever a column is added.
139fn report(label: &str, items: u64, passes: &[Pass]) {
140 report_line(label, items, items, passes, String::new());
141}
142
143/// As [`report`], plus the serialized size, and dividing by an explicit `ops`
144/// rather than by the item count.
145///
146/// The size is worth printing for its own sake -- it is the quantity a `ser`
147/// or `deser` `ns/op` is proportional to, so without it the timing cannot be
148/// interpreted -- but it is also a check the estimate cannot make. Two sides
149/// can agree exactly on the estimate while one compacts ordered and the other
150/// does not, or serializes a different format; the byte count differs the
151/// moment they do.
152fn report_bytes(label: &str, items: u64, ops: u64, passes: &[Pass], bytes: usize) {
153 report_line(label, items, ops, passes, format!(" bytes={bytes}"));
154}
155
156fn report_line(label: &str, items: u64, ops: u64, passes: &[Pass], suffix: String) {
157 for (i, pass) in passes.iter().enumerate() {
158 assert_eq!(
159 pass.estimate, passes[0].estimate,
160 "rep {i} estimated {} but rep 0 estimated {}: the reps are not running \
161 the same workload",
162 pass.estimate, passes[0].estimate
163 );
164 }
165 let mut ns_per_op: Vec<f64> = passes
166 .iter()
167 .map(|p| p.elapsed.as_secs_f64() * 1e9 / ops as f64)
168 .collect();
169 ns_per_op.sort_by(f64::total_cmp);
170 let median = ns_per_op[(ns_per_op.len() - 1) / 2];
171 let (min, max) = (ns_per_op[0], ns_per_op[ns_per_op.len() - 1]);
172 let rate = 1000.0 / median;
173 let (reps, estimate) = (passes.len(), passes[0].estimate);
174 println!(
175 "{label:9} {items:>12} items {median:>7.2} ns/op min {min:>7.2} max {max:>7.2} \
176 {rate:>8.1} M/s reps={reps} estimate={estimate:.0}{suffix}"
177 );
178}
179
180/// One timed pass over `items` updates, and the estimate the sketch held
181/// afterwards. Reading the estimate also keeps the update loop from being
182/// optimised out.
183struct Pass {
184 elapsed: Duration,
185 estimate: f64,
186}
187
188fn build() -> CpcSketch {
189 CpcSketchBuilder::new()
190 .lg_k(LG_K)
191 .build()
192 .expect("fixed valid parameters were rejected")
193}
194
195/// Each rep rebuilds the sketch: a reused one would already be full, so every
196/// rep after the first would measure a different workload. `report` asserts
197/// the per-rep estimates agree, which is what catches that if it regresses.
198fn bench_distinct(items: u64, reps: usize) {
199 let mut passes = Vec::with_capacity(reps);
200 for _ in 0..reps {
201 let mut sketch = build();
202 let start = Instant::now();
203 for key in 0..items {
204 sketch.update_u64(key);
205 }
206 let elapsed = start.elapsed();
207 passes.push(Pass {
208 elapsed,
209 estimate: sketch.get_estimate(),
210 });
211 }
212 report("distinct", items, &passes);
213}
214
215fn bench_hot(items: u64, reps: usize) {
216 let mut passes = Vec::with_capacity(reps);
217 for _ in 0..reps {
218 let mut sketch = build();
219 let start = Instant::now();
220 for i in 0..items {
221 sketch.update_u64(i % HOT_KEY_SPACE);
222 }
223 let elapsed = start.elapsed();
224 passes.push(Pass {
225 elapsed,
226 estimate: sketch.get_estimate(),
227 });
228 }
229 report("hot", items, &passes);
230}
231
232fn bench_str(items: u64, reps: usize) {
233 let keys = string_keys();
234 let mut passes = Vec::with_capacity(reps);
235 for _ in 0..reps {
236 let mut sketch = build();
237 let start = Instant::now();
238 for i in 0..items {
239 sketch.update_str(&keys[(i % STR_KEY_SPACE) as usize]);
240 }
241 let elapsed = start.elapsed();
242 passes.push(Pass {
243 elapsed,
244 estimate: sketch.get_estimate(),
245 });
246 }
247 report("str", items, &passes);
248}
249
250/// Serialization, measured per call rather than per item: its cost tracks the
251/// serialized size, which at `lg_k = 12` is the same at every ladder rung.
252///
253/// The sketch is built once and shared by both directions and every rep.
254/// Serializing does not mutate it, so unlike the update scenarios there is no
255/// state that a second rep would find already dirtied -- and rebuilding at the
256/// 100M rung would cost more than the measurement itself.
257fn bench_serde(items: u64, reps: usize) {
258 let mut sketch = build();
259 for key in 0..items {
260 sketch.update_u64(key);
261 }
262 let reference = sketch.serialize();
263
264 let mut passes = Vec::with_capacity(reps);
265 for _ in 0..reps {
266 let start = Instant::now();
267 let mut total = 0usize;
268 for _ in 0..SER_CALLS {
269 total += black_box(sketch.serialize()).len();
270 }
271 let elapsed = start.elapsed();
272 black_box(total);
273 passes.push(Pass {
274 elapsed,
275 estimate: sketch.get_estimate(),
276 });
277 }
278 report_bytes("ser", items, SER_CALLS, &passes, reference.len());
279
280 let deserialize = || CpcSketch::deserialize(&reference).expect("the bytes came from serialize");
281 let mut passes = Vec::with_capacity(reps);
282 for _ in 0..reps {
283 let start = Instant::now();
284 let mut total = 0.0;
285 for _ in 0..DESER_CALLS {
286 total += deserialize().get_estimate();
287 }
288 let elapsed = start.elapsed();
289 black_box(total);
290 passes.push(Pass {
291 elapsed,
292 estimate: deserialize().get_estimate(),
293 });
294 }
295 report_bytes("deser", items, DESER_CALLS, &passes, reference.len());
296}
297
298fn main() {
299 let (counts, reps) = parse_args();
300
301 // Off the hot path on purpose -- see the module docs.
302 init();
303
304 for items in counts {
305 println!("lg_k={LG_K} items={items} reps={reps}");
306 bench_distinct(items, reps);
307 bench_hot(items, reps);
308 bench_str(items, reps);
309 bench_serde(items, reps);
310 }
311}