Reed's News
← 返回精选

Linux 7.3 对显存过载性能的改进与根因分析

Tech 72 flaburgan 2026/8/18 4765 字 原文 ↗

今年早些时候,我曾在博客中分享过自己为游戏优化VRAM(显存)管理的工作。如今,经过数月邮件列表的讨论,相关内核补丁终于被上游合并,并将随Linux 7.3版本发布!太棒了!

为了庆祝这一进展,我们不妨深入聊聊我在上篇博客里写过的一句话:

只要游戏实际占用的显存不超过硬件上限,运行稳定性就会大幅提升。

那么问题来了:如果游戏真的超显存运行,会发生什么?

普遍的预期是,一旦显存耗尽,情况就会彻底失控——游戏频繁崩溃、帧率暴跌至无法游玩,流畅体验更是无从谈起。

但这真的是无法避免的宿命吗?显存耗尽为何如此棘手?最重要的是:我们能如何降低它的负面影响?

先理清楚原理

理论上,显存耗尽应该只影响性能,而非稳定性。GPU驱动从诞生起就支持显存超配:只要驱动开启超配机制,程序就能申请任意量的显存,最终获得的空间由内核驱动根据GPU物理显存的实际容量决定。

从性能角度看,显存耗尽导致卡顿的核心原因很简单:当游戏申请的显存超过物理上限时,部分数据会被转移(置换)到CPU内存中。对GPU而言,访问CPU内存的速度远慢于显存——不仅CPU内存本身的速度就不及专用GPU显存,所有内存访问还得通过PCI总线,这会额外增加延迟,同时成为带宽瓶颈。

受限于PCI总线的速度,显存超配必然会带来一些无法规避的性能限制。假设GPU通过PCIe 4.0x16接口连接,带宽约为32GiB/s,每毫秒可传输约32.2MiB数据。要达到每秒30帧的最低流畅标准(每帧耗时33.3毫秒),GPU单帧能访问的最大数据量约为1075.5MiB,略超1GiB。换句话说,如果置换到CPU内存的数据量过大,导致GPU单帧需要从CPU内存读取超过1GiB的数据,那么30帧的目标就根本无法实现。

内存也分三六九等

不过,GPU偶尔读取少量CPU内存,并不一定会直接导致性能崩盘。事实上,即便显存充足,GPU驱动有时也会让命令缓冲区等数据存放在CPU内存中!GPU执行这些命令时同样需要访问CPU内存,但整体运行却完全正常。那为什么这类访问没问题,显存耗尽却会引发灾难?

其中一个关键影响因素是缓存。无论缓存的数据来自CPU还是GPU内存,缓存命中时的访问延迟都是相同的,因此通过缓存命中可以在一定程度上分摊PCI总线传输的高昂初始成本。我们可以通过微基准测试,用刻意规避缓存的访问模式,测量不同大小缓冲区的访问延迟,以此估算CPU内存与显存的延迟差异。在RDNA3架构上测试,结果大致如下:

RDNA3 GPU缓存微基准测试结果

如预期所示,当缓冲区大小不超过L2缓存(或更高层级缓存)时,CPU内存与显存的访问延迟完全一致,因为此时数据直接从缓存读取。当缓冲区达到6MB(RDNA3的L2缓存容量)时,CPU内存的访问延迟飙升至约2400周期/次,而显存延迟则基本保持稳定。需要注意的是,显存访问会经过Infinity Cache,但CPU内存访问不会——一旦L2缓存未命中,就直接通过PCIe传输。推测这是因为Infinity Cache直接对接显存,任何不涉及显存的访问都不会经过它。

当然,数据一开始并不会被缓存,首次访问的延迟仍然会高很多。同时,失去Infinity Cache的加持也会造成不小影响:PCIe读取的延迟约为Infinity Cache命中的7.3倍,是显存读取的4.6倍。要完全抵消PCIe传输的额外成本,需要极高的缓存命中率。这意味着,只有在极少数场景下,即使显存充足,我们才会主动选择使用CPU内存。而当显存不足需要置换数据时,性能下降几乎是不可避免的。

