Skip to content

Commit 191c70d

Browse files
fix(perf): keep PerfStress responsive at 250/500 elements (#321)
* fix(perf): keep PerfStress responsive at 250/500 elements Two complementary fixes for the Reactor.TestApp PerfStress demo, where the UI froze under sustained high-frequency setState from an async sort loop. ReactorHost / ReactorHostControl: RequestRender already coalesces concurrent setStates within one dispatch tick, but only demoted to LOW priority when a state change happened *during* a render. The PerfStress pattern (setState between `await Task.Delay` ticks) never hit that path, so renders piled up at Normal priority and starved input/layout/paint on the same UI thread. RenderPriorityPolicy now demotes the next TryEnqueue to Low whenever the previous render exceeded a 16 ms frame budget — same mechanism the existing in-render path already uses, just extended to the between-renders path. PerfStressDemo: each render allocated ~1500 fresh SolidColorBrush instances at 500 elements (3 per bar × string `.Background("#hex")` overload), and because every brush had a different reference, UpdateBorder wrote b.Background unconditionally and WinUI re-painted every border every tick. A [ThreadStatic] brush cache keyed by color string collapses that to ~16 brushes for the lifetime of the demo and lets the reconciler's reference check short-circuit the redundant Background writes. Tests: - 9 new RenderPriorityPolicyTests pin the priority decisions and the ReactorHost/ReactorHostControl wiring contract. - Full unit suite (7534 tests) and selftest suite (689 tests) pass. * fix(perf): publish _lastRenderMs atomically (CR feedback) _lastRenderMs is written from the UI thread inside Render() but read from RequestRender(), which is documented thread-safe and may be called from any thread. A plain double write isn't guaranteed atomic on 32-bit and lacks publication semantics. Use Interlocked.Exchange on the write side and Volatile.Read on the read side so off-UI-thread callers observe a non-torn, freshly published value — matching the Interlocked pattern already used for _renderPending in the same files.
1 parent c2e27e4 commit 191c70d

5 files changed

Lines changed: 242 additions & 9 deletions

File tree

samples/Reactor.TestApp/Demos/PerfStressDemo.cs

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.UI.Reactor.Controls;
1010
using static Microsoft.UI.Reactor.Factories;
1111
using static Microsoft.UI.Reactor.Core.Theme;
12+
using WinMedia = Microsoft.UI.Xaml.Media;
1213

1314
class PerfStressDemo : Component
1415
{
@@ -20,6 +21,32 @@ record SortState(int[] Values, int[] Colors, int Pivot, int Left, int Right, boo
2021
"#4dd0e1", "#aed581", "#ffd54f", "#e57373", "#9575cd",
2122
];
2223

24+
// Brush cache: at 500 bars × 3 colored sub-elements per bar, the string
25+
// overload of .Background() would allocate ~1500 SolidColorBrush instances
26+
// per render tick — and because each new brush is reference-different,
27+
// UpdateBorder writes Background unconditionally and WinUI re-paints every
28+
// border every tick. Caching one brush per color string per host thread
29+
// collapses that to ~1500 dictionary lookups per render and lets the
30+
// reconciler's reference-equality check on Background short-circuit.
31+
//
32+
// SolidColorBrush is a DependencyObject — it has thread affinity, so the
33+
// cache is keyed by managed thread id. PerfStress only renders on one UI
34+
// thread; the per-thread keying is defensive for hot reload / multi-window
35+
// futures.
36+
[ThreadStatic]
37+
private static Dictionary<string, WinMedia.SolidColorBrush>? t_brushCache;
38+
39+
static WinMedia.SolidColorBrush Brush(string color)
40+
{
41+
var cache = t_brushCache ??= new Dictionary<string, WinMedia.SolidColorBrush>(StringComparer.OrdinalIgnoreCase);
42+
if (!cache.TryGetValue(color, out var brush))
43+
{
44+
brush = BrushHelper.Parse(color);
45+
cache[color] = brush;
46+
}
47+
return brush;
48+
}
49+
2350
public override Element Render()
2451
{
2552
var (elementCount, setElementCount) = UseState(100);
@@ -233,11 +260,14 @@ async Task QSort(int lo, int hi)
233260
int val = sortState.Values[i];
234261

235262
// Each bar contains child controls to stress the reconciler:
236-
// a tiny progress indicator + a value label + a colored pip
263+
// a tiny progress indicator + a value label + a colored pip.
264+
// All Background() calls go through the cached brush so the
265+
// reconciler sees stable brush references between renders
266+
// and skips redundant WinUI Background writes.
237267
Element barContent = VStack(0,
238268
// Top: small colored indicator pip (changes with sort state)
239269
Border(Empty())
240-
.Background(isPivot ? "#ffffff" : isActive ? "#ffeb3b" : BarColors[(colorIdx + 1) % BarColors.Length])
270+
.Background(Brush(isPivot ? "#ffffff" : isActive ? "#ffeb3b" : BarColors[(colorIdx + 1) % BarColors.Length]))
241271
.CornerRadius(1)
242272
.Width(Math.Min(barWidth - 1, 6))
243273
.Height(2),
@@ -247,15 +277,15 @@ async Task QSort(int lo, int hi)
247277
: Empty(),
248278
// Bottom: progress-like fill showing relative position
249279
Border(Empty())
250-
.Background(BarColors[(colorIdx + 2) % BarColors.Length])
280+
.Background(Brush(BarColors[(colorIdx + 2) % BarColors.Length]))
251281
.CornerRadius(0)
252282
.Width(Math.Max(1, barWidth * 0.6))
253283
.Height(Math.Max(1, barHeight * 0.15))
254284
.Opacity(0.5)
255285
);
256286

257287
Element bar = Border(barContent)
258-
.Background(BarColors[colorIdx])
288+
.Background(Brush(BarColors[colorIdx]))
259289
.CornerRadius(0)
260290
.Width(barWidth)
261291
.Height(barHeight)
@@ -270,7 +300,7 @@ async Task QSort(int lo, int hi)
270300
return HStack(0, barElements).Height(220).VAlign(VerticalAlignment.Bottom);
271301
}))
272302
.CornerRadius(8)
273-
.Background("#1a1a2e")
303+
.Background(Brush("#1a1a2e"))
274304
.Padding(8),
275305

