别全扔了:为即时模式 UI 做缓存
摘要
Blick Blog 讲解了即时模式 UI 如何与基于图块的渲染缓存配合工作:对每帧的绘制命令进行哈希,只重绘发生变化的区域。在 M3 MacBook Pro 上的基准测试显示,在轻度编辑任务中,CPU 和 GPU 的综合功耗降低了约 20%。
<p><a href="https://lobste.rs/s/cq7wn7/don_t_throw_it_all_away_caching_immediate">评论</a></p>
查看缓存全文
缓存时间:
2026/10/21 18:23
# 别全扔了:为即时模式 UI 做缓存 | Blick Blog
Source: https://blickeditor.com/blog/caching-an-immediate-mode-ui
对即时模式 UI 的一个常见批评是它会重复做功。应用程序每帧都要重新构建对界面的描述,即使界面的大部分内容并没有变化。表面上的权衡是以重复做功为代价换取编程模型的简洁性。
我想说明,情况未必如此。
一个直接的反驳是:现代计算机真的很快。如果重建 UI 最多只要一毫秒,我们有多在乎呢?
如果你真的不喜欢重复做功这个想法,那我能想到的最简单的方案就是:在什么都没发生时让应用程序休眠。据我所知,所有即时模式桌面应用都已经这么做了,而且这在很多场景下应该已经足够。实现起来也非常简单。
就此收尾也未尝不可,但一旦考虑到功耗,事情就没那么清楚了。你可能希望应用程序在笔记本电脑、手机和平板上运行良好,而不要过度耗电。此外,现代显示器往往具有高分辨率和高刷新率,这会加剧这个问题。一个闪烁的光标、移动的播放头或一个动画控件都可能阻止应用程序进入休眠,让界面持续更新,而几乎其他所有东西都没变。
与此同时,我们希望保留做出视觉设计选择的空间:半透明面板、动画控件和密集布局都应该能被渲染器很好地支持。
对于 Blick,我们有一个密集的界面,其中包含成千上万个大大小小的此类元素。渲染它需要与媒体处理和其他应用程序共享机器。
一个当前版本的 Blick,展示了绘制器需要处理的那种密集时间线。一个当前版本的 Blick,展示了绘制器需要处理的那种密集时间线。我们最终采用的基本方法是在系统的渲染侧附加一个缓存。UI 可以重新描述整个界面,而渲染器则保留已经生成的像素,只重新绘制那些渲染输入发生变化的部分。
**重建 UI 和重绘其像素是两种不同类型的工作。**即时模式前端可以与记住其先前输出的渲染器共存。
这正是我想要在这里展示的。
我们有相关的测量数据。在一个未接入电源的 M3 MacBook Pro 上进行的短时间脚本化灯光编辑测试中,启用缓存后 macOS 估计的 CPU 和 GPU 总功耗降低了约 20%。我将在下文进一步说明从中可以得出什么结论。
本文聚焦于当前的 Metal 后端,因为那里有具体的测量数据和示例。但所有这些同样适用于我们的其他后端:缓存的工作原理,以及 GPU 上解释执行的一种小型渲染语言是如何使其成为可能的。
### 在 UI 后面加一个缓存
想象你在一个窗口中悬停在一个按钮上。按钮改变颜色以表明它处于悬停且可点击状态。窗口中的其他任何东西都没有变化。我们可能会重建 UI 树,并基于该树为整个窗口发出一串绘制命令,但其中大多数命令仍然描述的是相同位置上的相同事物。如果我们能保留这些区域已有的像素就好了。
为了做到这一点,渲染器——我们喜欢称之为绘制器——将窗口分成瓦片(tile),并找出可能影响每个瓦片的命令。我们根据这些命令(包括它们的顺序)计算一个哈希,并与上一帧的哈希进行比较。如果匹配,我们保留像素;否则,重新绘制该瓦片。
在渲染器中做这件事的好处是,UI 系统不需要为它维护一个单独的变更控件列表。即使 UI 树本身已被重建,我们仍然可以从绘制命令中推断出需要重新绘制的部分。而且我们可以在 GPU 上完全完成这些工作!
当然,重建树、执行布局和生成命令仍然有成本。检查变更也需要工作。重要的是这些成本与我们所避免的工作相比如何。本文后文的测量数据给出了一个具体的例子。
让我们先看看我们交给绘制器处理的内容。
### 一种小型渲染语言
UI 可以重建其描述,同时绘制器保留上一帧的像素。UI 可以重建其描述,同时绘制器保留上一帧的像素。当一条命令到达绘制器时,布局已经完成。我们知道形状放在哪里、是什么颜色、应该在哪里被裁剪、以及使用什么纹理。
绘制器不需要知道弹出菜单、时间线或音频片段是什么。应用程序和 UI 系统负责处理这些。它理解的命令相当基础:
| 命令族 | 表达的内容 |
|---|---|
| 解析形状 | 矩形、圆、线、三角形、环和菱形 |
| 带纹理的形状 | 图像、字形覆盖度以及其他渲染通道的结果 |
| 形状组边界 | 合并几何体,然后应用统一的外观 |
| Alpha 屏障边界 | 将一组命令以共享的不透明度进行合成 |
在 Metal 后端中,每条 GPU 命令占用 64 字节。它们大小相同,着色器根据具体操作读取所需的字段。
我把这看作一种小型渲染语言,因为某些命令会影响我们如何处理后续命令。例如,我们可以开始一个形状组,或者开始收集应共享同一不透明度值的绘制操作。我稍后会详细介绍这两种操作。
因此,着色器看起来有点像一个小解释器。它遍历命令,并在命令之间保留少量状态。它所了解的操作全部内置在着色器中。
这也为我们探索 UI 的外观提供了空间。我们可以用多个部分构建一个形状,然后给整体加轮廓,或者让整个弹出菜单一起淡入。这些选择都表达在同一条命令流中,而我们正是将该命令流拆分为瓦片列表并进行变更检查。让我们看看如何处理这条命令流,然后看看这些操作能绘制什么。
### 给每个瓦片一个更小的程序
我们可以简单地为每个像素遍历所有命令,但那会是大量不必要的工作。大多数形状只覆盖窗口的一小部分。我们首先想找到在特定位置可能相关的命令。
这就是瓦片发挥作用的地方。我们将输出划分为一个网格,并使用普通的计算着色器进行处理。这些瓦片只是我们自己对工作的一种组织方式。
首先在一个粗略区域中找到候选命令,然后检查哪些与每个精细瓦片重叠。网格大小和命令数量仅为示意。网格中只显示了一个形状以保持图示清晰易读。首先在一个粗略区域中找到候选命令,然后检查哪些与每个精细瓦片重叠。网格大小和命令数量仅为示意。网格中只显示了一个形状以保持图示清晰易读。
在一个预处理通道中,我们计算每条命令可能影响的区域,并记录它与哪些粗略区域重叠。我们将其存储在位集(bitset)中,其中置位表示该命令可能在该区域中重要。然后,每个精细瓦片检查其所在粗略区域的候选命令,并收集那些包围盒与其重叠的命令。
UI 绘制器管线。分箱(binning)同时构建已变更瓦片的列表以及用于调度绘制的计数。高亮的瓦片会被重新绘制;其他瓦片保留其像素。还有一个可以跳过整个绘制器的检查,未在此图中显示。UI 绘制器管线。分箱(binning)同时构建已变更瓦片的列表以及用于调度绘制的计数。高亮的瓦片会被重新绘制;其他瓦片保留其像素。还有一个可以跳过整个绘制器的检查,未在此图中显示。
在此过程中我们必须保持绘制顺序不变。使用 Alpha 混合时,交换两个形状可能会改变结果。我们希望每个瓦片最终得到一个更短的命令列表,但仍然能为该区域生成正确的图像。
一个容易被忽略的细节是,组边界也需要包围盒。开始一个组的命令本身不绘制任何东西,但如果将它从某个瓦片的列表中排除,会影响后续形状的处理。我们需要在组起作用的地方包含它。
类似地,发光或阴影可能超出产生它的形状的范围。其包围盒必须包含该额外区域,否则我们会遗漏需要绘制的像素。
### 绘制一个像素
一旦我们得到了瓦片的命令列表,每个像素从前到后遍历该列表。
对于解析形状,我们在像素位置评估一个距离函数。这告诉我们像素相对于形状边界的位置,我们据此计算边缘覆盖、填充和轮廓。对于带纹理的形状,我们还会对图像或覆盖数据进行采样,并将结果混合到像素中。
从前到后有一个有用的特性:一旦结果完全不透明,其后面的内容就无法再产生贡献。我们可以停止处理——只要我们不在某个仍然需要完成的组或不透明度操作内部即可。
着色器将截至目前累积的颜色和不透明度保存在局部状态中。因此,我们可以控制两个方面:如何组合形状,以及一个像素何时已经完成了足够的工作。
### 将多个形状作为一个来绘制
假设我们用几个重叠的形状构建一个图标,并想给它加一个轮廓。单独给每个形状加轮廓也会画出图标内部的边界。我们通常想要的是围绕整体形状的一个轮廓。
这正是形状组的用途。在组内,我们合并形状的距离值。当到达组末尾时,我们对合并后的结果应用填充和轮廓。
先合并几何体,然后给结果统一填充和轮廓。内部边界消失了。先合并几何体,然后给结果统一填充和轮廓。内部边界消失了。
因此,UI 可以用现有形状组合出这些视觉效果,而无需我们为每个效果编写特殊的着色器。我们还可以使用组的几何体来绘制外发光、内发光或阴影。我们在命令流中放置这些操作的位置决定了它们与其他所有内容的混合方式。
这里有一个小问题:合并距离函数不一定会在各处都留下精确的有符号距离。平滑混合尤其需要在使用其结果绘制轮廓时格外小心。
但基本思路相当方便。我们可以更改构成对象的形状,然后给整体应用统一的外观,使用绘制器已经理解的操作即可。
### 让整个弹出菜单淡出
另一个独立绘制所有内容不太行得通的场景是让弹出菜单淡出。假设它有一个不透明的背景和不透明的文字,而我们希望整个弹出菜单以 50% 不透明度显示。我们需要先在背景上绘制文字,然后对合并后的结果进行淡化。
如果我们只是给两个元素都设置 50% 不透明度,背景会透过文字显示出来。在它们重叠的地方,合并后的不透明度是 75%。这与我们想要的图像不同。
两个简单形状也存在同样的问题:分别淡化它们会改变重叠区域。Alpha 屏障将其不透明度应用于它们的合并结果。两个简单形状也存在同样的问题:分别淡化它们会改变重叠区域。Alpha 屏障将其不透明度应用于它们的合并结果。
我们通过所谓的 Alpha 屏障来处理这个问题。当我们进入一个 Alpha 屏障时,绘制器保存目前为止累积的结果,并为弹出菜单开始一个独立的结果。在末尾,它应用弹出菜单的不透明度,然后将两者合并。从 UI 侧来看,我们只需要在该子树的根节点指定淡化效果即可。这个想法要归功于 Anton Mikhailov (https://x.com/antovsky)。
所有这些都发生在像素的局部着色器状态中。我们不需要为弹出菜单分配单独的离屏纹理。
目前,我们同一时间只支持一个激活的 Alpha 屏障,因此它们不能嵌套。这也保持了每个像素携带的状态量很小。
### 那波形和其他特殊视觉效果呢?
Blick 有一些需要超出形状和颜色才能绘制的东西。例如,音频波形和视频渐变需要访问媒体数据。如果我们把所有这些都直接放入绘制器,它将不得不了解越来越多关于应用程序的细节。
在 Metal 后端中,我们在一个独立的效果通道中绘制这些内容,并将结果存储在图集纹理(atlas texture)中。绘制器随后得到的是普通的带纹理绘制命令。字形也以图集中的覆盖度形式到达绘制器,尽管我们如何生成这种覆盖度是另一个话题。
效果通道处理媒体数据。绘制器获得绘制命令和用于采样的纹理。效果通道处理媒体数据。绘制器获得绘制命令和用于采样的纹理。
这使我们可以在不向绘制器解释其数据的情况下开发应用程序效果。对绘制器而言,它只需要对结果纹理进行采样,然后与其他形状混合即可。
我们必须区分在图集中保留空间和保留存储在其中的图像。仅仅因为分配仍然存在,并不意味着其内容是最新的。效果代码会跟踪内容是否已渲染,并在需要时将内容版本号包含在缓存输入中。
### 这些像素能保留吗?
在分箱之后,我们得到了每个瓦片相关命令的列表。我们可以使用同一列表来决定是否需要重新绘制它。
这包括我们刚看过的那些操作。一个合并的形状或一个淡化的弹出菜单最终仍然归结为命令及其输入。更改组的轮廓或弹出菜单的不透明度需要使受影响的瓦片失效,就像更改矩形的颜色一样。如果这些输入保持不变,我们可以保留已完成的像素,包括所有合成工作。
GPU 按绘制顺序从瓦片的命令计算哈希。如果它与上一帧的哈希匹配,我们就保持 UI 纹理的该部分不变。否则,我们将该瓦片追加到待处理工作列表中。
在这里,按钮恰好位于一个瓦片内,因此其悬停颜色变化只影响该瓦片的输入。网格为示意,实际控件可能跨越多个瓦片。在这里,按钮恰好位于一个瓦片内,因此其悬停颜色变化只影响该瓦片的输入。网格为示意,实际控件可能跨越多个瓦片。
GPU 还会产生用于调度绘制通道的计数。这意味着我们可以在不先将列表发送回 CPU 的情况下绘制变更的瓦片。
我们对命令内容及其顺序进行哈希。我们不是逐像素比较。理论上,恰好产生相同像素的不同命令可能导致我们不必要地重新绘制一个瓦片。
一个矩形可以保持在完全相同的位置,同时其内部的图像发生变化。我们也必须将纹理内容变更和动画纳入失效逻辑中。仅检查几何体会遗漏这些。
目前,我们可以在两个层面上跳过工作。首先,我们检查命令及其依赖项是否已变更、是否有效果需要渲染、以及缓存是否被显式失效。如果这些都不需要工作,我们可以完全跳过绘制器。当绘制器确实运行时,瓦片哈希仍然使我们能够跳过未变更区域的绘制。
还有一个相当直接的方法来检查我们是否保留了本应变更的像素。我们可以分别在启用和禁用缓存的情况下回放相同的 UI 交互,在匹配的时点捕获结果,并比较图像。在相同的输入和动画时
相似文章
Lobsters Hottest
Godot Engine 发布了一篇文章,详细介绍其 CPU 端渲染的优化过程,涵盖使用 Superluminal 和 Tracy 等性能分析器定位瓶颈的方法,以及过去一年中的真实优化案例。
Hacker News Top
作者描述了如何在 macOS 上使用 Metal、在 Linux 上使用 OpenGL 为 Emacs 构建基于 GPU 的显示后端,从而提升渲染性能并启用视频播放和动画光标等新效果,且无需修改核心重新显示引擎。
X AI KOLs Following
CacheBlend,EuroSys 2025 最佳论文,解决了由于提示缓存中严格的前缀匹配导致 90% 的 KV 缓存从未被重用的问题。通过选择性地仅重新计算文档之间的边界令牌,它在不损失质量的情况下实现了 2-4 倍的多文档处理速度提升,并在开源 LMCache 层中实现。
Reddit r/LocalLLaMA
一位爱好者制作了一个 llama.cpp 分支版本,通过 USB-C 将模型层从 24 GB MacBook 流式传输到 iPhone 17 Pro Max,使 Qwen 3.8 27B 的预填充吞吐提升 29–44%,并在超长上下文场景下将多达约 5.7 GB 的 KV cache 卸载到手机的 GPU 和 Neural Engine 上。代码和基准测试脚本已发布在 GitHub 上。
Reddit r/AI_Agents
作者开源了一种名为 catalyst-brain 的新型 KV-cache 解决方案,声称可大幅降低本地模型对 RAM 的需求,并可能实现无限上下文窗口。