← 文章 / 科技资讯
Hacker News 16小时前 · 2026-08-19 02:36:51 · 1 阅读

Linux 7.3 提升显存耗尽时的游戏性能

今年早些时候,我写过一篇博客,介绍我在改进游戏 显存管理方面做的工作。经过数月在邮件列表中的反复打磨,这些内核补丁终于被上游合并,并进入了 Linux 7.3 的发布队列!可喜可贺!

借此机会,我想更深入地聊聊我此前文章里写过的一句话:

[游戏] 的运行稳定性会大幅提升——前提是游戏本身使用的显存不超过你实际拥有的容量。

那么问题来了:如果游戏实际使用的显存真的超过了你的显卡容量,会发生什么?

通常来说,一旦出现这种情况,体验基本就完了:游戏开始频繁崩溃,性能暴跌到完全没法玩,良好的游戏体验无从谈起。

但这真的是不可避免的吗?到底是什么让"显存不足"变得如此糟糕?更重要的是:我们怎样才能让它不那么糟?

先说预期

理论上,显存耗尽应当只影响性能,而不影响稳定性。只要有 GPU 驱动存在,就一直支持显存超额分配(overcommit):驱动允许你申请任意大小的显存,分配到多少则由内核驱动根据 GPU 物理内存的实际容量来决定。

从性能角度看,显存耗尽时表现糟糕的根本原因其实很简单:一旦游戏请求的显存超过实际容量,部分显存数据就得转移到 CPU 内存里。对 GPU 来说,访问 CPU 内存比访问显存慢得多——不仅因为 CPU 内存在整体上就比独立显卡的显存慢,所有内存访问还必须走 PCI 总线,而 PCI 总线本身会增加延迟,并且通常是访问 CPU 内存时的带宽瓶颈。

由于 PCIe 的速度限制,超额分配 VRAM 时确实存在一些无法回避的性能瓶颈。假设 GPU 通过 PCIe 4.0x16 连接,可用的带宽略低于 32 GiB/s。每毫秒,PCIe 总线大约能传输 32.2 MiB 的数据。如果要求最低帧率为 30 FPS(每帧 33.3 毫秒),那么 GPU 在一帧之内最多只能访问约 1075.5 MiB(刚刚超过 1 GiB)的数据。换句话说,一旦被驱逐的内存多到让 GPU 不得不在单帧内从系统内存取回超过 1 GiB 的数据,那么保持 30 FPS 就是不可能完成的任务了。

不同内存的代价并不相同

与此同时,GPU 偶尔读一点 CPU 内存,并不一定立刻就会让性能崩盘。事实上,即便 VRAM 还很充裕,GPU 驱动有时也会主动把命令缓冲区之类的数据放在 CPU 内存里。每次 GPU 执行这些命令时都得访问 CPU 内存,但这种情况下一切运行良好。既然如此,这些访问为什么就没问题,而 VRAM 用尽时却像是世界末日?1

其中一个关键因素就是缓存。由于缓存命中时的访问延迟与这块数据原本是放在 CPU 还是 GPU 无关,通过 PCIe 总线初次读取时的高昂开销可以(在一定程度上)被后续的缓存命中摊薄。我们可以通过微基准测试来估算 CPU 内存与 VRAM 在访问延迟上的差异:写一个测试程序,让它在不同缓冲区大小下测量访问延迟(并采用对抗性的访问模式,尽可能压低缓存命中率)。得到的结果大致如下图所示(在 RDNA3 上测得):

RDNA3 GPU 的缓存微基准测试结果。

不出所料,如果缓冲区能放进 L2(或任何更高级的缓存),那么无论底层是 CPU 内存还是显存,访问延迟完全一致——因为数据都是从缓存直接读取的。到了 6MB(RDNA3 上 L2 缓存的大小)这个量级,CPU 内存的延迟会飙升到每次访问约 2400 个周期,而显存延迟基本维持在原来的水平。需要注意的是,显存访问也会经过 Infinity Cache,但 CPU 内存访问不会(L2 未命中时会直接走 PCIe)。我猜测这是因为 Infinity Cache 紧贴着显存放置,所以任何连显存都没命中的访问自然也到不了 Infinity Cache。

