Skip to content

性能

Microsoft.UI.Reactor(Reactor)应用会对自己所做的工作发出一份结构化账目 —— 每一轮 协调、每一次组件渲染、每一次副作用刷新、每一次状态写入、每一次 WinUI 路由事件跳转, 开始与结束各一个事件 —— 全部通过一个名为 Microsoft-UI-Reactor 的托管 EventSource 发出。你挂上性能分析器、掩码出你关心的关键字、 运行场景,然后读取得到的区间层级。本页按自顶向下的顺序走一遍流程: 如何用 dotnet-trace 或 PerfView 采集跟踪、每个区间意味着什么、 如何找出离群值,以及如何从 tests/perf_bench/ 复现一个 基准测试,好让回归背后有数字支撑。关于「这条流水线如何发出这些事件」的深层故事在 性能插桩 —— 如果你要新增事件 id,请接着读那一篇, 但不要在你还没采过第一次跟踪之前就读它。

参考

工具 来源 你读到什么 最适合
dotnet-trace EventPipe 在 speedscope.app 或 VS 分析器中打开的 .nettrace 任意操作系统上的纯 Reactor 计时,对 AOT 友好
PerfView / xperf 经典 ETW 在 PerfView 的 Events 视图或 WPA 中打开的 .etl 可能来自 Reactor WinUI 原生的帧卡顿
tests/perf_bench/PerfBench.* 进程内 BenchTracker 可执行文件旁的 *.report.txt 用 FPS/update-ms/GC 数字复现某项回归
协调高亮叠加层 Dev 菜单 每次重渲染闪出的矩形 不采跟踪就能找出是哪个组件重渲染了

协调高亮叠加层记录在 开发工具 中;当你唯一的信号只是「感觉有点卡」时, 先动用它。性能分析跟踪是下一个台阶:它的搭建成本更高, 但会给你每个区间的耗时微秒数,以及一份可以排序的完整层级。

提供程序及其关键字

Reactor 提供程序的事件分布在七个关键字之上。消费方 只掩码出自己想要的那些,因此一次「只看协调」的跟踪不会 为状态写入、MCP 流量或蹦床调度付出代价:

public static class Keywords
{
    public const EventKeywords Reconcile = (EventKeywords)0x1;
    public const EventKeywords Render = (EventKeywords)0x2;
    public const EventKeywords State = (EventKeywords)0x4;
    public const EventKeywords Mcp = (EventKeywords)0x8;
    public const EventKeywords Lifecycle = (EventKeywords)0x10;
    public const EventKeywords Errors = (EventKeywords)0x20;
    public const EventKeywords EventDispatch = (EventKeywords)0x40;
    // Spec 044 — subsystem coverage gaps. Each gets its own bit so a
    // consumer (dotnet-trace, EventListener, ReactorTrace.Subscribe) can
    // pick exactly the area it cares about without paying for the rest.
    public const EventKeywords Hosting = (EventKeywords)0x80;       // Window/HWND/DPI/Backdrop
    public const EventKeywords Persistence = (EventKeywords)0x100;  // settings store, placement
    public const EventKeywords Navigation = (EventKeywords)0x200;   // route push, cache, transitions
    public const EventKeywords Intl = (EventKeywords)0x400;         // missing keys, fallback, format
    public const EventKeywords Theme = (EventKeywords)0x800;        // theme apply, bindings
    public const EventKeywords Shell = (EventKeywords)0x1000;       // JumpList/Tray/ThumbnailToolbar
    public const EventKeywords HotReload = (EventKeywords)0x2000;   // spec 049 — state migration across edits
}

Reconcile 覆盖协调轮次的边界与子列表差异比对, Render 覆盖逐组件的渲染计时与副作用刷新边界, State 覆盖 UseState 写入,Mcp 覆盖 开发工具工具调度,Lifecycle 覆盖挂载与 卸载,Errors 是严重级别为 Error 的事件,EventDispatch 覆盖 把 WinUI 路由事件扇出到你的 Reactor 回调的那个蹦床。 这些值都是 EventKeywords 上的 2 的幂标志,因此 Reconcile | Render0x3Reconcile | Render | EventDispatch0x43

自顶向下的性能分析流程 —— 从运行中的应用经 dotnet-trace 或 PerfView 进入 Reactor 区间层级

采集纯 Reactor 跟踪

对于任意操作系统上的纯 Reactor 工作流,dotnet-trace 是 正确的工具。它会写出一个 .nettrace 文件,speedscope.app、Visual Studio 的分析器与 PerfView 都能打开:

dotnet tool install --global dotnet-trace
dotnet-trace collect --process-id <pid> --providers Microsoft-UI-Reactor:0x3:4

0x3 掩码出 Reconcile | Render:4 把级别钉在 Informational。钉住级别很重要:默认是 Verbose, 意味着每一个 StateChange 事件都会进入会话 —— 一个噪声大的应用 每分钟可能写出数百万条,跟踪会从几十 兆字节涨到几千兆字节。当你追查路由事件回归时,加上 EventDispatch 关键字 (合计 0x43);其余情况不要加。

