apache-datasketches 0.2.3

Safe, idiomatic Rust bindings for Apache DataSketches
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
//! Throughput harness for `ArrayOfDoublesSketch::update_*`.
//!
//! This exists because the ArrayOfDoubles update path crosses the FFI
//! boundary once per item and is the only hot loop in the crate where
//! per-call overhead in the shim is measurable against upstream C++. Run it
//! before and after any change to that path.
//!
//! Run with (release matters — a debug build measures nothing useful):
//!   cargo run --release --example bench_tuple_update --features tuple
//!   cargo run --release --example bench_tuple_update --features tuple -- 100000000
//!   cargo run --release --example bench_tuple_update --features tuple -- --ladder
//!
//! Accepts `[ITEMS] [--reps N] [--ladder]`. Every figure printed is the lower
//! median of `--reps` passes (default 3), with the spread alongside it, so a
//! single noisy pass cannot become a published number. `--ladder` sweeps a
//! range of item counts instead of one, because a family's per-update cost is
//! not constant as the sketch fills.
//!
//! Fixed parameters, so numbers are comparable across runs: `lg_k = 12`,
//! `num_values = 3`, `resize_factor` and `p` at their defaults. The item
//! count defaults to 10M.
//!
//! This measures absolute throughput. To get the number that actually matters
//! — how much of the cost is *this binding* rather than the algorithm — run
//! the native C++ counterpart on the same item count and divide:
//!
//!   ./benches/cpp_reference/run.sh 10000000
//!
//! That program mirrors this one's parameters and scenarios exactly. Keep the
//! two in sync: if you change `LG_K`, `NUM_VALUES` or `HOT_KEY_SPACE` here,
//! change them there too. Both print the sketch estimate, and the estimates
//! must match — that is the cheap check that they are doing the same work.
//!
//! Three scenarios, because they exercise different halves of upstream's
//! `update_tuple_sketch::update`:
//!
//! - `distinct` — every key is new. Once theta drops below 1.0 most keys are
//!   rejected by `hash_and_screen`, which returns *before* upstream ever
//!   reads the values. Per-call work in the shim that happens ahead of that
//!   screen is pure waste here.
//! - `hot` — keys drawn from a space small enough to stay fully retained, so
//!   every call reaches the summary-combine path.
//! - `str` — the string key path, which crosses the boundary as a borrowed
//!   `(pointer, length)` pair rather than an integer. The C++ counterpart
//!   calls the same `(data, length)` overload the shim does, so the difference
//!   between them is binding overhead and not a choice of overload.

use apache_datasketches::tuple::{
    array_of_doubles_jaccard_similarity, ArrayOfDoublesIntersection, ArrayOfDoublesSketch,
    ArrayOfDoublesSketchBuilder, ArrayOfDoublesUnionBuilder, CompactArrayOfDoublesSketch,
};
use std::hint::black_box;
use std::time::{Duration, Instant};

const LG_K: u8 = 12;

/// The `ser` and `deser` scenarios are the one place where the item count is
/// not the divisor: serialization cost tracks the serialized *size*, and at
/// this harness's `lg_k` the sketch saturates well below the ladder's bottom
/// rung, so the same buffer is produced at 1M items as at 100M. The printed
/// `ns/op` is therefore per serialize call, over a call count fixed here
/// rather than taken from the command line -- otherwise the number would
/// silently mean something different at each rung.
///
/// Keep in step with `bench_common.h`.
const SER_CALLS: u64 = 20_000;
const DESER_CALLS: u64 = 5_000;

/// Union, intersection and Jaccard cost tracks the retained-entries table (at
/// most `2^lg_k` entries), not the item count -- like `ser`/`deser`, the same
/// operand sketches produce the same cost at every ladder rung, so this is a
/// fixed call count rather than one taken from the command line.
///
/// Keep in step with `bench_common.h`.
const OP_CALLS: u64 = 5_000;

