Skip to main content

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}