显然,内存一开始并不会被缓存在任何地方,所以首次访问的延迟仍然会高得多。此外,失去 Infinity Cache 确实也会带来损失:走 PCIe 取数据的延迟大约是命中 Infinity Cache 的 7.3 倍,是直接从显存读取的 4.6 倍。要把走 PCIe 带来的额外开销完全摊薄,需要极高的缓存命中率。这意味着只有极少数用例中,使用 CPU 内存带来的性能下降小到可以忽略,让你愿意主动选择它而非显存。而在显存不足需要把内存换出时,性能下降几乎不可避免。

不过,即便性能下降无法避免,不同内存受换出影响的程度也不同。以非常缓存友好的方式访问的内存,受 CPU 内存延迟增加的影响较小。如果访问模式不友好,但内存本身访问频率很低,影响也不会太大,因为 GPU 很少需要真的从 CPU 内存取数据。很多内存分配可能只会被访问总量的一小部分,其余部分甚至从未被读过。如果把这些分配换出,可能换掉好几 GiB 的数据,但实际每帧访问的数据量仍然远低于 1GiB 的硬性上限。

所有这些变量都让我们很难准确预测显存被回收时实际性能表现如何。但简而言之:取决于被回收内存的访问频率以及这些访问的缓存命中率,在显存耗尽的情况下你或许仍然能保持(基本可用的)性能水平!

直面现实

我们已经从理论上把方案打磨得差不多了,现在应该能实现高性能的显存超量提交了。既然如此,那就启动 SteamOS、开个游戏、把画质设置拉满——
radv/amdgpu:指令提交内存不足。

呃。

事实证明,在实际场景中把显存用完确实会带来一大堆稳定性问题。

不过这个错误和一般的"分配失败、内存耗尽"还不太一样。注意看报错信息,它具体抱怨的是指令提交:RADV 在内核返回 -ENOMEM 时会打印这条消息2,而仅仅提交指令本身并不会分配任何新资源!所有指令缓冲区都是提前分配好的,它们的分配显然是成功的。尽管内存全部成功分配到了,但在 GPU 指令提交时却突然抛出了"内存不足"错误。

又该去内核里一探究竟了!说服内核接受这次提交应该不会太难——毕竟,它先前已经接受了所有内存分配3

内核锁的噩梦

每次提交指令时,amdgpu 驱动在指挥 GPU 开始执行指令之前,必须确保 GPU 指令可能引用到的所有内存都处于可访问状态。在比较现代的 bindless 图形 API 下,你得假设所有已分配内存都有可能在某个时刻被引用。因此,amdgpu 会尝试确保所有已分配的内存同样处于可访问状态。

每次内存分配都会携带信息,标明它能被哪种类型的内存正常访问——这里我们只关心系统内存和显存。大多数分配无论放在哪种内存里都能正常访问,amdgpu 驱动对此并不在意。但有些分配只能放在显存中,别的地方不行。如果因为其他应用占用了显存,这些分配被挤到了系统内存里,amdgpu 就必须把它们搬回显存。此时显存已经完全占满了,要搬回来就得先把别的内容挤出去。不知什么原因,挤占失败了,内核便报出了内存耗尽。

要解释为什么随机挑一个分配挤出去会失败,我们得先绕个弯,看看内核是如何为 GPU 分配处理(CPU 端)锁的。要想挤掉一个内存分配,必须先获取与该分配关联的锁。不过,在提交任务期间,你还需要把提交中引用的每个分配都锁住,以防你在准备 GPU 工作的过程中被其他应用把分配挪走。如果同时有另一个 GPU 提交也在做同样的事情,就可能出现这样的情况:

并发提交挤占时导致的死锁

一个提交想挤掉另一个提交已经锁住的分配,可后者为了继续推进也需要锁住前者持有的某个分配——这就是教科书式的 ABBA 死锁。

