性能¶
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 | Render 是 0x3,Reconcile | Render | EventDispatch
是 0x43。
采集纯 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 工具里
较易上手的:
Microsoft-Windows-XAML 上的 * 前缀表示按名称注册它
(PerfView 会替你解析 GUID)。复现完之后停止采集、
打开生成的 .etl,并使用 Events 视图 —— 而不是
调用栈视图 —— 来读取 Reactor 的计时数据。筛选提供程序
Microsoft-UI-Reactor,按耗时微秒数排序,
最慢的那一轮就会浮到顶部。
读一个区间:协调器入口¶
每一轮协调都从此开始。事件 id 对是 1 / 2
(ReconcileStart / 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 Update、
Anim Drops 与 GC Gen0 的差值就是回归信号。
提示¶
采集时钉住级别。 dotnet-trace 默认是
Verbose,它会捕获每一次 StateChange 写入。对于协调/渲染工作流,
钉到 Informational(:4),跟踪会缩小大约一个数量级 —— 一次原本 1.2 GB 的
30 秒会话会变成约 80 MB。
先动用叠加层,再考虑跟踪。 协调高亮 叠加层启用成本为零, 能在你根本不打开性能分析器的情况下解决「哪个组件重渲染过多」。 当你需要耗时微秒数时,跟踪才是对的工具;当你需要知道 「是哪一个」时,叠加层才是对的工具。
与匹配的 *.Direct 基准做对比。 孤立的一个 Reactor 基准
结果很难解释。在同一台硬件上跑一下兄弟
PerfBench.*.Direct 变体,两者的差值会告诉你声明式组合
相对原生 WinUI 的成本 —— 那是框架开销数字,而不是绝对
数字。
后续阅读¶
- 性能插桩 —— 底层原理配套篇:每个事件为何存在、IsEnabled 门、如何新增事件 id。
- 开发工具 —— 协调高亮叠加层以及
murCLI 的其余部分。 - DevTools 内部机制 —— 叠加层如何读取与性能分析器相同的提供程序事件。
- 协调 ——
ReconcileStop的计数器在描述什么;如何读键控子列表差异比对。 - Hook ——
UseMemo的依赖从何而来,以及什么让它们不稳定。