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 从不请求帧。
#为什么 MAX 要夹在显示器刷新率上
数 present 的时候这个上界是免费的:帧走 vsync 交给合成器,所以计数在物理上就超不过刷新率。 推导出来的数字没有这个天花板——3ms 的一帧会读成 333,一个谁也看不到的速率——所以必须显式夹。
GPUI 不暴露刷新率,因此 sampler 从 present 之间的间隔里把它推断出来:
- 落在 3ms–50ms 之外的间隔直接丢弃。更短的是合成器追帧,不是刷新;更长的是应用根本没东西可画。
- 其余按 0.5ms 分组计数。一个间隔必须重复出现才算数:144Hz 屏上两帧相隔 5.9ms 是一次抖动, 不是 169Hz 的显示器。
- 估计值取”出现最多的那一组及其邻域”的均值——分组会把它要测的分布切断,只取众数桶本身会偏高。
- 当一组明显更快(至少快一倍)且样本量成规模时优先取它。可变刷新率的面板大部分时间都低于自己的 上限:ProMotion 窗口滚动时 120Hz、静止时 60Hz,该夹的是 120,而不是它恰好停在的那个速率。
- 结果若落在某个标准刷新率的 2.5% 以内则吸附过去,这样 144Hz 屏读出的是 144 而不是 146。 不在标准表里的面板保留自己的估计值,而不是被向上取整到一个它并不具备的上限。
在窗口连续绘制到足以说明问题之前——没人碰过的窗口永远达不到——不做夹取,MAX 就是帧耗时算出来的值。
#各行含义
| 行 | 测的是 |
|---|---|
INTERVAL | present 之间的平均间隔,也就是平台自带 overlay 里的 frame interval,FPS 的倒数。它与 MAX 差得越大,说明窗口越空闲,而不是越慢。 |
FRAME | Window::draw 的平均耗时,按帧预算着色。觉得卡的时候看这一行。 |
P95 | 同一批帧的慢尾,着色规则相同。 |
DROP | 超出预算的帧占比。 |
INV | 合并进同一帧的失效次数。明显大于 1 说明窗口被要求重绘的频率超过了它能画的频率。 |
CPU | 本进程,采用 top / 活动监视器的口径:100 表示占满一个核,占用一核半读作 140。 |
MEM | 常驻内存。 |
FRAME、P95、DROP 按 frame_budget() 设定的预算着色,默认是一个 60Hz 帧。高刷屏上请设为
1/144s,否则健康的帧也会被标成琥珀色。
#最初的几帧不计入统计
窗口最初的几帧是最贵的——着色器、字形图集、图标,所有缓存都是冷的——它们并不代表应用的运行成本。 其中一帧可能是 100ms,而预算只有 16ms;只看过八帧的 HUD 会把它算成窗口十二分之一的工作量,用琥珀色 标出来,而此时读它的人什么都还没做。
所以 sampler 会丢掉两部分:HUD 挂载之前 GPUI 记录的全部帧(要么是别人的历史,要么是冷启动), 以及挂载之后的最初几帧。刚打开的窗口,默认读数就应该是健康的。
#HUD 自己的开销
每 500ms 一帧。它不驱动帧循环,但需要一个时钟——否则在一个已经停止绘制的窗口里没有任何东西能唤醒它, 读数会冻结在应用最后一次绘制的值上。这个时钟同时承担 CPU、GPU 与内存的采样。