1use {
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#[derive(Debug, Clone, Copy, Default, PartialEq)]
24pub struct Stat {
25 pub mean: f64,
26 pub stddev: f64,
29 pub n: usize,
30}
31
32impl Stat {
33 #[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#[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#[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#[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#[derive(Debug, Clone, Default)]
135pub struct StackAgg {
136 pub n_runs: usize,
137 pub metrics: BTreeMap<&'static str, Stat>,
140}
141
142#[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 #[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 #[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#[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#[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#[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#[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#[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#[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
349fn 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 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]); 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 #[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 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 #[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 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 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}