不过别担心,内核懂得如何检测并解决死锁!关于死锁检测的具体细节,可以参考这份内核文档,简单来说,内核会把加锁操作归到一个"事务"里(本质上就是记录持有了哪些锁)。如果两个事务发生死锁,其中一个会被标记为"受伤",它下次尝试获取锁时就会收到 -EDEADLCK 错误。这个错误要求事务中止:释放期间获取的所有锁,然后从头重新执行。在命令提交的语境下,这就意味着驱动会重新走一遍所有内存分配的检查,确认它们都可访问。

那这有什么问题呢?没问题。这种方案非常可靠且效果很好。

前提是它在所有地方都得到了实现。

在图形子系统中,"受伤—中止—重试"循环的具体实现被封装在一个名为 drm_exec 的小型辅助库中。开发者无需手动追踪哪些分配被锁住,也不用在遇到 -EDEADLCK 时手动释放锁,直接使用 drm_exec_lock_obj 辅助函数即可。如果你去看一下 Linux 共享 GPU 内存管理层 TTM 中的加锁代码,就会发现里面几乎没有用到 drm_exec

更糟的是,代码里有一条注释明确写着 -EDEADLCK 会导致回收失败。果然,问题就出在这里。一旦在命令提交过程中因内存压力过大触发了死锁,内核就直接放弃并拒绝提交,根本不会重试。

其实早就有人尝试在 TTM 中接入 drm_exec 辅助库,相关补丁早在 2024 年就提交了,但因为各种原因一直没有合入,其中就包括一些尚未解决的遗留 bug。所以我的任务就明确了:在当前内核版本上重新整理这些补丁,找出 bug 的根因。

补丁集 rebase 起来倒不算太麻烦,但定位 bug 实在让人抓狂——整整一周时间,反复经历游戏在 VRAM 竞争激烈时随机卡死三分钟的折磨。倒也不算最糟糕的体验!

我把找出的所有 bug 修复后重新提交了这个补丁集,希望这次能合进去,不过在那之前显然还有不少工作要做。

既然 VRAM 耗尽时应用至少不会再随机崩溃了,那就可以放心把画质调高看看性能表现了。初步测试得到了一张堪称"壮观"的性能曲线图:

糟糕的性能曲线

等等,性能怎么掉了这么多

要搞明白性能为什么这么拉胯,得先弄清楚系统到底在慢悠悠地干什么。对于"内核驱动究竟在搞什么??"这种比较宽泛的问题,我比较喜欢用 gpuvis。gpuvis 会利用内核 tracepoint 构建一个事件时间线(包含"GPU 任务提交开始/结束"等事件,由此可以推算出每次提交所耗费的时间)。

在系统 VRAM 耗尽时跑 gpuvis 抓取 trace,出来的时间线大致是这种情况:

性能惨不忍睹,SDMA 几乎全程忙碌

可以看到,大部分时间其实并没有花在处理任务提交上(也就是 gfx_0.0.0 那段活动),而是花在为提交做准备而搬移内存上(sdma0 的活动)!

结合 gpuvis 的事件列表,再加上针对特定 buffer object 的移动事件过滤器,就能更清楚地看出为什么会有这么多 buffer 搬移操作(这里随便挑了一个例子,大多数 buffer object 都呈现类似的规律):

gpuvis 事件过滤器显示 buffer 来回反复搬运

从列表中可以清楚地看到,相互竞争的进程(这里指的是 gamescope 和游戏本身)会不断地轮流驱逐同一块内存、又把它搬回来,反复循环。这非常糟糕!这让我想起在第一篇博客中写过的一段话:

一般来说,两个竞争的应用程序大致会轮流执行 GPU 工作——一个应用先提交任务,另一个接着提交,然后第一个再来,如此反复。这样一来,每次提交之后内存都会来回搬运。一个应用刚被踢出去就立刻被搬回来,同时把另一个踢出去(下一步又把内存搬回来)。来回折腾下来,性能反而完全不搬内存还要更差。

这描述的是一个老问题:过于激进的 VRAM 分配会导致类似乒乓球式的反复搬运。但这个问题后来通过一个简单办法修复了——没有空闲 VRAM 时就不再尝试占用,只有在我用 dmem cgroup 实现 VRAM 保护之后,内核才开始表现得比较激进。显然,正是这个改动以某种方式重新引入了乒乓效应。