276306
// Performance stats
@@ -315,7 +345,7 @@ async Task QSort(int lo, int hi)
315345
double h = Math.Min(50, t * 10); // 1ms = 10px
316346
string color = t < 2 ? "#81c784" : t < 8 ? "#fff176" : t < 16 ? "#ff8a65" : "#e57373";
317347
return (Element)Border(Empty())
318-
.Background(color)
348+
.Background(Brush(color))
319349
.CornerRadius(0)
320350
.Width(Math.Max(1, 600.0 / 100))
321351
.Height(Math.Max(1, h))
@@ -337,7 +367,7 @@ async Task QSort(int lo, int hi)
337367

338368
static Element LegendItem(string color, string label) =>
339369
HStack(4,
340-
Border(Empty()).Background(color).CornerRadius(2).Width(12).Height(12),
370+
Border(Empty()).Background(Brush(color)).CornerRadius(2).Width(12).Height(12),
341371
Caption(label).Foreground(SecondaryText)
342372
);
343373
}

src/Reactor/Hosting/ReactorHost.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ public sealed class ReactorHost : IDisposable
104104
private readonly Stopwatch _reportClock = Stopwatch.StartNew();
105105
private long _totalRenderCount;
106106

107+
// Last render's total duration (tree + reconcile + effects), in ms.
108+
// Read by RequestRender to demote the next enqueue to Low priority when a
109+
// slow render is starving the dispatcher of input/layout/paint slots.
110+
// Published via Interlocked.Exchange / read via Volatile.Read because the
111+
// write happens on the UI thread inside Render() but RequestRender() can
112+
// be called from any thread — a plain double write is not guaranteed
113+
// atomic on 32-bit and lacks the publication semantics this contract
114+
// implies. See RenderPriorityPolicy.
115+
private double _lastRenderMs;
116+
107117
// Public perf snapshot — updated every ~1 second, readable from components
108118
private RenderStats _stats;
109119

