Path: blob/main/crates/bevy_diagnostic/src/diagnostic.rs
6595 views
use alloc::{borrow::Cow, collections::VecDeque, string::String};1use core::{2hash::{Hash, Hasher},3time::Duration,4};56use bevy_app::{App, SubApp};7use bevy_ecs::resource::Resource;8use bevy_ecs::system::{Deferred, Res, SystemBuffer, SystemParam};9use bevy_platform::{collections::HashMap, hash::PassHash, time::Instant};10use const_fnv1a_hash::fnv1a_hash_str_64;1112use crate::DEFAULT_MAX_HISTORY_LENGTH;1314/// Unique diagnostic path, separated by `/`.15///16/// Requirements:17/// - Can't be empty18/// - Can't have leading or trailing `/`19/// - Can't have empty components.20#[derive(Debug, Clone)]21pub struct DiagnosticPath {22path: Cow<'static, str>,23hash: u64,24}2526impl DiagnosticPath {27/// Create a new `DiagnosticPath`. Usable in const contexts.28///29/// **Note**: path is not validated, so make sure it follows all the requirements.30pub const fn const_new(path: &'static str) -> DiagnosticPath {31DiagnosticPath {32path: Cow::Borrowed(path),33hash: fnv1a_hash_str_64(path),34}35}3637/// Create a new `DiagnosticPath` from the specified string.38pub fn new(path: impl Into<Cow<'static, str>>) -> DiagnosticPath {39let path = path.into();4041debug_assert!(!path.is_empty(), "diagnostic path should not be empty");42debug_assert!(43!path.starts_with('/'),44"diagnostic path should not start with `/`"45);46debug_assert!(47!path.ends_with('/'),48"diagnostic path should not end with `/`"49);50debug_assert!(51!path.contains("//"),52"diagnostic path should not contain empty components"53);5455DiagnosticPath {56hash: fnv1a_hash_str_64(&path),57path,58}59}6061/// Create a new `DiagnosticPath` from an iterator over components.62pub fn from_components<'a>(components: impl IntoIterator<Item = &'a str>) -> DiagnosticPath {63let mut buf = String::new();6465for (i, component) in components.into_iter().enumerate() {66if i > 0 {67buf.push('/');68}69buf.push_str(component);70}7172DiagnosticPath::new(buf)73}7475/// Returns full path, joined by `/`76pub fn as_str(&self) -> &str {77&self.path78}7980/// Returns an iterator over path components.81pub fn components(&self) -> impl Iterator<Item = &str> + '_ {82self.path.split('/')83}84}8586impl From<DiagnosticPath> for String {87fn from(path: DiagnosticPath) -> Self {88path.path.into()89}90}9192impl Eq for DiagnosticPath {}9394impl PartialEq for DiagnosticPath {95fn eq(&self, other: &Self) -> bool {96self.hash == other.hash && self.path == other.path97}98}99100impl Hash for DiagnosticPath {101fn hash<H: Hasher>(&self, state: &mut H) {102state.write_u64(self.hash);103}104}105106impl core::fmt::Display for DiagnosticPath {107fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {108self.path.fmt(f)109}110}111112/// A single measurement of a [`Diagnostic`].113#[derive(Debug)]114pub struct DiagnosticMeasurement {115/// When this measurement was taken.116pub time: Instant,117/// Value of the measurement.118pub value: f64,119}120121/// A timeline of [`DiagnosticMeasurement`]s of a specific type.122/// Diagnostic examples: frames per second, CPU usage, network latency123#[derive(Debug)]124pub struct Diagnostic {125path: DiagnosticPath,126/// Suffix to use when logging measurements for this [`Diagnostic`], for example to show units.127pub suffix: Cow<'static, str>,128history: VecDeque<DiagnosticMeasurement>,129sum: f64,130ema: f64,131ema_smoothing_factor: f64,132max_history_length: usize,133/// Disabled [`Diagnostic`]s are not measured or logged.134pub is_enabled: bool,135}136137impl Diagnostic {138/// Add a new value as a [`DiagnosticMeasurement`].139pub fn add_measurement(&mut self, measurement: DiagnosticMeasurement) {140if measurement.value.is_nan() {141// Skip calculating the moving average.142} else if let Some(previous) = self.measurement() {143let delta = (measurement.time - previous.time).as_secs_f64();144let alpha = (delta / self.ema_smoothing_factor).clamp(0.0, 1.0);145self.ema += alpha * (measurement.value - self.ema);146} else {147self.ema = measurement.value;148}149150if self.max_history_length > 1 {151if self.history.len() >= self.max_history_length152&& let Some(removed_diagnostic) = self.history.pop_front()153&& !removed_diagnostic.value.is_nan()154{155self.sum -= removed_diagnostic.value;156}157158if measurement.value.is_finite() {159self.sum += measurement.value;160}161} else {162self.history.clear();163if measurement.value.is_nan() {164self.sum = 0.0;165} else {166self.sum = measurement.value;167}168}169170self.history.push_back(measurement);171}172173/// Create a new diagnostic with the given path.174pub fn new(path: DiagnosticPath) -> Diagnostic {175Diagnostic {176path,177suffix: Cow::Borrowed(""),178history: VecDeque::with_capacity(DEFAULT_MAX_HISTORY_LENGTH),179max_history_length: DEFAULT_MAX_HISTORY_LENGTH,180sum: 0.0,181ema: 0.0,182ema_smoothing_factor: 2.0 / 21.0,183is_enabled: true,184}185}186187/// Set the maximum history length.188#[must_use]189pub fn with_max_history_length(mut self, max_history_length: usize) -> Self {190self.max_history_length = max_history_length;191192// reserve/reserve_exact reserve space for n *additional* elements.193let expected_capacity = self194.max_history_length195.saturating_sub(self.history.capacity());196self.history.reserve_exact(expected_capacity);197self.history.shrink_to(expected_capacity);198self199}200201/// Add a suffix to use when logging the value, can be used to show a unit.202#[must_use]203pub fn with_suffix(mut self, suffix: impl Into<Cow<'static, str>>) -> Self {204self.suffix = suffix.into();205self206}207208/// The smoothing factor used for the exponential smoothing used for209/// [`smoothed`](Self::smoothed).210///211/// If measurements come in less frequently than `smoothing_factor` seconds212/// apart, no smoothing will be applied. As measurements come in more213/// frequently, the smoothing takes a greater effect such that it takes214/// approximately `smoothing_factor` seconds for 83% of an instantaneous215/// change in measurement to e reflected in the smoothed value.216///217/// A smoothing factor of 0.0 will effectively disable smoothing.218#[must_use]219pub fn with_smoothing_factor(mut self, smoothing_factor: f64) -> Self {220self.ema_smoothing_factor = smoothing_factor;221self222}223224/// Get the [`DiagnosticPath`] that identifies this [`Diagnostic`].225pub fn path(&self) -> &DiagnosticPath {226&self.path227}228229/// Get the latest measurement from this diagnostic.230#[inline]231pub fn measurement(&self) -> Option<&DiagnosticMeasurement> {232self.history.back()233}234235/// Get the latest value from this diagnostic.236pub fn value(&self) -> Option<f64> {237self.measurement().map(|measurement| measurement.value)238}239240/// Return the simple moving average of this diagnostic's recent values.241/// N.B. this a cheap operation as the sum is cached.242pub fn average(&self) -> Option<f64> {243if !self.history.is_empty() {244Some(self.sum / self.history.len() as f64)245} else {246None247}248}249250/// Return the exponential moving average of this diagnostic.251///252/// This is by default tuned to behave reasonably well for a typical253/// measurement that changes every frame such as frametime. This can be254/// adjusted using [`with_smoothing_factor`](Self::with_smoothing_factor).255pub fn smoothed(&self) -> Option<f64> {256if !self.history.is_empty() {257Some(self.ema)258} else {259None260}261}262263/// Return the number of elements for this diagnostic.264pub fn history_len(&self) -> usize {265self.history.len()266}267268/// Return the duration between the oldest and most recent values for this diagnostic.269pub fn duration(&self) -> Option<Duration> {270if self.history.len() < 2 {271return None;272}273274let newest = self.history.back()?;275let oldest = self.history.front()?;276Some(newest.time.duration_since(oldest.time))277}278279/// Return the maximum number of elements for this diagnostic.280pub fn get_max_history_length(&self) -> usize {281self.max_history_length282}283284/// All measured values from this [`Diagnostic`], up to the configured maximum history length.285pub fn values(&self) -> impl Iterator<Item = &f64> {286self.history.iter().map(|x| &x.value)287}288289/// All measurements from this [`Diagnostic`], up to the configured maximum history length.290pub fn measurements(&self) -> impl Iterator<Item = &DiagnosticMeasurement> {291self.history.iter()292}293294/// Clear the history of this diagnostic.295pub fn clear_history(&mut self) {296self.history.clear();297self.sum = 0.0;298self.ema = 0.0;299}300}301302/// A collection of [`Diagnostic`]s.303#[derive(Debug, Default, Resource)]304pub struct DiagnosticsStore {305diagnostics: HashMap<DiagnosticPath, Diagnostic, PassHash>,306}307308impl DiagnosticsStore {309/// Add a new [`Diagnostic`].310///311/// If possible, prefer calling [`App::register_diagnostic`].312pub fn add(&mut self, diagnostic: Diagnostic) {313self.diagnostics.insert(diagnostic.path.clone(), diagnostic);314}315316/// Get the [`DiagnosticMeasurement`] with the given [`DiagnosticPath`], if it exists.317pub fn get(&self, path: &DiagnosticPath) -> Option<&Diagnostic> {318self.diagnostics.get(path)319}320321/// Mutably get the [`DiagnosticMeasurement`] with the given [`DiagnosticPath`], if it exists.322pub fn get_mut(&mut self, path: &DiagnosticPath) -> Option<&mut Diagnostic> {323self.diagnostics.get_mut(path)324}325326/// Get the latest [`DiagnosticMeasurement`] from an enabled [`Diagnostic`].327pub fn get_measurement(&self, path: &DiagnosticPath) -> Option<&DiagnosticMeasurement> {328self.diagnostics329.get(path)330.filter(|diagnostic| diagnostic.is_enabled)331.and_then(|diagnostic| diagnostic.measurement())332}333334/// Return an iterator over all [`Diagnostic`]s.335pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {336self.diagnostics.values()337}338339/// Return an iterator over all [`Diagnostic`]s, by mutable reference.340pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Diagnostic> {341self.diagnostics.values_mut()342}343}344345/// Record new [`DiagnosticMeasurement`]'s.346#[derive(SystemParam)]347pub struct Diagnostics<'w, 's> {348store: Res<'w, DiagnosticsStore>,349queue: Deferred<'s, DiagnosticsBuffer>,350}351352impl<'w, 's> Diagnostics<'w, 's> {353/// Add a measurement to an enabled [`Diagnostic`]. The measurement is passed as a function so that354/// it will be evaluated only if the [`Diagnostic`] is enabled. This can be useful if the value is355/// costly to calculate.356pub fn add_measurement<F>(&mut self, path: &DiagnosticPath, value: F)357where358F: FnOnce() -> f64,359{360if self361.store362.get(path)363.is_some_and(|diagnostic| diagnostic.is_enabled)364{365let measurement = DiagnosticMeasurement {366time: Instant::now(),367value: value(),368};369self.queue.0.insert(path.clone(), measurement);370}371}372}373374#[derive(Default)]375struct DiagnosticsBuffer(HashMap<DiagnosticPath, DiagnosticMeasurement, PassHash>);376377impl SystemBuffer for DiagnosticsBuffer {378fn apply(379&mut self,380_system_meta: &bevy_ecs::system::SystemMeta,381world: &mut bevy_ecs::world::World,382) {383let mut diagnostics = world.resource_mut::<DiagnosticsStore>();384for (path, measurement) in self.0.drain() {385if let Some(diagnostic) = diagnostics.get_mut(&path) {386diagnostic.add_measurement(measurement);387}388}389}390}391392/// Extend [`App`] with new `register_diagnostic` function.393pub trait RegisterDiagnostic {394/// Register a new [`Diagnostic`] with an [`App`].395///396/// Will initialize a [`DiagnosticsStore`] if it doesn't exist.397///398/// ```399/// use bevy_app::App;400/// use bevy_diagnostic::{Diagnostic, DiagnosticsPlugin, DiagnosticPath, RegisterDiagnostic};401///402/// const UNIQUE_DIAG_PATH: DiagnosticPath = DiagnosticPath::const_new("foo/bar");403///404/// App::new()405/// .register_diagnostic(Diagnostic::new(UNIQUE_DIAG_PATH))406/// .add_plugins(DiagnosticsPlugin)407/// .run();408/// ```409fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self;410}411412impl RegisterDiagnostic for SubApp {413fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self {414self.init_resource::<DiagnosticsStore>();415let mut diagnostics = self.world_mut().resource_mut::<DiagnosticsStore>();416diagnostics.add(diagnostic);417418self419}420}421422impl RegisterDiagnostic for App {423fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self {424SubApp::register_diagnostic(self.main_mut(), diagnostic);425self426}427}428429#[cfg(test)]430mod tests {431use super::*;432433#[test]434fn test_clear_history() {435const MEASUREMENT: f64 = 20.0;436437let mut diagnostic =438Diagnostic::new(DiagnosticPath::new("test")).with_max_history_length(5);439let mut now = Instant::now();440441for _ in 0..3 {442for _ in 0..5 {443diagnostic.add_measurement(DiagnosticMeasurement {444time: now,445value: MEASUREMENT,446});447// Increase time to test smoothed average.448now += Duration::from_secs(1);449}450assert!((diagnostic.average().unwrap() - MEASUREMENT).abs() < 0.1);451assert!((diagnostic.smoothed().unwrap() - MEASUREMENT).abs() < 0.1);452diagnostic.clear_history();453}454}455}456457458