不过,虽然性能下降无法避免,但不同内存的置换对整体性能的影响程度差异很大:访问模式对缓存友好的内存,受CPU内存速度的影响较小;如果访问模式不友好但访问频率很低,GPU很少需要从CPU内存读取数据,性能也不会受太大影响。还有很多内存分配,GPU只会访问其中一小部分,其余部分根本不会读取。这类内存即使被置换到CPU内存,哪怕总量达数GiB,实际单帧访问的数据量也可能远低于1GiB的硬上限。

这些变量导致,实际场景中很难预测内存置换后的性能表现。但简而言之:根据置换内存的访问频率和缓存命中率,显存耗尽后仍有可能维持(相对)正常的性能!

直面现实问题

我们已经从理论层面论证了高效显存超配的可能性,听起来很不错!那我们启动SteamOS,打开游戏,把画质拉满……

radv/amdgpu: Not enough memory for command submission.

哦,翻车了。

实际情况是,显存耗尽确实会引发诸多稳定性问题。

不过这个错误和普通的“内存不足,分配失败”不太一样:错误信息明确提到了“命令提交”——当内核在处理命令提交时返回-ENOMEM错误,RADV驱动就会打印这条信息。

看来又得深入内核一探究竟了!既然内核已经接受了所有内存分配,那让它接受命令提交应该不难吧?

内核锁的噩梦

amdgpu驱动在每次提交命令前,必须确保GPU命令可能引用的所有内存都处于可访问状态。在现代无绑定图形API下,驱动需要假设所有已分配的内存都有可能被引用,因此amdgpu会尝试确保所有内存都可访问。

每个内存分配都带有标记,说明它适合在何种内存(这里指系统内存或GPU显存)中访问。大多数内存可以在两种介质中访问,amdgpu对此没有限制,但有一小部分内存必须且只能存放在显存中。如果这类内存因为其他应用占用显存而被置换到系统内存,amdgpu就必须将其移回显存。但此时显存已完全耗尽,要移回就必须置换其他内存——而不知为何,这个置换过程失败了,内核因此返回了内存不足的错误。

要解释随机置换失败的原因,我们得先了解内核如何处理GPU内存分配的CPU端锁机制。要置换一块内存,必须先获取该内存对应的锁;而在命令提交过程中,也必须锁定所有被引用的内存,防止其他应用在GPU准备工作时移动这些内存。但如果有两个GPU提交操作同时进行,就可能出现以下情况:

并发提交时的死锁场景

如果一个提交操作想要置换另一个提交已锁定的内存,而另一个提交也需要锁定第一个提交的内存才能继续,就会出现典型的ABBA死锁。

不过不用担心,内核具备死锁检测与解决机制!具体原理可参考这份内核文档,简单来说,内核会将锁操作与一个“事务”关联(用于跟踪已获取的锁)。如果两个事务可能发生死锁,其中一个会被标记为“受损”,下次尝试获取锁时就会返回-EDEADLCK错误,要求事务中止:释放该事务获取的所有锁,然后从头开始执行。在命令提交场景中,这意味着驱动会重新检查所有内存分配,确保它们处于可访问状态。

听起来很完美?确实如此,这套机制非常可靠——至少在所有场景都实现了的情况下是这样。

在图形子系统中,死锁检测、中止与重试的复杂逻辑被封装在一个名为drm_exec的辅助库中。开发者无需手动跟踪锁定的内存,也不用在遇到-EDEADLCK时手动释放锁,只需调用drm_exec_lock_obj辅助函数即可。但如果查看Linux GPU内存管理共享层TTM的锁代码,你会发现它完全没有使用drm_exec

不仅如此,代码中还有注释指出,-EDEADLCK会导致置换失败!问题找到了:当显存压力过大,命令提交过程中出现死锁时,内核会直接放弃并拒绝提交,而不是重试。