@@ -480,7 +490,15 @@ internal void RequestRender(bool force = false)
480490
return;
481491
}
482492

483-
_dispatcherQueue.TryEnqueue(RenderLoop);
493+
// Demote to Low priority when the previous render exceeded the frame
494+
// budget — high-frequency setState sources (animation, simulation,
495+
// streaming data) otherwise pack the dispatcher with back-to-back
496+
// Normal-priority renders and starve input/layout/paint. See
497+
// RenderPriorityPolicy. Volatile.Read pairs with Interlocked.Exchange
498+
// in Render() so an off-UI-thread caller observes the latest value.
499+
_dispatcherQueue.TryEnqueue(
500+
RenderPriorityPolicy.PickPriority(Volatile.Read(ref _lastRenderMs)),
501+
RenderLoop);
484502
}
485503

486504
private void RenderLoop()
@@ -717,6 +735,14 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode)
717735

718736
double effectsMs = _phaseSw.Elapsed.TotalMilliseconds;
719737

738+
// Feed RenderPriorityPolicy so the next RequestRender knows whether
739+
// to demote to Low priority. Stored as the most-recent measurement
740+
// — no smoothing — so a single slow render is enough to back off,
741+
// and a single fast render is enough to return to Normal priority.
742+
// Interlocked publishes the value to off-UI-thread RequestRender
743+
// callers; the matching Volatile.Read is in RequestRender.
744+
Interlocked.Exchange(ref _lastRenderMs, treeBuildMs + reconcileMs + effectsMs);
745+
720746
OnRenderComplete?.Invoke(treeBuildMs, reconcileMs, effectsMs);
721747

722748
#if DEBUG

src/Reactor/Hosting/ReactorHostControl.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ public sealed partial class ReactorHostControl : ContentControl, IDisposable
8989
private readonly Stopwatch _reportClock = Stopwatch.StartNew();
9090
private long _totalRenderCount;
9191

92+
// Last render's total duration (tree + reconcile + effects), in ms.
93+
// Read by RequestRender to demote the next enqueue to Low priority when a
94+
// slow render is starving the dispatcher of input/layout/paint slots.
95+
// Published via Interlocked.Exchange / read via Volatile.Read — see the
96+
// matching note in ReactorHost.
97+
private double _lastRenderMs;
98+
9299
// Public perf snapshot — updated every ~1 second, readable from components
93100
private RenderStats _stats;
94101

@@ -299,7 +306,13 @@ private void RequestRender()
299306
// Between renders: CAS 0→1 gates a single TryEnqueue.
300307
if (Interlocked.CompareExchange(ref _renderPending, 1, 0) != 0) return;
301308

302-
_dispatcherQueue.TryEnqueue(RenderLoop);
309+
// Demote to Low priority after a slow render so input/layout/paint
310+
// catch up. See RenderPriorityPolicy and the matching code in
311+
// ReactorHost.RequestRender. Volatile.Read pairs with the
312+
// Interlocked.Exchange in Render().
313+
_dispatcherQueue.TryEnqueue(
314+
RenderPriorityPolicy.PickPriority(Volatile.Read(ref _lastRenderMs)),
315+
RenderLoop);
303316
}
304317

305318
private void RenderLoop()
@@ -509,6 +522,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode)
509522

510523
double effectsMs = _phaseSw.Elapsed.TotalMilliseconds;
511524

