← 返回博客

优化 Metal 渲染管线以在 GPUI 中保持 120 FPS

2024 年 2 月 7 日

得益于一系列优化,Zed 在今天发布的 0.121 版本中比以往任何时候都更流畅,这些优化始于知名主播 Theo Browne 的餐桌上。在我们开源发布之后,Theo 在一段精彩视频中提供了许多宝贵的反馈,但最突出的是他报告的卡顿滚动性能。这让我们非常惊讶,因为我们在自己的硬件上从未遇到过这种情况。

Zed 的三位创始人碰巧都在旧金山,所以我们问 Theo 是否可以拜访并观察 Zed 在他的机器上运行的情况。果然,在 Theo 的 M2 MacBook 上,我们确实观察到 Zed 出现了在我们 M1 机器上不明显的掉帧,所以我们启用了他 Zed 副本上的 Metal HUD 进行调查。

To enable the metal HUD, you can run MTL_HUD_ENABLED=1 /Applications/Zed.app/Contents/MacOS/zed.
要启用 Metal HUD,您可以运行 MTL_HUD_ENABLED=1 /Applications/Zed.app/Contents/MacOS/zed。

立即引人注目的是,Zed 在他的 M2 上以直接模式运行,而在我们的 M1 上则以合成模式运行。在合成模式下,应用程序不直接写入显示器的主帧缓冲区,而是写入中间表面,Quartz 合成器将这些表面组合成最终场景。我们最近了解到,要在 M1 上启用直接模式,您必须全屏运行应用程序。我们很少启用该模式,但一旦启用,我们立即重现了 Theo 的问题。合成器会引入延迟,所以您可能会认为绕过它会使 Zed 性能更好,但我们观察到的情况恰恰相反。

我们很快就开始怀疑我们添加到 GPUI 的 MetalRenderer 中的逻辑,该逻辑旨在确保 AppKit 对我们窗口的重绘与我们通过 Metal 绘制的窗口内容正确同步。默认情况下,呈现到 CAMetalLayer 不会阻止操作系统绘制窗口,从而迫使系统通过拉伸前一帧的窗口内容直到下一帧内容到达来插值窗口内容。这对于视频游戏来说可能足够好,但它不适合桌面应用程序。

为了避免这种情况,我们对每个 GPUI 窗口根视图后面的 CAMetalLayer 启用了 presentsWithTransaction,它协调图层内容的呈现与当前 CoreAnimation 事务。我们还通过调用命令缓冲区上的 waitUntilCompleted 来阻止主线程呈现新窗口内容。这确保了主线程在我们完成呈现其内容之前无法完成绘制窗口。

以下是 MetalRenderer::draw 结尾处的几行代码:

self.instance_buffer.did_modify_range(NSRange {
    location: 0,
    length: instance_offset as NSUInteger,
});
command_buffer.commit();
// Blocks the thread to avoid jitter.
// We can't finish drawing the window until its contents are completed.
command_buffer.wait_until_completed();
drawable.present();

上面的代码包含一个 bug。在合成模式下,它工作得足够好,其中“完成”意味着像素已写入合成器的中间缓冲区。然而,在直接模式下,“完成”意味着像素实际上已写入显卡的帧缓冲区,并且我们观察到此调用在该状态下阻塞的时间明显更长。

解决方案是保留我们的同步,但通过调用 wait_until_scheduled 而不是 wait_until_completed 来稍微放松它。这确保了窗口内容计划与窗口本身同步交付,同时避免了不必要的长时间阻塞。

Antonio 在 Theo 的餐桌上构建了一个二进制文件,并空投给他以确认它解决了卡顿的滚动问题。问题解决了。

三重缓冲

嗯……不完全是。在我们急着赶 Uber 飞往 Boulder 的航班时,我们忽略了充分考虑我们更改的含义。合并后不久,Thorsten 和 Kirill 开始注意到我们光栅化输出中的损坏。

A screenshot of glitches in Zed due to memory corruption.
Zed 中由于内存损坏导致的故障截图。

看了一眼截图,我们得到了一个相当清晰的线索。通过从 wait_until_completed 切换到 wait_until_scheduled,我们引入了一个竞争条件。在某些情况下,当 GPU 从帧 N 读取内存时,Zed 正在写入相同的内存以准备绘制帧 N + 1。为了解决这个问题,我们将渲染完全同步时工作的单个实例缓冲区替换为多个实例缓冲区的池。我们在帧开始时从池中获取一个实例缓冲区,并在命令缓冲区完成后异步释放它。