从概念上讲,dmem cgroup VRAM 保护的设计不应该导致乒乓式搬运,因为内核只应该驱逐那些没有关联任何 cgroup VRAM 保护的内存。没有 VRAM 保护时,你通常也不应该被允许驱逐受保护的 VRAM。

这条规则有一个例外:有些内存必须放在 VRAM 中,系统才能正常工作。这类内存分配始终允许被移到 VRAM,以确保系统稳定。一般来说,应用提交的内容几乎没有什么是必须放在 VRAM 中才能正确运行的,但有一个来自应用的缓冲区对象确实属于这种情况:包含要扫描输出到显示器的图像数据的缓冲区4

显示硬件很古怪

显示硬件不仅要求扫描输出的图像位于 VRAM 中,还会完全绕过 GPU 的虚拟内存架构,只使用物理地址。因此,扫描输出的图像在物理内存中还必须是连续的

借助虚拟内存和页表机制,典型的应用缓冲区在虚拟内存中是连续的,但在物理内存中可能散布在各个位置5。虚拟地址 0x5000 处的缓冲区第一页,在页表中可能映射到物理地址 0x1234000,但虚拟地址 0x6000 处的第二页却可能指向完全不同的物理地址 0x4321000

下图展示了在内存高度碎片化(VRAM 严重不足时通常如此)的情况下,虚拟分配到物理分配的映射关系:

Contiguous virtual memory mapping to fragmented physical memory.

图中箭头表示第一块分配中各段到物理内存的页表映射。为保持可读性,其余分配的映射关系未画出。

如果你要分配用于显示扫描输出的数据,则不允许存在这种碎片化,因为物理内存必须是连续的。这一点与其他数据的逐出操作产生了非常糟糕的交互。假设扫描输出数据已经被逐出,但现在需要扫描输出这些数据,就必须把它们重新移回 VRAM。

仅仅逐出一个缓冲区是不够的,即使这个缓冲区的大小和扫描输出数据相同也不行——因为逐出它之后,仍然没有足够的连续物理空间来放置扫描输出数据!更糟糕的是,逐出算法完全没有考虑物理内存的约束。它是一个极其简单的循环,逻辑大致如下:

while (true) {
   evict(getLeastRecentlyUsedBuffer())
   if (tryAllocate(newBuffer) == SUCCESS)
      break;
}

按这个算法(假设分配按 LRU 顺序排列),即便你逐出了前 3 个分配(绿色、蓝色和红色),腾出的空间仍然不足以容纳扫描输出缓冲区!正如更新后的示意图所示,连最大的可用空闲空间也只差那么一点点:

Still no space for the scanout buffer.

在我们的例子里,要找到一块足够大的物理连续内存区域,每次往 VRAM 里分配内存都会触发淘汰!实际场景中,我观察到为了腾出空间给扫描输出图像(R11G11B10 像素格式每张约 32MiB 的像素数据),VRAM 中被清掉的内存高达 4GiB。这代价相当惨痛,光是把这些数据从 VRAM 中搬出来,按照之前估算的 PCIe 传输速率,就至少要花 ~130ms。

用启发式方法应对问题

扫描输出虽然是最严重的失败案例,但这个问题其实更具普遍性:总会有某些内存分配被反复移入 VRAM,可能会把应用程序更希望留在 VRAM 里的内存挤出去。硬顶着把被淘汰的内存搬回去,大概率会适得其反。

虽然 dmem cgroup 保护机制并不能彻底解决这个问题,但它能大幅缩小问题的范围。有了 cgroup 保护,就能确保任何随机应用都不会随意地把重要的游戏资源挤出去。任何被强制搬回 VRAM 的内存,应该都有充分的理由待在 VRAM 里。因此,即便有了 dmem cgroup 保护,我们也应当谨慎行事,不要强行回收被淘汰的内存。

经过反复测试,我总结出了一套启发式策略,在游戏实际遇到的大多数场景下都能取得不错的效果(既要避免在重要系统分配导致内存淘汰时太过激进,也要在游戏内存被淘汰时足够迅速地将其回收,比如游戏暂停、切出 Steam 菜单导致大量游戏内存被淘汰,然后切回游戏继续的情况)。

