Skip to main content

waterui_cli/bench/
report.rs

1//! Rendering for `water bench` reports.
2//!
3//! Budget evaluation happens inside `waterui-testing` while the benches run;
4//! this module only aggregates the collected [`BenchReport`]s and renders them
5//! as JSON, GitHub-Action-benchmark JSON, or a standalone HTML page. The
6//! terminal layer prints the human summary itself.
7
8use 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/// `crate/bench` label identifying one report in every output format.
18#[must_use]
19pub fn bench_target_label(report: &BenchReport) -> String {
20    format!("{}/{}", report.crate_name, report.bench_name)
21}
22
23/// Render a microsecond count the way the bench report displays it.
24#[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/// Render a byte count the way the bench report displays it.
59#[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/// CPU, memory, GPU and scene-complexity aggregates over a measurement's frames.
66#[derive(Debug, Clone, Copy)]
67pub struct BenchResourceSummary {
68    /// Mean CPU utilization across sampled frames.
69    pub avg_cpu_percent: f64,
70    /// Peak CPU utilization across sampled frames.
71    pub max_cpu_percent: f64,
72    /// Peak resident memory.
73    pub max_memory_bytes: u64,
74    /// Mean GPU frame time.
75    pub avg_gpu_frame_us: u64,
76    /// Peak GPU frame time.
77    pub max_gpu_frame_us: u64,
78    /// Mean number of scene layers.
79    pub avg_scene_layers: f64,
80    /// Peak number of scene layers.
81    pub max_scene_layers: u64,
82    /// Mean number of clip layers.
83    pub avg_clip_layers: f64,
84    /// Peak clip nesting depth.
85    pub max_clip_depth: u64,
86}
87
88/// Aggregate a measurement's per-frame resource samples, if it recorded any.
89///
90/// # Panics
91/// Panics if a measurement records frames whose sample count does not fit a `f64`.
92#[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
162/// Write every report as JSON on stdout.
163///
164/// # Errors
165/// Returns an error if stdout cannot be written.
166pub 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
175/// Write every report as JSON to a file.
176///
177/// # Errors
178/// Returns an error if the file cannot be written.
179pub 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
192/// Write the `github-action-benchmark` `customSmallerIsBetter` JSON.
193///
194/// Every measurement contributes its frame p95 and frame mean in
195/// microseconds, named `<crate>/<bench>/<measurement> <metric>`.
196///
197/// # Errors
198/// Returns an error if the file cannot be written.
199pub 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
225/// Render every report into a standalone HTML page.
226///
227/// # Errors
228/// Returns an error if the file cannot be written.
229pub 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/// One SVG sample dot in a bench chart, with the data attributes the report's
266/// hover inspector reads. `build`/`dispatch`/`finish` are present only for the
267/// frame-timeline charts; `value` only for the single-metric trend charts.
268#[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
405/// Renders a template, treating any rendering error as a bug (the templates are
406/// compile-checked and the data is plain owned values, so this is infallible).
407fn render_bench_template<T: Template>(template: &T) -> String {
408    template
409        .render()
410        .expect("bench report template rendering is infallible")
411}
412
413/// Builds the chart-agnostic part of a sample dot (position + the data
414/// attributes every chart exposes). Callers fill in the chart-specific
415/// `build`/`dispatch`/`finish` (frame timeline) or `value` (metric trend).
416fn 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        // Metric-trend circles do not carry the frame-timeline-only phase attrs.
936        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        // Frame-timeline circles do not carry the metric-trend-only value attr.
977        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}