speedscope.app 中打开生成的文件, 选择「Time order」视图 —— 那就是火焰图。每一对 ReconcileStart/ReconcileStop 就是一轮协调;下面嵌套的 ChildReconcileStart/ChildReconcileStop 带则是 该轮内部的键控子列表差异比对。

采集关联跟踪(Reactor + WinUI)

当一次帧卡顿可能来自 Reactor、WinUI 或合成器时, 你需要 ETW,因为 Microsoft-Windows-XAML 是一个不会 经 EventPipe 流动的原生提供程序。PerfView 是两种 ETW 工具里 较易上手的:

perfview /providers="Microsoft-UI-Reactor:0x3:4,*Microsoft-Windows-XAML" collect

Microsoft-Windows-XAML 上的 * 前缀表示按名称注册它 (PerfView 会替你解析 GUID)。复现完之后停止采集、 打开生成的 .etl,并使用 Events 视图 —— 而不是 调用栈视图 —— 来读取 Reactor 的计时数据。筛选提供程序 Microsoft-UI-Reactor,按耗时微秒数排序, 最慢的那一轮就会浮到顶部。

读一个区间:协调器入口

每一轮协调都从此开始。事件 id 对是 1 / 2ReconcileStart / ReconcileStop),ReconcileStop 上报的 计数器就是该轮的成本摘要:

bool traceEnabled = Diagnostics.ReactorEventSource.Log.IsEnabled(
    global::System.Diagnostics.Tracing.EventLevel.Informational,
    Diagnostics.ReactorEventSource.Keywords.Reconcile);
bool emitTrace = traceEnabled && _reconcileTraceDepth++ == 0;
if (emitTrace)
{
    Diagnostics.ReactorEventSource.Log.ReconcileStart(
        newElement?.GetType().Name ?? "null");
}

与之配对的 finally 恰好递减它递增过的那个深度, 并且只为最外层那一轮发出 stop 载荷:

if (traceEnabled)
{
    _reconcileTraceDepth--;
    if (emitTrace)
    {
        Diagnostics.ReactorEventSource.Log.ReconcileStop(
            DebugElementsDiffed, DebugElementsSkipped,
            DebugUIElementsCreated, DebugUIElementsModified);
    }
}

只有顶层轮次才会发出 —— 深度计数器抑制了单轮向子树所做的那些嵌套 Reconcile() 调用,因此查看器里的一条带就是一次 用户可见的更新,而不是数百个相互重叠的碎片。真实代码位于 src/Reactor/Core/Reconciler.cs 中的 Reconciler.Reconcile

在火焰图查看器里,你会看到一条上边缘为 ReconcileStart、下边缘为 ReconcileStop 的带。这条带的 宽度就是该轮的挂钟时长。悬停在带上,载荷字段就会显示出来: elementsDiffed 是协调器遍历过的元素 记录数,elementsSkipped 是它因 ShouldUpdate 或引用相等而 提前退出的数量,uiElementsCreated 是本轮分配的 WinUI FrameworkElement 实例数,uiElementsModified 是对既有控件做属性写入的 次数。

elementsSkipped 高而轮次短,是健康的情形 —— 提前退出机制在起作用。elementsSkipped 低而轮次长,通常 意味着 UseMemo 的依赖数组不稳定:一个 新数组或新闭包作为依赖会让每次渲染看起来都是新的, 提前退出就无法触发。协调高亮 叠加层 比跟踪更廉价地捕获这一点 —— 打开它, 看看你每次打字时哪个组件在闪。

注意: Debug 构建会关闭协调器的若干提前退出优化, 以便暴露不变式违规。同一场景在 Debug 构建上会显示 elementsDiffed 为 Release 的 3 到 4 倍,耗时微秒数大约为 Release 的 2 倍。请始终针对 -c Release 做性能分析;来自 Debug 构建的 火焰图不是关于 Release 性能的有效信号。

EventDispatch 关键字

EventDispatch 关键字覆盖了逐事件的蹦床,它让 Reactor 能在两次渲染之间替换你的处理函数闭包,而无需 与 WinUI 事件解绑:

private static void EnsureSizeChangedSubscribed(FrameworkElement fe, ModifierEventHandlerState state, Action<object, SizeChangedEventArgs>? handler)
{
    state.CurrentSizeChanged = handler;
    if (state.SizeChangedTrampoline is null && handler is not null)
    {
        state.SizeChangedTrampoline = (s, e) =>
        {
            Diagnostics.ReactorEventSource.Log.EventTrampolineDispatch("SizeChanged");
            state.CurrentSizeChanged?.Invoke(s!, e);
        };
        fe.SizeChanged += state.SizeChangedTrampoline;
        Diagnostics.ReactorEventSource.Log.EventTrampolineAttached("SizeChanged", fe.GetType().Name);
    }
}

