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 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 13:34:50 +00:00
co-authored by Claude Sonnet 5.5
parent 5e66778301
commit 5f9d186150
8 changed files with 146 additions and 15 deletions
+3
View File
@@ -1018,6 +1018,9 @@ public sealed partial class AppSession : IDisposable
/// put on the bandmap in one go. /// put on the bandmap in one go.
private void FlushSpots() 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<Spot> batch; List<Spot> batch;
lock (arrivingGuard) lock (arrivingGuard)
{ {
@@ -37,6 +37,11 @@ public sealed partial class AvailableWindow : RefreshableWindow
private readonly AppSession session; private readonly AppSession session;
private readonly Action<Frequency, string> tune; private readonly Action<Frequency, string> tune;
private readonly DispatcherTimer timer = new() { Interval = TimeSpan.FromSeconds(1) }; 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 /// The bands a click in the summary table limits the list to. Empty is
/// every band. /// every band.
@@ -77,10 +82,16 @@ public sealed partial class AvailableWindow : RefreshableWindow
Spots.PointerExited += (_, _) => Resume(); Spots.PointerExited += (_, _) => Resume();
Spots.LoadingRow += (_, e) => Paint(e.Row); Spots.LoadingRow += (_, e) => Paint(e.Row);
Spots.ContextMenu = BuildMenu(); Spots.ContextMenu = BuildMenu();
Coalesced changed = new(Refresh); // replacing the grid's rows is the dearest thing this window does, and
EventHandler spotsChanged = (_, _) => changed.Request(); // 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; session.Bandmap.Changed += spotsChanged;
Closed += (_, _) => session.Bandmap.Changed -= spotsChanged; Closed += (_, _) =>
{
session.Bandmap.Changed -= spotsChanged;
limited.Cancel();
};
timer.Tick += (_, _) => OnTick(); timer.Tick += (_, _) => OnTick();
timer.Start(); timer.Start();
Closed += (_, _) => timer.Stop(); Closed += (_, _) => timer.Stop();
@@ -277,7 +288,6 @@ public sealed partial class AvailableWindow : RefreshableWindow
Frequency? tuned = session.Position?.Frequency; Frequency? tuned = session.Position?.Frequency;
IReadOnlyList<ModeCategory> contestModes = session.Position?.Contest.Modes ?? []; IReadOnlyList<ModeCategory> contestModes = session.Position?.Contest.Modes ?? [];
bool summaryShown = Summary.IsVisible; bool summaryShown = Summary.IsVisible;
bandmap.DropOlderThan(now);
IReadOnlyList<AvailableStation> all = await available.AllAsync(); IReadOnlyList<AvailableStation> all = await available.AllAsync();
Computed computed = await Task.Run(() => Compute(all, now, settings, plan, bandmap, picked, tuned, contestModes, summaryShown)); Computed computed = await Task.Run(() => Compute(all, now, settings, plan, bandmap, picked, tuned, contestModes, summaryShown));
if (again) if (again)
+15 -1
View File
@@ -199,7 +199,7 @@ public sealed class BandmapView : Control
{ {
IBrush colour = ColourOf(label.Spot); IBrush colour = ColourOf(label.Spot);
context.DrawLine( context.DrawLine(
new Pen(Opaque(ink, 0.55), 1), LeaderPen(ink),
new Point(ScaleX + 2, label.MarkY), new Point(ScaleX + 2, label.MarkY),
new Point(LabelX - 2, label.LabelY + (LabelHeight / 2))); new Point(LabelX - 2, label.LabelY + (LabelHeight / 2)));
context.DrawText( context.DrawText(
@@ -285,6 +285,20 @@ public sealed class BandmapView : Control
private IBrush Foreground() => private IBrush Foreground() =>
this.FindResource("FormForeground") as IBrush ?? Brushes.Gray; 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 = []; private readonly Dictionary<(IBrush Ink, double Opacity), IBrush> faded = [];
/// Kept, so the same brush comes back each time and the text drawn with it /// Kept, so the same brush comes back each time and the text drawn with it
@@ -110,7 +110,6 @@ public sealed partial class BandmapWindow : RefreshableWindow
public override void Refresh() public override void Refresh()
{ {
session.Bandmap.DropOlderThan(DateTime.UtcNow);
Band? band = session.Position is null ? null : Bands.ForFrequency(session.Position.Frequency); Band? band = session.Position is null ? null : Bands.ForFrequency(session.Position.Frequency);
if (session.Position is null || band is null) if (session.Position is null || band is null)
{ {
+62
View File
@@ -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();
}
}
@@ -11,13 +11,21 @@ public sealed partial class ScoreWindow : RefreshableWindow
private readonly AppSession session; private readonly AppSession session;
private int columnCount = 4; private int columnCount = 4;
private readonly RateLimited limited;
public ScoreWindow(AppSession session) public ScoreWindow(AppSession session)
{ {
this.session = session; this.session = session;
InitializeComponent(); InitializeComponent();
limited = new RateLimited(TimeSpan.FromSeconds(1), Refresh);
Closed += (_, _) => limited.Cancel();
Refresh(); 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 /// What the table and the total line showed last, so a refresh that
/// changes nothing leaves the controls alone. /// changes nothing leaves the controls alone.
+7 -3
View File
@@ -43,7 +43,11 @@ public sealed partial class TelnetWindow : RefreshableWindow
LoadClusters(); LoadClusters();
LoadFilters(); LoadFilters();
Listen(session.Cluster); Listen(session.Cluster);
Closed += (_, _) => Forget(session.Cluster); Closed += (_, _) =>
{
Forget(session.Cluster);
flushWaiting?.Cancel();
};
Refresh(); Refresh();
} }
@@ -150,12 +154,12 @@ public sealed partial class TelnetWindow : RefreshableWindow
private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() => private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() =>
{ {
waiting.Add((line, colour, strong)); waiting.Add((line, colour, strong));
flushWaiting ??= new Coalesced(AddWaiting); flushWaiting ??= new RateLimited(TimeSpan.FromMilliseconds(250), AddWaiting);
flushWaiting.Request(); flushWaiting.Request();
}); });
private readonly List<(string Line, IBrush? Colour, bool Strong)> waiting = []; private readonly List<(string Line, IBrush? Colour, bool Strong)> waiting = [];
private Coalesced? flushWaiting; private RateLimited? flushWaiting;
private void AddWaiting() private void AddWaiting()
{ {
+34 -3
View File
@@ -20,6 +20,13 @@ public sealed class Bandmap
private readonly Lock guard = new(); private readonly Lock guard = new();
private readonly Func<DateTime> clock; private readonly Func<DateTime> 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<Band, IReadOnlyList<Spot>> onBand = [];
private IReadOnlyList<Spot>? everything;
public Bandmap(TimeSpan? lifetime = null, Func<DateTime>? clock = null) public Bandmap(TimeSpan? lifetime = null, Func<DateTime>? clock = null)
{ {
Lifetime = lifetime ?? TimeSpan.FromHours(1); Lifetime = lifetime ?? TimeSpan.FromHours(1);
@@ -43,6 +50,7 @@ public sealed class Bandmap
{ {
spots[key] = spot; spots[key] = spot;
arrived[key] = clock(); arrived[key] = clock();
Forget();
} }
Changed?.Invoke(this, EventArgs.Empty); Changed?.Invoke(this, EventArgs.Empty);
} }
@@ -61,6 +69,7 @@ public sealed class Bandmap
arrived[key] = clock(); arrived[key] = clock();
added++; added++;
} }
Forget();
} }
if (added > 0) if (added > 0)
{ {
@@ -76,6 +85,7 @@ public sealed class Bandmap
{ {
arrived.Remove(key); arrived.Remove(key);
removed = spots.Remove(key); removed = spots.Remove(key);
Forget();
} }
if (removed) if (removed)
{ {
@@ -99,6 +109,10 @@ public sealed class Bandmap
arrived.Remove(key); arrived.Remove(key);
} }
dropped = stale.Count; dropped = stale.Count;
if (dropped > 0)
{
Forget();
}
} }
if (dropped > 0) 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<Spot> On(Band band) public IReadOnlyList<Spot> On(Band band)
{ {
lock (guard) lock (guard)
{ {
return spots.Values if (!onBand.TryGetValue(band, out IReadOnlyList<Spot>? found))
{
found = spots.Values
.Where(s => s.Band == band) .Where(s => s.Band == band)
.OrderBy(s => s.Frequency.Hertz) .OrderBy(s => s.Frequency.Hertz)
.ToList(); .ToList();
onBand[band] = found;
}
return found;
} }
} }
@@ -134,7 +160,7 @@ public sealed class Bandmap
{ {
lock (guard) 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; Spot? best = null;
long bestDistance = long.MaxValue; 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<Spot> candidates = Bands.ForFrequency(frequency) is { } band
? On(band)
: spots.Values;
foreach (Spot spot in candidates)
{ {
long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz); long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz);
if (distance <= window.Hertz && distance < bestDistance) if (distance <= window.Hertz && distance < bestDistance)