525+
// Feed RenderPriorityPolicy. Interlocked publishes to off-UI-thread
526+
// RequestRender callers. See matching note in ReactorHost.Render.
527+
Interlocked.Exchange(ref _lastRenderMs, treeBuildMs + reconcileMs + effectsMs);
528+
512529
OnRenderComplete?.Invoke(treeBuildMs, reconcileMs, effectsMs);
513530

514531
#if DEBUG
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using Microsoft.UI.Dispatching;
2+
3+
namespace Microsoft.UI.Reactor.Hosting;
4+
5+
/// <summary>
6+
/// Decides the dispatcher-queue priority for the next render enqueue based on
7+
/// recent render duration.
8+
/// <para>
9+
/// When a render is faster than a 60 Hz frame, the next render is enqueued at
10+
/// <see cref="DispatcherQueuePriority.Normal"/> — the default, lowest latency.
11+
/// When the previous render exceeded the budget, subsequent renders are demoted
12+
/// to <see cref="DispatcherQueuePriority.Low"/> so that input, layout, and paint
13+
/// messages on the same UI thread are interleaved between renders. Without this,
14+
/// a high-frequency state-change source (animation, simulation, streaming data)
15+
/// can fill the dispatcher with back-to-back renders that starve pointer/keyboard
16+
/// input — the app feels frozen even though renders are still committing pixels.
17+
/// </para>
18+
/// </summary>
19+
internal static class RenderPriorityPolicy
20+
{
21+
/// <summary>
22+
/// Render-duration ceiling beyond which subsequent renders are demoted to
23+
/// Low priority. 16 ms is one 60 Hz frame; a render past this point gives
24+
/// up its Normal-priority slot so input/layout/paint catch up.
25+
/// </summary>
26+
public const double DefaultFrameBudgetMs = 16.0;
27+
28+
/// <summary>
29+
/// Decide the priority for the next render enqueue.
30+
/// Returns <see cref="DispatcherQueuePriority.Low"/> when the last render
31+
/// exceeded the budget, <see cref="DispatcherQueuePriority.Normal"/>
32+
/// otherwise (including the cold-start case where no render has run yet
33+
/// and <paramref name="lastRenderMs"/> is 0).
34+
/// </summary>
35+
public static DispatcherQueuePriority PickPriority(
36+
double lastRenderMs,
37+
double budgetMs = DefaultFrameBudgetMs)
38+
=> lastRenderMs > budgetMs
39+
? DispatcherQueuePriority.Low
40+
: DispatcherQueuePriority.Normal;
41+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using Microsoft.UI.Dispatching;
2+
using Microsoft.UI.Reactor.Hosting;
3+
using Xunit;
4+
5+
namespace Microsoft.UI.Reactor.Tests.Hosting;
6+
7+
/// <summary>
8+
/// The render-loop responsiveness fix for PerfStress at 250/500 elements:
9+
/// when a render exceeds the 60 Hz frame budget, the host enqueues the next
10+
/// render at <see cref="DispatcherQueuePriority.Low"/> so the message pump can
11+
/// interleave input/layout/paint. Without this, back-to-back Normal-priority
12+
/// renders triggered from <c>await Task.Delay</c> continuations starve UI
13+
/// input and the app feels frozen until the work finishes.
14+
///
15+
/// These tests pin the policy's decisions independent of the dispatcher so
16+
/// regressions show up at unit-test time rather than as a UI freeze in the
17+
/// PerfStress demo.
18+
/// </summary>
19+
public class RenderPriorityPolicyTests
20+
{
21+
[Fact]
22+
public void ColdStart_UsesNormalPriority()
23+
{
24+
// Before any render has run, lastRenderMs == 0. The host MUST use
25+
// Normal priority — Low-priority cold-start would queue behind every
26+
// pending dispatcher item and delay first paint noticeably.
27+
Assert.Equal(
28+
DispatcherQueuePriority.Normal,
29+
RenderPriorityPolicy.PickPriority(lastRenderMs: 0));
30+
}
31+
32+
[Fact]
33+
public void FastRender_StaysAtNormalPriority()
34+
{
35+
// A render that fits inside one 60 Hz frame keeps Normal priority.
36+
// Demoting fast renders would add latency for no benefit.
37+
Assert.Equal(
38+
DispatcherQueuePriority.Normal,
39+
RenderPriorityPolicy.PickPriority(lastRenderMs: 8));
40+
}
41+
42+
[Fact]
43+
public void RenderAtFrameBoundary_StaysAtNormalPriority()
44+
{
45+
// Exactly at the budget is treated as "fit" — only renders strictly
46+
// longer than the budget demote. This avoids flip-flopping when a
47+
// render lands right at the boundary.
48+
Assert.Equal(
49+
DispatcherQueuePriority.Normal,
50+
RenderPriorityPolicy.PickPriority(lastRenderMs: RenderPriorityPolicy.DefaultFrameBudgetMs));
51+
}
52+
53+
[Fact]
54+
public void SlowRender_DemotesToLowPriority()
55+
{
56+
// This is the PerfStress fix: a render that exceeds one frame budget
57+
// moves the next enqueue to Low priority. The PerfStress scenario
58+
// tops out at ~100 ms/render at 500 elements — well past the budget.
59+
Assert.Equal(
60+
DispatcherQueuePriority.Low,
61+
RenderPriorityPolicy.PickPriority(lastRenderMs: 100));
62+
}
63+
64+
[Fact]
65+
public void JustOverBudget_DemotesToLowPriority()
66+
{
67+
// Even a small overrun (just past the 16 ms ceiling) demotes —
68+
// the goal is to keep the dispatcher from monopolizing the UI thread.
69+
Assert.Equal(
70+
DispatcherQueuePriority.Low,
71+
RenderPriorityPolicy.PickPriority(lastRenderMs: RenderPriorityPolicy.DefaultFrameBudgetMs + 0.5));
72+
}
73+
74+
[Fact]
75+
public void CustomBudget_DemotesPastCustomCeiling()
76+
{
77+
// A perf-sensitive host can raise or lower the ceiling. The policy
78+
// honors the override.
79+
Assert.Equal(
80+
DispatcherQueuePriority.Low,
81+
RenderPriorityPolicy.PickPriority(lastRenderMs: 12, budgetMs: 8));
82+
83+
Assert.Equal(
84+
DispatcherQueuePriority.Normal,
85+
RenderPriorityPolicy.PickPriority(lastRenderMs: 12, budgetMs: 32));
86+
}
87+
88+
[Fact]
89+
public void DefaultBudget_IsOneFrameAt60Hz()
90+
{
91+
// Pin the budget so a future "just bump it up" change is a conscious
92+
// edit, not a silent regression.
93+
Assert.Equal(16.0, RenderPriorityPolicy.DefaultFrameBudgetMs);
94+
}
95+
96+
[Fact]
97+
public void ReactorHost_TracksLastRenderMs()
98+
{
99+
// The wiring contract: ReactorHost stores the most-recent render
100+
// duration so RequestRender can consult RenderPriorityPolicy.
101+
// Without this field, the policy can't see a slow render and the
102+
// PerfStress regression returns.
103+
var field = typeof(ReactorHost).GetField("_lastRenderMs",
104+
global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Instance);
105+
Assert.NotNull(field);
106+
Assert.Equal(typeof(double), field!.FieldType);
107+
}
108+
109+
[Fact]
110+
public void ReactorHostControl_TracksLastRenderMs()
111+
{
112+
// Symmetric contract with ReactorHost — embedded hosts must also
113+
// demote to Low priority on slow renders.
114+
var field = typeof(ReactorHostControl).GetField("_lastRenderMs",
115+
global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Instance);
116+
Assert.NotNull(field);
117+
Assert.Equal(typeof(double), field!.FieldType);
118+
}
119+
}

0 commit comments

Comments
 (0)