FPS Monitor
gpui-fps 在窗口上叠加一个性能 HUD:一个主读数、一条滚动的帧耗时曲线,以及本进程的
CPU、GPU 与内存。它只依赖 gpui,任何 GPUI 应用都能用。
use gpui_fps::fps_monitor;
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.relative()
.size_full()
.child(self.content.clone())
.when(self.show_fps, |this| this.child(fps_monitor(window, cx)))
}
父元素必须是 relative()——HUD 用绝对定位;是否显示由调用方自己决定。
#主读数
那个大数字回答两个问题之一,MAX 标记说明当前是哪一个。右键切换,左键折叠成一个小标签。
| 读的是 | 含义 | |
|---|---|---|
MAX FPS(默认) | 1 / FRAME,并夹在显示器刷新率上 | 这个窗口做一次完整重绘能撑住的速率 |
FPS | 每秒 present 的帧数 | 窗口实际在画多快 |
这是两个不同的问题,而按需绘制的应用给出的答案差得很远:一个空闲的窗口每秒画两帧,但它 其实能画一百二十帧——只有其中一个数字代表性能问题。
#为什么 MAX 是推导出来的,而不是数出来的
要让帧计数器读出”这个 UI 能跑多快”,最直接的做法是像游戏里的帧数器那样不停地要帧。但在
这里这件事并不免费:标脏任意一个 view 排的是一次窗口绘制,GPUI 会重新 render 该窗口中
除 Entity::cached 边界后面之外的所有 view —— 于是 HUD 每要一帧,代价就是应用的一次
完整 layout 与 paint,而下面那行 CPU 报的正是 HUD 自己制造出来的开销。在 story gallery 的
Table 页上,这意味着没人碰窗口时也有约 62% 的 CPU。
而帧耗时本身已经回答了这个问题。FRAME 就是一次完整重绘的成本,取倒数就是这种重绘能撑住
的速率,一帧都不用多画。HUD 从不请求帧。
#为什么是「问平台」而不是「测出来」
3ms 的一帧会读成 333,没有任何面板能显示这个数。数 present 的时候这个上界是免费的(帧走 vsync 交给合成器),而从帧耗时推导出来的数字没有这个天花板,所以必须显式地夹。
它推不出来。 present 之间的间隔是面板周期的整数倍,所以只能给出刷新率的下界,永远给不出 上界:41.7ms 在 144Hz 屏上是 6 个刷新周期,在 24Hz 屏上是 1 个,时序本身无法区分。试过的每一种 估计法都在真实窗口上读错过——取最短间隔得到 169、取最稠密的一组得到 149、隔帧绘制的窗口得到 75、 而一个自身定时器每 41.7ms 触发一次的应用得到 24。
所以改成向平台索取。GPUI 通过 DisplayId 把平台自己的显示器句柄透了出来,HUD 从那里接手:
- macOS —— 用
CGDirectDisplayID调CGDisplayCopyDisplayMode。内置屏报告「没有固定速率」, 在 ProMotion 上这是实情,按「不夹」处理。 - Windows —— 用显示器的设备名调
EnumDisplaySettingsW。 - Wayland —— 另开一个连接重新枚举 outputs,再按 GPUI 从名字派生的标识与它的 display 对上; 因为对象 id 是每连接独立的,跨连接没有意义。
- X11 及其它 —— 没有查询,也就不夹。
窗口移动到另一块显示器时会重新索取,其余时候不会。没人能给出答案时保持不夹,而不是按猜测夹: 夹到真值以下会把读者想看的数字藏起来。
#各行含义
| 行 | 测的是 |
|---|---|
INTERVAL | present 之间的平均间隔,也就是平台自带 overlay 里的 frame interval,FPS 的倒数。它与 MAX 差得越大,说明窗口越空闲,而不是越慢。 |
FRAME | Window::draw 的平均耗时,按帧预算着色。觉得卡的时候看这一行。 |
P95 | 同一批帧的慢尾,着色规则相同。 |
DROP | 超出预算的帧占比。 |
INV | 合并进同一帧的失效次数。明显大于 1 说明窗口被要求重绘的频率超过了它能画的频率。 |
CPU | 本进程,采用 top / 活动监视器的口径:100 表示占满一个核,占用一核半读作 140。 |
MEM | 常驻内存。 |
FRAME、P95、DROP 按帧预算着色:平台报得出上面那个刷新率时,预算就是窗口所在面板的一次刷新;
报不出时是一个 60Hz 帧。frame_budget() 可以钉一个自己的预算,之后面板不再替换它。
#最初的几帧不计入统计
窗口最初的几帧是最贵的——着色器、字形图集、图标,所有缓存都是冷的——它们并不代表应用的运行成本。 其中一帧可能是 100ms,而预算只有 16ms;只看过八帧的 HUD 会把它算成窗口十二分之一的工作量,用琥珀色 标出来,而此时读它的人什么都还没做。
所以 sampler 会丢掉两部分:HUD 挂载之前 GPUI 记录的全部帧(要么是别人的历史,要么是冷启动), 以及挂载之后的最初几帧。刚打开的窗口,默认读数就应该是健康的。
#HUD 自己的开销
每 500ms 一帧。它不驱动帧循环,但需要一个时钟——否则在一个已经停止绘制的窗口里没有任何东西能唤醒它, 读数会冻结在应用最后一次绘制的值上。这个时钟同时承担 CPU、GPU 与内存的采样。
这些帧不计入读数。对 GPUI 来说,时钟的 notify 和任何一次失效没有区别,都会换来一次整窗绘制;
如果留在读数里,就是每 500ms 一帧冷帧被当成应用的 FRAME 和 MAX。所以时钟每次触发都会先告知采样器,
采样器把回应它的那次绘制排除在外——除非应用自己也请求了这一帧,那这份工作本来就是应用要的,成本照算。
隐藏起来的 HUD 没有任何开销。连续两个 tick(一秒)没有被渲染,时钟就停下,资源探针随之停止, frame trace 也会放掉(除非别处还持有)。下一次渲染再从一个空的采样器重新开始:trace 缓冲区随开关被清空了, 中间那些帧也本来就不归谁报告。