From 5f9d18615034fbf5be583e79ffa40c6e388e2221 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sun, 25 Oct 2026 13:34:50 +0000 Subject: [PATCH] Cut idle CPU with a busy cluster feed and many windows open Found by tracing the app with a fake skimmer feed, a fake rigctld and eight tool windows open (about 3,500 spots on the bandmap). - Drop expired spots from the one-second spot timer instead of from each window's refresh on the UI thread. - Bandmap keeps the lists On and All return until the spots change, and Near searches only the band the frequency is in. - The Available window replaces its rows at most every two seconds, the Score window redraws at most once a second, and the Telnet list takes new lines four times a second at most (RateLimited). - The bandmap leader line pen is made once. Idle CPU in that setup went from about 33-70% of one core to about 16-25%. Co-Authored-By: Claude Sonnet 5.5 --- src/Nonemm.App/AppSession.cs | 3 + .../Windows/AvailableWindow.axaml.cs | 18 ++++-- src/Nonemm.App/Windows/BandmapView.cs | 16 ++++- src/Nonemm.App/Windows/BandmapWindow.axaml.cs | 1 - src/Nonemm.App/Windows/RateLimited.cs | 62 +++++++++++++++++++ src/Nonemm.App/Windows/ScoreWindow.axaml.cs | 8 +++ src/Nonemm.App/Windows/TelnetWindow.axaml.cs | 10 ++- src/Nonemm.Spotting/Bandmap.cs | 43 +++++++++++-- 8 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 src/Nonemm.App/Windows/RateLimited.cs diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 8ee6bb8..c39b6c1 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -1018,6 +1018,9 @@ public sealed partial class AppSession : IDisposable /// put on the bandmap in one go. private void FlushSpots() { + // here and not in the windows: it scans every spot, and it only has to + // happen as often as this timer + Bandmap.DropOlderThan(DateTime.UtcNow); List batch; lock (arrivingGuard) { diff --git a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs index eb683f8..6d28d51 100644 --- a/src/Nonemm.App/Windows/AvailableWindow.axaml.cs +++ b/src/Nonemm.App/Windows/AvailableWindow.axaml.cs @@ -37,6 +37,11 @@ public sealed partial class AvailableWindow : RefreshableWindow private readonly AppSession session; private readonly Action tune; private readonly DispatcherTimer timer = new() { Interval = TimeSpan.FromSeconds(1) }; + private readonly RateLimited limited; + + /// What the entry window's changes ask for. The window's own controls and + /// its timer call `Refresh` directly. + public override void RefreshFromSession() => limited.Request(); /// The bands a click in the summary table limits the list to. Empty is /// every band. @@ -77,10 +82,16 @@ public sealed partial class AvailableWindow : RefreshableWindow Spots.PointerExited += (_, _) => Resume(); Spots.LoadingRow += (_, e) => Paint(e.Row); Spots.ContextMenu = BuildMenu(); - Coalesced changed = new(Refresh); - EventHandler spotsChanged = (_, _) => changed.Request(); + // replacing the grid's rows is the dearest thing this window does, and + // spots arrive and the radio moves many times a second + limited = new RateLimited(TimeSpan.FromSeconds(2), Refresh); + EventHandler spotsChanged = (_, _) => limited.Request(); session.Bandmap.Changed += spotsChanged; - Closed += (_, _) => session.Bandmap.Changed -= spotsChanged; + Closed += (_, _) => + { + session.Bandmap.Changed -= spotsChanged; + limited.Cancel(); + }; timer.Tick += (_, _) => OnTick(); timer.Start(); Closed += (_, _) => timer.Stop(); @@ -277,7 +288,6 @@ public sealed partial class AvailableWindow : RefreshableWindow Frequency? tuned = session.Position?.Frequency; IReadOnlyList contestModes = session.Position?.Contest.Modes ?? []; bool summaryShown = Summary.IsVisible; - bandmap.DropOlderThan(now); IReadOnlyList all = await available.AllAsync(); Computed computed = await Task.Run(() => Compute(all, now, settings, plan, bandmap, picked, tuned, contestModes, summaryShown)); if (again) diff --git a/src/Nonemm.App/Windows/BandmapView.cs b/src/Nonemm.App/Windows/BandmapView.cs index b3f305f..8d00812 100644 --- a/src/Nonemm.App/Windows/BandmapView.cs +++ b/src/Nonemm.App/Windows/BandmapView.cs @@ -199,7 +199,7 @@ public sealed class BandmapView : Control { IBrush colour = ColourOf(label.Spot); context.DrawLine( - new Pen(Opaque(ink, 0.55), 1), + LeaderPen(ink), new Point(ScaleX + 2, label.MarkY), new Point(LabelX - 2, label.LabelY + (LabelHeight / 2))); context.DrawText( @@ -285,6 +285,20 @@ public sealed class BandmapView : Control private IBrush Foreground() => this.FindResource("FormForeground") as IBrush ?? Brushes.Gray; + private Pen? leaderPen; + private IBrush? leaderInk; + + /// The line from the scale to a callsign. Every label has the same one. + private Pen LeaderPen(IBrush ink) + { + if (leaderPen is null || !ReferenceEquals(leaderInk, ink)) + { + leaderPen = new Pen(Opaque(ink, 0.55), 1); + leaderInk = ink; + } + return leaderPen; + } + private readonly Dictionary<(IBrush Ink, double Opacity), IBrush> faded = []; /// Kept, so the same brush comes back each time and the text drawn with it diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs index 94bdbb8..0013d1f 100644 --- a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs +++ b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs @@ -110,7 +110,6 @@ public sealed partial class BandmapWindow : RefreshableWindow public override void Refresh() { - session.Bandmap.DropOlderThan(DateTime.UtcNow); Band? band = session.Position is null ? null : Bands.ForFrequency(session.Position.Frequency); if (session.Position is null || band is null) { diff --git a/src/Nonemm.App/Windows/RateLimited.cs b/src/Nonemm.App/Windows/RateLimited.cs new file mode 100644 index 0000000..cd49a27 --- /dev/null +++ b/src/Nonemm.App/Windows/RateLimited.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using Avalonia.Threading; + +namespace Nonemm.App.Windows; + +/// Runs an action on the UI thread at most once in `every`. The first request +/// after a quiet spell runs at once; the ones that follow within `every` are +/// answered by one run when it is up. It is for windows that redraw a whole +/// list, where following every change costs more than it shows. +public sealed class RateLimited +{ + private readonly TimeSpan every; + private readonly Action action; + private readonly DispatcherTimer timer = new(); + private readonly Coalesced onUiThread; + private long lastRun = long.MinValue; + private bool cancelled; + + public RateLimited(TimeSpan every, Action action) + { + this.every = every; + this.action = action; + onUiThread = new Coalesced(Ask); + timer.Tick += (_, _) => + { + timer.Stop(); + Run(); + }; + } + + /// Safe to call from any thread. + public void Request() => onUiThread.Request(); + + /// For a window that has closed. + public void Cancel() + { + cancelled = true; + timer.Stop(); + } + + private void Ask() + { + if (cancelled || timer.IsEnabled) + { + return; + } + TimeSpan since = lastRun == long.MinValue ? every : Stopwatch.GetElapsedTime(lastRun); + if (since >= every) + { + Run(); + return; + } + timer.Interval = every - since; + timer.Start(); + } + + private void Run() + { + lastRun = Stopwatch.GetTimestamp(); + action(); + } +} diff --git a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs index 70bed7c..1a99a50 100644 --- a/src/Nonemm.App/Windows/ScoreWindow.axaml.cs +++ b/src/Nonemm.App/Windows/ScoreWindow.axaml.cs @@ -11,13 +11,21 @@ public sealed partial class ScoreWindow : RefreshableWindow private readonly AppSession session; private int columnCount = 4; + private readonly RateLimited limited; + public ScoreWindow(AppSession session) { this.session = session; InitializeComponent(); + limited = new RateLimited(TimeSpan.FromSeconds(1), Refresh); + Closed += (_, _) => limited.Cancel(); Refresh(); } + /// The figures change when a contact is logged, not as the radio is tuned, + /// so the entry window's changes redraw this at most once a second. + public override void RefreshFromSession() => limited.Request(); + /// What the table and the total line showed last, so a refresh that /// changes nothing leaves the controls alone. diff --git a/src/Nonemm.App/Windows/TelnetWindow.axaml.cs b/src/Nonemm.App/Windows/TelnetWindow.axaml.cs index 3c1196c..6e52076 100644 --- a/src/Nonemm.App/Windows/TelnetWindow.axaml.cs +++ b/src/Nonemm.App/Windows/TelnetWindow.axaml.cs @@ -43,7 +43,11 @@ public sealed partial class TelnetWindow : RefreshableWindow LoadClusters(); LoadFilters(); Listen(session.Cluster); - Closed += (_, _) => Forget(session.Cluster); + Closed += (_, _) => + { + Forget(session.Cluster); + flushWaiting?.Cancel(); + }; Refresh(); } @@ -150,12 +154,12 @@ public sealed partial class TelnetWindow : RefreshableWindow private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() => { waiting.Add((line, colour, strong)); - flushWaiting ??= new Coalesced(AddWaiting); + flushWaiting ??= new RateLimited(TimeSpan.FromMilliseconds(250), AddWaiting); flushWaiting.Request(); }); private readonly List<(string Line, IBrush? Colour, bool Strong)> waiting = []; - private Coalesced? flushWaiting; + private RateLimited? flushWaiting; private void AddWaiting() { diff --git a/src/Nonemm.Spotting/Bandmap.cs b/src/Nonemm.Spotting/Bandmap.cs index 7503751..d3ed827 100644 --- a/src/Nonemm.Spotting/Bandmap.cs +++ b/src/Nonemm.Spotting/Bandmap.cs @@ -20,6 +20,13 @@ public sealed class Bandmap private readonly Lock guard = new(); private readonly Func clock; + /// The lists `On` and `All` hand out, kept until the next change. The + /// windows ask on every refresh, a refresh can come many times a second + /// while the spots change once a second, and each answer is a scan and a + /// sort of every spot. + private readonly Dictionary> onBand = []; + private IReadOnlyList? everything; + public Bandmap(TimeSpan? lifetime = null, Func? clock = null) { Lifetime = lifetime ?? TimeSpan.FromHours(1); @@ -43,6 +50,7 @@ public sealed class Bandmap { spots[key] = spot; arrived[key] = clock(); + Forget(); } Changed?.Invoke(this, EventArgs.Empty); } @@ -61,6 +69,7 @@ public sealed class Bandmap arrived[key] = clock(); added++; } + Forget(); } if (added > 0) { @@ -76,6 +85,7 @@ public sealed class Bandmap { arrived.Remove(key); removed = spots.Remove(key); + Forget(); } if (removed) { @@ -99,6 +109,10 @@ public sealed class Bandmap arrived.Remove(key); } dropped = stale.Count; + if (dropped > 0) + { + Forget(); + } } if (dropped > 0) { @@ -119,14 +133,26 @@ public sealed class Bandmap } } + /// Called under the lock by everything that changes the spots. + private void Forget() + { + onBand.Clear(); + everything = null; + } + public IReadOnlyList On(Band band) { lock (guard) { - return spots.Values - .Where(s => s.Band == band) - .OrderBy(s => s.Frequency.Hertz) - .ToList(); + if (!onBand.TryGetValue(band, out IReadOnlyList? found)) + { + found = spots.Values + .Where(s => s.Band == band) + .OrderBy(s => s.Frequency.Hertz) + .ToList(); + onBand[band] = found; + } + return found; } } @@ -134,7 +160,7 @@ public sealed class Bandmap { lock (guard) { - return spots.Values.OrderBy(s => s.Frequency.Hertz).ToList(); + return everything ??= spots.Values.OrderBy(s => s.Frequency.Hertz).ToList(); } } @@ -146,7 +172,12 @@ public sealed class Bandmap { Spot? best = null; long bestDistance = long.MaxValue; - foreach (Spot spot in spots.Values) + // only the band the frequency is in is searched, which is a few + // hundred spots rather than all of them + IEnumerable candidates = Bands.ForFrequency(frequency) is { } band + ? On(band) + : spots.Values; + foreach (Spot spot in candidates) { long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz); if (distance <= window.Hertz && distance < bestDistance)