这个模式之所以与性能有关,是因为蹦床在每个元素的生命周期中只绑定 一次 —— 之后每次渲染给元素传入新回调时,更新的都是 state 里的一个字段,而不是 WinUI 的事件订阅列表。没有这个技巧, 每次改变处理函数的渲染都要调用 fe.SizeChanged -=fe.SizeChanged +=;在底层,WinUI 每次都要遍历一条链表,而在有数百个已订阅元素的列表上 你会看到可测量的损耗。

当你确实看到 EventTrampolineDispatch 事件在一次 verbose 级跟踪中占据主导时,那是蹦床在干它真正的活 —— WinUI 每发出一个路由事件,这些事件就在 UI 线程上触发。 除非你在专门调试输入或指针流,否则请把 EventDispatch 掩码掉 (用 0x3 而不是 0x43)。

复现基准测试

回归结论需要数字,而 tests/perf_bench/ 下的基准测试工具链 就是取得数字的方式。每个基准有三个兄弟 变体 —— *.Reactor*.Direct(原生 WinUI)与 *.Bound (WinUI + MVVM 绑定)—— 因此你可以把 Reactor 与其 非声明式基线做比较。入口就是标准的顶层 ReactorApp.Run 调用,外加一个 CLI 选项解析器:

var opts = BenchCliOptions.Parse(args);
if (opts.Headless) ConsoleHelper.EnsureConsole();
DirtyTrackingApp.Opts = opts;
ReactorApp.Run<DirtyTrackingApp>("EXP-1 DirtyTracking.Reactor", fullScreen: true);

--headless --duration 10 运行,应用会在可执行文件旁写出一个 *.report.txt,然后退出。报告包含工具链逐帧记录的 指标 —— FPS、更新毫秒数、 内存、GC 各代回收次数、最长帧阻塞:

sb.AppendLine($"Avg FPS:       {_fpsSamples.Average():F1}");
sb.AppendLine($"Min FPS:       {_fpsSamples.Min():F1}");
sb.AppendLine($"Max FPS:       {_fpsSamples.Max():F1}");

if (_updateTimeSamples.Count > 0)
{
    sb.AppendLine($"Avg Update:    {_updateTimeSamples.Average():F2} ms");
    sb.AppendLine($"Max Update:    {_updateTimeSamples.Max():F2} ms");
}

sb.AppendLine($"Avg Memory:    {_memorySamples.Average() / (1024 * 1024):F1} MB");
sb.AppendLine($"Peak Memory:   {_memorySamples.Max() / (1024 * 1024):F1} MB");

// GC
int gen0Total = GC.CollectionCount(0) - _baselineGen0;
int gen1Total = GC.CollectionCount(1) - _baselineGen1;
int gen2Total = GC.CollectionCount(2) - _baselineGen2;
sb.AppendLine($"GC Gen0:       {gen0Total}");
sb.AppendLine($"GC Gen1:       {gen1Total}");
sb.AppendLine($"GC Gen2:       {gen2Total}");

// Frame jank
sb.AppendLine($"Longest Block: {_longestFrameBlockMs:F1} ms");
sb.AppendLine($"Anim Drops:    {_animationDrops}");

要在本地复现某个回归:

dotnet build tests/perf_bench/PerfBench.DirtyTracking/DirtyTracking.Reactor -c Release
./tests/perf_bench/PerfBench.DirtyTracking/DirtyTracking.Reactor/bin/<plat>/Release/<tfm>/DirtyTracking.Reactor.exe --headless --duration 30
cat ./tests/.../bin/<plat>/Release/<tfm>/EXP1_DirtyTracking_Reactor.report.txt

main 与你的分支上重跑同一条命令;Avg UpdateAnim DropsGC Gen0 的差值就是回归信号。

提示

采集时钉住级别。 dotnet-trace 默认是 Verbose,它会捕获每一次 StateChange 写入。对于协调/渲染工作流, 钉到 Informational:4),跟踪会缩小大约一个数量级 —— 一次原本 1.2 GB 的 30 秒会话会变成约 80 MB。

先动用叠加层,再考虑跟踪。 协调高亮 叠加层启用成本为零, 能在你根本不打开性能分析器的情况下解决「哪个组件重渲染过多」。 当你需要耗时微秒数时,跟踪才是对的工具;当你需要知道 「是哪一个」时,叠加层才是对的工具。

与匹配的 *.Direct 基准做对比。 孤立的一个 Reactor 基准 结果很难解释。在同一台硬件上跑一下兄弟 PerfBench.*.Direct 变体,两者的差值会告诉你声明式组合 相对原生 WinUI 的成本 —— 那是框架开销数字,而不是绝对 数字。

后续阅读

  • 性能插桩 —— 底层原理配套篇:每个事件为何存在、IsEnabled 门、如何新增事件 id。
  • 开发工具 —— 协调高亮叠加层以及 mur CLI 的其余部分。
  • DevTools 内部机制 —— 叠加层如何读取与性能分析器相同的提供程序事件。
  • 协调 —— ReconcileStop 的计数器在描述什么;如何读键控子列表差异比对。
  • Hook —— UseMemo 的依赖从何而来,以及什么让它们不稳定。