const NUM_VALUES: u8 = 3;
const HOT_KEY_SPACE: u64 = 1 << 10;

/// Values passed on every update. Length must equal `NUM_VALUES`.
const VALUES: [f64; 3] = [1.0, 2.0, 3.0];

/// Size of the pre-built string-key pool. See `string_keys`.
const STR_KEY_SPACE: u64 = 1 << 16;

/// Built once, outside every timed region: formatting a key costs more than
/// the update does, and it costs a different amount in each language, so
/// including it would swamp the per-call delta this harness exists to show.
/// Keep the format identical to the C++ counterpart or the estimates diverge.
fn string_keys() -> Vec<String> {
    (0..STR_KEY_SPACE).map(|i| format!("key_{i:010}")).collect()
}

/// Item counts for `--ladder`, which exists because a single item count hides
/// the shape: a family's per-update cost is not constant as the sketch fills.
///
/// Starts at 1M rather than lower. Below that the cheap families are still in
/// a warm-up regime -- HLL's coupon list, CPC's flavour transitions -- so the
/// printed ns/op would be an average taken across a regime change rather than
/// a steady-state cost, which is precisely the kind of number the ladder
/// exists to stop people quoting.
const LADDER: [u64; 3] = [1_000_000, 10_000_000, 100_000_000];
const DEFAULT_ITEMS: u64 = 10_000_000;
const DEFAULT_REPS: usize = 3;

/// Parses `[ITEMS] [--reps N] [--ladder]`. Hand-rolled: three flags do not
/// justify pulling an argument crate into a bench example.
fn parse_args() -> (Vec<u64>, usize) {
    let mut items = None;
    let mut reps = DEFAULT_REPS;
    let mut ladder = false;
    let mut args = std::env::args().skip(1);
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--ladder" => ladder = true,
            "--reps" => {
                reps = args
                    .next()
                    .and_then(|v| v.parse().ok())
                    .filter(|&n| n > 0)
                    .expect("--reps needs a positive integer")
            }
            other => {
                let n = other
                    .parse()
                    .expect("item count must be a positive integer");
                assert!(n > 0, "item count must be a positive integer");
                items = Some(n);
            }
        }
    }
    // Rejected rather than resolved by precedence: silently ignoring an
    // explicit item count would make a mis-typed invocation look like it
    // measured what was asked for.
    assert!(
        !(ladder && items.is_some()),
        "pass an item count or --ladder, not both"
    );
    let counts = if ladder {
        LADDER.to_vec()
    } else {
        vec![items.unwrap_or(DEFAULT_ITEMS)]
    };
    (counts, reps)
}

/// Prints the lower median of the passes plus the spread, so a published
/// figure is never a single noisy point -- the AGENTS.md rule that a
/// performance claim rest on a median of at least three runs is enforced here
/// rather than left to whoever happens to be running it.
///
/// Lower median (`sorted[(n - 1) / 2]`), not the average of the two middle
/// values: every number printed is then one that an actual pass produced. At
/// the default `reps = 3` the two definitions agree; this only matters for an
/// even `--reps`.
///
/// The estimates are asserted equal across reps rather than merely reported.
/// These workloads are deterministic, so a disagreement means the reps are not
/// running the same thing -- most likely a sketch reused across reps instead
/// of rebuilt, which would quietly lower the ns/op of every rep after the
/// first.
///
/// `ns/op`, `reps` and `estimate` are printed as labelled values rather than
/// as bare numbers in fixed columns, so that reading them back does not mean
/// counting awk fields that shift whenever a column is added.
fn report(label: &str, items: u64, passes: &[Pass]) {
    report_line(label, items, items, passes, String::new());
}

/// As [`report`], plus the serialized size, and dividing by an explicit `ops`
/// rather than by the item count.
///
/// The size is worth printing for its own sake -- it is the quantity a `ser`
/// or `deser` `ns/op` is proportional to, so without it the timing cannot be
/// interpreted -- but it is also a check the estimate cannot make. Two sides
/// can agree exactly on the estimate while one compacts ordered and the other
/// does not, or serializes a different format; the byte count differs the
/// moment they do.
fn report_bytes(label: &str, items: u64, ops: u64, passes: &[Pass], bytes: usize) {
    report_line(label, items, ops, passes, format!(" bytes={bytes}"));
}