// Acquire an instance buffer from the pool.
let mut instance_buffer = self.instance_buffer_pool.lock().pop().unwrap_or_else(|| {
    self.device.new_buffer(
        INSTANCE_BUFFER_SIZE as u64,
        MTLResourceOptions::StorageModeManaged,
    )
});
 
// Populate this buffer with primitives to draw in this frame
// ...
 
instance_buffer.did_modify_range(NSRange {
    location: 0,
    length: instance_offset as NSUInteger,
});
 
// Associate the command buffer with a "completed handler"
// which returns the instance buffer to the pool asynchronously
// once the frame is done rendering.
let instance_buffer_pool = self.instance_buffer_pool.clone();
let instance_buffer = Cell::new(Some(instance_buffer));
let block = ConcreteBlock::new(move |_| {
    if let Some(instance_buffer) = instance_buffer.take() {
        instance_buffer_pool.lock().push(instance_buffer);
    }
});
let block = block.copy();
command_buffer.add_completed_handler(&block);
 
command_buffer.commit();
command_buffer.wait_until_scheduled();
drawable.present();

纠正了实例缓冲区周围的疏忽后,我们觉得我们有了一个可靠的解决方案。

但随后我们注意到了一些事情。滚动很流畅,但光标移动却不太流畅。我们都将光标重复率提高到 10 毫秒,并且在直接模式下移动时会注意到间歇性掉帧。即使我们持续测量帧时间低于 4 毫秒,我们也能用肉眼看到它们。为什么我们会掉帧?

Screenshot of the Metal HUD after moving the cursor via the keyboard. Notice how the frame rate is not consistent.
通过键盘移动光标后 Metal HUD 的截图。请注意帧率不一致。

在 Instruments 中盯着时间线看了一会儿,我们才想到一个问题。如果我们渲染时间不到 4 毫秒,但帧实际上并没有以那个帧率交付怎么办?这时我们想到了 ProMotion,这是一个通过调节显示器刷新率来省电的功能。Antonio 禁用了他笔记本电脑上的 ProMotion,卡顿现象就消失了。

我们的下一个问题是:如何防止显示器降频?我们做了一些研究,并了解了更多关于 CADisplayLink API 的信息,该 API 与显示器的刷新率同步,并在显示器呈现每一帧时调用回调。通过实验,我们发现如果我们在每一帧上持续呈现一个可绘制对象,显示器将继续以恒定的刷新率运行。一旦我们忽略绘制一帧,它的刷新率就会下降。

所以我们现在在上次输入事件后渲染重复帧 1 秒,以确保最大响应速度。这允许显示器在一段时间不活动后降频以节省电量,但确保它在我们与 Zed 交互时不会这样做。现在,当您积极编辑时,我们确保显示器已准备好以最小的延迟响应您的输入。

在 GPUI 中,我们使用 PlatformWindow trait 上的 on_request_frame 方法抽象了 CADisplayLink。以下是负责在输入后保持刷新率 1 秒的完整代码:

platform_window.on_request_frame(Box::new({
    let mut cx = cx.to_async();
    let dirty = dirty.clone();
    let last_input_timestamp = last_input_timestamp.clone();
    move || {
        if dirty.get() {
            measure("frame duration", || {
                handle
                    .update(&mut cx, |_, cx| {
                        cx.draw();
                        cx.present();
                    })
                    .log_err();
            })
        } else if last_input_timestamp.get().elapsed() < Duration::from_secs(1) {
            // Keep presenting the current scene for 1 extra second since the
            // last input to prevent the display from underclocking the refresh rate.
            handle.update(&mut cx, |_, cx| cx.present()).log_err();
        }
    }
}));

经过对非活动窗口的显示链接进行了一些优化,我们现在拥有了一个性能更好的解决方案。我们也比上周更了解图形编程了。

前几天我们也发布了同样的视频,这是在 M1 MacBook 上,ProMotion 开启的情况下,光标以 10 毫秒的重复率移动。我们现在稳定地达到了 120 fps

结论

再次感谢 Theo 花时间帮助我们发现这个隐藏问题,并向社区大声致敬,感谢他们帮助我们在各种显示器上测试。我们现在对直接模式与合成模式以及 ProMotion 对响应能力的影响有了更好的理解。

我们在 Zed 这里边发布边学习,显然我们在这些优化方面的学习过程就是明证。感谢我们过去几天学到的知识,v0.121.5 应该感觉是 Zed 有史以来最流畅的版本。如果不是这样,我们希望您能告诉我们。感谢阅读!

