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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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))
|
||||||
.Where(s => s.Band == band)
|
{
|
||||||
.OrderBy(s => s.Frequency.Hertz)
|
found = spots.Values
|
||||||
.ToList();
|
.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)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user