fn report_line(label: &str, items: u64, ops: u64, passes: &[Pass], suffix: String) {
    for (i, pass) in passes.iter().enumerate() {
        assert_eq!(
            pass.estimate, passes[0].estimate,
            "rep {i} estimated {} but rep 0 estimated {}: the reps are not running \
             the same workload",
            pass.estimate, passes[0].estimate
        );
    }
    let mut ns_per_op: Vec<f64> = passes
        .iter()
        .map(|p| p.elapsed.as_secs_f64() * 1e9 / ops as f64)
        .collect();
    ns_per_op.sort_by(f64::total_cmp);
    let median = ns_per_op[(ns_per_op.len() - 1) / 2];
    let (min, max) = (ns_per_op[0], ns_per_op[ns_per_op.len() - 1]);
    let rate = 1000.0 / median;
    let (reps, estimate) = (passes.len(), passes[0].estimate);
    println!(
        "{label:9} {items:>12} items  {median:>7.2} ns/op  min {min:>7.2}  max {max:>7.2}  \
         {rate:>8.1} M/s  reps={reps} estimate={estimate:.0}{suffix}"
    );
}

/// As [`report`], but for Jaccard: the result is a confidence interval in
/// `[0.0, 1.0]`, not a scale-free count, so `report`'s fixed `{:.0}` precision
/// would round every printed value to 0 and make the parity check vacuous.
/// Nine decimal digits instead -- both sides call into the exact same
/// vendored jaccard implementation, so the bits already agree and only the
/// print format needs to.
fn report_jaccard(label: &str, items: u64, ops: u64, passes: &[JaccardPass]) {
    for (i, pass) in passes.iter().enumerate() {
        assert_eq!(
            (pass.lower_bound, pass.estimate, pass.upper_bound),
            (
                passes[0].lower_bound,
                passes[0].estimate,
                passes[0].upper_bound
            ),
            "rep {i} did not reproduce rep 0's bounds: the reps are not running the same workload"
        );
    }
    let mut ns_per_op: Vec<f64> = passes
        .iter()
        .map(|p| p.elapsed.as_secs_f64() * 1e9 / ops as f64)
        .collect();
    ns_per_op.sort_by(f64::total_cmp);
    let median = ns_per_op[(ns_per_op.len() - 1) / 2];
    let (min, max) = (ns_per_op[0], ns_per_op[ns_per_op.len() - 1]);
    let rate = 1000.0 / median;
    let reps = passes.len();
    let (lower_bound, estimate, upper_bound) = (
        passes[0].lower_bound,
        passes[0].estimate,
        passes[0].upper_bound,
    );
    println!(
        "{label:9} {items:>12} items  {median:>7.2} ns/op  min {min:>7.2}  max {max:>7.2}  \
         {rate:>8.1} M/s  reps={reps} lower={lower_bound:.9} estimate={estimate:.9} \
         upper={upper_bound:.9}"
    );
}

/// One timed pass over `items` updates, and the estimate the sketch held
/// afterwards. Reading the estimate also keeps the update loop from being
/// optimised out.
struct Pass {
    elapsed: Duration,
    estimate: f64,
}

/// As [`Pass`], but for the three-field Jaccard result.
struct JaccardPass {
    elapsed: Duration,
    lower_bound: f64,
    estimate: f64,
    upper_bound: f64,
}

fn build() -> ArrayOfDoublesSketch {
    ArrayOfDoublesSketchBuilder::new()
        .lg_k(LG_K)
        .num_values(NUM_VALUES)
        .build()
        .expect("builder rejected fixed valid parameters")
}

