Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
bevyengine
GitHub Repository: bevyengine/bevy
Path: blob/main/crates/bevy_diagnostic/src/diagnostic.rs
6595 views
1
use alloc::{borrow::Cow, collections::VecDeque, string::String};
2
use core::{
3
hash::{Hash, Hasher},
4
time::Duration,
5
};
6
7
use bevy_app::{App, SubApp};
8
use bevy_ecs::resource::Resource;
9
use bevy_ecs::system::{Deferred, Res, SystemBuffer, SystemParam};
10
use bevy_platform::{collections::HashMap, hash::PassHash, time::Instant};
11
use const_fnv1a_hash::fnv1a_hash_str_64;
12
13
use crate::DEFAULT_MAX_HISTORY_LENGTH;
14
15
/// Unique diagnostic path, separated by `/`.
16
///
17
/// Requirements:
18
/// - Can't be empty
19
/// - Can't have leading or trailing `/`
20
/// - Can't have empty components.
21
#[derive(Debug, Clone)]
22
pub struct DiagnosticPath {
23
path: Cow<'static, str>,
24
hash: u64,
25
}
26
27
impl DiagnosticPath {
28
/// Create a new `DiagnosticPath`. Usable in const contexts.
29
///
30
/// **Note**: path is not validated, so make sure it follows all the requirements.
31
pub const fn const_new(path: &'static str) -> DiagnosticPath {
32
DiagnosticPath {
33
path: Cow::Borrowed(path),
34
hash: fnv1a_hash_str_64(path),
35
}
36
}
37
38
/// Create a new `DiagnosticPath` from the specified string.
39
pub fn new(path: impl Into<Cow<'static, str>>) -> DiagnosticPath {
40
let path = path.into();
41
42
debug_assert!(!path.is_empty(), "diagnostic path should not be empty");
43
debug_assert!(
44
!path.starts_with('/'),
45
"diagnostic path should not start with `/`"
46
);
47
debug_assert!(
48
!path.ends_with('/'),
49
"diagnostic path should not end with `/`"
50
);
51
debug_assert!(
52
!path.contains("//"),
53
"diagnostic path should not contain empty components"
54
);
55
56
DiagnosticPath {
57
hash: fnv1a_hash_str_64(&path),
58
path,
59
}
60
}
61
62
/// Create a new `DiagnosticPath` from an iterator over components.
63
pub fn from_components<'a>(components: impl IntoIterator<Item = &'a str>) -> DiagnosticPath {
64
let mut buf = String::new();
65
66
for (i, component) in components.into_iter().enumerate() {
67
if i > 0 {
68
buf.push('/');
69
}
70
buf.push_str(component);
71
}
72
73
DiagnosticPath::new(buf)
74
}
75
76
/// Returns full path, joined by `/`
77
pub fn as_str(&self) -> &str {
78
&self.path
79
}
80
81
/// Returns an iterator over path components.
82
pub fn components(&self) -> impl Iterator<Item = &str> + '_ {
83
self.path.split('/')
84
}
85
}
86
87
impl From<DiagnosticPath> for String {
88
fn from(path: DiagnosticPath) -> Self {
89
path.path.into()
90
}
91
}
92
93
impl Eq for DiagnosticPath {}
94
95
impl PartialEq for DiagnosticPath {
96
fn eq(&self, other: &Self) -> bool {
97
self.hash == other.hash && self.path == other.path
98
}
99
}
100
101
impl Hash for DiagnosticPath {
102
fn hash<H: Hasher>(&self, state: &mut H) {
103
state.write_u64(self.hash);
104
}
105
}
106
107
impl core::fmt::Display for DiagnosticPath {
108
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
109
self.path.fmt(f)
110
}
111
}
112
113
/// A single measurement of a [`Diagnostic`].
114
#[derive(Debug)]
115
pub struct DiagnosticMeasurement {
116
/// When this measurement was taken.
117
pub time: Instant,
118
/// Value of the measurement.
119
pub value: f64,
120
}
121
122
/// A timeline of [`DiagnosticMeasurement`]s of a specific type.
123
/// Diagnostic examples: frames per second, CPU usage, network latency
124
#[derive(Debug)]
125
pub struct Diagnostic {
126
path: DiagnosticPath,
127
/// Suffix to use when logging measurements for this [`Diagnostic`], for example to show units.
128
pub suffix: Cow<'static, str>,
129
history: VecDeque<DiagnosticMeasurement>,
130
sum: f64,
131
ema: f64,
132
ema_smoothing_factor: f64,
133
max_history_length: usize,
134
/// Disabled [`Diagnostic`]s are not measured or logged.
135
pub is_enabled: bool,
136
}
137
138
impl Diagnostic {
139
/// Add a new value as a [`DiagnosticMeasurement`].
140
pub fn add_measurement(&mut self, measurement: DiagnosticMeasurement) {
141
if measurement.value.is_nan() {
142
// Skip calculating the moving average.
143
} else if let Some(previous) = self.measurement() {
144
let delta = (measurement.time - previous.time).as_secs_f64();
145
let alpha = (delta / self.ema_smoothing_factor).clamp(0.0, 1.0);
146
self.ema += alpha * (measurement.value - self.ema);
147
} else {
148
self.ema = measurement.value;
149
}
150
151
if self.max_history_length > 1 {
152
if self.history.len() >= self.max_history_length
153
&& let Some(removed_diagnostic) = self.history.pop_front()
154
&& !removed_diagnostic.value.is_nan()
155
{
156
self.sum -= removed_diagnostic.value;
157
}
158
159
if measurement.value.is_finite() {
160
self.sum += measurement.value;
161
}
162
} else {
163
self.history.clear();
164
if measurement.value.is_nan() {
165
self.sum = 0.0;
166
} else {
167
self.sum = measurement.value;
168
}
169
}
170
171
self.history.push_back(measurement);
172
}
173
174
/// Create a new diagnostic with the given path.
175
pub fn new(path: DiagnosticPath) -> Diagnostic {
176
Diagnostic {
177
path,
178
suffix: Cow::Borrowed(""),
179
history: VecDeque::with_capacity(DEFAULT_MAX_HISTORY_LENGTH),
180
max_history_length: DEFAULT_MAX_HISTORY_LENGTH,
181
sum: 0.0,
182
ema: 0.0,
183
ema_smoothing_factor: 2.0 / 21.0,
184
is_enabled: true,
185
}
186
}
187
188
/// Set the maximum history length.
189
#[must_use]
190
pub fn with_max_history_length(mut self, max_history_length: usize) -> Self {
191
self.max_history_length = max_history_length;
192
193
// reserve/reserve_exact reserve space for n *additional* elements.
194
let expected_capacity = self
195
.max_history_length
196
.saturating_sub(self.history.capacity());
197
self.history.reserve_exact(expected_capacity);
198
self.history.shrink_to(expected_capacity);
199
self
200
}
201
202
/// Add a suffix to use when logging the value, can be used to show a unit.
203
#[must_use]
204
pub fn with_suffix(mut self, suffix: impl Into<Cow<'static, str>>) -> Self {
205
self.suffix = suffix.into();
206
self
207
}
208
209
/// The smoothing factor used for the exponential smoothing used for
210
/// [`smoothed`](Self::smoothed).
211
///
212
/// If measurements come in less frequently than `smoothing_factor` seconds
213
/// apart, no smoothing will be applied. As measurements come in more
214
/// frequently, the smoothing takes a greater effect such that it takes
215
/// approximately `smoothing_factor` seconds for 83% of an instantaneous
216
/// change in measurement to e reflected in the smoothed value.
217
///
218
/// A smoothing factor of 0.0 will effectively disable smoothing.
219
#[must_use]
220
pub fn with_smoothing_factor(mut self, smoothing_factor: f64) -> Self {
221
self.ema_smoothing_factor = smoothing_factor;
222
self
223
}
224
225
/// Get the [`DiagnosticPath`] that identifies this [`Diagnostic`].
226
pub fn path(&self) -> &DiagnosticPath {
227
&self.path
228
}
229
230
/// Get the latest measurement from this diagnostic.
231
#[inline]
232
pub fn measurement(&self) -> Option<&DiagnosticMeasurement> {
233
self.history.back()
234
}
235
236
/// Get the latest value from this diagnostic.
237
pub fn value(&self) -> Option<f64> {
238
self.measurement().map(|measurement| measurement.value)
239
}
240
241
/// Return the simple moving average of this diagnostic's recent values.
242
/// N.B. this a cheap operation as the sum is cached.
243
pub fn average(&self) -> Option<f64> {
244
if !self.history.is_empty() {
245
Some(self.sum / self.history.len() as f64)
246
} else {
247
None
248
}
249
}
250
251
/// Return the exponential moving average of this diagnostic.
252
///
253
/// This is by default tuned to behave reasonably well for a typical
254
/// measurement that changes every frame such as frametime. This can be
255
/// adjusted using [`with_smoothing_factor`](Self::with_smoothing_factor).
256
pub fn smoothed(&self) -> Option<f64> {
257
if !self.history.is_empty() {
258
Some(self.ema)
259
} else {
260
None
261
}
262
}
263
264
/// Return the number of elements for this diagnostic.
265
pub fn history_len(&self) -> usize {
266
self.history.len()
267
}
268
269
/// Return the duration between the oldest and most recent values for this diagnostic.
270
pub fn duration(&self) -> Option<Duration> {
271
if self.history.len() < 2 {
272
return None;
273
}
274
275
let newest = self.history.back()?;
276
let oldest = self.history.front()?;
277
Some(newest.time.duration_since(oldest.time))
278
}
279
280
/// Return the maximum number of elements for this diagnostic.
281
pub fn get_max_history_length(&self) -> usize {
282
self.max_history_length
283
}
284
285
/// All measured values from this [`Diagnostic`], up to the configured maximum history length.
286
pub fn values(&self) -> impl Iterator<Item = &f64> {
287
self.history.iter().map(|x| &x.value)
288
}
289
290
/// All measurements from this [`Diagnostic`], up to the configured maximum history length.
291
pub fn measurements(&self) -> impl Iterator<Item = &DiagnosticMeasurement> {
292
self.history.iter()
293
}
294
295
/// Clear the history of this diagnostic.
296
pub fn clear_history(&mut self) {
297
self.history.clear();
298
self.sum = 0.0;
299
self.ema = 0.0;
300
}
301
}
302
303
/// A collection of [`Diagnostic`]s.
304
#[derive(Debug, Default, Resource)]
305
pub struct DiagnosticsStore {
306
diagnostics: HashMap<DiagnosticPath, Diagnostic, PassHash>,
307
}
308
309
impl DiagnosticsStore {
310
/// Add a new [`Diagnostic`].
311
///
312
/// If possible, prefer calling [`App::register_diagnostic`].
313
pub fn add(&mut self, diagnostic: Diagnostic) {
314
self.diagnostics.insert(diagnostic.path.clone(), diagnostic);
315
}
316
317
/// Get the [`DiagnosticMeasurement`] with the given [`DiagnosticPath`], if it exists.
318
pub fn get(&self, path: &DiagnosticPath) -> Option<&Diagnostic> {
319
self.diagnostics.get(path)
320
}
321
322
/// Mutably get the [`DiagnosticMeasurement`] with the given [`DiagnosticPath`], if it exists.
323
pub fn get_mut(&mut self, path: &DiagnosticPath) -> Option<&mut Diagnostic> {
324
self.diagnostics.get_mut(path)
325
}
326
327
/// Get the latest [`DiagnosticMeasurement`] from an enabled [`Diagnostic`].
328
pub fn get_measurement(&self, path: &DiagnosticPath) -> Option<&DiagnosticMeasurement> {
329
self.diagnostics
330
.get(path)
331
.filter(|diagnostic| diagnostic.is_enabled)
332
.and_then(|diagnostic| diagnostic.measurement())
333
}
334
335
/// Return an iterator over all [`Diagnostic`]s.
336
pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
337
self.diagnostics.values()
338
}
339
340
/// Return an iterator over all [`Diagnostic`]s, by mutable reference.
341
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Diagnostic> {
342
self.diagnostics.values_mut()
343
}
344
}
345
346
/// Record new [`DiagnosticMeasurement`]'s.
347
#[derive(SystemParam)]
348
pub struct Diagnostics<'w, 's> {
349
store: Res<'w, DiagnosticsStore>,
350
queue: Deferred<'s, DiagnosticsBuffer>,
351
}
352
353
impl<'w, 's> Diagnostics<'w, 's> {
354
/// Add a measurement to an enabled [`Diagnostic`]. The measurement is passed as a function so that
355
/// it will be evaluated only if the [`Diagnostic`] is enabled. This can be useful if the value is
356
/// costly to calculate.
357
pub fn add_measurement<F>(&mut self, path: &DiagnosticPath, value: F)
358
where
359
F: FnOnce() -> f64,
360
{
361
if self
362
.store
363
.get(path)
364
.is_some_and(|diagnostic| diagnostic.is_enabled)
365
{
366
let measurement = DiagnosticMeasurement {
367
time: Instant::now(),
368
value: value(),
369
};
370
self.queue.0.insert(path.clone(), measurement);
371
}
372
}
373
}
374
375
#[derive(Default)]
376
struct DiagnosticsBuffer(HashMap<DiagnosticPath, DiagnosticMeasurement, PassHash>);
377
378
impl SystemBuffer for DiagnosticsBuffer {
379
fn apply(
380
&mut self,
381
_system_meta: &bevy_ecs::system::SystemMeta,
382
world: &mut bevy_ecs::world::World,
383
) {
384
let mut diagnostics = world.resource_mut::<DiagnosticsStore>();
385
for (path, measurement) in self.0.drain() {
386
if let Some(diagnostic) = diagnostics.get_mut(&path) {
387
diagnostic.add_measurement(measurement);
388
}
389
}
390
}
391
}
392
393
/// Extend [`App`] with new `register_diagnostic` function.
394
pub trait RegisterDiagnostic {
395
/// Register a new [`Diagnostic`] with an [`App`].
396
///
397
/// Will initialize a [`DiagnosticsStore`] if it doesn't exist.
398
///
399
/// ```
400
/// use bevy_app::App;
401
/// use bevy_diagnostic::{Diagnostic, DiagnosticsPlugin, DiagnosticPath, RegisterDiagnostic};
402
///
403
/// const UNIQUE_DIAG_PATH: DiagnosticPath = DiagnosticPath::const_new("foo/bar");
404
///
405
/// App::new()
406
/// .register_diagnostic(Diagnostic::new(UNIQUE_DIAG_PATH))
407
/// .add_plugins(DiagnosticsPlugin)
408
/// .run();
409
/// ```
410
fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self;
411
}
412
413
impl RegisterDiagnostic for SubApp {
414
fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self {
415
self.init_resource::<DiagnosticsStore>();
416
let mut diagnostics = self.world_mut().resource_mut::<DiagnosticsStore>();
417
diagnostics.add(diagnostic);
418
419
self
420
}
421
}
422
423
impl RegisterDiagnostic for App {
424
fn register_diagnostic(&mut self, diagnostic: Diagnostic) -> &mut Self {
425
SubApp::register_diagnostic(self.main_mut(), diagnostic);
426
self
427
}
428
}
429
430
#[cfg(test)]
431
mod tests {
432
use super::*;
433
434
#[test]
435
fn test_clear_history() {
436
const MEASUREMENT: f64 = 20.0;
437
438
let mut diagnostic =
439
Diagnostic::new(DiagnosticPath::new("test")).with_max_history_length(5);
440
let mut now = Instant::now();
441
442
for _ in 0..3 {
443
for _ in 0..5 {
444
diagnostic.add_measurement(DiagnosticMeasurement {
445
time: now,
446
value: MEASUREMENT,
447
});
448
// Increase time to test smoothed average.
449
now += Duration::from_secs(1);
450
}
451
assert!((diagnostic.average().unwrap() - MEASUREMENT).abs() < 0.1);
452
assert!((diagnostic.smoothed().unwrap() - MEASUREMENT).abs() < 0.1);
453
diagnostic.clear_history();
454
}
455
}
456
}
457
458