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 就是帧耗时算出来的值。

#各行含义

测的是
INTERVALpresent 之间的平均间隔,也就是平台自带 overlay 里的 frame interval,FPS 的倒数。它与 MAX 差得越大,说明窗口越空闲,而不是越慢。
FRAMEWindow::draw 的平均耗时,按帧预算着色。觉得卡的时候看这一行。
P95同一批帧的慢尾,着色规则相同。
DROP超出预算的帧占比。
INV合并进同一帧的失效次数。明显大于 1 说明窗口被要求重绘的频率超过了它能画的频率。
CPU本进程,采用 top / 活动监视器的口径:100 表示占满一个核,占用一核半读作 140。
MEM常驻内存。

FRAMEP95DROPframe_budget() 设定的预算着色,默认是一个 60Hz 帧。高刷屏上请设为 1/144s,否则健康的帧也会被标成琥珀色。

#最初的几帧不计入统计

窗口最初的几帧是最贵的——着色器、字形图集、图标,所有缓存都是冷的——它们并不代表应用的运行成本。 其中一帧可能是 100ms,而预算只有 16ms;只看过八帧的 HUD 会把它算成窗口十二分之一的工作量,用琥珀色 标出来,而此时读它的人什么都还没做。

所以 sampler 会丢掉两部分:HUD 挂载之前 GPUI 记录的全部帧(要么是别人的历史,要么是冷启动), 以及挂载之后的最初几帧。刚打开的窗口,默认读数就应该是健康的。

#HUD 自己的开销

每 500ms 一帧。它不驱动帧循环,但需要一个时钟——否则在一个已经停止绘制的窗口里没有任何东西能唤醒它, 读数会冻结在应用最后一次绘制的值上。这个时钟同时承担 CPU、GPU 与内存的采样。