/// Each rep rebuilds the sketch: a reused one would already be full, so every
/// rep after the first would measure a different workload. `report` asserts
/// the per-rep estimates agree, which is what catches that if it regresses.
fn bench_distinct(items: u64, reps: usize) {
    let mut passes = Vec::with_capacity(reps);
    for _ in 0..reps {
        let mut sketch = build();
        let start = Instant::now();
        for key in 0..items {
            sketch
                .update_u64(key, &VALUES)
                .expect("update rejected a correctly-sized value slice");
        }
        let elapsed = start.elapsed();
        passes.push(Pass {
            elapsed,
            estimate: sketch.get_estimate(),
        });
    }
    report("distinct", items, &passes);
}

fn bench_hot(items: u64, reps: usize) {
    let mut passes = Vec::with_capacity(reps);
    for _ in 0..reps {
        let mut sketch = build();
        let start = Instant::now();
        for i in 0..items {
            sketch
                .update_u64(i % HOT_KEY_SPACE, &VALUES)
                .expect("update rejected a correctly-sized value slice");
        }
        let elapsed = start.elapsed();
        passes.push(Pass {
            elapsed,
            estimate: sketch.get_estimate(),
        });
    }
    report("hot", items, &passes);
}

fn bench_str(items: u64, reps: usize) {
    let keys = string_keys();
    let mut passes = Vec::with_capacity(reps);
    for _ in 0..reps {
        let mut sketch = build();
        let start = Instant::now();
        for i in 0..items {
            sketch
                .update_str(&keys[(i % STR_KEY_SPACE) as usize], &VALUES)
                .expect("update rejected a correctly-sized value slice");
        }
        let elapsed = start.elapsed();
        passes.push(Pass {
            elapsed,
            estimate: sketch.get_estimate(),
        });
    }
    report("str", items, &passes);
}

/// Serialization, measured per call rather than per item: its cost tracks the
/// serialized size, which at `lg_k = 12` is the same at every ladder rung.
///
/// The sketch is built once and shared by both directions and every rep.
/// Serializing does not mutate it, so unlike the update scenarios there is no
/// state that a second rep would find already dirtied -- and rebuilding at the
/// 100M rung would cost more than the measurement itself.
fn bench_serde(items: u64, reps: usize) {
    let mut update_sketch = build();
    for key in 0..items {
        update_sketch
            .update_u64(key, &VALUES)
            .expect("update rejected a correctly-sized value slice");
    }
    let sketch = update_sketch.compact(true);
    let reference = sketch.serialize();

    let mut passes = Vec::with_capacity(reps);
    for _ in 0..reps {
        let start = Instant::now();
        let mut total = 0usize;
        for _ in 0..SER_CALLS {
            total += black_box(sketch.serialize()).len();
        }
        let elapsed = start.elapsed();
        black_box(total);
        passes.push(Pass {
            elapsed,
            estimate: sketch.get_estimate(),
        });
    }
    report_bytes("ser", items, SER_CALLS, &passes, reference.len());

    let deserialize = || {
        CompactArrayOfDoublesSketch::deserialize(&reference).expect("the bytes came from serialize")
    };
    let mut passes = Vec::with_capacity(reps);
    for _ in 0..reps {
        let start = Instant::now();
        let mut total = 0.0;
        for _ in 0..DESER_CALLS {
            total += deserialize().get_estimate();
        }
        let elapsed = start.elapsed();
        black_box(total);
        passes.push(Pass {
            elapsed,
            estimate: deserialize().get_estimate(),
        });
    }
    report_bytes("deser", items, DESER_CALLS, &passes, reference.len());
}

/// Two operands with 50% overlap, built once outside every timed region: the
/// operand-construction cost belongs to the setup, not to the union/
/// intersection/jaccard call being measured.
fn build_operands(items: u64) -> (CompactArrayOfDoublesSketch, CompactArrayOfDoublesSketch) {
    let mut a = build();
    for key in 0..items {
        a.update_u64(key, &VALUES)
            .expect("update rejected a correctly-sized value slice");
    }
    let mut b = build();
    for key in (items / 2)..(items + items / 2) {
        b.update_u64(key, &VALUES)
            .expect("update rejected a correctly-sized value slice");
    }
    (a.compact(true), b.compact(true))
}

