← 返回博客

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

2024 年 2 月 7 日


由于一系列优化,Zed 比以往任何时候都更流畅,这要归功于热门主播 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();

上面的代码包含一个错误。它在合成模式下运行良好,其中“已完成”表示像素已写入合成器的中间缓冲区。但是,在*直接*模式下,“已完成”表示像素实际上已写入显卡的帧缓冲区,并且我们观察到在此状态下此调用阻塞的时间明显更长。

解决方案是保持我们的同步,但通过调用 wait_until_scheduled 而不是 wait_until_completed 来稍微放宽它。这确保了窗口内容被安排与窗口本身同步传递,同时避免了不必要的长时间阻塞。

Antonio 在 Theo 的餐桌上构建了一个二进制文件,并通过 AirDrop 将其发送给他,以确认它解决了滚动不稳定的问题。问题解决了。

三重缓冲

嗯……不完全是。在我们急于赶上 Uber 去博尔德的航班时,我们忽略了充分考虑我们所做更改的影响。合并后不久,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 特征上的 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();
        }
    }
}));

通过一些改进来暂停非活动窗口上的显示链接,我们现在有了一个性能更好的解决方案。我们对图形编程的理解也比上周多得多。

我们在前几天发了同样的视频,但是这是在具有 ProMotion 的 M1 MacBook 上以 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,盯着我们——嘲笑我们?这说不通:为什么我们在前一天获得 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 获得令人难以置信的流畅滚动。