Fix unbounded memory growth during monitoring

While the app was running, working set grew ~20 MB/min (reaching tens of GB
over a day). dotnet-gcdump traced it to COM-callable wrappers accumulating from
the per-tick binding interop: SensorTreeItemViewModel.RefreshValues raised
PropertyChanged for Value/Min/Max/ToolTip on every sensor every update tick, and
ViewModelBase allocated a new PropertyChangedEventArgs per raise. WinUI's binding
engine is native, so each raised event marshals its args across the boundary and
retains a wrapper.

- ViewModelBase: reuse one cached PropertyChangedEventArgs per property name.
- SensorTreeItemViewModel.RefreshValues: raise PropertyChanged only when the
  formatted text actually changed.

Also close a leaked D3DKMT adapter handle: D3DDisplayDevice.GetDeviceInfoByIdentifier
opened the adapter but skipped CloseAdapter on every early-return failure path,
and it runs on each GPU's Update() tick. Close it in a finally block.

Verified with dotnet-gcdump over an 18-minute soak: managed heap and live object
count stay flat (~50 MB) instead of climbing 47 -> 470 MB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 16:16:40 -05:00
co-authored by Claude Opus 4.8
parent 041e21f9a6
commit 1bde5d531d
3 changed files with 119 additions and 66 deletions
@@ -30,6 +30,10 @@ public sealed class SensorTreeItemViewModel : ViewModelBase
private Visibility _valueColumnVisibility = Visibility.Visible;
private Color? _penColor;
private TemperatureUnit _temperatureUnit;
private string? _lastValue;
private string? _lastMin;
private string? _lastMax;
private string? _lastToolTip;
private SensorTreeItemViewModel(SensorTreeItemKind kind, AppSettings? settings)
{
@@ -259,10 +263,38 @@ public sealed class SensorTreeItemViewModel : ViewModelBase
public void RefreshValues()
{
OnPropertyChanged(nameof(Value));
OnPropertyChanged(nameof(Min));
OnPropertyChanged(nameof(Max));
OnPropertyChanged(nameof(ToolTip));
// Value/Min/Max/ToolTip are computed (no backing field), so raise PropertyChanged only when the formatted text
// actually changed. Unconditionally notifying all four for every item on every update tick made the native
// binding engine re-marshal unchanged values across the boundary continuously — most sensors don't change each
// tick, so this cuts the per-tick interop churn (and its retained wrappers) by a large factor.
string value = Value;
if (value != _lastValue)
{
_lastValue = value;
OnPropertyChanged(nameof(Value));
}
string min = Min;
if (min != _lastMin)
{
_lastMin = min;
OnPropertyChanged(nameof(Min));
}
string max = Max;
if (max != _lastMax)
{
_lastMax = max;
OnPropertyChanged(nameof(Max));
}
string toolTip = ToolTip;
if (toolTip != _lastToolTip)
{
_lastToolTip = toolTip;
OnPropertyChanged(nameof(ToolTip));
}
foreach (SensorTreeItemViewModel child in Children)
child.RefreshValues();
}
@@ -2,6 +2,7 @@
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Copyright (C) LibreHardwareMonitor and Contributors.
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
@@ -10,6 +11,12 @@ namespace LibreHardwareMonitor.Windows.WinUI.ViewModels;
public abstract class ViewModelBase : INotifyPropertyChanged
{
// Reuse one PropertyChangedEventArgs per property name. WinUI's binding engine handles PropertyChanged natively, so
// each raised event marshals its args across the managed/native boundary and creates a COM-callable wrapper that the
// interop layer retains. Allocating a fresh PropertyChangedEventArgs on every raise — RefreshValues fires four per
// sensor on every update tick — therefore leaked a wrapper per raise (managed-heap growth ~28 MB/min → multi-GB).
private static readonly ConcurrentDictionary<string, PropertyChangedEventArgs> EventArgsByName = new();
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
@@ -24,6 +31,11 @@ public abstract class ViewModelBase : INotifyPropertyChanged
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
PropertyChangedEventHandler? handler = PropertyChanged;
if (handler == null)
return;
PropertyChangedEventArgs args = EventArgsByName.GetOrAdd(propertyName ?? string.Empty, static name => new PropertyChangedEventArgs(name));
handler(this, args);
}
}
@@ -65,78 +65,87 @@ internal static class D3DDisplayDevice
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
GetAdapterType(out status, adapter, out D3DKMT_ADAPTERTYPE adapterType);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
if (adapterType.Anonymous.Anonymous.SoftwareDevice)
return false;
deviceInfo.Integrated = adapterType.Anonymous.Anonymous.HybridIntegrated;
GetQueryStatisticsAdapterInformation(out status, adapter, out D3DKMT_QUERYSTATISTICS_ADAPTER_INFORMATION adapterInformation);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
uint segmentCount = adapterInformation.NbSegments;
uint nodeCount = adapterInformation.NodeCount;
deviceInfo.Nodes = new D3DDeviceNodeInfo[nodeCount];
DateTime queryTime = DateTime.Now;
for (uint nodeId = 0; nodeId < nodeCount; nodeId++)
// Once the adapter is open it must be closed on every exit path. The guard clauses below bail out on the first
// failed D3DKMT query, and this method runs on every GPU's Update() tick, so a missing close leaks a kernel
// adapter handle (and its driver-side allocations) once per tick — unbounded over time. Close it in finally.
try
{
GetNodeMetaData(out status, adapter, nodeId, out D3DKMT_NODEMETADATA nodeMetaData);
GetAdapterType(out status, adapter, out D3DKMT_ADAPTERTYPE adapterType);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
GetQueryStatisticsNode(out status, adapter, nodeId, out D3DKMT_QUERYSTATISTICS_NODE_INFORMATION nodeInformation);
if (adapterType.Anonymous.Anonymous.SoftwareDevice)
return false;
deviceInfo.Integrated = adapterType.Anonymous.Anonymous.HybridIntegrated;
GetQueryStatisticsAdapterInformation(out status, adapter, out D3DKMT_QUERYSTATISTICS_ADAPTER_INFORMATION adapterInformation);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
deviceInfo.Nodes[nodeId] = new D3DDeviceNodeInfo
uint segmentCount = adapterInformation.NbSegments;
uint nodeCount = adapterInformation.NodeCount;
deviceInfo.Nodes = new D3DDeviceNodeInfo[nodeCount];
DateTime queryTime = DateTime.Now;
for (uint nodeId = 0; nodeId < nodeCount; nodeId++)
{
Id = nodeId,
Name = GetNodeEngineTypeString(nodeMetaData),
RunningTime = nodeInformation.GlobalInformation.RunningTime,
QueryTime = queryTime
};
GetNodeMetaData(out status, adapter, nodeId, out D3DKMT_NODEMETADATA nodeMetaData);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
GetQueryStatisticsNode(out status, adapter, nodeId, out D3DKMT_QUERYSTATISTICS_NODE_INFORMATION nodeInformation);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
deviceInfo.Nodes[nodeId] = new D3DDeviceNodeInfo
{
Id = nodeId,
Name = GetNodeEngineTypeString(nodeMetaData),
RunningTime = nodeInformation.GlobalInformation.RunningTime,
QueryTime = queryTime
};
}
GetSegmentSize(out status, adapter, out D3DKMT_SEGMENTSIZEINFO segmentSizeInfo);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
deviceInfo.GpuSharedLimit = segmentSizeInfo.SharedSystemMemorySize;
deviceInfo.GpuVideoMemoryLimit = segmentSizeInfo.DedicatedVideoMemorySize;
deviceInfo.GpuDedicatedLimit = segmentSizeInfo.DedicatedSystemMemorySize;
for (uint segmentId = 0; segmentId < segmentCount; segmentId++)
{
GetQueryStatisticsSegment(out status, adapter, segmentId, out D3DKMT_QUERYSTATISTICS_SEGMENT_INFORMATION segmentInformation);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
ulong bytesResident = segmentInformation.BytesResident;
ulong bytesCommitted = segmentInformation.BytesCommitted;
uint aperture = segmentInformation.Aperture;
if (aperture == 1)
{
deviceInfo.GpuSharedUsed += bytesResident;
deviceInfo.GpuSharedMax += bytesCommitted;
}
else
{
deviceInfo.GpuDedicatedUsed += bytesResident;
deviceInfo.GpuDedicatedMax += bytesCommitted;
}
}
return true;
}
GetSegmentSize(out status, adapter, out D3DKMT_SEGMENTSIZEINFO segmentSizeInfo);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
deviceInfo.GpuSharedLimit = segmentSizeInfo.SharedSystemMemorySize;
deviceInfo.GpuVideoMemoryLimit = segmentSizeInfo.DedicatedVideoMemorySize;
deviceInfo.GpuDedicatedLimit = segmentSizeInfo.DedicatedSystemMemorySize;
for (uint segmentId = 0; segmentId < segmentCount; segmentId++)
finally
{
GetQueryStatisticsSegment(out status, adapter, segmentId, out D3DKMT_QUERYSTATISTICS_SEGMENT_INFORMATION segmentInformation);
if (status != NTSTATUS.STATUS_SUCCESS)
return false;
ulong bytesResident = segmentInformation.BytesResident;
ulong bytesCommitted = segmentInformation.BytesCommitted;
uint aperture = segmentInformation.Aperture;
if (aperture == 1)
{
deviceInfo.GpuSharedUsed += bytesResident;
deviceInfo.GpuSharedMax += bytesCommitted;
}
else
{
deviceInfo.GpuDedicatedUsed += bytesResident;
deviceInfo.GpuDedicatedMax += bytesCommitted;
}
CloseAdapter(out _, adapter);
}
CloseAdapter(out status, adapter);
return status == NTSTATUS.STATUS_SUCCESS;
}
private static string GetNodeEngineTypeString(D3DKMT_NODEMETADATA nodeMetaData)