附录:一天之后,限制在 60 FPS,UI 冻结以及回到 120 FPS

我是 Thorsten。在我们发布这篇文章的第二天,当 Discord 和 GitHub 问题中的用户报告他们的滚动根本不流畅时,Zed 的流畅滚动被戛然而止。事实上,他们说,它被限制在 60 FPS,并且感觉不稳定。等等,这是怎么回事?最重要的是,Mikayla、Conrad 和更多用户报告说,他们构建的 Zed 中的 UI 偶尔会冻结一秒钟。好吧,到底发生了什么?

Antonio 和我昨天一早就下定决心要查明真相。我们打开了 Metal HUD,果然,它就在那里:一个稳定、坚定不移的 60 FPS 正盯着我们——是在嘲笑我们吗?这没有道理:为什么我们前一天能可靠地达到 120 FPS,而现在却不行了?

一位用户通过恢复出厂设置 MacBook“修复”了这个问题。这显然不是我们可以向用户推荐的方法,但这是一个线索:问题不一定出在我们的代码中,而是 macOS 可能会处于一种状态,即它不会要求应用程序渲染超过 60 FPS。然后我发现,如果我关闭 MacBook 显示器的 ProMotion 并重新打开,macOS 会再次要求 Zed 更多帧——回到 120 FPS。“你尝试过关闭 ProMotion 然后再打开吗?”——也不是解决方案。

Antonio 接着想到用 CVDisplayLink 替换我们使用的 CADisplayLink:这是我们一直使用的 API 的 Core Video 等效项。所以我们照做了,并且严格按照 Apple 的说明和示例代码执行:使用 CVDisplayLink 获取与显示器刷新率同步的回调,然后使用 dispatch_source_create 将帧请求推送到主队列,并在窗口更改时启动和停止显示链接。

但是它没有奏效:使用 CVDisplayLink 我们不再被限制在 60 FPS,但我们从未达到稳定的 120 FPS,滚动仍然感觉很糟糕,根据 Metal HUD,我们的帧时间在 8 毫秒和 16 毫秒之间波动。

接下来是数小时的尝试各种方法(如果你想专业一点:胡乱尝试):改变队列的优先级,自己记录帧时间并在被调用过于频繁时提前退出,告诉 macOS 我们总是更喜欢 120 FPS —— 谢谢你,但这些都没有奏效。

直到我们有了一个“不妨一试,为什么不呢?”的时刻,并更改了本文前面显示的精确代码位:我们更改了 MetalRenderer::draw 方法中上面解释过的那些部分。

我们取消了新引入的 .wait_until_scheduled 调用,将其删除,将这段代码

command_buffer.commit();
command_buffer.wait_until_scheduled();
drawable.present();

变成了这样

command_buffer.present_drawable(drawable);
command_buffer.commit();

并且再次禁用了 presentsWithTransaction

之所以进行此更改,是因为使用 CVDisplayLink,我们似乎从 macOS 获得了大量的帧回调,但我们的同步代码似乎随后使其不同步。

通过这一改变,我们回到了尽可能快地绘制尽可能多的内容(但保留了三重缓冲)。

结果:稳定在 120 FPS 的流畅滚动。在我的机器上,关键的是,在 Antonio 的机器上也是如此,他从未重启或重置过,也从未关闭或打开过 ProMotion。

如果“这让我今天很开心”这句话还没有出现,我相信我们昨天肯定会创造它。Metal HUD 中那条平坦的蓝线,就在 120 FPS 下方,经过一整天的努力,它让我们很开心。

这种方法唯一的问题是它并非总是有效:在启动应用程序或调整窗口大小时,我们确实需要 presentsWithTransactionwait_until_scheduled,否则您会看到抖动。但 Antonio 在我需要离开半小时的时候很快就解决了这个问题,然后,在我们记录了与 Zed 创始人进行的另一次对话之后,整个团队已经在使用包含完整修复的构建。没有人报告任何问题。

此后不久发布了两个版本:Zed 0.121.7Zed Preview 0.122.2——两者都包含此修复和另一个修复,应该能为每个人提供令人难以置信的 120 FPS 流畅滚动。


正在寻找更好的编辑器吗?

您今天就可以在 macOS、Windows 或 Linux 上试用 Zed。立即下载


我们正在招聘!

如果您对我们博客中涵盖的主题充满热情,请考虑加入我们的团队,帮助我们实现软件开发的未来。