diff --git a/samples/Reactor.TestApp/Demos/PerfStressDemo.cs b/samples/Reactor.TestApp/Demos/PerfStressDemo.cs index 62b38ae5a..a5beed792 100644 --- a/samples/Reactor.TestApp/Demos/PerfStressDemo.cs +++ b/samples/Reactor.TestApp/Demos/PerfStressDemo.cs @@ -9,6 +9,7 @@ using Microsoft.UI.Reactor.Controls; using static Microsoft.UI.Reactor.Factories; using static Microsoft.UI.Reactor.Core.Theme; +using WinMedia = Microsoft.UI.Xaml.Media; class PerfStressDemo : Component { @@ -20,6 +21,32 @@ record SortState(int[] Values, int[] Colors, int Pivot, int Left, int Right, boo "#4dd0e1", "#aed581", "#ffd54f", "#e57373", "#9575cd", ]; + // Brush cache: at 500 bars × 3 colored sub-elements per bar, the string + // overload of .Background() would allocate ~1500 SolidColorBrush instances + // per render tick — and because each new brush is reference-different, + // UpdateBorder writes Background unconditionally and WinUI re-paints every + // border every tick. Caching one brush per color string per host thread + // collapses that to ~1500 dictionary lookups per render and lets the + // reconciler's reference-equality check on Background short-circuit. + // + // SolidColorBrush is a DependencyObject — it has thread affinity, so the + // cache is keyed by managed thread id. PerfStress only renders on one UI + // thread; the per-thread keying is defensive for hot reload / multi-window + // futures. + [ThreadStatic] + private static Dictionary? t_brushCache; + + static WinMedia.SolidColorBrush Brush(string color) + { + var cache = t_brushCache ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!cache.TryGetValue(color, out var brush)) + { + brush = BrushHelper.Parse(color); + cache[color] = brush; + } + return brush; + } + public override Element Render() { var (elementCount, setElementCount) = UseState(100); @@ -233,11 +260,14 @@ async Task QSort(int lo, int hi) int val = sortState.Values[i]; // Each bar contains child controls to stress the reconciler: - // a tiny progress indicator + a value label + a colored pip + // a tiny progress indicator + a value label + a colored pip. + // All Background() calls go through the cached brush so the + // reconciler sees stable brush references between renders + // and skips redundant WinUI Background writes. Element barContent = VStack(0, // Top: small colored indicator pip (changes with sort state) Border(Empty()) - .Background(isPivot ? "#ffffff" : isActive ? "#ffeb3b" : BarColors[(colorIdx + 1) % BarColors.Length]) + .Background(Brush(isPivot ? "#ffffff" : isActive ? "#ffeb3b" : BarColors[(colorIdx + 1) % BarColors.Length])) .CornerRadius(1) .Width(Math.Min(barWidth - 1, 6)) .Height(2), @@ -247,7 +277,7 @@ async Task QSort(int lo, int hi) : Empty(), // Bottom: progress-like fill showing relative position Border(Empty()) - .Background(BarColors[(colorIdx + 2) % BarColors.Length]) + .Background(Brush(BarColors[(colorIdx + 2) % BarColors.Length])) .CornerRadius(0) .Width(Math.Max(1, barWidth * 0.6)) .Height(Math.Max(1, barHeight * 0.15)) @@ -255,7 +285,7 @@ async Task QSort(int lo, int hi) ); Element bar = Border(barContent) - .Background(BarColors[colorIdx]) + .Background(Brush(BarColors[colorIdx])) .CornerRadius(0) .Width(barWidth) .Height(barHeight) @@ -270,7 +300,7 @@ async Task QSort(int lo, int hi) return HStack(0, barElements).Height(220).VAlign(VerticalAlignment.Bottom); })) .CornerRadius(8) - .Background("#1a1a2e") + .Background(Brush("#1a1a2e")) .Padding(8), // Performance stats @@ -315,7 +345,7 @@ async Task QSort(int lo, int hi) double h = Math.Min(50, t * 10); // 1ms = 10px string color = t < 2 ? "#81c784" : t < 8 ? "#fff176" : t < 16 ? "#ff8a65" : "#e57373"; return (Element)Border(Empty()) - .Background(color) + .Background(Brush(color)) .CornerRadius(0) .Width(Math.Max(1, 600.0 / 100)) .Height(Math.Max(1, h)) @@ -337,7 +367,7 @@ async Task QSort(int lo, int hi) static Element LegendItem(string color, string label) => HStack(4, - Border(Empty()).Background(color).CornerRadius(2).Width(12).Height(12), + Border(Empty()).Background(Brush(color)).CornerRadius(2).Width(12).Height(12), Caption(label).Foreground(SecondaryText) ); } diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index eaf3cc6e2..c5535aaaf 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -104,6 +104,16 @@ public sealed class ReactorHost : IDisposable private readonly Stopwatch _reportClock = Stopwatch.StartNew(); private long _totalRenderCount; + // Last render's total duration (tree + reconcile + effects), in ms. + // Read by RequestRender to demote the next enqueue to Low priority when a + // slow render is starving the dispatcher of input/layout/paint slots. + // Published via Interlocked.Exchange / read via Volatile.Read because the + // write happens on the UI thread inside Render() but RequestRender() can + // be called from any thread — a plain double write is not guaranteed + // atomic on 32-bit and lacks the publication semantics this contract + // implies. See RenderPriorityPolicy. + private double _lastRenderMs; + // Public perf snapshot — updated every ~1 second, readable from components private RenderStats _stats; @@ -480,7 +490,15 @@ internal void RequestRender(bool force = false) return; } - _dispatcherQueue.TryEnqueue(RenderLoop); + // Demote to Low priority when the previous render exceeded the frame + // budget — high-frequency setState sources (animation, simulation, + // streaming data) otherwise pack the dispatcher with back-to-back + // Normal-priority renders and starve input/layout/paint. See + // RenderPriorityPolicy. Volatile.Read pairs with Interlocked.Exchange + // in Render() so an off-UI-thread caller observes the latest value. + _dispatcherQueue.TryEnqueue( + RenderPriorityPolicy.PickPriority(Volatile.Read(ref _lastRenderMs)), + RenderLoop); } private void RenderLoop() @@ -717,6 +735,14 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) double effectsMs = _phaseSw.Elapsed.TotalMilliseconds; + // Feed RenderPriorityPolicy so the next RequestRender knows whether + // to demote to Low priority. Stored as the most-recent measurement + // — no smoothing — so a single slow render is enough to back off, + // and a single fast render is enough to return to Normal priority. + // Interlocked publishes the value to off-UI-thread RequestRender + // callers; the matching Volatile.Read is in RequestRender. + Interlocked.Exchange(ref _lastRenderMs, treeBuildMs + reconcileMs + effectsMs); + OnRenderComplete?.Invoke(treeBuildMs, reconcileMs, effectsMs); #if DEBUG diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index 55c3efcb5..992374ac2 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -89,6 +89,13 @@ public sealed partial class ReactorHostControl : ContentControl, IDisposable private readonly Stopwatch _reportClock = Stopwatch.StartNew(); private long _totalRenderCount; + // Last render's total duration (tree + reconcile + effects), in ms. + // Read by RequestRender to demote the next enqueue to Low priority when a + // slow render is starving the dispatcher of input/layout/paint slots. + // Published via Interlocked.Exchange / read via Volatile.Read — see the + // matching note in ReactorHost. + private double _lastRenderMs; + // Public perf snapshot — updated every ~1 second, readable from components private RenderStats _stats; @@ -299,7 +306,13 @@ private void RequestRender() // Between renders: CAS 0→1 gates a single TryEnqueue. if (Interlocked.CompareExchange(ref _renderPending, 1, 0) != 0) return; - _dispatcherQueue.TryEnqueue(RenderLoop); + // Demote to Low priority after a slow render so input/layout/paint + // catch up. See RenderPriorityPolicy and the matching code in + // ReactorHost.RequestRender. Volatile.Read pairs with the + // Interlocked.Exchange in Render(). + _dispatcherQueue.TryEnqueue( + RenderPriorityPolicy.PickPriority(Volatile.Read(ref _lastRenderMs)), + RenderLoop); } private void RenderLoop() @@ -509,6 +522,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) double effectsMs = _phaseSw.Elapsed.TotalMilliseconds; + // Feed RenderPriorityPolicy. Interlocked publishes to off-UI-thread + // RequestRender callers. See matching note in ReactorHost.Render. + Interlocked.Exchange(ref _lastRenderMs, treeBuildMs + reconcileMs + effectsMs); + OnRenderComplete?.Invoke(treeBuildMs, reconcileMs, effectsMs); #if DEBUG diff --git a/src/Reactor/Hosting/RenderPriorityPolicy.cs b/src/Reactor/Hosting/RenderPriorityPolicy.cs new file mode 100644 index 000000000..110d45617 --- /dev/null +++ b/src/Reactor/Hosting/RenderPriorityPolicy.cs @@ -0,0 +1,41 @@ +using Microsoft.UI.Dispatching; + +namespace Microsoft.UI.Reactor.Hosting; + +/// +/// Decides the dispatcher-queue priority for the next render enqueue based on +/// recent render duration. +/// +/// When a render is faster than a 60 Hz frame, the next render is enqueued at +/// — the default, lowest latency. +/// When the previous render exceeded the budget, subsequent renders are demoted +/// to so that input, layout, and paint +/// messages on the same UI thread are interleaved between renders. Without this, +/// a high-frequency state-change source (animation, simulation, streaming data) +/// can fill the dispatcher with back-to-back renders that starve pointer/keyboard +/// input — the app feels frozen even though renders are still committing pixels. +/// +/// +internal static class RenderPriorityPolicy +{ + /// + /// Render-duration ceiling beyond which subsequent renders are demoted to + /// Low priority. 16 ms is one 60 Hz frame; a render past this point gives + /// up its Normal-priority slot so input/layout/paint catch up. + /// + public const double DefaultFrameBudgetMs = 16.0; + + /// + /// Decide the priority for the next render enqueue. + /// Returns when the last render + /// exceeded the budget, + /// otherwise (including the cold-start case where no render has run yet + /// and is 0). + /// + public static DispatcherQueuePriority PickPriority( + double lastRenderMs, + double budgetMs = DefaultFrameBudgetMs) + => lastRenderMs > budgetMs + ? DispatcherQueuePriority.Low + : DispatcherQueuePriority.Normal; +} diff --git a/tests/Reactor.Tests/Hosting/RenderPriorityPolicyTests.cs b/tests/Reactor.Tests/Hosting/RenderPriorityPolicyTests.cs new file mode 100644 index 000000000..8a70fba22 --- /dev/null +++ b/tests/Reactor.Tests/Hosting/RenderPriorityPolicyTests.cs @@ -0,0 +1,119 @@ +using Microsoft.UI.Dispatching; +using Microsoft.UI.Reactor.Hosting; +using Xunit; + +namespace Microsoft.UI.Reactor.Tests.Hosting; + +/// +/// The render-loop responsiveness fix for PerfStress at 250/500 elements: +/// when a render exceeds the 60 Hz frame budget, the host enqueues the next +/// render at so the message pump can +/// interleave input/layout/paint. Without this, back-to-back Normal-priority +/// renders triggered from await Task.Delay continuations starve UI +/// input and the app feels frozen until the work finishes. +/// +/// These tests pin the policy's decisions independent of the dispatcher so +/// regressions show up at unit-test time rather than as a UI freeze in the +/// PerfStress demo. +/// +public class RenderPriorityPolicyTests +{ + [Fact] + public void ColdStart_UsesNormalPriority() + { + // Before any render has run, lastRenderMs == 0. The host MUST use + // Normal priority — Low-priority cold-start would queue behind every + // pending dispatcher item and delay first paint noticeably. + Assert.Equal( + DispatcherQueuePriority.Normal, + RenderPriorityPolicy.PickPriority(lastRenderMs: 0)); + } + + [Fact] + public void FastRender_StaysAtNormalPriority() + { + // A render that fits inside one 60 Hz frame keeps Normal priority. + // Demoting fast renders would add latency for no benefit. + Assert.Equal( + DispatcherQueuePriority.Normal, + RenderPriorityPolicy.PickPriority(lastRenderMs: 8)); + } + + [Fact] + public void RenderAtFrameBoundary_StaysAtNormalPriority() + { + // Exactly at the budget is treated as "fit" — only renders strictly + // longer than the budget demote. This avoids flip-flopping when a + // render lands right at the boundary. + Assert.Equal( + DispatcherQueuePriority.Normal, + RenderPriorityPolicy.PickPriority(lastRenderMs: RenderPriorityPolicy.DefaultFrameBudgetMs)); + } + + [Fact] + public void SlowRender_DemotesToLowPriority() + { + // This is the PerfStress fix: a render that exceeds one frame budget + // moves the next enqueue to Low priority. The PerfStress scenario + // tops out at ~100 ms/render at 500 elements — well past the budget. + Assert.Equal( + DispatcherQueuePriority.Low, + RenderPriorityPolicy.PickPriority(lastRenderMs: 100)); + } + + [Fact] + public void JustOverBudget_DemotesToLowPriority() + { + // Even a small overrun (just past the 16 ms ceiling) demotes — + // the goal is to keep the dispatcher from monopolizing the UI thread. + Assert.Equal( + DispatcherQueuePriority.Low, + RenderPriorityPolicy.PickPriority(lastRenderMs: RenderPriorityPolicy.DefaultFrameBudgetMs + 0.5)); + } + + [Fact] + public void CustomBudget_DemotesPastCustomCeiling() + { + // A perf-sensitive host can raise or lower the ceiling. The policy + // honors the override. + Assert.Equal( + DispatcherQueuePriority.Low, + RenderPriorityPolicy.PickPriority(lastRenderMs: 12, budgetMs: 8)); + + Assert.Equal( + DispatcherQueuePriority.Normal, + RenderPriorityPolicy.PickPriority(lastRenderMs: 12, budgetMs: 32)); + } + + [Fact] + public void DefaultBudget_IsOneFrameAt60Hz() + { + // Pin the budget so a future "just bump it up" change is a conscious + // edit, not a silent regression. + Assert.Equal(16.0, RenderPriorityPolicy.DefaultFrameBudgetMs); + } + + [Fact] + public void ReactorHost_TracksLastRenderMs() + { + // The wiring contract: ReactorHost stores the most-recent render + // duration so RequestRender can consult RenderPriorityPolicy. + // Without this field, the policy can't see a slow render and the + // PerfStress regression returns. + var field = typeof(ReactorHost).GetField("_lastRenderMs", + global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Instance); + Assert.NotNull(field); + Assert.Equal(typeof(double), field!.FieldType); + } + + [Fact] + public void ReactorHostControl_TracksLastRenderMs() + { + // Symmetric contract with ReactorHost — embedded hosts must also + // demote to Low priority on slow renders. + var field = typeof(ReactorHostControl).GetField("_lastRenderMs", + global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Instance); + Assert.NotNull(field); + Assert.Equal(typeof(double), field!.FieldType); + } +}