/// A fresh union is built inside the timed loop, so the figure is
/// construct + two updates + get_result, not the merge alone -- reusing one
/// accumulator across `OP_CALLS` iterations would have each iteration merge
/// into an ever-growing result, measuring a different workload every time.
fn bench_union(items: u64, reps: usize) {
    let (a, b) = build_operands(items);
    let mut passes = Vec::with_capacity(reps);
    for _ in 0..reps {
        let start = Instant::now();
        let mut total = 0.0;
        let mut estimate = 0.0;
        for _ in 0..OP_CALLS {
            let mut union = ArrayOfDoublesUnionBuilder::new()
                .lg_k(LG_K)
                .num_values(NUM_VALUES)
                .build()
                .expect("fixed valid parameters were rejected");
            union
                .update(&a)
                .expect("operands match the union's num_values");
            union
                .update(&b)
                .expect("operands match the union's num_values");
            estimate = union.get_result(true).get_estimate();
            total += estimate;
        }
        let elapsed = start.elapsed();
        black_box(total);
        passes.push(Pass { elapsed, estimate });
    }
    report_line("union", items, OP_CALLS, &passes, String::new());
}

/// As [`bench_union`]: a fresh intersection per iteration, so the figure is
/// construct + two updates + get_result.
fn bench_intersect(items: u64, reps: usize) {
    let (a, b) = build_operands(items);
    let mut passes = Vec::with_capacity(reps);
    for _ in 0..reps {
        let start = Instant::now();
        let mut total = 0.0;
        let mut estimate = 0.0;
        for _ in 0..OP_CALLS {
            let mut intersection = ArrayOfDoublesIntersection::new(NUM_VALUES)
                .expect("fixed valid num_values was rejected");
            intersection
                .update(&a)
                .expect("operands match the intersection's num_values");
            intersection
                .update(&b)
                .expect("operands match the intersection's num_values");
            estimate = intersection
                .get_result(true)
                .expect("both operands were non-empty")
                .get_estimate();
            total += estimate;
        }
        let elapsed = start.elapsed();
        black_box(total);
        passes.push(Pass { elapsed, estimate });
    }
    report_line("intersect", items, OP_CALLS, &passes, String::new());
}

/// `array_of_doubles_jaccard_similarity` is a pure function of its two
/// operands -- no accumulator to rebuild, unlike union and intersection.
fn bench_jaccard(items: u64, reps: usize) {
    let (a, b) = build_operands(items);
    let mut passes = Vec::with_capacity(reps);
    for _ in 0..reps {
        let start = Instant::now();
        let mut total = 0.0;
        let mut bounds =
            array_of_doubles_jaccard_similarity(&a, &b).expect("operands agree on num_values");
        for _ in 0..OP_CALLS {
            bounds =
                array_of_doubles_jaccard_similarity(&a, &b).expect("operands agree on num_values");
            total += bounds.estimate;
        }
        let elapsed = start.elapsed();
        black_box(total);
        passes.push(JaccardPass {
            elapsed,
            lower_bound: bounds.lower_bound,
            estimate: bounds.estimate,
            upper_bound: bounds.upper_bound,
        });
    }
    report_jaccard("jaccard", items, OP_CALLS, &passes);
}

fn main() {
    let (counts, reps) = parse_args();
    for items in counts {
        println!("lg_k={LG_K} num_values={NUM_VALUES} items={items} reps={reps}");
        bench_distinct(items, reps);
        bench_hot(items, reps);
        bench_str(items, reps);
        bench_serde(items, reps);
        bench_union(items, reps);
        bench_intersect(items, reps);
        bench_jaccard(items, reps);
    }
}