From 8e978518af2bf9f5a19ce060b1f94c67708b06c2 Mon Sep 17 00:00:00 2001 From: Paul Sinnett Date: Sun, 15 Sep 2024 13:50:19 +0100 Subject: [PATCH] faster percentile updates The sorted array used to calculate the 1% and 0.1% lows is generated from a histogram. This is an O(n) operation where n is the number of entries in 1% of sample buffer. The histogram itself is implemented with a SortedList. Values are added and removed by maintaining the buffer as a double ended queue. Since this can also be used to calculate the average each frame without having to re-sum all the values, I have hooked that in as well. I have also adjusted the calculation of the quantiles to more accurately reflect the statistics. --- Runtime/Fps/G_FpsMonitor.cs | 113 +++++++------- Runtime/Util/G_DoubleEndedQueue.cs | 198 ++++++++++++++++++++++++ Runtime/Util/G_DoubleEndedQueue.cs.meta | 11 ++ Runtime/Util/G_Histogram.cs | 158 +++++++++++++++++++ Runtime/Util/G_Histogram.cs.meta | 11 ++ 5 files changed, 434 insertions(+), 57 deletions(-) create mode 100644 Runtime/Util/G_DoubleEndedQueue.cs create mode 100644 Runtime/Util/G_DoubleEndedQueue.cs.meta create mode 100644 Runtime/Util/G_Histogram.cs create mode 100644 Runtime/Util/G_Histogram.cs.meta diff --git a/Runtime/Fps/G_FpsMonitor.cs b/Runtime/Fps/G_FpsMonitor.cs index 5481146..8e8968b 100644 --- a/Runtime/Fps/G_FpsMonitor.cs +++ b/Runtime/Fps/G_FpsMonitor.cs @@ -1,5 +1,5 @@ /* --------------------------------------- - * Author: Martin Pane (martintayx@gmail.com) (@martinTayx) + * Author: Martin Pane (martintayx@gmail.com) (@martinTayx), modified by Paul Sinnett (paul.sinnett@gmail.com) (@paulsinnett) * Contributors: https://github.com/Tayx94/graphy/graphs/contributors * Project: Graphy - Ultimate Stats Monitor * Date: 15-Dec-17 @@ -11,7 +11,7 @@ * Attribution is not required, but it is always welcomed! * -------------------------------------*/ -using System; +using Tayx.Graphy.Utils; using UnityEngine; namespace Tayx.Graphy.Fps @@ -20,15 +20,19 @@ public class G_FpsMonitor : MonoBehaviour { #region Variables -> Private - private short[] m_fpsSamples; + private G_DoubleEndedQueue m_fpsSamples; private short[] m_fpsSamplesSorted; private short m_fpsSamplesCapacity = 1024; private short m_onePercentSamples = 10; - private short m_zero1PercentSamples = 1; private short m_fpsSamplesCount = 0; - private short m_indexSample = 0; - private float m_unscaledDeltaTime = 0f; + private int m_fpsAverageWindowSum = 0; + private G_Histogram m_histogram; + + // This cap prevents the histogram from re-allocating memory in the + // case of an unexpectedly high frame rate. The limit is somewhat + // arbitrary. The only real cost to a higher cap is memory. + private const short m_histogramFpsCap = 999; #endregion @@ -60,63 +64,25 @@ private void Update() uint averageAddedFps = 0; - m_indexSample++; - - if( m_indexSample >= m_fpsSamplesCapacity ) m_indexSample = 0; - - m_fpsSamples[ m_indexSample ] = CurrentFPS; - - if( m_fpsSamplesCount < m_fpsSamplesCapacity ) - { - m_fpsSamplesCount++; - } + m_fpsSamplesCount = UpdateStatistics( CurrentFPS ); - for( int i = 0; i < m_fpsSamplesCount; i++ ) - { - averageAddedFps += (uint) m_fpsSamples[ i ]; - } + averageAddedFps = (uint) m_fpsAverageWindowSum; AverageFPS = (short) ((float) averageAddedFps / (float) m_fpsSamplesCount); // Update percent lows - m_fpsSamples.CopyTo( m_fpsSamplesSorted, 0 ); - - /* - * TODO: Find a faster way to do this. - * We can probably avoid copying the full array every time - * and insert the new item already sorted in the list. - */ - Array.Sort( m_fpsSamplesSorted, - ( x, y ) => x.CompareTo( y ) ); // The lambda expression avoids garbage generation - - bool zero1PercentCalculated = false; - - uint totalAddedFps = 0; - - short samplesToIterateThroughForOnePercent = m_fpsSamplesCount < m_onePercentSamples - ? m_fpsSamplesCount - : m_onePercentSamples; - - short samplesToIterateThroughForZero1Percent = m_fpsSamplesCount < m_zero1PercentSamples - ? m_fpsSamplesCount - : m_zero1PercentSamples; + short samplesBelowOnePercent = (short) Mathf.Min( m_fpsSamplesCount - 1, m_onePercentSamples ); - short sampleToStartIn = (short) (m_fpsSamplesCapacity - m_fpsSamplesCount); + m_histogram.WriteToSortedArray( m_fpsSamplesSorted, samplesBelowOnePercent + 1 ); - for( short i = sampleToStartIn; i < sampleToStartIn + samplesToIterateThroughForOnePercent; i++ ) - { - totalAddedFps += (ushort) m_fpsSamplesSorted[ i ]; - - if( !zero1PercentCalculated && i >= samplesToIterateThroughForZero1Percent - 1 ) - { - zero1PercentCalculated = true; + // Calculate 0.1% and 1% quantiles, these values represent the fps + // values below which fall 0.1% and 1% of the samples within the + // moving window. - Zero1PercentFps = (short) ((float) totalAddedFps / (float) m_zero1PercentSamples); - } - } + Zero1PercentFps = (short) Mathf.RoundToInt( CalculateQuantile( 0.001f ) ); - OnePercentFPS = (short) ((float) totalAddedFps / (float) m_onePercentSamples); + OnePercentFPS = (short) Mathf.RoundToInt( CalculateQuantile( 0.01f ) ); } #endregion @@ -126,7 +92,10 @@ private void Update() public void UpdateParameters() { m_onePercentSamples = (short) (m_fpsSamplesCapacity / 100); - m_zero1PercentSamples = (short) (m_fpsSamplesCapacity / 1000); + if( m_onePercentSamples + 1 > m_fpsSamplesSorted.Length ) + { + m_fpsSamplesSorted = new short[ m_onePercentSamples + 1 ]; + } } #endregion @@ -135,12 +104,42 @@ public void UpdateParameters() private void Init() { - m_fpsSamples = new short[m_fpsSamplesCapacity]; - m_fpsSamplesSorted = new short[m_fpsSamplesCapacity]; - + m_fpsSamples = new G_DoubleEndedQueue( m_fpsSamplesCapacity ); + m_fpsSamplesSorted = new short[ m_onePercentSamples + 1 ]; + m_histogram = new G_Histogram( 0, m_histogramFpsCap ); UpdateParameters(); } + private short UpdateStatistics( short fps ) + { + if( m_fpsSamples.Full ) + { + short remove = m_fpsSamples.PopFront(); + m_fpsAverageWindowSum -= remove; + m_histogram.RemoveSample( remove ); + } + m_fpsSamples.PushBack( fps ); + m_fpsAverageWindowSum += fps; + m_histogram.AddSample( fps ); + return m_fpsSamples.Count; + } + + private float CalculateQuantile( float quantile ) + { + // If there aren't enough samples to calculate the quantile yet, + // this function will instead return the lowest value in the + // histogram. + + short samples = m_fpsSamples.Count; + float position = ( samples + 1 ) * quantile - 1; + short indexLow = (short) ( position > 0 ? Mathf.FloorToInt( position ) : 0 ); + short indexHigh = (short) ( indexLow + 1 < samples? indexLow + 1 : indexLow ); + float valueLow = m_fpsSamplesSorted[ indexLow ]; + float valueHigh = m_fpsSamplesSorted[ indexHigh ]; + float lerp = Mathf.Max( position - indexLow, 0 ); + return Mathf.Lerp( valueLow, valueHigh, lerp ); + } + #endregion } } \ No newline at end of file diff --git a/Runtime/Util/G_DoubleEndedQueue.cs b/Runtime/Util/G_DoubleEndedQueue.cs new file mode 100644 index 0000000..8556fe2 --- /dev/null +++ b/Runtime/Util/G_DoubleEndedQueue.cs @@ -0,0 +1,198 @@ +/* --------------------------------------- + * Author: Paul Sinnett (paul.sinnett@gmail.com) (@paulsinnett) + * Contributors: https://github.com/Tayx94/graphy/graphs/contributors + * Project: Graphy - Ultimate Stats Monitor + * Date: 06-Sep-24 + * Studio: Powered Up Games + * + * Git repo: https://github.com/Tayx94/graphy + * + * This project is released under the MIT license. + * Attribution is not required, but it is always welcomed! + * -------------------------------------*/ + +using UnityEngine.Assertions; + +namespace Tayx.Graphy.Utils +{ + public class G_DoubleEndedQueue + { + #region Variables -> Private + + /// + /// Fixed size array for holding the values. + /// + private short[] m_values; + + /// + /// Index of the head element. + /// + private short m_head; + + /// + /// Index of the entry after the tail element. + /// + private short m_tail; + + /// + /// Number of items in the queue. + /// + private short m_count; + + /// + /// Programming error messages for assert failures. + /// + private const string m_errorEmpty = "queue is empty"; + private const string m_errorFull = "queue is full"; + + #endregion + + #region Properties -> Public + + /// + /// The current number of items in the queue. + /// + public short Count => m_count; + + /// + /// True if the queue is currently at full capacity. + /// + public bool Full => m_count == m_values.Length; + + #endregion + + #region Methods -> Public + + /// + /// Construct a queue. + /// + /// + /// Maximum number of values in the queue. + /// + public G_DoubleEndedQueue( short capacity ) + { + m_values = new short[ capacity ]; + m_head = 0; + m_tail = 0; + m_count = 0; + } + + /// + /// Clear the content of the queue, O(1). + /// + public void Clear() + { + m_head = 0; + m_tail = 0; + m_count = 0; + } + + /// + /// Add a value to the front of the queue, O(1). + /// Asserts that the queue is not already full. + /// + /// + /// The value of the entry. + /// + public void PushFront( short value ) + { + AssertNotFull(); + m_head = Previous( m_head ); + m_values[ m_head ] = value; + m_count++; + } + + /// + /// Add a value to the back of the queue, O(1). + /// Asserts that the queue is not already full. + /// + /// + /// The value of the entry. + /// + public void PushBack( short value ) + { + AssertNotFull(); + m_values[ m_tail ] = value; + m_tail = Next( m_tail ); + m_count++; + } + + /// + /// Removes the value at the front of the queue, O(1). + /// Asserts that the queue is not empty. + /// + /// the removed value + public short PopFront() + { + AssertNotEmpty(); + short value = m_values[ m_head ]; + m_head = Next( m_head ); + m_count--; + return value; + } + + /// + /// Removes the value at the back of the queue, O(1). + /// Asserts that the queue is not empty. + /// + /// the removed value + public short PopBack() + { + AssertNotEmpty(); + m_tail = Previous( m_tail ); + short value = m_values[ m_tail ]; + m_count--; + return value; + } + + /// + /// Returns the value at the front of the queue, O(1). + /// Asserts that the queue is not empty. + /// + /// the value at the front of the queue + public short PeekFront() + { + AssertNotEmpty(); + return m_values[ m_head ]; + } + + /// + /// Returns the value at the back of the queue, O(1). + /// Asserts that the queue is not empty. + /// + /// the value at the back of the queue + public short PeekBack() + { + AssertNotEmpty(); + return m_values[ Previous( m_tail ) ]; + } + + #endregion + + #region Methods -> Private + + void AssertNotEmpty() + { + Assert.IsTrue( m_count > 0, m_errorEmpty ); + } + + void AssertNotFull() + { + Assert.IsTrue( m_count < m_values.Length, m_errorFull ); + } + + short LastIndex => (short) ( m_values.Length - 1 ); + + short Next( short index ) + { + return (short) ( index < LastIndex? index + 1 : 0 ); + } + + short Previous( short index ) + { + return (short) ( index > 0? index - 1 : LastIndex ); + } + + #endregion + } +} \ No newline at end of file diff --git a/Runtime/Util/G_DoubleEndedQueue.cs.meta b/Runtime/Util/G_DoubleEndedQueue.cs.meta new file mode 100644 index 0000000..ded911d --- /dev/null +++ b/Runtime/Util/G_DoubleEndedQueue.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6cee3527c9744c54d858a52fe3b80a32 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Util/G_Histogram.cs b/Runtime/Util/G_Histogram.cs new file mode 100644 index 0000000..7e4173c --- /dev/null +++ b/Runtime/Util/G_Histogram.cs @@ -0,0 +1,158 @@ +/* --------------------------------------- + * Author: Paul Sinnett (paul.sinnett@gmail.com) (@paulsinnett) + * Contributors: https://github.com/Tayx94/graphy/graphs/contributors + * Project: Graphy - Ultimate Stats Monitor + * Date: 06-Sep-24 + * Studio: Powered Up Games + * + * Git repo: https://github.com/Tayx94/graphy + * + * This project is released under the MIT license. + * Attribution is not required, but it is always welcomed! + * -------------------------------------*/ + +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Assertions; + +namespace Tayx.Graphy.Utils +{ + public class G_Histogram + { + #region Variables -> Private + + /// + /// Fixed size array for holding the histogram values. + /// + private SortedList m_histogram; + + /// + /// Total number of data points in the histogram. + /// + private short m_count; + + /// + /// Minimum limit for histogram values. Values below this limit + /// will be clamped to this value. + private short m_minimum; + + /// + /// Maximum limit for histogram values. Values above this limit + /// will be clamped to this value. + private short m_maximum; + + /// + /// Programming error messages for assert failures. + /// + private const string m_errorMinimumLessThanMaximum = "minimum must be less than maximum"; + private const string m_errorSampleNotFound = "sample not found"; + private const string m_errorOutputArrayTooSmall = "output array too small"; + private const string m_errorNotEnoughData = "not enough data in histogram"; + + #endregion + + #region Methods -> Public + + /// + /// Construct a histogram. + /// + /// + /// Values added below this value will be clamped. + /// + /// + /// Values added above this value will be clamped. + /// + public G_Histogram( short minimum, short maximum ) + { + Assert.IsTrue( minimum < maximum, m_errorMinimumLessThanMaximum ); + m_histogram = new SortedList( maximum - minimum ); + m_minimum = minimum; + m_maximum = maximum; + m_count = 0; + } + + /// + /// Add a sample to the histogram, O(log₂ n) where n is the + /// number of distinct sample entries. + /// + /// + /// The sample to add to this histogram. + /// + public void AddSample( short sample ) + { + sample = (short) Mathf.Clamp( sample, m_minimum, m_maximum ); + if( m_histogram.ContainsKey( sample ) ) + { + m_histogram[ sample ]++; + } + else + { + m_histogram.Add( sample, 1 ); + } + m_count++; + } + + /// + /// Remove a sample from the histogram, O(log₂ n) where n is the + /// number of distinct sample values. + /// + /// + /// The sample to remove. + /// + public void RemoveSample( short sample ) + { + sample = (short) Mathf.Clamp( sample, m_minimum, m_maximum ); + Assert.IsTrue( m_histogram.ContainsKey( sample ), m_errorSampleNotFound ); + m_histogram[ sample ]--; + if( m_histogram[ sample ] == 0 ) + { + m_histogram.Remove( sample ); + } + m_count--; + } + + /// + /// Write out the required number of samples in order from lowest + /// to the highest, O(n) where n is the count of samples to write + /// out. + /// + /// + /// An array to write into. + /// + /// + /// The number of samples to write out. + /// + public void WriteToSortedArray( short[] output, int count ) + { + Assert.IsTrue( count <= output.Length, m_errorOutputArrayTooSmall ); + Assert.IsTrue( count <= m_count, m_errorNotEnoughData ); + + int index = 0; + var keys = m_histogram.Keys; + var values = m_histogram.Values; + int entries = keys.Count; + for( short entry = 0; entry < entries && index < count; entry++ ) + { + short sample = keys[ entry ]; + int instances = values[ entry ]; + for( int i = 0; i < instances && index < count; i++ ) + { + output[ index ] = sample; + index++; + } + } + } + + /// + /// Clear the histogram, O(n) where n is the number of entries + /// in the histogram. + /// + public void Clear() + { + m_histogram.Clear(); + m_count = 0; + } + + #endregion + } +} \ No newline at end of file diff --git a/Runtime/Util/G_Histogram.cs.meta b/Runtime/Util/G_Histogram.cs.meta new file mode 100644 index 0000000..a5bce87 --- /dev/null +++ b/Runtime/Util/G_Histogram.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 37599e96045db254aac69d2eace4d109 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: