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/// Yaw-only rotations preserve the historical origin orbit. Pitched or rolled
95/// rotations target the rotated mesh center so the object stays in frame.
96pub fn neocortx_targeting_policy(rotation: &ObjectRotation) -> TargetingPolicy {
97    if rotation.pitch.abs() > f64::EPSILON || rotation.roll.abs() > f64::EPSILON {
98        TargetingPolicy::MeshCenter
99    } else {
100        TargetingPolicy::Origin
101    }
102}
103
104/// Convert a duration to milliseconds as f64.
105pub fn duration_ms(duration: Duration) -> f64 {
106    duration.as_secs_f64() * 1000.0
107}
108
109/// Timing summary over one or more per-frame markers.
110///
111/// Fixed-orbit session runs measure one marker per object/rotation group
112/// (`elapsed_group_ms / viewpoints`). Persistent runs measure one marker per
113/// rendered step.
114#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
115pub struct TimingSummary {
116    pub total_frames: usize,
117    pub measurement_count: usize,
118    pub total_ms: f64,
119    pub frames_per_second: f64,
120    pub mean_ms_per_frame: f64,
121    pub p50_ms_per_frame: f64,
122    pub p95_ms_per_frame: f64,
123    pub min_ms_per_frame: f64,
124    pub max_ms_per_frame: f64,
125}
126
127impl TimingSummary {
128    pub fn empty() -> Self {
129        Self {
130            total_frames: 0,
131            measurement_count: 0,
132            total_ms: 0.0,
133            frames_per_second: 0.0,
134            mean_ms_per_frame: 0.0,
135            p50_ms_per_frame: 0.0,
136            p95_ms_per_frame: 0.0,
137            min_ms_per_frame: 0.0,
138            max_ms_per_frame: 0.0,
139        }
140    }
141}
142
143/// Summarize per-frame timing markers.
144pub fn summarize_timing(
145    per_frame_ms_markers: &[f64],
146    total_frames: usize,
147    total_ms: f64,
148) -> TimingSummary {
149    if per_frame_ms_markers.is_empty() || total_frames == 0 {
150        return TimingSummary::empty();
151    }
152
153    let mean_ms_per_frame = total_ms / total_frames as f64;
154    let frames_per_second = if total_ms > 0.0 {
155        total_frames as f64 / (total_ms / 1000.0)
156    } else {
157        0.0
158    };
159
160    TimingSummary {
161        total_frames,
162        measurement_count: per_frame_ms_markers.len(),
163        total_ms,
164        frames_per_second,
165        mean_ms_per_frame,
166        p50_ms_per_frame: percentile(per_frame_ms_markers, 0.50).unwrap_or(0.0),
167        p95_ms_per_frame: percentile(per_frame_ms_markers, 0.95).unwrap_or(0.0),
168        min_ms_per_frame: per_frame_ms_markers
169            .iter()
170            .copied()
171            .fold(f64::INFINITY, f64::min),
172        max_ms_per_frame: per_frame_ms_markers
173            .iter()
174            .copied()
175            .fold(f64::NEG_INFINITY, f64::max),
176    }
177}
178
179/// Nearest-rank percentile over finite samples.
180pub fn percentile(samples: &[f64], quantile: f64) -> Option<f64> {
181    let mut values: Vec<f64> = samples
182        .iter()
183        .copied()
184        .filter(|value| value.is_finite())
185        .collect();
186    if values.is_empty() {
187        return None;
188    }
189
190    values.sort_by(|a, b| a.total_cmp(b));
191    let q = quantile.clamp(0.0, 1.0);
192    let rank = (q * (values.len().saturating_sub(1)) as f64).ceil() as usize;
193    values.get(rank).copied()
194}