这套启发式策略大致如下:

  1. 内核检测到某个应用的内存被淘汰时,会进入持续几毫秒的「强限制」阶段。在此阶段,它完全不会尝试把该应用的任何内存移回 VRAM(当然前提是所有内存都能正常访问)。
  2. 该阶段过后,会切换到「弱限制」阶段。在此阶段,它可以通过把内存搬回 VRAM 来回收空闲空间,但不会试图挤占其他应用已分配的内存。这一阶段最长可持续几秒,以确保系统充分进入稳定状态。
  3. 如果"软限速"阶段已经结束,且期间没有再次发生任何内存回收,那么系统就被认为已经达到了一个相当稳定的状态,回收其他应用内存的限制也会随之解除。

在我看来,这就在两方面之间取得了不错的平衡:一方面不会因为过于激进地回收其他应用内存而自食其果,另一方面当你的大量内存被突然回收时(比如游戏暂停、用户去 Steam 上随便逛逛),恢复速度仍然足够快。

初见成效

有了这些启发式策略,终于可以把设置真正拉高来试一试了。

我最终选了《Indiana Jones: The Great Circle》来做测试,因为它很方便地开放了流式池大小的设置,你可以直接调整它来改变显存占用。

结果相当令人惊喜:即使把设置调到有点离谱的程度——游戏请求 9GiB 的显存,但实际只有 8GiB(也就是说整整有 1GiB 的游戏资源被超额分配到了 CPU 内存中),性能却不再崩盘了!平均每帧 19.6ms 的成绩,我觉得完全可以流畅运行。

我甚至可以把设置调得更加离谱,把超额分配的内存量翻倍,让游戏在 8GiB 的系统上请求 10GiB 的显存(也就是 2GiB 的资源被超额分配)。此时帧时间的波动明显增大,频繁出现超过 33.3ms 的尖峰。整体平均在 29.8ms 左右,虽然不能说很差,但再加上波动,实际游戏中开始能感受到差异了。

虽然这已经是巨大的进步,但还远谈不上完美。显存超额分配下的体验有时仍然有点看运气,帧时间会因你当前观察的游戏物体不同而出现明显波动。

请记住,要真正实现良好的回收性能,关键在于被回收的内存是如何被 GPU 使用的。而目前这一点完全没有被考虑进去!如果我们能让回收决策更多地基于应用访问 CPU 内存时的实际表现,这些波动很可能就会消失。

把控制权交出去

应用自身的内存访问模式往往只有应用自己最清楚,驱动很难直接利用这些信息。最理想的做法是提供一套 API,让应用能够告知驱动某段内存分配是否适合被换出。

Vulkan 中正好有这样一个接口——vkSetDeviceMemoryPriorityEXTVK_EXT_pageable_device_local_memory 扩展允许应用为任意设备内存设置优先级,刚好满足我们的需求。只要应用合理地提供提示,再结合内核中的优先级机制,整体稳定性就能大幅提升。

在内核里接入优先级其实比想象中简单。内核本来就维护着一份 LRU(最近最少使用)内存列表,换出时按顺序依次尝试,直到腾出足够空间为止。

这份 LRU 列表天然适合作为换出顺序的启发依据:长时间没有提交任务的应用大概率不会立刻用到内存,它们的分配排在 LRU 靠前位置,会优先被换出。

应用在使用一组 buffer 时,这组 buffer 会作为一个整体被挪到 LRU 列表末尾,但组内各项的相对顺序并不会被刻意调整。也就是说,一旦内核决定换出某个应用的内存,具体换出哪一块基本是不可预期的6。可以大致这样示意:

Unsorted LRU list visualization

如果内核这样遍历 LRU 列表,它会优先淘汰优先级为 2 的缓冲区,尽管 LRU 列表中其他位置还有优先级低得多的缓冲区。如果只是第一个优先级为 2 的缓冲区被淘汰,可能问题不大;但如果那个优先级为 4 的高重要性缓冲区也跟着被淘汰,那大概率会出问题。

