Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/AdaptiveCache`2.cs
Jiří Polášek dca532cf4b CmdPal: Icon cache (#44538)
## Summary of the Pull Request

This PR implements actual cache in IconCacheService and adds some fixes
on top for free.

The good
- `IconCacheService` now caches decoded icons
- Ensures that UI thread is not starved by loading icons by limiting
number of threads that can load icons at any given time
- `IconCacheService` decodes bitmaps directly to the required size to
reduce memory usage
- `IconBox` now reacts to theme, DPI scale, and size changes immediately
- Introduced `AdaptiveCache` with time-based decay to improve icon reuse
- Updated `IconCacheProvider` and `IconCacheService` to handle multiple
icon sizes and scale-aware caching
- Added priority-based decoding in `IconCacheService` for more
responsive loading
- Extended `IconPathConverter` to support target icon sizes
- Switched hero images in `ShellPage` to use the jumbo icon cache
- Made `MainWindow` title bar logic resilient to a null `XamlRoot`
- Fixed Tag icon positioning
- Removes custom `TypedEventHandlerExtensions` in favor of
`CommunityToolkit.WinUI.Deferred`.

The bad
- Since IconData lacks a unique identity, when it includes a stream, it
relies on simple reference equality, acknowledging that it might not be
stable. We might cache some obsolete garbage because of this, but it is
fast and better than nothing at all. Yet another task for the future me.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: 
- [ ] Closes: #38284
- [ ] Related to: #44407
- [ ] Related to: https://github.com/zadjii-msft/PowerToys/issues/333
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-02-02 11:16:43 -06:00

300 lines
8.8 KiB
C#

// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.CmdPal.Core.Common.Helpers;
namespace Microsoft.CmdPal.UI.Helpers;
/// <summary>
/// A high-performance, near-lock-free adaptive cache optimized for UI Icons.
/// Eviction merely drops references to allow the GC to manage UI-bound lifetimes.
/// </summary>
internal sealed class AdaptiveCache<TKey, TValue>
where TKey : IEquatable<TKey>
{
private readonly int _capacity;
private readonly double _decayFactor;
private readonly TimeSpan _decayInterval;
private readonly ConcurrentDictionary<TKey, CacheEntry> _map;
private readonly ConcurrentStack<CacheEntry> _pool = [];
private readonly WaitCallback _maintenanceCallback;
private long _currentTick;
private long _lastDecayTicks = DateTime.UtcNow.Ticks;
private InterlockedBoolean _maintenanceSwitch = new(false);
public AdaptiveCache(int capacity = 384, TimeSpan? decayInterval = null, double decayFactor = 0.5)
{
_capacity = capacity;
_decayInterval = decayInterval ?? TimeSpan.FromMinutes(5);
_decayFactor = decayFactor;
_map = new ConcurrentDictionary<TKey, CacheEntry>(Environment.ProcessorCount, capacity);
_maintenanceCallback = static state =>
{
var cache = (AdaptiveCache<TKey, TValue>)state!;
try
{
cache.PerformCleanup();
}
finally
{
cache._maintenanceSwitch.Clear();
}
};
}
public TValue GetOrAdd<TArg>(TKey key, Func<TKey, TArg, TValue> factory, TArg arg)
{
if (_map.TryGetValue(key, out var entry))
{
entry.Update(Interlocked.Increment(ref _currentTick));
return entry.Value!;
}
if (!_pool.TryPop(out var newEntry))
{
newEntry = new CacheEntry();
}
var value = factory(key, arg);
var tick = Interlocked.Increment(ref _currentTick);
newEntry.Initialize(key, value, 1.0, tick);
if (!_map.TryAdd(key, newEntry))
{
newEntry.Clear();
_pool.Push(newEntry);
if (_map.TryGetValue(key, out var existing))
{
existing.Update(tick);
return existing.Value!;
}
}
if (ShouldMaintenanceRun())
{
TryRunMaintenance();
}
return value;
}
public bool TryGet(TKey key, [MaybeNullWhen(false)] out TValue value)
{
if (_map.TryGetValue(key, out var entry))
{
entry.Update(Interlocked.Increment(ref _currentTick));
value = entry.Value;
return true;
}
value = default;
return false;
}
public void Add(TKey key, TValue value)
{
var tick = Interlocked.Increment(ref _currentTick);
if (_map.TryGetValue(key, out var existing))
{
existing.Update(tick);
existing.SetValue(value);
return;
}
if (!_pool.TryPop(out var newEntry))
{
newEntry = new CacheEntry();
}
newEntry.Initialize(key, value, 1.0, tick);
if (!_map.TryAdd(key, newEntry))
{
newEntry.Clear();
_pool.Push(newEntry);
}
if (ShouldMaintenanceRun())
{
TryRunMaintenance();
}
}
public bool TryRemove(TKey key)
{
if (_map.TryRemove(key, out var evicted))
{
evicted.Clear();
_pool.Push(evicted);
return true;
}
return false;
}
public void Clear()
{
foreach (var key in _map.Keys)
{
TryRemove(key);
}
Interlocked.Exchange(ref _currentTick, 0);
}
private bool ShouldMaintenanceRun()
{
return _map.Count > _capacity || (DateTime.UtcNow.Ticks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks;
}
private void TryRunMaintenance()
{
if (_maintenanceSwitch.Set())
{
ThreadPool.UnsafeQueueUserWorkItem(_maintenanceCallback, this);
}
}
private void PerformCleanup()
{
var nowTicks = DateTime.UtcNow.Ticks;
var isDecay = (nowTicks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks;
if (isDecay)
{
Interlocked.Exchange(ref _lastDecayTicks, nowTicks);
}
var currentTick = Interlocked.Read(ref _currentTick);
foreach (var (key, entry) in _map)
{
if (isDecay)
{
entry.Decay(_decayFactor);
}
var score = CalculateScore(entry, currentTick);
if (score < 0.1 || _map.Count > _capacity)
{
if (_map.TryRemove(key, out var evicted))
{
evicted.Clear();
_pool.Push(evicted);
}
}
}
}
/// <summary>
/// Calculates the survival score of an entry.
/// Higher score = stay in cache; Lower score = priority for eviction.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double CalculateScore(CacheEntry entry, long currentTick)
{
// Tuning parameter: How much weight to give recency vs frequency.
// - a larger ageWeight makes the cache behave more like LRU (Least Recently Used).
// - a smaller ageWeight makes it behave more like LFU (Least Frequently Used).
const double ageWeight = 0.001;
var frequency = entry.GetFrequency();
var age = currentTick - entry.GetLastAccess();
return frequency - (age * ageWeight);
}
/// <summary>
/// Represents a single pooled entry in the cache, containing the value and
/// atomic metadata for adaptive eviction logic.
/// </summary>
private sealed class CacheEntry
{
/// <summary>
/// Gets the key associated with this entry. Used primarily for identification during cleanup.
/// </summary>
public TKey Key { get; private set; } = default!;
/// <summary>
/// Gets the cached value. This reference is cleared on eviction to allow GC collection.
/// </summary>
public TValue Value { get; private set; } = default!;
/// <summary>
/// Stores the frequency count as double bits to allow for Interlocked atomic math.
/// Frequencies are decayed over time to ensure the cache adapts to new usage patterns.
/// </summary>
/// <remarks>
/// This allows the use of Interlocked.CompareExchange to perform thread-safe floating point
/// arithmetic without a global lock.
/// </remarks>
private long _frequencyBits;
/// <summary>
/// The tick (monotonically increasing counter) of the last time this entry was accessed.
/// </summary>
private long _lastAccessTick;
public void Initialize(TKey key, TValue value, double frequency, long lastAccessTick)
{
Key = key;
Value = value;
_frequencyBits = BitConverter.DoubleToInt64Bits(frequency);
_lastAccessTick = lastAccessTick;
}
public void SetValue(TValue value)
{
Value = value;
}
public void Clear()
{
Key = default!;
Value = default!;
}
public void Update(long tick)
{
Interlocked.Exchange(ref _lastAccessTick, tick);
long initial, updated;
do
{
initial = Interlocked.Read(ref _frequencyBits);
updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) + 1.0);
}
while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial);
}
public void Decay(double factor)
{
long initial, updated;
do
{
initial = Interlocked.Read(ref _frequencyBits);
updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) * factor);
}
while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial);
}
public double GetFrequency()
{
return BitConverter.Int64BitsToDouble(Interlocked.Read(ref _frequencyBits));
}
public long GetLastAccess()
{
return Interlocked.Read(ref _lastAccessTick);
}
}
}