summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI/Framework')
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs48
-rw-r--r--src/SMAPI/Framework/Events/IManagedEvent.cs15
-rw-r--r--src/SMAPI/Framework/Events/ManagedEvent.cs58
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs34
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs38
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs35
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs125
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs205
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs30
-rw-r--r--src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs184
-rw-r--r--src/SMAPI/Framework/SCore.cs14
-rw-r--r--src/SMAPI/Framework/SGame.cs11
12 files changed, 763 insertions, 34 deletions
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs
index 892cbc7b..a9dfda97 100644
--- a/src/SMAPI/Framework/Events/EventManager.cs
+++ b/src/SMAPI/Framework/Events/EventManager.cs
@@ -1,5 +1,8 @@
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.PerformanceMonitoring;
namespace StardewModdingAPI.Framework.Events
{
@@ -173,28 +176,32 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>Construct an instance.</summary>
/// <param name="monitor">Writes messages to the log.</param>
/// <param name="modRegistry">The mod registry with which to identify mods.</param>
- public EventManager(IMonitor monitor, ModRegistry modRegistry)
+ /// <param name="performanceMonitor">Tracks performance metrics.</param>
+ public EventManager(IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor)
{
// create shortcut initializers
- ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName) => new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry);
+ ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName, bool isPerformanceCritical = false)
+ {
+ return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry, performanceMonitor, isPerformanceCritical);
+ }
// init events (new)
this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged));
- this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering));
- this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered));
- this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld));
- this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld));
- this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu));
- this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu));
- this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud));
- this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud));
+ this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering), isPerformanceCritical: true);
+ this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered), isPerformanceCritical: true);
+ this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld), isPerformanceCritical: true);
+ this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld), isPerformanceCritical: true);
+ this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu), isPerformanceCritical: true);
+ this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu), isPerformanceCritical: true);
+ this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud), isPerformanceCritical: true);
+ this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud), isPerformanceCritical: true);
this.WindowResized = ManageEventOf<WindowResizedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.WindowResized));
this.GameLaunched = ManageEventOf<GameLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.GameLaunched));
- this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking));
- this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked));
- this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking));
- this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked));
+ this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking), isPerformanceCritical: true);
+ this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked), isPerformanceCritical: true);
+ this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking), isPerformanceCritical: true);
+ this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked), isPerformanceCritical: true);
this.SaveCreating = ManageEventOf<SaveCreatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreating));
this.SaveCreated = ManageEventOf<SaveCreatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreated));
this.Saving = ManageEventOf<SavingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saving));
@@ -207,7 +214,7 @@ namespace StardewModdingAPI.Framework.Events
this.ButtonPressed = ManageEventOf<ButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed));
this.ButtonReleased = ManageEventOf<ButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased));
- this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved));
+ this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved), isPerformanceCritical: true);
this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled));
this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived));
@@ -228,8 +235,15 @@ namespace StardewModdingAPI.Framework.Events
this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged));
this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
- this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking));
- this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked));
+ this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true);
+ this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked), isPerformanceCritical: true);
+ }
+
+ /// <summary>Get all managed events.</summary>
+ public IEnumerable<IManagedEvent> GetAllEvents()
+ {
+ foreach (FieldInfo field in this.GetType().GetFields())
+ yield return (IManagedEvent)field.GetValue(this);
}
}
}
diff --git a/src/SMAPI/Framework/Events/IManagedEvent.cs b/src/SMAPI/Framework/Events/IManagedEvent.cs
new file mode 100644
index 00000000..e4e3ca08
--- /dev/null
+++ b/src/SMAPI/Framework/Events/IManagedEvent.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <summary>Metadata for an event raised by SMAPI.</summary>
+ internal interface IManagedEvent
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A human-readable name for the event.</summary>
+ string EventName { get; }
+
+ /// <summary>Whether the event is typically called at least once per second.</summary>
+ bool IsPerformanceCritical { get; }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs
index 2afe7a03..118b73ac 100644
--- a/src/SMAPI/Framework/Events/ManagedEvent.cs
+++ b/src/SMAPI/Framework/Events/ManagedEvent.cs
@@ -1,12 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using StardewModdingAPI.Framework.PerformanceMonitoring;
namespace StardewModdingAPI.Framework.Events
{
/// <summary>An event wrapper which intercepts and logs errors in handler code.</summary>
/// <typeparam name="TEventArgs">The event arguments type.</typeparam>
- internal class ManagedEvent<TEventArgs>
+ internal class ManagedEvent<TEventArgs> : IManagedEvent
{
/*********
** Fields
@@ -14,9 +15,6 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>The underlying event.</summary>
private event EventHandler<TEventArgs> Event;
- /// <summary>A human-readable name for the event.</summary>
- private readonly string EventName;
-
/// <summary>Writes messages to the log.</summary>
private readonly IMonitor Monitor;
@@ -29,6 +27,19 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>The cached invocation list.</summary>
private EventHandler<TEventArgs>[] CachedInvocationList;
+ /// <summary>Tracks performance metrics.</summary>
+ private readonly PerformanceMonitor PerformanceMonitor;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A human-readable name for the event.</summary>
+ public string EventName { get; }
+
+ /// <summary>Whether the event is typically called at least once per second.</summary>
+ public bool IsPerformanceCritical { get; }
+
/*********
** Public methods
@@ -37,11 +48,15 @@ namespace StardewModdingAPI.Framework.Events
/// <param name="eventName">A human-readable name for the event.</param>
/// <param name="monitor">Writes messages to the log.</param>
/// <param name="modRegistry">The mod registry with which to identify mods.</param>
- public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry)
+ /// <param name="performanceMonitor">Tracks performance metrics.</param>
+ /// <param name="isPerformanceCritical">Whether the event is typically called at least once per second.</param>
+ public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor, bool isPerformanceCritical = false)
{
this.EventName = eventName;
this.Monitor = monitor;
this.ModRegistry = modRegistry;
+ this.PerformanceMonitor = performanceMonitor;
+ this.IsPerformanceCritical = isPerformanceCritical;
}
/// <summary>Get whether anything is listening to the event.</summary>
@@ -81,17 +96,21 @@ namespace StardewModdingAPI.Framework.Events
if (this.Event == null)
return;
- foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList)
+
+ this.PerformanceMonitor.Track(this.EventName, () =>
{
- try
+ foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList)
{
- handler.Invoke(null, args);
- }
- catch (Exception ex)
- {
- this.LogError(handler, ex);
+ try
+ {
+ this.PerformanceMonitor.Track(this.EventName, this.GetModNameForPerformanceCounters(handler), () => handler.Invoke(null, args));
+ }
+ catch (Exception ex)
+ {
+ this.LogError(handler, ex);
+ }
}
- }
+ });
}
/// <summary>Raise the event and notify all handlers.</summary>
@@ -122,6 +141,19 @@ namespace StardewModdingAPI.Framework.Events
/*********
** Private methods
*********/
+ /// <summary>Get the mod name for a given event handler to display in performance monitoring reports.</summary>
+ /// <param name="handler">The event handler.</param>
+ private string GetModNameForPerformanceCounters(EventHandler<TEventArgs> handler)
+ {
+ IModMetadata mod = this.GetSourceMod(handler);
+ if (mod == null)
+ return Constants.GamePerformanceCounterName;
+
+ return mod.HasManifest()
+ ? mod.Manifest.UniqueID
+ : mod.DisplayName;
+ }
+
/// <summary>Track an event handler.</summary>
/// <param name="mod">The mod which added the handler.</param>
/// <param name="handler">The event handler.</param>
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs
new file mode 100644
index 00000000..01197f74
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs
@@ -0,0 +1,34 @@
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>The context for an alert.</summary>
+ internal struct AlertContext
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The source which triggered the alert.</summary>
+ public string Source { get; }
+
+ /// <summary>The elapsed milliseconds.</summary>
+ public double Elapsed { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="source">The source which triggered the alert.</param>
+ /// <param name="elapsed">The elapsed milliseconds.</param>
+ public AlertContext(string source, double elapsed)
+ {
+ this.Source = source;
+ this.Elapsed = elapsed;
+ }
+
+ /// <summary>Get a human-readable text form of this instance.</summary>
+ public override string ToString()
+ {
+ return $"{this.Source}: {this.Elapsed:F2}ms";
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs
new file mode 100644
index 00000000..f5b80189
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs
@@ -0,0 +1,38 @@
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>A single alert entry.</summary>
+ internal struct AlertEntry
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The collection in which the alert occurred.</summary>
+ public PerformanceCounterCollection Collection { get; }
+
+ /// <summary>The actual execution time in milliseconds.</summary>
+ public double ExecutionTimeMilliseconds { get; }
+
+ /// <summary>The configured alert threshold in milliseconds.</summary>
+ public double ThresholdMilliseconds { get; }
+
+ /// <summary>The sources involved in exceeding the threshold.</summary>
+ public AlertContext[] Context { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="collection">The collection in which the alert occurred.</param>
+ /// <param name="executionTimeMilliseconds">The actual execution time in milliseconds.</param>
+ /// <param name="thresholdMilliseconds">The configured alert threshold in milliseconds.</param>
+ /// <param name="context">The sources involved in exceeding the threshold.</param>
+ public AlertEntry(PerformanceCounterCollection collection, double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext[] context)
+ {
+ this.Collection = collection;
+ this.ExecutionTimeMilliseconds = executionTimeMilliseconds;
+ this.ThresholdMilliseconds = thresholdMilliseconds;
+ this.Context = context;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs
new file mode 100644
index 00000000..cff502ad
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>A peak invocation time.</summary>
+ internal struct PeakEntry
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The actual execution time in milliseconds.</summary>
+ public double ExecutionTimeMilliseconds { get; }
+
+ /// <summary>When the entry occurred.</summary>
+ public DateTime EventTime { get; }
+
+ /// <summary>The sources involved in exceeding the threshold.</summary>
+ public AlertContext[] Context { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="executionTimeMilliseconds">The actual execution time in milliseconds.</param>
+ /// <param name="eventTime">When the entry occurred.</param>
+ /// <param name="context">The sources involved in exceeding the threshold.</param>
+ public PeakEntry(double executionTimeMilliseconds, DateTime eventTime, AlertContext[] context)
+ {
+ this.ExecutionTimeMilliseconds = executionTimeMilliseconds;
+ this.EventTime = eventTime;
+ this.Context = context;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs
new file mode 100644
index 00000000..3cf668ee
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounter.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Harmony;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>Tracks metadata about a particular code event.</summary>
+ internal class PerformanceCounter
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The size of the ring buffer.</summary>
+ private readonly int MaxEntries = 16384;
+
+ /// <summary>The collection to which this performance counter belongs.</summary>
+ private readonly PerformanceCounterCollection ParentCollection;
+
+ /// <summary>The performance counter entries.</summary>
+ private readonly Stack<PerformanceCounterEntry> Entries;
+
+ /// <summary>The entry with the highest execution time.</summary>
+ private PerformanceCounterEntry? PeakPerformanceCounterEntry;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The name of the source.</summary>
+ public string Source { get; }
+
+ /// <summary>The alert threshold in milliseconds</summary>
+ public double AlertThresholdMilliseconds { get; set; }
+
+ /// <summary>If alerting is enabled or not</summary>
+ public bool EnableAlerts { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="parentCollection">The collection to which this performance counter belongs.</param>
+ /// <param name="source">The name of the source.</param>
+ public PerformanceCounter(PerformanceCounterCollection parentCollection, string source)
+ {
+ this.ParentCollection = parentCollection;
+ this.Source = source;
+ this.Entries = new Stack<PerformanceCounterEntry>(this.MaxEntries);
+ }
+
+ /// <summary>Add a performance counter entry to the list, update monitoring, and raise alerts if needed.</summary>
+ /// <param name="entry">The entry to add.</param>
+ public void Add(PerformanceCounterEntry entry)
+ {
+ // add entry
+ if (this.Entries.Count > this.MaxEntries)
+ this.Entries.Pop();
+ this.Entries.Add(entry);
+
+ // update metrics
+ if (this.PeakPerformanceCounterEntry == null || entry.ElapsedMilliseconds > this.PeakPerformanceCounterEntry.Value.ElapsedMilliseconds)
+ this.PeakPerformanceCounterEntry = entry;
+
+ // raise alert
+ if (this.EnableAlerts && entry.ElapsedMilliseconds > this.AlertThresholdMilliseconds)
+ this.ParentCollection.AddAlert(entry.ElapsedMilliseconds, this.AlertThresholdMilliseconds, new AlertContext(this.Source, entry.ElapsedMilliseconds));
+ }
+
+ /// <summary>Clear all performance counter entries and monitoring.</summary>
+ public void Reset()
+ {
+ this.Entries.Clear();
+ this.PeakPerformanceCounterEntry = null;
+ }
+
+ /// <summary>Get the peak entry.</summary>
+ public PerformanceCounterEntry? GetPeak()
+ {
+ return this.PeakPerformanceCounterEntry;
+ }
+
+ /// <summary>Get the entry with the highest execution time.</summary>
+ /// <param name="range">The time range to search.</param>
+ /// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param>
+ public PerformanceCounterEntry? GetPeak(TimeSpan range, DateTime? endTime = null)
+ {
+ endTime ??= DateTime.UtcNow;
+ DateTime startTime = endTime.Value.Subtract(range);
+
+ return this.Entries
+ .Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime)
+ .OrderByDescending(x => x.ElapsedMilliseconds)
+ .FirstOrDefault();
+ }
+
+ /// <summary>Get the last entry added to the list.</summary>
+ public PerformanceCounterEntry? GetLastEntry()
+ {
+ if (this.Entries.Count == 0)
+ return null;
+
+ return this.Entries.Peek();
+ }
+
+ /// <summary>Get the average over a given time span.</summary>
+ /// <param name="range">The time range to search.</param>
+ /// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param>
+ public double GetAverage(TimeSpan range, DateTime? endTime = null)
+ {
+ endTime ??= DateTime.UtcNow;
+ DateTime startTime = endTime.Value.Subtract(range);
+
+ double[] entries = this.Entries
+ .Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime)
+ .Select(p => p.ElapsedMilliseconds)
+ .ToArray();
+
+ return entries.Length > 0
+ ? entries.Average()
+ : 0;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs
new file mode 100644
index 00000000..0bb78c74
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterCollection.cs
@@ -0,0 +1,205 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ internal class PerformanceCounterCollection
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The number of peak invocations to keep.</summary>
+ private readonly int MaxEntries = 16384;
+
+ /// <summary>The sources involved in exceeding alert thresholds.</summary>
+ private readonly List<AlertContext> TriggeredPerformanceCounters = new List<AlertContext>();
+
+ /// <summary>The stopwatch used to track the invocation time.</summary>
+ private readonly Stopwatch InvocationStopwatch = new Stopwatch();
+
+ /// <summary>The performance counter manager.</summary>
+ private readonly PerformanceMonitor PerformanceMonitor;
+
+ /// <summary>The time to calculate average calls per second.</summary>
+ private DateTime CallsPerSecondStart = DateTime.UtcNow;
+
+ /// <summary>The number of invocations.</summary>
+ private long CallCount;
+
+ /// <summary>The peak invocations.</summary>
+ private readonly Stack<PeakEntry> PeakInvocations;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The associated performance counters.</summary>
+ public IDictionary<string, PerformanceCounter> PerformanceCounters { get; } = new Dictionary<string, PerformanceCounter>();
+
+ /// <summary>The name of this collection.</summary>
+ public string Name { get; }
+
+ /// <summary>Whether the source is typically invoked at least once per second.</summary>
+ public bool IsPerformanceCritical { get; }
+
+ /// <summary>The alert threshold in milliseconds.</summary>
+ public double AlertThresholdMilliseconds { get; set; }
+
+ /// <summary>Whether alerts are enabled.</summary>
+ public bool EnableAlerts { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="performanceMonitor">The performance counter manager.</param>
+ /// <param name="name">The name of this collection.</param>
+ /// <param name="isPerformanceCritical">Whether the source is typically invoked at least once per second.</param>
+ public PerformanceCounterCollection(PerformanceMonitor performanceMonitor, string name, bool isPerformanceCritical = false)
+ {
+ this.PeakInvocations = new Stack<PeakEntry>(this.MaxEntries);
+ this.Name = name;
+ this.PerformanceMonitor = performanceMonitor;
+ this.IsPerformanceCritical = isPerformanceCritical;
+ }
+
+ /// <summary>Track a single invocation for a named source.</summary>
+ /// <param name="source">The name of the source.</param>
+ /// <param name="entry">The entry.</param>
+ public void Track(string source, PerformanceCounterEntry entry)
+ {
+ // add entry
+ if (!this.PerformanceCounters.ContainsKey(source))
+ this.PerformanceCounters.Add(source, new PerformanceCounter(this, source));
+ this.PerformanceCounters[source].Add(entry);
+
+ // raise alert
+ if (this.EnableAlerts)
+ this.TriggeredPerformanceCounters.Add(new AlertContext(source, entry.ElapsedMilliseconds));
+ }
+
+ /// <summary>Get the average execution time for all non-game internal sources in milliseconds.</summary>
+ /// <param name="interval">The interval for which to get the average, relative to now</param>
+ public double GetModsAverageExecutionTime(TimeSpan interval)
+ {
+ return this.PerformanceCounters
+ .Where(entry => entry.Key != Constants.GamePerformanceCounterName)
+ .Sum(entry => entry.Value.GetAverage(interval));
+ }
+
+ /// <summary>Get the overall average execution time in milliseconds.</summary>
+ /// <param name="interval">The interval for which to get the average, relative to now</param>
+ public double GetAverageExecutionTime(TimeSpan interval)
+ {
+ return this.PerformanceCounters
+ .Sum(entry => entry.Value.GetAverage(interval));
+ }
+
+ /// <summary>Get the average execution time for game-internal sources in milliseconds.</summary>
+ public double GetGameAverageExecutionTime(TimeSpan interval)
+ {
+ return this.PerformanceCounters.TryGetValue(Constants.GamePerformanceCounterName, out PerformanceCounter gameExecTime)
+ ? gameExecTime.GetAverage(interval)
+ : 0;
+ }
+
+ /// <summary>Get the peak execution time in milliseconds.</summary>
+ /// <param name="range">The time range to search.</param>
+ /// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param>
+ public double GetPeakExecutionTime(TimeSpan range, DateTime? endTime = null)
+ {
+ if (this.PeakInvocations.Count == 0)
+ return 0;
+
+ endTime ??= DateTime.UtcNow;
+ DateTime startTime = endTime.Value.Subtract(range);
+
+ return this.PeakInvocations
+ .Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime)
+ .OrderByDescending(x => x.ExecutionTimeMilliseconds)
+ .Select(p => p.ExecutionTimeMilliseconds)
+ .FirstOrDefault();
+ }
+
+ /// <summary>Start tracking the invocation of this collection.</summary>
+ public void BeginTrackInvocation()
+ {
+ this.TriggeredPerformanceCounters.Clear();
+ this.InvocationStopwatch.Reset();
+ this.InvocationStopwatch.Start();
+
+ this.CallCount++;
+ }
+
+ /// <summary>End tracking the invocation of this collection, and raise an alert if needed.</summary>
+ public void EndTrackInvocation()
+ {
+ this.InvocationStopwatch.Stop();
+
+ // add invocation
+ if (this.PeakInvocations.Count >= this.MaxEntries)
+ this.PeakInvocations.Pop();
+ this.PeakInvocations.Push(new PeakEntry(this.InvocationStopwatch.Elapsed.TotalMilliseconds, DateTime.UtcNow, this.TriggeredPerformanceCounters.ToArray()));
+
+ // raise alert
+ if (this.EnableAlerts && this.InvocationStopwatch.Elapsed.TotalMilliseconds >= this.AlertThresholdMilliseconds)
+ this.AddAlert(this.InvocationStopwatch.Elapsed.TotalMilliseconds, this.AlertThresholdMilliseconds, this.TriggeredPerformanceCounters.ToArray());
+ }
+
+ /// <summary>Add an alert.</summary>
+ /// <param name="executionTimeMilliseconds">The execution time in milliseconds.</param>
+ /// <param name="thresholdMilliseconds">The configured threshold.</param>
+ /// <param name="alerts">The sources involved in exceeding the threshold.</param>
+ public void AddAlert(double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext[] alerts)
+ {
+ this.PerformanceMonitor.AddAlert(
+ new AlertEntry(this, executionTimeMilliseconds, thresholdMilliseconds, alerts)
+ );
+ }
+
+ /// <summary>Add an alert.</summary>
+ /// <param name="executionTimeMilliseconds">The execution time in milliseconds.</param>
+ /// <param name="thresholdMilliseconds">The configured threshold.</param>
+ /// <param name="alert">The source involved in exceeding the threshold.</param>
+ public void AddAlert(double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext alert)
+ {
+ this.AddAlert(executionTimeMilliseconds, thresholdMilliseconds, new[] { alert });
+ }
+
+ /// <summary>Reset the calls per second counter.</summary>
+ public void ResetCallsPerSecond()
+ {
+ this.CallCount = 0;
+ this.CallsPerSecondStart = DateTime.UtcNow;
+ }
+
+ /// <summary>Reset all performance counters in this collection.</summary>
+ public void Reset()
+ {
+ this.PeakInvocations.Clear();
+ foreach (var counter in this.PerformanceCounters)
+ counter.Value.Reset();
+ }
+
+ /// <summary>Reset the performance counter for a specific source.</summary>
+ /// <param name="source">The source name.</param>
+ public void ResetSource(string source)
+ {
+ foreach (var i in this.PerformanceCounters)
+ if (i.Value.Source.Equals(source, StringComparison.InvariantCultureIgnoreCase))
+ i.Value.Reset();
+ }
+
+ /// <summary>Get the average calls per second.</summary>
+ public long GetAverageCallsPerSecond()
+ {
+ long runtimeInSeconds = (long)DateTime.UtcNow.Subtract(this.CallsPerSecondStart).TotalSeconds;
+ return runtimeInSeconds > 0
+ ? this.CallCount / runtimeInSeconds
+ : 0;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs
new file mode 100644
index 00000000..8adbd88d
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>A single performance counter entry.</summary>
+ internal struct PerformanceCounterEntry
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>When the entry occurred.</summary>
+ public DateTime EventTime { get; }
+
+ /// <summary>The elapsed milliseconds.</summary>
+ public double ElapsedMilliseconds { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="eventTime">When the entry occurred.</param>
+ /// <param name="elapsedMilliseconds">The elapsed milliseconds.</param>
+ public PerformanceCounterEntry(DateTime eventTime, double elapsedMilliseconds)
+ {
+ this.EventTime = eventTime;
+ this.ElapsedMilliseconds = elapsedMilliseconds;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs
new file mode 100644
index 00000000..dfc4f31a
--- /dev/null
+++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceMonitor.cs
@@ -0,0 +1,184 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using StardewModdingAPI.Framework.Events;
+
+namespace StardewModdingAPI.Framework.PerformanceMonitoring
+{
+ /// <summary>Tracks performance metrics.</summary>
+ internal class PerformanceMonitor
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The recorded alerts.</summary>
+ private readonly IList<AlertEntry> Alerts = new List<AlertEntry>();
+
+ /// <summary>The monitor for output logging.</summary>
+ private readonly IMonitor Monitor;
+
+ /// <summary>The invocation stopwatch.</summary>
+ private readonly Stopwatch InvocationStopwatch = new Stopwatch();
+
+ /// <summary>The underlying performance counter collections.</summary>
+ private readonly IDictionary<string, PerformanceCounterCollection> Collections = new Dictionary<string, PerformanceCounterCollection>(StringComparer.InvariantCultureIgnoreCase);
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether alerts are paused.</summary>
+ public bool PauseAlerts { get; set; }
+
+ /// <summary>Whether performance counter tracking is enabled.</summary>
+ public bool EnableTracking { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="monitor">The monitor for output logging.</param>
+ public PerformanceMonitor(IMonitor monitor)
+ {
+ this.Monitor = monitor;
+ }
+
+ /// <summary>Reset all performance counters in all collections.</summary>
+ public void Reset()
+ {
+ foreach (PerformanceCounterCollection collection in this.Collections.Values)
+ collection.Reset();
+ }
+
+ /// <summary>Track the invocation time for a collection.</summary>
+ /// <param name="collectionName">The name of the collection.</param>
+ /// <param name="action">The action to execute and track.</param>
+ public void Track(string collectionName, Action action)
+ {
+ if (!this.EnableTracking)
+ {
+ action();
+ return;
+ }
+
+ PerformanceCounterCollection collection = this.GetOrCreateCollectionByName(collectionName);
+ collection.BeginTrackInvocation();
+ try
+ {
+ action();
+ }
+ finally
+ {
+ collection.EndTrackInvocation();
+ }
+ }
+
+ /// <summary>Track a single performance counter invocation in a specific collection.</summary>
+ /// <param name="collectionName">The name of the collection.</param>
+ /// <param name="sourceName">The name of the source.</param>
+ /// <param name="action">The action to execute and track.</param>
+ public void Track(string collectionName, string sourceName, Action action)
+ {
+ if (!this.EnableTracking)
+ {
+ action();
+ return;
+ }
+
+ PerformanceCounterCollection collection = this.GetOrCreateCollectionByName(collectionName);
+ DateTime eventTime = DateTime.UtcNow;
+ this.InvocationStopwatch.Reset();
+ this.InvocationStopwatch.Start();
+
+ try
+ {
+ action();
+ }
+ finally
+ {
+ this.InvocationStopwatch.Stop();
+ collection.Track(sourceName, new PerformanceCounterEntry(eventTime, this.InvocationStopwatch.Elapsed.TotalMilliseconds));
+ }
+ }
+
+ /// <summary>Reset the performance counters for a specific collection.</summary>
+ /// <param name="name">The collection name.</param>
+ public void ResetCollection(string name)
+ {
+ if (this.Collections.TryGetValue(name, out PerformanceCounterCollection collection))
+ {
+ collection.ResetCallsPerSecond();
+ collection.Reset();
+ }
+ }
+
+ /// <summary>Reset performance counters for a specific source.</summary>
+ /// <param name="name">The name of the source.</param>
+ public void ResetSource(string name)
+ {
+ foreach (PerformanceCounterCollection performanceCounterCollection in this.Collections.Values)
+ performanceCounterCollection.ResetSource(name);
+ }
+
+ /// <summary>Print any queued alerts.</summary>
+ public void PrintQueuedAlerts()
+ {
+ if (this.Alerts.Count == 0)
+ return;
+
+ StringBuilder report = new StringBuilder();
+
+ foreach (AlertEntry alert in this.Alerts)
+ {
+ report.AppendLine($"{alert.Collection.Name} took {alert.ExecutionTimeMilliseconds:F2}ms (exceeded threshold of {alert.ThresholdMilliseconds:F2}ms)");
+
+ foreach (AlertContext context in alert.Context.OrderByDescending(p => p.Elapsed))
+ report.AppendLine(context.ToString());
+ }
+
+ this.Alerts.Clear();
+ this.Monitor.Log(report.ToString(), LogLevel.Error);
+ }
+
+ /// <summary>Add an alert to the queue.</summary>
+ /// <param name="entry">The alert to add.</param>
+ public void AddAlert(AlertEntry entry)
+ {
+ if (!this.PauseAlerts)
+ this.Alerts.Add(entry);
+ }
+
+ /// <summary>Initialize the default performance counter collections.</summary>
+ /// <param name="eventManager">The event manager.</param>
+ public void InitializePerformanceCounterCollections(EventManager eventManager)
+ {
+ foreach (IManagedEvent @event in eventManager.GetAllEvents())
+ this.Collections[@event.EventName] = new PerformanceCounterCollection(this, @event.EventName, @event.IsPerformanceCritical);
+ }
+
+ /// <summary>Get the underlying performance counters.</summary>
+ public IEnumerable<PerformanceCounterCollection> GetCollections()
+ {
+ return this.Collections.Values;
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get a collection by name and creates it if it doesn't exist.</summary>
+ /// <param name="name">The name of the collection.</param>
+ private PerformanceCounterCollection GetOrCreateCollectionByName(string name)
+ {
+ if (!this.Collections.TryGetValue(name, out PerformanceCounterCollection collection))
+ {
+ collection = new PerformanceCounterCollection(this, name);
+ this.Collections[name] = collection;
+ }
+ return collection;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 81b7c2e8..9139b371 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -23,6 +23,7 @@ using StardewModdingAPI.Framework.Models;
using StardewModdingAPI.Framework.ModHelpers;
using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.Patching;
+using StardewModdingAPI.Framework.PerformanceMonitoring;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Serialization;
using StardewModdingAPI.Patches;
@@ -109,7 +110,7 @@ namespace StardewModdingAPI.Framework
"Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.",
#endif
logLevel: LogLevel.Error
- ),
+ ),
// save file not found error
new ReplaceLogPattern(
@@ -133,6 +134,10 @@ namespace StardewModdingAPI.Framework
/// <remarks>This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.</remarks>
internal static DeprecationManager DeprecationManager { get; private set; }
+ /// <summary>Manages performance counters.</summary>
+ /// <remarks>This is initialized after the game starts. This is non-private for use by Console Commands.</remarks>
+ internal static PerformanceMonitor PerformanceMonitor { get; private set; }
+
/*********
** Public methods
@@ -164,7 +169,11 @@ namespace StardewModdingAPI.Framework
ShowFullStampInConsole = this.Settings.DeveloperMode
};
this.MonitorForGame = this.GetSecondaryMonitor("game");
- this.EventManager = new EventManager(this.Monitor, this.ModRegistry);
+
+ SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor);
+ this.EventManager = new EventManager(this.Monitor, this.ModRegistry, SCore.PerformanceMonitor);
+ SCore.PerformanceMonitor.InitializePerformanceCounterCollections(this.EventManager);
+
SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
// redirect direct console output
@@ -242,6 +251,7 @@ namespace StardewModdingAPI.Framework
jsonHelper: this.Toolkit.JsonHelper,
modRegistry: this.ModRegistry,
deprecationManager: SCore.DeprecationManager,
+ performanceMonitor: SCore.PerformanceMonitor,
onGameInitialized: this.InitializeAfterGameStart,
onGameExiting: this.Dispose,
cancellationToken: this.CancellationToken,
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index d6c3b836..4b346059 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -17,6 +17,7 @@ using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Networking;
+using StardewModdingAPI.Framework.PerformanceMonitoring;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.Snapshots;
@@ -58,6 +59,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Manages deprecation warnings.</summary>
private readonly DeprecationManager DeprecationManager;
+ /// <summary>Tracks performance metrics.</summary>
+ private readonly PerformanceMonitor PerformanceMonitor;
+
/// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary>
private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second
@@ -152,11 +156,12 @@ namespace StardewModdingAPI.Framework
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
/// <param name="modRegistry">Tracks the installed mods.</param>
/// <param name="deprecationManager">Manages deprecation warnings.</param>
+ /// <param name="performanceMonitor">Tracks performance metrics.</param>
/// <param name="onGameInitialized">A callback to invoke after the game finishes initializing.</param>
/// <param name="onGameExiting">A callback to invoke when the game exits.</param>
/// <param name="cancellationToken">Propagates notification that SMAPI should exit.</param>
/// <param name="logNetworkTraffic">Whether to log network traffic.</param>
- internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic)
+ internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, PerformanceMonitor performanceMonitor, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic)
{
this.OnLoadingFirstAsset = SGame.ConstructorHack.OnLoadingFirstAsset;
SGame.ConstructorHack = null;
@@ -176,6 +181,7 @@ namespace StardewModdingAPI.Framework
this.Reflection = reflection;
this.Translator = translator;
this.DeprecationManager = deprecationManager;
+ this.PerformanceMonitor = performanceMonitor;
this.OnGameInitialized = onGameInitialized;
this.OnGameExiting = onGameExiting;
Game1.input = new SInputState();
@@ -307,6 +313,7 @@ namespace StardewModdingAPI.Framework
try
{
this.DeprecationManager.PrintQueued();
+ this.PerformanceMonitor.PrintQueuedAlerts();
/*********
** First-tick initialization
@@ -382,7 +389,7 @@ namespace StardewModdingAPI.Framework
// state while mods are running their code. This is risky, because data changes can
// conflict (e.g. collection changed during enumeration errors) and data may change
// unexpectedly from one mod instruction to the next.
- //
+ //
// Therefore we can just run Game1.Update here without raising any SMAPI events. There's
// a small chance that the task will finish after we defer but before the game checks,
// which means technically events should be raised, but the effects of missing one