既然我们已经知道各个分配的具体优先级,LRU 列表就是一个非常简单的切入点。做法很简单,只需把同一个应用程序内的列表条目按优先级排序7

Sorted LRU list visualization

这样,当内核遍历 LRU 列表寻找可淘汰的对象时,它首先找到并尝试淘汰的必定是优先级最低的缓冲区。优先级最高的缓冲区排在列表末尾,只有在所有更低优先级的缓冲区都不够淘汰时,才会被波及。

应用对内存优先级的支持

遗憾的是,并非所有应用都会通过 VK_EXT_pageable_device_local_memory 设置优先级。至于原生 Vulkan 应用,我目前还没看到有 idTech 游戏直接使用这个扩展,至少目前没有 :/

D3D 这边情况要好得多,因为 vkd3d-proton 在可用时已经会使用 VK_EXT_pageable_device_local_memory,把 ID3D12Device::MakeResident/ID3D12Device::Evict 这两个 API 调用,以及通过 ID3D12Device1::SetResidencyPriority 设置的优先级,统一转换为通过 Vulkan 的 vkSetDeviceMemoryPriority 命令设定的优先级值。大量 D3D12 游戏至少会用到上述 API 之一,因此这些游戏给出的提示信息现在终于能被实际利用起来了。

我没有特别可靠的数据来量化大多数 D3D12 应用到底超额提交了多少显存——它们一般不会像 idTech 的性能叠加层那样,把请求的显存总量以一种易读的方式呈现出来。不过,认真对待内存优先级通常还是有望改善体验的:性能随时间变化更加稳定(因为内核随便挑哪些 buffer 踢掉的影响变小了)。在少数能拿到的对照点上,我估计在最理想的情况下,相比内核随机挑东西踢掉的策略,性能提升最高可达 30%——但请对这一数字保持巨大的怀疑,因为结果几乎完全取决于踢掉哪块内存,纯看运气。

总结

说到底,显存耗尽时的表现到底怎么样?

我觉得还挺不错的!很多情况下你会惊讶地发现,即使把 1GB 甚至更多的内存踢到系统内存,性能损失也可以相当有限。但话说回来,这当然算是比较乐观的情况;要是把不该踢的东西放进了 CPU 内存,性能会很快显著下滑。踢内存要做恰到好处很难,而且在某种程度上,性能总会受到影响。如果一个游戏即便把所有东西都放在显存里时也只能勉强跑到 30fps,那在此基础上还要踢掉一部分内存,错过这 30fps 的目标有时候是不可避免的。

不管怎样,我希望这篇博客能说明:哪怕一部分内存最终被踢到了系统内存,性能下降也是可控的。驱动(尤其是内核驱动)可以采取很多措施让超额提交尽可能快,应用本身也可以与驱动栈配合,尽量降低自己的内存被踢出去时的影响。各项措施都到位之后,显存超额提交其实并不像乍一看那么可怕。

本文描述的所有改动早已随 SteamOS 发布(Stable 和 Preview 通道都已经包含,只要系统保持更新就能用上)。

关于向上游提交的说明

当然,我已经在着手把这一系列改动向上游提交,让所有人都能用上。不过这里面涉及大量模块和不少核心概念的深度重构,所以大概还需要一些时间打磨才能全部合入上游。

同时,我也不想发一篇博文讲了一堆很酷的代码,最后却来一句"其实你自己也跑不了,等都合并到上游再说吧 lol"。

折中一下,我把内核相关的改动 rebase 到了较新的上游内核版本上,并把 git 分支发布在这里。理论上效果应该差不多,但它**没有**像 SteamOS 内核那样经过严格测试。里面很可能存在 SteamOS 版本中没有的 bug 和不稳定问题。基本上就是**后果自负**。我不打算花太多精力去维护这个分支,因为我想把精力放在把这些补丁正式合入上游上。

为了把应用层的优先级提示传递给内核,你还需要我推送到这里的一个自定义 Mesa 分支。这边的注意事项和内核分支类似。

我自己的一些疑问

虽然到现在为止我自认为对驱动层面的内存管理已经有了相当不错的整体了解,但我对应用程序如何 de

原始来源: Hacker News

评论 (0)