1use std::io::Write as _;
9use std::path::Path;
10
11use askama::Template;
12use eyre::Result;
13use serde::Serialize;
14
15use waterui_preview_protocol::bench::{BenchReport, PerfFrame, PerfMeasurement};
16
17#[must_use]
19pub fn bench_target_label(report: &BenchReport) -> String {
20 format!("{}/{}", report.crate_name, report.bench_name)
21}
22
23#[must_use]
25pub fn micros_label(value: u64) -> String {
26 format!("{value}us")
27}
28
29#[expect(
30 clippy::cast_precision_loss,
31 reason = "bench charts intentionally project integer telemetry into floating-point display coordinates"
32)]
33pub(crate) const fn metric_to_f64(value: u64) -> f64 {
34 value as f64
35}
36
37#[expect(
38 clippy::cast_precision_loss,
39 reason = "bench chart sample counts are converted only for display averages and coordinates"
40)]
41pub(crate) const fn sample_count_to_f64(value: usize) -> f64 {
42 value as f64
43}
44
45#[expect(
46 clippy::cast_possible_truncation,
47 clippy::cast_sign_loss,
48 reason = "bench chart scales contain finite non-negative telemetry and labels use rounded integers"
49)]
50pub(crate) fn rounded_metric_to_u64(value: f64) -> u64 {
51 assert!(
52 value.is_finite() && value >= 0.0 && value <= metric_to_f64(u64::MAX),
53 "bench chart metric must be finite, non-negative, and fit into u64"
54 );
55 value.round() as u64
56}
57
58#[must_use]
60pub fn bytes_label(value: u64) -> String {
61 const MIB: f64 = 1_048_576.0;
62 format!("{:.1}MiB", metric_to_f64(value) / MIB)
63}
64
65#[derive(Debug, Clone, Copy)]
67pub struct BenchResourceSummary {
68 pub avg_cpu_percent: f64,
70 pub max_cpu_percent: f64,
72 pub max_memory_bytes: u64,
74 pub avg_gpu_frame_us: u64,
76 pub max_gpu_frame_us: u64,
78 pub avg_scene_layers: f64,
80 pub max_scene_layers: u64,
82 pub avg_clip_layers: f64,
84 pub max_clip_depth: u64,
86}
87
88#[must_use]
93pub fn resource_summary(measurement: &PerfMeasurement) -> Option<BenchResourceSummary> {
94 if measurement.frames.is_empty() {
95 return None;
96 }
97 let sample_count = sample_count_to_f64(measurement.frames.len());
98 let avg_cpu_percent = measurement
99 .frames
100 .iter()
101 .map(|frame| frame.cpu_percent)
102 .sum::<f64>()
103 / sample_count;
104 let avg_gpu_frame_us = measurement
105 .frames
106 .iter()
107 .map(|frame| frame.gpu_frame_us)
108 .sum::<u64>()
109 / u64::try_from(measurement.frames.len()).expect("bench sample count should fit u64");
110 Some(BenchResourceSummary {
111 avg_cpu_percent,
112 max_cpu_percent: measurement
113 .frames
114 .iter()
115 .map(|frame| frame.cpu_percent)
116 .fold(0.0, f64::max),
117 max_memory_bytes: measurement
118 .frames
119 .iter()
120 .map(|frame| frame.memory_bytes)
121 .max()
122 .unwrap_or_default(),
123 avg_gpu_frame_us,
124 max_gpu_frame_us: measurement
125 .frames
126 .iter()
127 .map(|frame| frame.gpu_frame_us)
128 .max()
129 .unwrap_or_default(),
130 avg_scene_layers: measurement
131 .frames
132 .iter()
133 .map(|frame| metric_to_f64(frame.scene_layers))
134 .sum::<f64>()
135 / sample_count,
136 max_scene_layers: measurement
137 .frames
138 .iter()
139 .map(|frame| frame.scene_layers)
140 .max()
141 .unwrap_or_default(),
142 avg_clip_layers: measurement
143 .frames
144 .iter()
145 .map(|frame| metric_to_f64(frame.clip_layers))
146 .sum::<f64>()
147 / sample_count,
148 max_clip_depth: measurement
149 .frames
150 .iter()
151 .map(|frame| frame.max_clip_depth)
152 .max()
153 .unwrap_or_default(),
154 })
155}
156
157#[derive(Debug, Serialize)]
158struct BenchOutput<'a> {
159 reports: &'a [BenchReport],
160}
161
162pub fn write_bench_stdout_json(reports: &[BenchReport]) -> Result<()> {
167 let json = serde_json::to_vec_pretty(&BenchOutput { reports })?;
168 let mut stdout = std::io::stdout().lock();
169 stdout.write_all(&json)?;
170 stdout.write_all(b"\n")?;
171 stdout.flush()?;
172 Ok(())
173}
174
175pub async fn write_bench_output_json(path: &Path, reports: &[BenchReport]) -> Result<()> {
180 create_parent_dir(path).await?;
181 smol::fs::write(path, serde_json::to_vec_pretty(&BenchOutput { reports })?).await?;
182 Ok(())
183}
184
185#[derive(Debug, Serialize)]
186struct GhaBenchmarkEntry {
187 name: String,
188 unit: &'static str,
189 value: u64,
190}
191
192pub async fn write_bench_gha_json(path: &Path, reports: &[BenchReport]) -> Result<()> {
200 let entries = reports
201 .iter()
202 .flat_map(|report| {
203 let target = bench_target_label(report);
204 report.measurements.iter().flat_map(move |measurement| {
205 [
206 GhaBenchmarkEntry {
207 name: format!("{target}/{} frame p95", measurement.name),
208 unit: "us",
209 value: measurement.p95_us,
210 },
211 GhaBenchmarkEntry {
212 name: format!("{target}/{} frame mean", measurement.name),
213 unit: "us",
214 value: measurement.mean_us,
215 },
216 ]
217 })
218 })
219 .collect::<Vec<_>>();
220 create_parent_dir(path).await?;
221 smol::fs::write(path, serde_json::to_vec_pretty(&entries)?).await?;
222 Ok(())
223}
224
225pub async fn write_bench_html(path: &Path, reports: &[BenchReport]) -> Result<()> {
230 let report_cards = reports
231 .iter()
232 .map(render_bench_report_html)
233 .collect::<String>();
234 let worst_p95 = reports
235 .iter()
236 .flat_map(|report| &report.measurements)
237 .map(|measurement| measurement.p95_us)
238 .max()
239 .unwrap_or_default();
240 let missed_120 = reports
241 .iter()
242 .flat_map(|report| &report.measurements)
243 .map(|measurement| measurement.missed_120fps_frames)
244 .sum::<u64>();
245 let html = render_bench_template(&BenchPageView {
246 worst_p95: micros_label(worst_p95),
247 missed_120: missed_120.to_string(),
248 reports: report_cards,
249 });
250 create_parent_dir(path).await?;
251 smol::fs::write(path, html).await?;
252 Ok(())
253}
254
255async fn create_parent_dir(path: &Path) -> Result<()> {
256 if let Some(parent) = path
257 .parent()
258 .filter(|parent| !parent.as_os_str().is_empty())
259 {
260 smol::fs::create_dir_all(parent).await?;
261 }
262 Ok(())
263}
264
265#[derive(Default)]
269struct BenchSampleView {
270 cx: String,
271 cy: String,
272 frame: u64,
273 total: String,
274 gpu: String,
275 render: String,
276 rebuild: String,
277 cpu: String,
278 memory: String,
279 fps: String,
280 layers: u64,
281 clip_layers: u64,
282 clip_depth: u64,
283 build: Option<String>,
284 dispatch: Option<String>,
285 finish: Option<String>,
286 value: Option<String>,
287}
288
289#[derive(Template)]
290#[template(path = "src/templates/bench/diagnosis.html", escape = "html")]
291struct DiagnosisView {
292 p95: String,
293 mean: String,
294 median: String,
295 rendered_p95: String,
296 rendered_frames: u64,
297 idle_frames: u64,
298 worst_frame: String,
299 samples: u64,
300 bottleneck_name: &'static str,
301 bottleneck_mean: String,
302 rebuild_ratio: String,
303 rebuilt_frames: u64,
304 cache_hit_ratio: String,
305 cache_hits: u64,
306 cache_misses: u64,
307}
308
309struct PhaseSegmentView {
310 class: &'static str,
311 width: String,
312 name: &'static str,
313 label: String,
314}
315
316#[derive(Template)]
317#[template(path = "src/templates/bench/phase_stack.html", escape = "html")]
318struct PhaseStackView {
319 segments: Vec<PhaseSegmentView>,
320}
321
322#[derive(Template)]
323#[template(path = "src/templates/bench/metric_chart.html", escape = "html")]
324struct MetricChartView {
325 title: String,
326 min_label: String,
327 max_label: String,
328 line_class: String,
329 points: String,
330 samples: Vec<BenchSampleView>,
331}
332
333struct BudgetLineView {
334 class: &'static str,
335 y: String,
336}
337
338struct TimingChartView {
339 min_label: String,
340 max_label: String,
341 budget_lines: Vec<BudgetLineView>,
342 total_points: String,
343 gpu_points: String,
344 render_points: String,
345 rebuild_points: String,
346 samples: Vec<BenchSampleView>,
347}
348
349struct FpsChartView {
350 min_label: String,
351 max_label: String,
352 budget_lines: Vec<BudgetLineView>,
353 fps_points: String,
354 samples: Vec<BenchSampleView>,
355}
356
357#[derive(Template)]
358#[template(path = "src/templates/bench/frame_timeline.html", escape = "html")]
359struct FrameTimelineView {
360 timing: TimingChartView,
361 fps: FpsChartView,
362}
363
364#[derive(Template)]
365#[template(path = "src/templates/bench/resources.html", escape = "html")]
366struct ResourcesView {
367 avg_cpu: String,
368 max_cpu: String,
369 max_memory: String,
370 avg_gpu: String,
371 max_gpu: String,
372 avg_layers: String,
373 max_layers: u64,
374 avg_clip: String,
375 max_clip_depth: u64,
376 charts: Vec<String>,
377}
378
379#[derive(Template)]
380#[template(path = "src/templates/bench/measurement.html", escape = "html")]
381struct MeasurementView {
382 name: String,
383 budget_label: &'static str,
384 diagnosis: String,
385 phase_stack: String,
386 frame_timeline: String,
387 resources: String,
388}
389
390#[derive(Template)]
391#[template(path = "src/templates/bench/report_card.html", escape = "html")]
392struct ReportCardView {
393 target: String,
394 measurements: String,
395}
396
397#[derive(Template)]
398#[template(path = "src/templates/bench_report.html", escape = "html")]
399struct BenchPageView {
400 worst_p95: String,
401 missed_120: String,
402 reports: String,
403}
404
405fn render_bench_template<T: Template>(template: &T) -> String {
408 template
409 .render()
410 .expect("bench report template rendering is infallible")
411}
412
413fn bench_common_sample(frame: &PerfFrame, cx: f64, cy: f64) -> BenchSampleView {
417 BenchSampleView {
418 cx: format!("{cx:.2}"),
419 cy: format!("{cy:.2}"),
420 frame: frame.index,
421 total: micros_label(frame.total_us),
422 gpu: micros_label(frame.gpu_frame_us),
423 render: micros_label(frame.render_us),
424 rebuild: micros_label(frame.rebuild_us),
425 cpu: format!("{:.1}%", frame.cpu_percent),
426 memory: bytes_label(frame.memory_bytes),
427 fps: fps_label(bench_throughput_fps(frame)),
428 layers: frame.scene_layers,
429 clip_layers: frame.clip_layers,
430 clip_depth: frame.max_clip_depth,
431 ..BenchSampleView::default()
432 }
433}
434
435fn render_bench_report_html(report: &BenchReport) -> String {
436 let measurements = report
437 .measurements
438 .iter()
439 .map(render_bench_measurement_html)
440 .collect::<String>();
441 render_bench_template(&ReportCardView {
442 target: bench_target_label(report),
443 measurements,
444 })
445}
446
447fn render_bench_measurement_html(measurement: &PerfMeasurement) -> String {
448 let diagnosis = render_bench_diagnosis_html(measurement);
449 let resources = render_bench_resource_timeline_html(measurement);
450 let frame_timeline = render_bench_frame_timeline_html(measurement);
451 let phase_stack = render_bench_phase_stack_html(measurement);
452 render_bench_template(&MeasurementView {
453 name: measurement.name.clone(),
454 budget_label: bench_budget_label(measurement),
455 diagnosis,
456 phase_stack,
457 frame_timeline,
458 resources,
459 })
460}
461
462const fn bench_budget_label(measurement: &PerfMeasurement) -> &'static str {
463 if measurement.p95_us > 16_666 {
464 "misses 60fps"
465 } else if measurement.p95_us > 8_333 {
466 "misses 120fps"
467 } else {
468 "120fps ready"
469 }
470}
471
472fn render_bench_diagnosis_html(measurement: &PerfMeasurement) -> String {
473 let worst = measurement.frames.iter().max_by_key(|frame| frame.total_us);
474 let bottleneck = bench_bottleneck(measurement);
475 let rebuild_ratio = ratio_percent(measurement.rebuilt_frames, measurement.samples);
476 let cache_total = measurement
477 .measurement_cache_hits
478 .saturating_add(measurement.measurement_cache_misses);
479 let cache_hit_ratio = ratio_percent(measurement.measurement_cache_hits, cache_total);
480 let worst_frame = worst.map_or_else(
481 || "none".to_string(),
482 |frame| format!("frame {} / {}", frame.index, micros_label(frame.total_us)),
483 );
484 render_bench_template(&DiagnosisView {
485 p95: micros_label(measurement.p95_us),
486 mean: micros_label(measurement.mean_us),
487 median: micros_label(measurement.median_us),
488 rendered_p95: micros_label(measurement.rendered_p95_us),
489 rendered_frames: measurement.rendered_frames,
490 idle_frames: measurement.idle_frames,
491 worst_frame,
492 samples: measurement.samples,
493 bottleneck_name: bottleneck.name,
494 bottleneck_mean: micros_label(bottleneck.mean_us),
495 rebuild_ratio: format!("{rebuild_ratio:.1}"),
496 rebuilt_frames: measurement.rebuilt_frames,
497 cache_hit_ratio: format!("{cache_hit_ratio:.1}"),
498 cache_hits: measurement.measurement_cache_hits,
499 cache_misses: measurement.measurement_cache_misses,
500 })
501}
502
503struct BenchBottleneck {
504 name: &'static str,
505 mean_us: u64,
506}
507
508fn bench_bottleneck(measurement: &PerfMeasurement) -> BenchBottleneck {
509 [
510 ("render", measurement.phases.render_mean_us),
511 ("rebuild", measurement.phases.rebuild_mean_us),
512 ("build content", measurement.phases.build_content_mean_us),
513 ("scene dispatch", measurement.phases.scene_dispatch_mean_us),
514 ("scene finish", measurement.phases.scene_finish_mean_us),
515 ("animation", measurement.phases.animation_mean_us),
516 ("input", measurement.phases.input_mean_us),
517 ]
518 .into_iter()
519 .max_by_key(|(_, value)| *value)
520 .map(|(name, mean_us)| BenchBottleneck { name, mean_us })
521 .expect("bench bottleneck phase list is non-empty")
522}
523
524fn render_bench_resource_timeline_html(measurement: &PerfMeasurement) -> String {
525 let Some(summary) = resource_summary(measurement) else {
526 return String::new();
527 };
528 let cpu_chart = render_bench_metric_chart_html(
529 "CPU usage",
530 "line-cpu",
531 &measurement.frames,
532 |frame| frame.cpu_percent,
533 |value| format!("{value:.1}%"),
534 );
535 let memory_chart = render_bench_metric_chart_html(
536 "Memory",
537 "line-memory",
538 &measurement.frames,
539 |frame| metric_to_f64(frame.memory_bytes) / 1_048_576.0,
540 |value| format!("{value:.1} MiB"),
541 );
542 let gpu_chart = render_bench_metric_chart_html(
543 "GPU pipeline",
544 "line-gpu",
545 &measurement.frames,
546 |frame| metric_to_f64(frame.gpu_frame_us),
547 |value| micros_label(rounded_metric_to_u64(value)),
548 );
549 let layer_chart = render_bench_metric_chart_html(
550 "Compositor layers",
551 "line-layers",
552 &measurement.frames,
553 |frame| metric_to_f64(frame.scene_layers),
554 |value| format!("{value:.0}"),
555 );
556 let clip_chart = render_bench_metric_chart_html(
557 "Clip layers",
558 "line-clip",
559 &measurement.frames,
560 |frame| metric_to_f64(frame.clip_layers),
561 |value| format!("{value:.0}"),
562 );
563 render_bench_template(&ResourcesView {
564 avg_cpu: format!("{:.1}", summary.avg_cpu_percent),
565 max_cpu: format!("{:.1}", summary.max_cpu_percent),
566 max_memory: bytes_label(summary.max_memory_bytes),
567 avg_gpu: micros_label(summary.avg_gpu_frame_us),
568 max_gpu: micros_label(summary.max_gpu_frame_us),
569 avg_layers: format!("{:.1}", summary.avg_scene_layers),
570 max_layers: summary.max_scene_layers,
571 avg_clip: format!("{:.1}", summary.avg_clip_layers),
572 max_clip_depth: summary.max_clip_depth,
573 charts: vec![cpu_chart, memory_chart, gpu_chart, layer_chart, clip_chart],
574 })
575}
576
577fn render_bench_frame_timeline_html(measurement: &PerfMeasurement) -> String {
578 if measurement.frames.len() < 2 {
579 return String::new();
580 }
581 let timing_values = measurement
582 .frames
583 .iter()
584 .flat_map(|frame| {
585 [
586 metric_to_f64(frame.total_us),
587 metric_to_f64(frame.render_us),
588 metric_to_f64(frame.rebuild_us),
589 metric_to_f64(frame.gpu_frame_us),
590 ]
591 })
592 .collect::<Vec<_>>();
593 let timing_scale = BenchChartScale::new(timing_values.iter().copied());
594 let fps_values = measurement
595 .frames
596 .iter()
597 .map(bench_throughput_fps)
598 .map(|value| value.min(1_000.0))
599 .collect::<Vec<_>>();
600 let fps_scale = BenchChartScale::new(fps_values.iter().copied());
601 let fps_points = render_bench_float_polyline_points(&measurement.frames, fps_scale, |frame| {
602 bench_throughput_fps(frame).min(1_000.0)
603 });
604 let total_points =
605 render_bench_float_polyline_points(&measurement.frames, timing_scale, |frame| {
606 metric_to_f64(frame.total_us)
607 });
608 let render_points =
609 render_bench_float_polyline_points(&measurement.frames, timing_scale, |frame| {
610 metric_to_f64(frame.render_us)
611 });
612 let rebuild_points =
613 render_bench_float_polyline_points(&measurement.frames, timing_scale, |frame| {
614 metric_to_f64(frame.rebuild_us)
615 });
616 let gpu_points =
617 render_bench_float_polyline_points(&measurement.frames, timing_scale, |frame| {
618 metric_to_f64(frame.gpu_frame_us)
619 });
620 let timing_samples = measurement
621 .frames
622 .iter()
623 .map(|frame| {
624 let cx = frame_chart_x(frame.index, measurement.frames.len());
625 let cy = timing_scale.y(metric_to_f64(frame.total_us));
626 BenchSampleView {
627 build: Some(micros_label(frame.build_content_us)),
628 dispatch: Some(micros_label(frame.scene_dispatch_us)),
629 finish: Some(micros_label(frame.scene_finish_us)),
630 ..bench_common_sample(frame, cx, cy)
631 }
632 })
633 .collect();
634 let fps_samples = measurement
635 .frames
636 .iter()
637 .map(|frame| {
638 let cx = frame_chart_x(frame.index, measurement.frames.len());
639 let cy = fps_scale.y(bench_throughput_fps(frame).min(1_000.0));
640 BenchSampleView {
641 build: Some(micros_label(frame.build_content_us)),
642 dispatch: Some(micros_label(frame.scene_dispatch_us)),
643 finish: Some(micros_label(frame.scene_finish_us)),
644 ..bench_common_sample(frame, cx, cy)
645 }
646 })
647 .collect();
648 render_bench_template(&FrameTimelineView {
649 timing: TimingChartView {
650 min_label: micros_label(rounded_metric_to_u64(timing_scale.min)),
651 max_label: micros_label(rounded_metric_to_u64(timing_scale.max)),
652 budget_lines: render_bench_budget_lines(timing_scale),
653 total_points,
654 gpu_points,
655 render_points,
656 rebuild_points,
657 samples: timing_samples,
658 },
659 fps: FpsChartView {
660 min_label: fps_label(fps_scale.min),
661 max_label: fps_label(fps_scale.max),
662 budget_lines: render_bench_fps_budget_lines(fps_scale),
663 fps_points,
664 samples: fps_samples,
665 },
666 })
667}
668
669fn render_bench_phase_stack_html(measurement: &PerfMeasurement) -> String {
670 let phases = [
671 ("input", measurement.phases.input_mean_us, "phase-input"),
672 (
673 "animation",
674 measurement.phases.animation_mean_us,
675 "phase-animation",
676 ),
677 (
678 "build",
679 measurement.phases.build_content_mean_us,
680 "phase-rebuild",
681 ),
682 (
683 "dispatch",
684 measurement.phases.scene_dispatch_mean_us,
685 "phase-rebuild",
686 ),
687 (
688 "finish",
689 measurement.phases.scene_finish_mean_us,
690 "phase-rebuild",
691 ),
692 ("render", measurement.phases.render_mean_us, "phase-render"),
693 ];
694 let total = phases
695 .iter()
696 .map(|(_, value, _)| *value)
697 .sum::<u64>()
698 .max(1);
699 let segments = phases
700 .iter()
701 .map(|(name, value, class)| PhaseSegmentView {
702 class,
703 width: format!("{:.2}", ratio_percent(*value, total).max(0.5)),
704 name,
705 label: micros_label(*value),
706 })
707 .collect();
708 render_bench_template(&PhaseStackView { segments })
709}
710
711fn render_bench_float_polyline_points(
712 frames: &[PerfFrame],
713 scale: BenchChartScale,
714 value: impl Fn(&PerfFrame) -> f64,
715) -> String {
716 frames
717 .iter()
718 .map(|frame| {
719 format!(
720 "{:.2},{:.2}",
721 frame_chart_x(frame.index, frames.len()),
722 scale.y(value(frame))
723 )
724 })
725 .collect::<Vec<_>>()
726 .join(" ")
727}
728
729fn render_bench_metric_chart_html(
730 title: &str,
731 line_class: &str,
732 frames: &[PerfFrame],
733 value: impl Fn(&PerfFrame) -> f64,
734 label: impl Fn(f64) -> String,
735) -> String {
736 if frames.len() < 2 {
737 return String::new();
738 }
739 let values = frames.iter().map(&value).collect::<Vec<_>>();
740 let actual_min = values
741 .iter()
742 .copied()
743 .reduce(f64::min)
744 .expect("bench metric chart has at least two frames");
745 let actual_max = values
746 .iter()
747 .copied()
748 .reduce(f64::max)
749 .expect("bench metric chart has at least two frames");
750 let scale = BenchChartScale::new(values.iter().copied());
751 let points = render_bench_float_polyline_points(frames, scale, &value);
752 let samples = frames
753 .iter()
754 .map(|frame| {
755 let current_value = value(frame);
756 let cx = frame_chart_x(frame.index, frames.len());
757 let cy = scale.y(current_value);
758 BenchSampleView {
759 value: Some(label(current_value)),
760 ..bench_common_sample(frame, cx, cy)
761 }
762 })
763 .collect();
764 render_bench_template(&MetricChartView {
765 title: title.to_owned(),
766 min_label: label(actual_min),
767 max_label: label(actual_max),
768 line_class: line_class.to_owned(),
769 points,
770 samples,
771 })
772}
773
774fn frame_chart_x(index: u64, frame_count: usize) -> f64 {
775 if frame_count <= 1 {
776 return 6.0;
777 }
778 (metric_to_f64(index) / sample_count_to_f64(frame_count - 1)).mul_add(88.0, 6.0)
779}
780
781#[derive(Clone, Copy)]
782struct BenchChartScale {
783 min: f64,
784 max: f64,
785}
786
787impl BenchChartScale {
788 fn new(values: impl IntoIterator<Item = f64>) -> Self {
789 let mut values = values.into_iter();
790 let first = values.next().unwrap_or(0.0);
791 let (mut min, mut max) = (first, first);
792 for value in values {
793 min = min.min(value);
794 max = max.max(value);
795 }
796 let span = max - min;
797 let padding = if span <= f64::EPSILON {
798 max.abs().mul_add(0.02, 1.0)
799 } else {
800 span * 0.12
801 };
802 Self {
803 min: (min - padding).max(0.0),
804 max: max + padding,
805 }
806 }
807
808 fn contains(self, value: f64) -> bool {
809 (self.min..=self.max).contains(&value)
810 }
811
812 fn y(self, value: f64) -> f64 {
813 if self.max <= self.min {
814 return 50.0;
815 }
816 let clamped = value.clamp(self.min, self.max);
817 ((clamped - self.min) / (self.max - self.min)).mul_add(-84.0, 92.0)
818 }
819}
820
821fn render_bench_budget_lines(scale: BenchChartScale) -> Vec<BudgetLineView> {
822 [(8_333.0, "budget-120"), (16_666.0, "budget-60")]
823 .into_iter()
824 .filter(|(value, _)| scale.contains(*value))
825 .map(|(value, class)| BudgetLineView {
826 class,
827 y: format!("{:.2}", scale.y(value)),
828 })
829 .collect()
830}
831
832fn render_bench_fps_budget_lines(scale: BenchChartScale) -> Vec<BudgetLineView> {
833 [(120.0, "budget-120"), (60.0, "budget-60")]
834 .into_iter()
835 .filter(|(value, _)| scale.contains(*value))
836 .map(|(value, class)| BudgetLineView {
837 class,
838 y: format!("{:.2}", scale.y(value)),
839 })
840 .collect()
841}
842
843pub(crate) fn bench_throughput_fps(frame: &PerfFrame) -> f64 {
844 1_000_000.0 / metric_to_f64(frame.total_us.max(1))
845}
846
847pub(crate) fn fps_label(value: f64) -> String {
848 if value >= 1_000.0 {
849 ">=1000fps".to_string()
850 } else {
851 format!("{value:.1}fps")
852 }
853}
854
855pub(crate) fn ratio_percent(numerator: u64, denominator: u64) -> f64 {
856 if denominator == 0 {
857 return 0.0;
858 }
859 (metric_to_f64(numerator) / metric_to_f64(denominator)) * 100.0
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865
866 fn sample_circle_fixture() -> BenchSampleView {
867 BenchSampleView {
868 cx: "6.00".to_owned(),
869 cy: "50.00".to_owned(),
870 frame: 0,
871 total: "10ms".to_owned(),
872 gpu: "4ms".to_owned(),
873 render: "3ms".to_owned(),
874 rebuild: "2ms".to_owned(),
875 cpu: "5.0%".to_owned(),
876 memory: "1.0 MiB".to_owned(),
877 fps: "100.0fps".to_owned(),
878 layers: 3,
879 clip_layers: 1,
880 clip_depth: 2,
881 ..BenchSampleView::default()
882 }
883 }
884
885 #[test]
886 fn bench_diagnosis_template_is_faithful() {
887 let html = render_bench_template(&DiagnosisView {
888 p95: "12ms".to_owned(),
889 mean: "8ms".to_owned(),
890 median: "7ms".to_owned(),
891 rendered_p95: "11ms".to_owned(),
892 rendered_frames: 90,
893 idle_frames: 10,
894 worst_frame: "frame 3 / 20ms".to_owned(),
895 samples: 100,
896 bottleneck_name: "render",
897 bottleneck_mean: "5ms".to_owned(),
898 rebuild_ratio: "12.5".to_owned(),
899 rebuilt_frames: 12,
900 cache_hit_ratio: "98.0".to_owned(),
901 cache_hits: 980,
902 cache_misses: 20,
903 });
904 assert!(html.contains("<section class=\"diagnosis\">"));
905 assert!(html.contains(
906 "<div><span>p95</span><strong>12ms</strong><small>mean 8ms / median 7ms</small></div>"
907 ));
908 assert!(html.contains(
909 "<div><span>rebuild pressure</span><strong>12.5%</strong><small>12/100 frames</small></div>"
910 ));
911 assert!(html.contains(
912 "<div><span>layout cache</span><strong>98.0% hit</strong><small>980 hits / 20 misses</small></div>"
913 ));
914 }
915
916 #[test]
917 fn bench_metric_chart_circle_carries_value_not_build() {
918 let html = render_bench_template(&MetricChartView {
919 title: "CPU usage".to_owned(),
920 min_label: "1.0%".to_owned(),
921 max_label: "9.0%".to_owned(),
922 line_class: "line-cpu".to_owned(),
923 points: "6.00,50.00 94.00,20.00".to_owned(),
924 samples: vec![BenchSampleView {
925 value: Some("5.0%".to_owned()),
926 ..sample_circle_fixture()
927 }],
928 });
929 assert!(html.contains(
930 "<polyline class=\"line-cpu\" points=\"6.00,50.00 94.00,20.00\"></polyline>"
931 ));
932 assert!(html.contains("aria-label=\"CPU usage trend\""));
933 assert!(html.contains("data-value=\"5.0%\""));
934 assert!(html.contains("data-cpu=\"5.0%\""));
935 assert!(!html.contains("data-build"));
937 }
938
939 #[test]
940 fn bench_frame_timeline_circle_carries_build_not_value() {
941 let timeline_sample = || BenchSampleView {
942 build: Some("1ms".to_owned()),
943 dispatch: Some("2ms".to_owned()),
944 finish: Some("3ms".to_owned()),
945 ..sample_circle_fixture()
946 };
947 let html = render_bench_template(&FrameTimelineView {
948 timing: TimingChartView {
949 min_label: "1ms".to_owned(),
950 max_label: "20ms".to_owned(),
951 budget_lines: vec![BudgetLineView {
952 class: "budget-120",
953 y: "30.00".to_owned(),
954 }],
955 total_points: "6.00,50.00".to_owned(),
956 gpu_points: "6.00,60.00".to_owned(),
957 render_points: "6.00,70.00".to_owned(),
958 rebuild_points: "6.00,80.00".to_owned(),
959 samples: vec![timeline_sample()],
960 },
961 fps: FpsChartView {
962 min_label: "50.0fps".to_owned(),
963 max_label: "120.0fps".to_owned(),
964 budget_lines: Vec::new(),
965 fps_points: "6.00,40.00".to_owned(),
966 samples: vec![timeline_sample()],
967 },
968 });
969 assert!(html.contains("<section class=\"timeline-grid\">"));
970 assert!(html.contains(
971 "<line class=\"budget budget-120\" x1=\"6\" y1=\"30.00\" x2=\"94\" y2=\"30.00\"></line>"
972 ));
973 assert!(html.contains("<polyline class=\"line-total\" points=\"6.00,50.00\"></polyline>"));
974 assert!(html.contains("<polyline class=\"line-fps\" points=\"6.00,40.00\"></polyline>"));
975 assert!(html.contains("data-build=\"1ms\" data-dispatch=\"2ms\" data-finish=\"3ms\""));
976 assert!(!html.contains("data-value"));
978 }
979
980 #[test]
981 fn bench_page_template_embeds_summary_and_reports() {
982 let html = render_bench_template(&BenchPageView {
983 worst_p95: "16ms".to_owned(),
984 missed_120: "4".to_owned(),
985 reports: "<section class=\"report\"><h2>demo</h2></section>".to_owned(),
986 });
987 assert!(html.contains("<title>WaterUI Bench Report</title>"));
988 assert!(html.contains("<strong>16ms</strong>"));
989 assert!(html.contains("<strong>4</strong>"));
990 assert!(html.contains("<section class=\"report\"><h2>demo</h2></section>"));
991 assert!(html.contains("formatSample"));
992 }
993}