Skip to main content

bevy_sensor/
benchmark.rs

1//! Benchmark helpers for renderer throughput artifacts.
2//!
3//! This module intentionally keeps the reusable pieces small and deterministic:
4//! workload sizing, NeoCortx-compatible targeting policy selection, and timing
5//! summaries. GPU work and file output live in `src/bin/render_benchmark.rs`.
6
7use crate::{ObjectRotation, TargetingPolicy};
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11/// Renderer path exercised by a benchmark run.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum BenchmarkRenderPath {
15    /// Fixed-orbit episodes rendered through one retained `RenderSession`.
16    FixedOrbitSession,
17    /// Per-step camera updates rendered through `PersistentRenderer`.
18    PersistentSteps,
19}
20
21impl BenchmarkRenderPath {
22    /// Stable label for reports and result tables.
23    pub fn label(self) -> &'static str {
24        match self {
25            Self::FixedOrbitSession => "fixed-orbit-session",
26            Self::PersistentSteps => "persistent-steps",
27        }
28    }
29}
30
31/// Serializable workload description included in benchmark artifacts.
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33pub struct BenchmarkWorkload {
34    pub name: String,
35    pub render_path: BenchmarkRenderPath,
36    pub objects: Vec<String>,
37    pub rotations_per_object: usize,
38    pub viewpoints_per_rotation: usize,
39    pub steps_per_object: Option<usize>,
40    pub total_frames: usize,
41}
42
43impl BenchmarkWorkload {
44    pub fn fixed_orbit(
45        name: impl Into<String>,
46        objects: Vec<String>,
47        rotations_per_object: usize,
48        viewpoints_per_rotation: usize,
49    ) -> Self {
50        let total_frames =
51            fixed_orbit_frame_count(objects.len(), rotations_per_object, viewpoints_per_rotation);
52        Self {
53            name: name.into(),
54            render_path: BenchmarkRenderPath::FixedOrbitSession,
55            objects,
56            rotations_per_object,
57            viewpoints_per_rotation,
58            steps_per_object: None,
59            total_frames,
60        }
61    }
62
63    pub fn persistent_steps(
64        name: impl Into<String>,
65        objects: Vec<String>,
66        steps_per_object: usize,
67    ) -> Self {
68        let total_frames = objects.len().saturating_mul(steps_per_object);
69        Self {
70            name: name.into(),
71            render_path: BenchmarkRenderPath::PersistentSteps,
72            objects,
73            rotations_per_object: 1,
74            viewpoints_per_rotation: 0,
75            steps_per_object: Some(steps_per_object),
76            total_frames,
77        }
78    }
79}
80
81/// Total captures in a fixed-orbit NeoCortx-style workload.
82pub fn fixed_orbit_frame_count(
83    object_count: usize,
84    rotations_per_object: usize,
85    viewpoints_per_rotation: usize,
86) -> usize {
87    object_count
88        .saturating_mul(rotations_per_object)
89        .saturating_mul(viewpoints_per_rotation)
90}
91
92/// Current NeoCortx targeting behavior for fixed-orbit YCB episodes.
93///
94/// NeoCortx uses the rotated mesh center for all fixed-orbit rotations so
95/// object-centered YCB views stay comparable across yaw-only and tilted poses.
96pub fn neocortx_targeting_policy(_rotation: &ObjectRotation) -> TargetingPolicy {
97    TargetingPolicy::MeshCenter
98}
99
100/// Convert a duration to milliseconds as f64.
101pub fn duration_ms(duration: Duration) -> f64 {
102    duration.as_secs_f64() * 1000.0
103}
104
105/// Timing summary over one or more per-frame markers.
106///
107/// Fixed-orbit session runs measure one marker per object/rotation group
108/// (`elapsed_group_ms / viewpoints`). Persistent runs measure one marker per
109/// rendered step.
110#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
111pub struct TimingSummary {
112    pub total_frames: usize,
113    pub measurement_count: usize,
114    pub total_ms: f64,
115    pub frames_per_second: f64,
116    pub mean_ms_per_frame: f64,
117    pub p50_ms_per_frame: f64,
118    pub p95_ms_per_frame: f64,
119    pub min_ms_per_frame: f64,
120    pub max_ms_per_frame: f64,
121}
122
123impl TimingSummary {
124    pub fn empty() -> Self {
125        Self {
126            total_frames: 0,
127            measurement_count: 0,
128            total_ms: 0.0,
129            frames_per_second: 0.0,
130            mean_ms_per_frame: 0.0,
131            p50_ms_per_frame: 0.0,
132            p95_ms_per_frame: 0.0,
133            min_ms_per_frame: 0.0,
134            max_ms_per_frame: 0.0,
135        }
136    }
137}
138
139/// Summarize per-frame timing markers.
140pub fn summarize_timing(
141    per_frame_ms_markers: &[f64],
142    total_frames: usize,
143    total_ms: f64,
144) -> TimingSummary {
145    if per_frame_ms_markers.is_empty() || total_frames == 0 {
146        return TimingSummary::empty();
147    }
148
149    let mean_ms_per_frame = total_ms / total_frames as f64;
150    let frames_per_second = if total_ms > 0.0 {
151        total_frames as f64 / (total_ms / 1000.0)
152    } else {
153        0.0
154    };
155
156    TimingSummary {
157        total_frames,
158        measurement_count: per_frame_ms_markers.len(),
159        total_ms,
160        frames_per_second,
161        mean_ms_per_frame,
162        p50_ms_per_frame: percentile(per_frame_ms_markers, 0.50).unwrap_or(0.0),
163        p95_ms_per_frame: percentile(per_frame_ms_markers, 0.95).unwrap_or(0.0),
164        min_ms_per_frame: per_frame_ms_markers
165            .iter()
166            .copied()
167            .fold(f64::INFINITY, f64::min),
168        max_ms_per_frame: per_frame_ms_markers
169            .iter()
170            .copied()
171            .fold(f64::NEG_INFINITY, f64::max),
172    }
173}
174
175/// Nearest-rank percentile over finite samples.
176pub fn percentile(samples: &[f64], quantile: f64) -> Option<f64> {
177    let mut values: Vec<f64> = samples
178        .iter()
179        .copied()
180        .filter(|value| value.is_finite())
181        .collect();
182    if values.is_empty() {
183        return None;
184    }
185
186    values.sort_by(|a, b| a.total_cmp(b));
187    let q = quantile.clamp(0.0, 1.0);
188    let rank = (q * (values.len().saturating_sub(1)) as f64).ceil() as usize;
189    values.get(rank).copied()
190}