早在2024年就有人提交过补丁,试图在TTM中集成drm_exec辅助库[https://lore.kernel.org/all/20240710124301.1628-7-christian.koenig@amd.com/],但由于一些未解决的bug,这些补丁始终未能合并。我的任务很明确:将补丁移植到我使用的内核版本,并找出那些遗留bug。

移植补丁不算麻烦,而找出bug只花了我一周时间——期间游戏在显存高负载下随机挂起的经历堪称“痛苦”,不过也不算最糟!

我修复了所有发现的bug,重新提交了补丁[https://lore.kernel.org/all/20260703-ttm_2_drm_exec-v1-0-43685ac1286b@gmx.de/],希望这次能顺利合并,但在合并前还有一些工作要做。

现在,显存耗尽至少不会导致应用随机崩溃了,我们终于可以放心地拉高画质,看看性能表现。但最初的结果却给出了一张惨不忍睹的性能图:

糟糕的性能表现图

性能到底去哪了?

要找出性能暴跌的原因,得先搞清楚系统到底在做什么耗时的操作。对于这类“内核驱动在干嘛?”的问题,我习惯用gpuvis工具。它通过内核跟踪点构建事件时间线,包括“GPU工作提交开始/结束”,由此可以推断每次提交的耗时。

用gpuvis分析显存耗尽时的跟踪日志,时间线显示出这样的情况:

SDMA长时间占用导致性能糟糕

原来,大部分时间根本不是花在处理提交操作(gfx_0.0.0活动)上,而是花在提交前的内存移动上(sdma0活动)!

如果用gpuvis的事件列表,过滤出某个特定缓冲区对象的移动事件(我随机选了一个,大多数缓冲区的模式都类似),就会明白为什么内存会频繁移动:

gpuvis事件过滤器显示缓冲区反复来回移动

列表清晰地显示,相互竞争的进程(这里是gamescope和游戏本身)会不断地置换和移回同一块内存,循环往复。这太糟糕了!这让我想起自己在第一篇博客中写过的内容:

通常,两个相互竞争的应用会交替执行GPU任务——一个提交任务,然后是另一个,如此循环。这种情况下,内存会在每次提交后被来回移动:一个应用的内存被置换出去,又立刻被移回,把另一个应用的内存挤出去,而后者会在下一次提交时再次移回。这种反复移动的性能,甚至比一开始就不移动内存还要差。

这描述的是一个旧问题:过度激进的显存分配会导致内存反复来回移动。后来我们通过一个简单的方法解决了这个问题——当显存耗尽时,不再尝试抢占显存。而当我用dmem cgroups实现显存保护后,内核又变得有些激进,显然是重新引入了这种“乒乓效应”。

从设计上看,dmem cgroup显存保护机制本不应导致乒乓移动,因为内核只会置换没有cgroup显存保护的内存。没有保护的内存通常不能置换受保护的显存。

唯一的例外是那些必须存放在显存中才能保证系统正常运行的内存。这类内存总是会被优先移回显存,以确保系统稳定。通常,应用程序几乎没有必须存放在显存中的数据,但有一个例外:用于显示输出的图像数据缓冲区。

显示硬件的特殊要求

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

借助虚拟内存和页表,普通应用缓冲区只需在虚拟地址上连续,物理地址可以分散在各处——比如虚拟地址0x5000可能映射到物理地址0x1234000,而相邻的虚拟地址0x6000可能映射到完全不同的物理地址0x4321000

下面的图展示了显存严重不足时,虚拟内存分配到物理内存的映射情况(此时物理内存通常会非常碎片化):

虚拟连续内存映射到碎片化物理内存

箭头表示第一个内存分配的不同段到物理内存的页表映射,为了可读性,其他分配的映射未标出。

对于显示输出数据的分配,这种碎片化是不可接受的,因为它需要连续的物理内存。这会与内存置换产生非常糟糕的交互。假设输出图像数据已被置换到系统内存,现在需要显示它,必须将其移回显存。

此时,即使置换出一块与输出图像大小相同的缓冲区也不够,因为置换后无法腾出足够的连续物理空间来存放输出数据!更糟的是,当前的置换算法完全没有考虑物理内存的限制,只是一个非常简单的循环:

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

假设内存分配按最近最少使用(LRU)顺序排列,使用这个算法,即使置换前3个分配(绿色、蓝色和红色),也无法腾出足够大的空间来存放输出缓冲区!最大的空闲空间仍然略小,如更新后的图所示:

仍然没有足够空间存放输出缓冲区

在这个例子中,要找到足够大的连续物理内存区域,必须置换显存中的所有内存!在实际场景中,我观察到为了存放约32MiB的输出图像(R11G11B10像素格式),最多需要置换4GiB的显存。这影响太大了!按照之前估算的PCIe传输速率,仅仅是把这些数据移出显存就至少需要130毫秒。

唉

用启发式规则解决问题

虽然显示输出是最极端的情况,但这个问题具有普遍性:总有一些内存会被反复移回显存,可能会挤掉应用希望保留在显存中的内存。如果强行将被置换的内存移回,很可能会适得其反。

尽管dmem cgroup保护不能完全解决这个问题,但它大大缩小了问题范围。有了cgroup保护,我们可以确保其他应用不会随意置换重要的游戏资源。任何被强行移回显存的内存,都必然有充分的理由。因此,即使有dmem cgroup保护,我们也应该谨慎,不要强行回收被置换的内存。

经过反复测试,我总结出一套启发式规则,能较好地应对游戏遇到的大多数场景(比如,当重要系统内存置换了游戏数据时,不要过于激进地抢回显存;但当游戏暂停,Steam菜单启动置换了大量游戏内存,之后游戏恢复时,又要能快速回收显存)。

这套规则大致如下:

  • 当内核检测到应用的内存被置换时,会进入“强节流”阶段,持续数毫秒。在此阶段,完全不会尝试将该应用的内存移回显存(当然,前提是所有内存都能正常访问)。
  • 之后进入“弱节流”阶段,此时可以通过移回内存来回收空闲空间,但不会尝试置换其他应用已分配的内存。这个阶段最长可持续数秒,确保系统达到稳定状态。
  • 如果“弱节流”阶段结束后,没有再发生内存置换,就认为系统已进入稳定状态,此时可以解除对置换其他应用内存的限制。

根据我的经验,这套规则能在避免过度激进置换其他应用内存和快速回收显存之间取得平衡。比如,当游戏暂停,用户浏览Steam菜单导致大量游戏内存被置换,之后恢复游戏时,系统能较快地恢复显存占用。

终于有进展了

有了这套启发式规则,我们终于可以真正拉高画质测试了。

我选择了《夺宝奇兵:古老之圈》,因为它提供了“流池大小”设置,可以直接调整显存占用量。

你猜怎么着?即使把设置调到夸张的程度——游戏申请9GiB显存,而硬件只有8GiB(即1GiB的游戏数据超配到CPU内存)——性能也没有彻底崩盘!平均每帧耗时19.6毫秒,完全可以流畅游玩。

《夺宝奇兵:古老之圈》9GiB/8GiB超配测试

我还可以把设置调得更夸张,让超配量翻倍——在8GiB显存的系统上,游戏申请10GiB显存(即2GiB数据超配到CPU内存)。此时帧率波动明显增大,频繁出现超过33.3毫秒的帧耗时峰值,平均每帧约29.8毫秒。虽然不算特别糟糕,但结合帧率波动,游戏体验会受到明显影响。

尽管这已经是巨大的进步,但我们还没有完全解决问题。显存超配下的体验仍不稳定,帧率可能会因游戏中视角的变化而出现明显波动。

别忘了,要实现良好的置换性能,被置换内存的GPU访问模式至关重要!但目前内核完全没有考虑这一点。如果我们能根据应用内存对CPU内存的适配性来决定置换优先级,很多帧率波动问题就能迎刃而解。

把控制权交还给应用

应用的内存访问模式只有应用自己最清楚,驱动无法直接获取这些信息。理想情况下,应该有一套API让应用向驱动提供提示,说明某块内存是否适合被置换。

VK_EXT_pageable_device_local_memory扩展提供的vkSetDeviceMemoryPriorityEXT接口,正好满足我们的需求——它允许应用为任意设备内存设置优先级。只要应用通过这个扩展提供合理的提示,内核就能基于这些优先级进行置换决策,大幅提升稳定性!

在内核中集成优先级机制比想象中简单。内核已经维护了一个内存分配的LRU列表,置换时会按顺序遍历列表,尝试置换内存直到腾出足够空间。

这个LRU列表本身就是一个不错的置换优先级启发式规则:长时间未提交任务的应用,近期不太可能需要内存,因此它们的内存会排在LRU列表前面,被优先置换。

当应用使用一组缓冲区时,这组缓冲区会被批量移到LRU列表的末尾,但组内各内存的顺序完全不受控制。这意味着,当内核开始置换某个应用的内存时,具体置换哪块内存几乎是随机的。

未排序的LRU列表示意图

如果内核按这个顺序遍历LRU列表,会优先置换优先级为2的缓冲区,尽管列表中还有优先级更低的缓冲区。如果只置换这块优先级为2的缓冲区可能还好,但如果连优先级为4的重要缓冲区也被置换,就会出问题。

既然我们已经知道每个内存分配的具体优先级,只需在LRU列表中按优先级排序即可:

排序后的LRU列表示意图

现在,内核遍历LRU列表寻找置换目标时,会首先尝试置换优先级最低的缓冲区,优先级最高的缓冲区排在最后,只有当所有低优先级缓冲区都被置换后仍不足够时,才会被置换。

应用对内存优先级的支持情况

遗憾的是,并非所有应用都会通过VK_EXT_pageable_device_local_memory设置内存优先级。至少我还没有观察到任何idTech引擎的原生Vulkan游戏直接使用这个扩展:(

不过Direct3D(D3D)方面的情况要好得多:vkd3d-proton会在支持时使用VK_EXT_pageable_device_local_memory,将ID3D12Device::MakeResident/ID3D12Device::Evict调用,以及通过ID3D12Device1::SetResidencyPriority设置的优先级,转换为Vulkan的vkSetDeviceMemoryPriority命令。很多D3D12游戏都会使用至少一种这类API,因此它们提供的提示现在能被内核利用。

我没有准确的数据说明大多数D3D12应用的超配量,因为它们不像idTech引擎那样,会在性能面板中直接显示申请的显存总量。但合理利用内存优先级确实有望改善体验:性能会更稳定(因为不再依赖内核随机置换的运气)。在一些有对比的场景中,我估计性能最多能比内核随机置换提升30%——不过这个数字仅供参考,因为它很大程度上取决于置换的运气。

总结

说了这么多,显存耗尽后的实际表现到底如何?

我认为已经相当不错了!很多情况下,即使置换了1GiB甚至更多的内存,你也会惊讶地发现性能并没有损失太多!当然,这是比较乐观的情况,如果被置换到CPU内存的是关键数据,性能会立刻大幅下降。内存置换很难做到尽善尽美,性能总会受到一定影响。如果游戏在显存充足时都难以达到30帧,那么显存耗尽后的置换操作可能会让这个目标彻底无法实现。

但我希望这篇博客能说明:即使部分内存被置换到系统内存,性能下降也可以控制在可接受范围内。驱动(尤其是内核驱动)可以采取措施让超配尽可能高效,应用也可以与驱动协作,减轻内存置换的影响。当所有机制都到位后,显存超配其实并没有想象中那么可怕。

我在这里描述的所有工作,已经在SteamOS中发布一段时间了(稳定版和预览版都已包含,只要你的系统是最新版本,就能使用这些功能!)。

关于上游合并

当然,我已经在推进这些工作的上游合并,让所有用户都能受益!但由于涉及大量组件,且需要对一些核心概念进行深度重构,可能需要一段时间才能全部合并完成。

同时,我也不想写了一篇介绍这么多精彩代码的博客,最后却告诉大家“你们现在还没法体验,等上游合并吧”。

作为折中方案,我将内核代码移植到了最新的上游内核版本,并发布了一个git分支[https://gitlab.freedesktop.org/pixelcluster/kernel/-/commits/vramstuff-rebase]。理论上它能实现类似的效果,但没有经过SteamOS内核那样严格的测试,可能存在一些SteamOS版本中没有的bug和稳定性问题。**请自行承担风险使用**。我不会花太多精力维护这个分支,而是会专注于将补丁正式合并到上游。

要让应用的优先级提示传递到内核,你还需要使用我发布的自定义Mesa分支[https://gitlab.freedesktop.org/pixelcluster/mesa/-/commits/vram-prios]。同样,这个分支也存在类似的稳定性风险。

我的一些疑问

虽然我自认为对驱动端的内存管理有相当深入的了解,但对应用内部如何制定内存管理策略却知之甚少。我猜测,优化显存耗尽的场景可能并不是开发者的首要任务(不过,内存短缺的现状可能正在改变这种情况?:P),或许这里还有未被发掘的性能提升空间?

如果你恰好了解大型游戏/引擎的显存管理(尤其是显存耗尽时的处理),我很乐意和你交流!我有种预感,通过应用与驱动的更好协作,还能进一步提升性能,同时我也很想了解应用开发者的视角。