Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions samples/Reactor.TestApp/Demos/PerfStressDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<string, WinMedia.SolidColorBrush>? t_brushCache;

static WinMedia.SolidColorBrush Brush(string color)
{
var cache = t_brushCache ??= new Dictionary<string, WinMedia.SolidColorBrush>(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);
Expand Down Expand Up @@ -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),
Expand All @@ -247,15 +277,15 @@ 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))
.Opacity(0.5)
);

Element bar = Border(barContent)
.Background(BarColors[colorIdx])
.Background(Brush(BarColors[colorIdx]))
.CornerRadius(0)
.Width(barWidth)
.Height(barHeight)
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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)
);
}
21 changes: 20 additions & 1 deletion src/Reactor/Hosting/ReactorHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ 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.
// See RenderPriorityPolicy.
private double _lastRenderMs;

// Public perf snapshot — updated every ~1 second, readable from components
private RenderStats _stats;

Expand Down Expand Up @@ -480,7 +486,14 @@ 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.
_dispatcherQueue.TryEnqueue(
RenderPriorityPolicy.PickPriority(_lastRenderMs),
RenderLoop);
}

Comment thread
codemonkeychris marked this conversation as resolved.
Outdated
private void RenderLoop()
Expand Down Expand Up @@ -717,6 +730,12 @@ 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.
_lastRenderMs = treeBuildMs + reconcileMs + effectsMs;

OnRenderComplete?.Invoke(treeBuildMs, reconcileMs, effectsMs);

#if DEBUG
Expand Down
15 changes: 14 additions & 1 deletion src/Reactor/Hosting/ReactorHostControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ 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.
private double _lastRenderMs;

// Public perf snapshot — updated every ~1 second, readable from components
private RenderStats _stats;

Expand Down Expand Up @@ -299,7 +304,12 @@ 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.
_dispatcherQueue.TryEnqueue(
RenderPriorityPolicy.PickPriority(_lastRenderMs),
Comment thread
codemonkeychris marked this conversation as resolved.
Outdated
RenderLoop);
}

private void RenderLoop()
Expand Down Expand Up @@ -509,6 +519,9 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode)

double effectsMs = _phaseSw.Elapsed.TotalMilliseconds;

// Feed RenderPriorityPolicy. See matching note in ReactorHost.Render.
_lastRenderMs = treeBuildMs + reconcileMs + effectsMs;

OnRenderComplete?.Invoke(treeBuildMs, reconcileMs, effectsMs);

#if DEBUG
Expand Down
41 changes: 41 additions & 0 deletions src/Reactor/Hosting/RenderPriorityPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Microsoft.UI.Dispatching;

namespace Microsoft.UI.Reactor.Hosting;

/// <summary>
/// Decides the dispatcher-queue priority for the next render enqueue based on
/// recent render duration.
/// <para>
/// When a render is faster than a 60 Hz frame, the next render is enqueued at
/// <see cref="DispatcherQueuePriority.Normal"/> — the default, lowest latency.
/// When the previous render exceeded the budget, subsequent renders are demoted
/// to <see cref="DispatcherQueuePriority.Low"/> 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.
/// </para>
/// </summary>
internal static class RenderPriorityPolicy
{
/// <summary>
/// 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.
Comment thread
codemonkeychris marked this conversation as resolved.
/// </summary>
public const double DefaultFrameBudgetMs = 16.0;

/// <summary>
/// Decide the priority for the next render enqueue.
/// Returns <see cref="DispatcherQueuePriority.Low"/> when the last render
/// exceeded the budget, <see cref="DispatcherQueuePriority.Normal"/>
/// otherwise (including the cold-start case where no render has run yet
/// and <paramref name="lastRenderMs"/> is 0).
/// </summary>
public static DispatcherQueuePriority PickPriority(
double lastRenderMs,
double budgetMs = DefaultFrameBudgetMs)
=> lastRenderMs > budgetMs
? DispatcherQueuePriority.Low
: DispatcherQueuePriority.Normal;
}
119 changes: 119 additions & 0 deletions tests/Reactor.Tests/Hosting/RenderPriorityPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using Microsoft.UI.Dispatching;
using Microsoft.UI.Reactor.Hosting;
using Xunit;

namespace Microsoft.UI.Reactor.Tests.Hosting;

/// <summary>
/// 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 <see cref="DispatcherQueuePriority.Low"/> so the message pump can
/// interleave input/layout/paint. Without this, back-to-back Normal-priority
/// renders triggered from <c>await Task.Delay</c> 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.
/// </summary>
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.
Comment thread
codemonkeychris marked this conversation as resolved.
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);
}
}
Loading