Skip to main content

krabka_bench_driver/
aggregate.rs

1//! Cross-run aggregation, the "accurate averages" layer.
2//!
3//! A benchmark *cell* is one [`CellKey`]: a scenario name with the whole
4//! topology it ran at. The harness runs
5//! each cell `N` times per stack. This module reduces those `N` [`RunOutput`]s
6//! into a mean ± sample-stddev per metric, and averages the per-interval time
7//! series across runs at each time offset. Both the Markdown summary and the
8//! Plotly graphs read these aggregates, so one place computes "the average" and
9//! no renderer re-derives it.
10
11use {
12    crate::{
13        ids::TimeOffsetMs,
14        numeric::{mebibytes_f64, millis_f64, to_f64},
15        scenario::{BrokerSample, RunOutput, Sample, Stack},
16    },
17    krabka_units::prelude::*,
18    std::collections::BTreeMap,
19};
20
21/// Mean and spread of a single scalar metric over the runs of one
22/// `(cell, stack)`.
23#[derive(Debug, Clone, Copy, Default, PartialEq)]
24pub struct Stat {
25    pub mean: f64,
26    /// Bessel-corrected sample standard deviation. This is `0.0` for fewer than
27    /// two runs, because a single run has no measurable spread.
28    pub stddev: f64,
29    pub n: usize,
30}
31
32impl Stat {
33    /// Coefficient of variation as a percentage (`stddev ÷ mean × 100`). This is
34    /// `0` when the mean is `0`, so an absent or zero metric renders cleanly.
35    #[must_use]
36    pub fn cv_percent(&self) -> f64 {
37        if self.mean == 0.0 {
38            0.0
39        } else {
40            (self.stddev / self.mean).abs() * 100.0
41        }
42    }
43}
44
45/// Mean, sample stddev, and count over a sample of run-level values.
46#[must_use]
47pub fn stat(values: &[f64]) -> Stat {
48    let n = values.len();
49    if n == 0 {
50        return Stat::default();
51    }
52    let mean = values.iter().sum::<f64>() / to_f64(n);
53    let stddev = if n < 2 {
54        0.0
55    } else {
56        let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (to_f64(n) - 1.0);
57        var.sqrt()
58    };
59    Stat { mean, stddev, n }
60}
61
62/// A headline scalar metric. It holds its key, a way to pull it from a run, and
63/// which direction counts as "better", so a renderer can colour and ratio it
64/// correctly.
65///
66/// `extract` returns a plain number in the metric's stated `unit`, because the
67/// statistics below and the Plotly axes above are dimension-agnostic. A mean and
68/// a stddev run over whatever unit the axis carries. The quantity-to-number seam
69/// is in each metric's `extract`.
70#[derive(Clone, Copy)]
71pub struct ScalarMetric {
72    pub key: &'static str,
73    pub label: &'static str,
74    pub unit: &'static str,
75    pub higher_better: bool,
76    extract: fn(&RunOutput) -> f64,
77}
78
79impl ScalarMetric {
80    #[must_use]
81    pub fn value(&self, r: &RunOutput) -> f64 { (self.extract)(r) }
82}
83
84/// The headline scalar metrics rendered as krabka-vs-kafka bars.
85#[must_use]
86pub fn scalar_metrics() -> Vec<ScalarMetric> {
87    vec![
88        ScalarMetric {
89            key: "producer_msgs_per_sec",
90            label: "Producer throughput",
91            unit: "msgs/s",
92            higher_better: true,
93            extract: |r| r.throughput.producer_rate.per_sec_f64(),
94        },
95        ScalarMetric {
96            key: "consumer_msgs_per_sec",
97            label: "Consumer throughput",
98            unit: "msgs/s",
99            higher_better: true,
100            extract: |r| r.throughput.consumer_rate.per_sec_f64(),
101        },
102        ScalarMetric {
103            key: "producer_p99_ms",
104            label: "Producer ack p99",
105            unit: "ms",
106            higher_better: false,
107            extract: |r| millis_f64(r.producer_latency.p99),
108        },
109        ScalarMetric {
110            key: "consumer_e2e_p99_ms",
111            label: "Consumer e2e p99",
112            unit: "ms",
113            higher_better: false,
114            extract: |r| millis_f64(r.consumer_e2e_latency.p99),
115        },
116        ScalarMetric {
117            key: "msgs_per_cpu_core",
118            label: "Efficiency",
119            unit: "msgs/s per CPU-core",
120            higher_better: true,
121            extract: |r| r.resource.msgs_per_cpu_second.per_sec_f64(),
122        },
123        ScalarMetric {
124            key: "mem_working_set_mb",
125            label: "Broker working set",
126            unit: "MiB",
127            higher_better: false,
128            extract: |r| mebibytes_f64(r.resource.mem_cgroup_working_set),
129        },
130    ]
131}
132
133/// Per-metric aggregates for one stack within a cell.
134#[derive(Debug, Clone, Default)]
135pub struct StackAgg {
136    pub n_runs: usize,
137    /// A map from metric key to the [`Stat`] across this stack's runs in the
138    /// cell.
139    pub metrics: BTreeMap<&'static str, Stat>,
140}
141
142/// What makes two runs comparable: one scenario at one topology.
143///
144/// The key carries the whole topology and not only the broker count. A
145/// scenario rerun after a partition-count or replication-factor change leaves
146/// both sets of JSON in `bench/results`, and averaging those two together
147/// reports a number that no single experiment produced. Every grouping in the
148/// report, the graphs and the CSVs keys on this, so a cell holds one
149/// experiment.
150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
151pub struct CellKey {
152    pub scenario: String,
153    pub broker_count: u32,
154    pub partitions: i32,
155    pub replication_factor: i16,
156}
157
158impl CellKey {
159    /// The cell one run belongs to.
160    #[must_use]
161    pub fn of(run: &RunOutput) -> Self {
162        Self {
163            scenario: run.scenario.name.clone(),
164            broker_count: run.topology.broker_count,
165            partitions: run.topology.partitions,
166            replication_factor: run.topology.replication_factor,
167        }
168    }
169
170    /// The heading a report, a chart or a violation message names the cell by.
171    #[must_use]
172    pub fn label(&self) -> String {
173        format!(
174            "{} @ {} broker(s), {} partitions, RF={}",
175            self.scenario, self.broker_count, self.partitions, self.replication_factor
176        )
177    }
178}
179
180/// One benchmark cell. It holds a `(scenario, topology)` with the krabka and
181/// kafka aggregates side by side.
182#[derive(Debug, Clone)]
183pub struct CellAgg {
184    pub key: CellKey,
185    pub krabka: StackAgg,
186    pub kafka: StackAgg,
187}
188
189fn runs_by_cell(runs: &[RunOutput]) -> BTreeMap<CellKey, Vec<&RunOutput>> {
190    let mut cells: BTreeMap<CellKey, Vec<&RunOutput>> = BTreeMap::new();
191    for run in runs {
192        cells.entry(CellKey::of(run)).or_default().push(run);
193    }
194    cells
195}
196
197/// Groups runs by [`CellKey`] and reduces each stack's runs to a per-metric
198/// [`Stat`]. This returns the cells in key order.
199#[must_use]
200pub fn aggregate_cells(runs: &[RunOutput]) -> Vec<CellAgg> {
201    let metrics = scalar_metrics();
202    let by_cell = runs_by_cell(runs);
203
204    let mut out = Vec::with_capacity(by_cell.len());
205    for (key, cell_runs) in by_cell {
206        let agg_stack = |stack: Stack| -> StackAgg {
207            let stack_runs: Vec<&RunOutput> = cell_runs
208                .iter()
209                .copied()
210                .filter(|r| r.stack == stack)
211                .collect();
212            let mut by_metric = BTreeMap::new();
213            for m in &metrics {
214                let vals: Vec<f64> = stack_runs.iter().map(|r| m.value(r)).collect();
215                by_metric.insert(m.key, stat(&vals));
216            }
217            StackAgg {
218                n_runs: stack_runs.len(),
219                metrics: by_metric,
220            }
221        };
222        out.push(CellAgg {
223            key,
224            krabka: agg_stack(Stack::Krabka),
225            kafka: agg_stack(Stack::Kafka),
226        });
227    }
228    out
229}
230
231/// One averaged point of a time series. It holds the across-run mean of a metric
232/// at a fixed offset into the run, and how many runs gave that offset.
233#[derive(Debug, Clone, Copy, PartialEq)]
234pub struct TsPoint {
235    pub t_offset_ms: TimeOffsetMs,
236    pub mean: f64,
237    pub n: usize,
238}
239
240/// An across-run-averaged time series for one `(cell, stack, metric)`.
241#[derive(Debug, Clone)]
242pub struct TsSeries {
243    pub key: CellKey,
244    pub stack: Stack,
245    pub metric: &'static str,
246    pub points: Vec<TsPoint>,
247}
248
249type SampleExtract = fn(&Sample) -> f64;
250type BrokerExtract = fn(&BrokerSample) -> f64;
251
252/// A time-series metric key paired with the function that reads its value out
253/// of one sample.
254#[derive(Debug, Clone, Copy)]
255struct TsMetricSource<Extract> {
256    metric: &'static str,
257    extract: Extract,
258}
259
260fn client_ts_metrics() -> Vec<TsMetricSource<SampleExtract>> {
261    vec![
262        TsMetricSource {
263            metric: "producer_msgs_per_sec",
264            extract: |s| s.producer_rate.per_sec_f64(),
265        },
266        TsMetricSource {
267            metric: "consumer_msgs_per_sec",
268            extract: |s| s.consumer_rate.per_sec_f64(),
269        },
270        TsMetricSource {
271            metric: "producer_p99_ms",
272            extract: |s| millis_f64(s.producer_p99),
273        },
274        TsMetricSource {
275            metric: "consumer_e2e_p99_ms",
276            extract: |s| millis_f64(s.consumer_e2e_p99),
277        },
278    ]
279}
280
281fn broker_ts_metrics() -> Vec<TsMetricSource<BrokerExtract>> {
282    vec![
283        TsMetricSource {
284            metric: "broker_cpu_cores",
285            extract: |b| b.cpu_cores,
286        },
287        TsMetricSource {
288            metric: "broker_mem_working_set_mb",
289            extract: |b| mebibytes_f64(b.mem_working_set),
290        },
291    ]
292}
293
294/// For each `(cell, stack, metric)`, averages the per-interval samples across
295/// all runs at each shared time offset.
296///
297/// A ragged run that ended early gives fewer points to the tail offsets.
298/// `TsPoint::n` records how many runs backed each averaged point.
299///
300/// The cell is a [`CellKey`], so two runs of one scenario at different
301/// partition counts stay in separate series.
302#[must_use]
303pub fn averaged_timeseries(runs: &[RunOutput]) -> Vec<TsSeries> {
304    let client_metrics = client_ts_metrics();
305    let broker_metrics = broker_ts_metrics();
306
307    let by_cell = runs_by_cell(runs);
308
309    let mut out = Vec::new();
310    for (key, cell_runs) in by_cell {
311        for stack in [Stack::Krabka, Stack::Kafka] {
312            let stack_runs: Vec<&RunOutput> = cell_runs
313                .iter()
314                .copied()
315                .filter(|r| r.stack == stack)
316                .collect();
317            if stack_runs.is_empty() {
318                continue;
319            }
320
321            for &TsMetricSource { metric, extract } in &client_metrics {
322                let mut buckets: BTreeMap<TimeOffsetMs, Vec<f64>> = BTreeMap::new();
323                for r in &stack_runs {
324                    for s in &r.samples {
325                        buckets.entry(s.t_offset_ms).or_default().push(extract(s));
326                    }
327                }
328                if let Some(series) = series_from_buckets(&key, stack, metric, buckets) {
329                    out.push(series);
330                }
331            }
332
333            for &TsMetricSource { metric, extract } in &broker_metrics {
334                let mut buckets: BTreeMap<TimeOffsetMs, Vec<f64>> = BTreeMap::new();
335                for r in &stack_runs {
336                    for b in &r.broker_samples {
337                        buckets.entry(b.t_offset_ms).or_default().push(extract(b));
338                    }
339                }
340                if let Some(series) = series_from_buckets(&key, stack, metric, buckets) {
341                    out.push(series);
342                }
343            }
344        }
345    }
346    out
347}
348
349/// Averages each time-offset bucket across runs into a [`TsSeries`]. Returns
350/// `None` when no run in the group produced any sample for this metric.
351fn series_from_buckets(
352    key: &CellKey,
353    stack: Stack,
354    metric: &'static str,
355    buckets: BTreeMap<TimeOffsetMs, Vec<f64>>,
356) -> Option<TsSeries> {
357    if buckets.is_empty() {
358        return None;
359    }
360    let points = buckets
361        .into_iter()
362        .map(|(t_offset_ms, vals)| {
363            let s = stat(&vals);
364            TsPoint {
365                t_offset_ms,
366                mean: s.mean,
367                n: s.n,
368            }
369        })
370        .collect();
371    Some(TsSeries {
372        key: key.clone(),
373        stack,
374        metric,
375        points,
376    })
377}
378
379#[cfg(test)]
380mod tests {
381    use {
382        super::*,
383        crate::scenario::{
384            Topology,
385            fixture::{RunSetup, empty_run as run},
386        },
387    };
388
389    fn sample(t: TimeOffsetMs, producer_rate: Frequency) -> Sample {
390        Sample {
391            t_offset_ms: t,
392            producer_rate,
393            ..Sample::default()
394        }
395    }
396
397    fn producer_series(series: &[TsSeries], stack: Stack) -> &TsSeries {
398        series
399            .iter()
400            .find(|s| s.metric == "producer_msgs_per_sec" && s.stack == stack)
401            .expect("producer series present")
402    }
403
404    #[test]
405    fn stat_computes_mean_and_sample_stddev() {
406        let s = stat(&[2.0, 4.0, 6.0]);
407        assert2::assert!(s.n == 3);
408        assert2::assert!((s.mean - 4.0).abs() < f64::EPSILON);
409        // sample variance = ((−2)²+0²+2²)/(3−1) = 8/2 = 4 → stddev 2.
410        assert2::assert!((s.stddev - 2.0).abs() < f64::EPSILON);
411    }
412
413    #[test]
414    fn stat_single_and_empty() {
415        assert2::assert!(
416            stat(&[5.0])
417                == Stat {
418                    mean: 5.0,
419                    stddev: 0.0,
420                    n: 1
421                }
422        );
423        assert2::assert!(stat(&[]) == Stat::default());
424    }
425
426    #[test]
427    fn cv_percent_is_zero_when_mean_zero() {
428        assert2::assert!(stat(&[0.0, 0.0]).cv_percent().abs() < f64::EPSILON);
429        let s = stat(&[100.0, 200.0, 300.0]); // mean 200, stddev 100 → cv 50%
430        assert2::assert!((s.cv_percent() - 50.0).abs() < f64::EPSILON);
431    }
432
433    #[test]
434    fn aggregate_cells_groups_and_averages_each_stack() {
435        let runs = vec![
436            run().scenario("sat").producer_rate(per_sec(100)).call(),
437            run().scenario("sat").producer_rate(per_sec(200)).call(),
438            run().scenario("sat").producer_rate(per_sec(300)).call(),
439            run()
440                .scenario("sat")
441                .stack(Stack::Kafka)
442                .producer_rate(per_sec(50))
443                .call(),
444            run()
445                .scenario("sat")
446                .stack(Stack::Kafka)
447                .producer_rate(per_sec(50))
448                .call(),
449        ];
450        let cells = aggregate_cells(&runs);
451        assert2::assert!(cells.len() == 1);
452        let c = &cells[0];
453        assert2::assert!(
454            c.key
455                == CellKey {
456                    scenario: "sat".into(),
457                    broker_count: 6,
458                    partitions: 100,
459                    replication_factor: 3,
460                }
461        );
462        assert2::assert!(c.krabka.n_runs == 3);
463        assert2::assert!(c.kafka.n_runs == 2);
464        let cm = c.krabka.metrics["producer_msgs_per_sec"];
465        assert2::assert!((cm.mean - 200.0).abs() < f64::EPSILON);
466        assert2::assert!((cm.stddev - 100.0).abs() < f64::EPSILON);
467        let km = c.kafka.metrics["producer_msgs_per_sec"];
468        assert2::assert!((km.mean - 50.0).abs() < f64::EPSILON);
469        assert2::assert!(km.stddev.abs() < f64::EPSILON);
470        assert2::assert!(km.n == 2);
471    }
472
473    #[test]
474    fn aggregate_cells_separates_topologies() {
475        let runs = vec![
476            run()
477                .scenario("sat")
478                .topology(Topology {
479                    broker_count: 3,
480                    ..RunSetup::default().topology
481                })
482                .producer_rate(per_sec(10))
483                .call(),
484            run().scenario("sat").producer_rate(per_sec(20)).call(),
485        ];
486        let cells = aggregate_cells(&runs);
487        assert2::assert!(cells.len() == 2);
488        assert2::assert!(cells[0].key.broker_count == 3);
489        assert2::assert!(cells[1].key.broker_count == 6);
490    }
491
492    /// A rerun after a partition-count or replication-factor change leaves both
493    /// sets of JSON in the results directory. The two are different
494    /// experiments, so they must not average into one cell.
495    #[test]
496    fn aggregate_cells_separates_partition_and_replication_changes() {
497        let topology = |partitions, replication_factor| Topology {
498            partitions,
499            replication_factor,
500            broker_count: 6,
501        };
502        let runs = vec![
503            run()
504                .scenario("sat")
505                .topology(topology(12, 3))
506                .producer_rate(per_sec(10))
507                .call(),
508            run().scenario("sat").topology(topology(100, 3)).call(),
509            run()
510                .scenario("sat")
511                .topology(topology(100, 1))
512                .producer_rate(per_sec(5000))
513                .call(),
514        ];
515        let cells = aggregate_cells(&runs);
516        assert2::assert!(cells.len() == 3);
517        let means: Vec<f64> = cells
518            .iter()
519            .map(|c| c.krabka.metrics["producer_msgs_per_sec"].mean)
520            .collect();
521        // Each cell holds exactly its own run, so no mean is the average of
522        // two topologies.
523        assert2::assert!(cells.iter().all(|c| c.krabka.n_runs == 1));
524        assert2::assert!(means.contains(&10.0));
525        assert2::assert!(means.contains(&1000.0));
526        assert2::assert!(means.contains(&5000.0));
527    }
528
529    /// `averaged_timeseries` keys on the same cell, so two partition counts
530    /// give two series and neither point is the mean of both.
531    #[test]
532    fn averaged_timeseries_separates_partition_changes() {
533        let topology = |partitions| Topology {
534            partitions,
535            replication_factor: 3,
536            broker_count: 6,
537        };
538        let runs = vec![
539            run()
540                .scenario("sat")
541                .topology(topology(12))
542                .producer_rate(Frequency::ZERO)
543                .samples(vec![sample(TimeOffsetMs(0), per_sec(100))])
544                .call(),
545            run()
546                .scenario("sat")
547                .topology(topology(100))
548                .producer_rate(Frequency::ZERO)
549                .samples(vec![sample(TimeOffsetMs(0), per_sec(900))])
550                .call(),
551        ];
552        let series = averaged_timeseries(&runs);
553        let producer: Vec<&TsSeries> = series
554            .iter()
555            .filter(|s| s.metric == "producer_msgs_per_sec")
556            .collect();
557        assert2::assert!(producer.len() == 2);
558        let mut means: Vec<f64> = producer.iter().map(|s| s.points[0].mean).collect();
559        means.sort_by(f64::total_cmp);
560        assert2::assert!((means[0] - 100.0).abs() < f64::EPSILON);
561        assert2::assert!((means[1] - 900.0).abs() < f64::EPSILON);
562        assert2::assert!(producer.iter().all(|s| s.points[0].n == 1));
563    }
564
565    #[test]
566    fn averaged_timeseries_averages_across_runs_per_offset() {
567        let runs = vec![
568            run()
569                .scenario("sat")
570                .producer_rate(Frequency::ZERO)
571                .samples(vec![
572                    sample(TimeOffsetMs(0), per_sec(1000)),
573                    sample(TimeOffsetMs(2000), per_sec(2000)),
574                ])
575                .broker_samples(vec![BrokerSample {
576                    t_offset_ms: TimeOffsetMs(0),
577                    cpu_cores: 2.0,
578                    mem_working_set: mebibytes(1),
579                }])
580                .call(),
581            run()
582                .scenario("sat")
583                .producer_rate(Frequency::ZERO)
584                .samples(vec![
585                    sample(TimeOffsetMs(0), per_sec(3000)),
586                    sample(TimeOffsetMs(2000), per_sec(4000)),
587                ])
588                .broker_samples(vec![BrokerSample {
589                    t_offset_ms: TimeOffsetMs(0),
590                    cpu_cores: 4.0,
591                    mem_working_set: mebibytes(3),
592                }])
593                .call(),
594        ];
595        let series = averaged_timeseries(&runs);
596        let prod = producer_series(&series, Stack::Krabka);
597        assert2::assert!(
598            prod.points
599                == vec![
600                    TsPoint {
601                        t_offset_ms: TimeOffsetMs(0),
602                        mean: 2000.0,
603                        n: 2
604                    },
605                    TsPoint {
606                        t_offset_ms: TimeOffsetMs(2000),
607                        mean: 3000.0,
608                        n: 2
609                    },
610                ]
611        );
612        let cpu = series
613            .iter()
614            .find(|s| s.metric == "broker_cpu_cores")
615            .expect("broker cpu series present");
616        assert2::assert!(
617            cpu.points[0]
618                == TsPoint {
619                    t_offset_ms: TimeOffsetMs(0),
620                    mean: 3.0,
621                    n: 2
622                }
623        );
624        // mem in MiB: (1 + 3) / 2 = 2.0
625        let mem = series
626            .iter()
627            .find(|s| s.metric == "broker_mem_working_set_mb")
628            .expect("broker mem series present");
629        assert2::assert!((mem.points[0].mean - 2.0).abs() < f64::EPSILON);
630    }
631
632    #[test]
633    fn averaged_timeseries_handles_ragged_runs() {
634        let runs = vec![
635            run()
636                .scenario("sat")
637                .stack(Stack::Kafka)
638                .producer_rate(Frequency::ZERO)
639                .samples(vec![
640                    sample(TimeOffsetMs(0), per_sec(10)),
641                    sample(TimeOffsetMs(2000), per_sec(20)),
642                ])
643                .call(),
644            run()
645                .scenario("sat")
646                .stack(Stack::Kafka)
647                .producer_rate(Frequency::ZERO)
648                .samples(vec![sample(TimeOffsetMs(0), per_sec(30))])
649                .call(),
650        ];
651        let series = averaged_timeseries(&runs);
652        let prod = producer_series(&series, Stack::Kafka);
653        assert2::assert!(
654            prod.points[0]
655                == TsPoint {
656                    t_offset_ms: TimeOffsetMs(0),
657                    mean: 20.0,
658                    n: 2
659                }
660        );
661        // Only one run reached t=2000.
662        assert2::assert!(
663            prod.points[1]
664                == TsPoint {
665                    t_offset_ms: TimeOffsetMs(2000),
666                    mean: 20.0,
667                    n: 1
668                }
669        );
670    }
671}