不过,这个错误并不完全像普通的“无法分配,内存不足”错误。注意消息具体抱怨的是命令提交:当内核在尝试提交命令时返回 -ENOMEM,RADV 会打印此消息2,但仅仅提交命令并不会分配任何新资源!所有命令缓冲区都是预先分配的,显然它们的分配是成功的。即使所有内存都成功分配了,在 GPU 提交中使用它也会突然抛出“内存不足”错误。
又到了内核冒险的时间了!让内核接受提交肯定不难——毕竟,内核已经接受了所有分配3!
amdgpu 驱动程序在每次提交时,在指导 GPU 开始执行命令之前,必须确保所有可能被 GPU 命令引用的内存都是可访问的。使用更现代的 bindless 图形 API 时,你必须假设所有已分配的内存都可能被引用。因此,amdgpu 会尝试确保所有已分配的内存都是可访问的。
每个内存分配都包含关于它可以从哪种类型的内存(这里指系统 RAM 或 GPU VRAM)正常访问的信息。大多数分配既可以从 CPU RAM 也可以从 VRAM 访问,amdgpu 对内存分配位于这两种类型中的任何一种都满意。然而,有些分配必须放在 VRAM 中,且只能放在 VRAM 中。如果这些内存分配因为其他应用同时分配了 VRAM 而被驱逐到系统 RAM,amdgpu 必须将它们移回 VRAM。由于完全没有空闲 VRAM,将分配移回需要驱逐其他东西。出于某种原因,该操作失败了,内核报告了内存不足状况。
为了解释为什么驱逐操作会随机失败,我们需要绕一点路来看看内核如何处理 GPU 分配的(CPU 侧)锁。为了驱逐一个内存分配,你必须获得与该分配关联的锁。然而,在提交期间,你还必须锁定提交中引用的每个分配,以防止其他应用在准备 GPU 工作时将分配移走。但如果另一个 GPU 提交同时也在做同样的事情,你可能会遇到这样的情况:
如果一个提交想要驱逐另一个提交已经锁定的分配,但另一个提交也需要锁定第一个提交中的某个分配才能推进,那就出现了典型的 ABBA 死锁情况。
但别担心,内核知道如何检测和解决死锁!死锁检测的细节在这个内核文档页面中有解释,但粗略地说,内核将锁定操作与“事务”关联(基本上就是跟踪获得了哪些锁)。如果两个事务会死锁,其中一个事务会被标记为“受伤”,下次它尝试获取锁时,会返回 -EDEADLCK 错误。该错误要求中止事务:应释放事务期间获得的所有锁,并从头重新开始事务。在命令提交的上下文中,这仅意味着驱动程序将重新开始遍历所有内存分配并确保它们可访问的过程。
那么问题出在哪里?没有。这种方法坚如磐石,效果很好。
至少只要它在所有地方都实现了。
在图形子系统中,受伤-中止-重试循环的内部细节通过一个名为 drm_exec 的小型辅助库抽象。你不必手动跟踪哪些分配被锁定,并在遇到 -EDEADLCK 时释放锁,只需使用 drm_exec_lock_obj 辅助函数。如果你研究 TTM 中的锁定代码,即共享的 Linux GPU 内存管理层,你会注意到明显没有使用 drm_exec。
相反,甚至有一条注释指出 -EDEADLCK 会导致驱逐失败。我们找到了问题所在!一旦由于命令提交期间内存压力巨大而遇到此死锁情况,内核就会退出并拒绝提交,而不是重试。
已经有一些补丁集在 TTM 中接入 drm_exec 辅助程序,早在 2024 年就发送了,但由于一些原因从未被合并,其中包括一些尚未解决的剩余 bug。我的任务已经明确:将补丁集重新基于我的内核版本,并找出那些剩余的错误。
重新基于补丁集并不是太麻烦,而找出错误只用了整整一周的强烈痛苦,游戏在重度 VRAM 争用中随机挂起 3 分钟。还不错!
我尝试重新发送 带有我找到的所有错误修复的补丁集,希望这次能合并,但还需要做更多工作才能合并。
既然 VRAM 耗尽至少不会随机崩溃你的应用,我们现在可以适当地调高设置并查看性能。初步结果给了我一个绝对华丽的性能图,如下所示:
找出为什么性能如此糟糕,需要弄清楚系统实际在做什么导致如此缓慢。对于“内核驱动程序在做什么?”这类广泛的问题,我喜欢使用 gpuvis。gpuvis 使用内核跟踪点构建事件时间线(包括“GPU 工作提交开始/停止”,可以从中推断每次提交所花费的时间)。
在系统 VRAM 耗尽时记录跟踪并启动 gpuvis,时间线显示如下情况:
事实证明,大部分时间实际上不是花在处理提交上(那是 gfx_0.0.0 活动),而是花在移动内存上,为提交做准备(sdma0 活动)!
为什么总是有这么多缓冲区移动,如果你使用 gpuvis 的事件列表,并过滤只显示某个特定缓冲区对象(我随机选了一个,大多数缓冲区对象都有类似模式)的捕获移动事件,就会更明显:
列表清楚地显示,竞争的进程(在这种情况下是 gamescope 和游戏本身)会不断地轮流驱逐和移回同一块内存,一次又一次。这真的很糟糕!这让我想起了我在第一篇博客文章中写的内容:
通常,两个竞争的应用可以预期大致轮流执行 GPU 工作——第一个应用提交工作,然后另一个,然后第一个再次提交,依此类推。采用这种方法,内存会在每次提交后被反复移来移去。一个应用被踢出,然后立即被移回,同时踢出另一个(下一步移动内存回来)。所有这些移动最终导致性能比最初不移动内存还要差。
这描述了一个旧问题:过于激进的 VRAM 分配会导致持续发生乒乓般的移动。但这个问题后来通过简单地在没有空闲 VRAM 时不去尝试占有 VRAM 而修复,并且内核在我实现 dmem cgroup 的 VRAM 保护后才开始有点激进。显然,这必定以某种方式重新引入了乒乓效应。
从概念上讲,dmem cgroup VRAM 保护的设计不应该导致乒乓移动,因为内核只被允许驱逐没有关联 cgroup VRAM 保护的内存。如果没有 VRAM 保护,你通常不应该被允许驱逐受保护的 VRAM。
该规则的唯一例外是那些为了正常工作而必须位于 VRAM 中的内存。这些类型的内存分配总是允许被移动到 VRAM 以确保系统稳定性。通常,几乎没有任何来自应用的东西真的需要位于 VRAM 中才能正确操作,但有一个来自应用的缓冲区对象确实是:包含要扫描输出到显示器的图像数据的缓冲区4。
不仅显示硬件喜欢将扫描输出图像放在 VRAM 中,它还完全绕过 GPU 的虚拟内存架构,仅使用物理地址。因此,扫描输出的图像也必须是物理内存中连续的。
有了虚拟内存和页表的能力,典型的应用缓冲区只在虚拟内存中是连续的,可能在物理内存中到处散布5。虚拟地址 0x5000 的缓冲区第一页可能在页表中映射到物理地址 0x1234000,但虚拟地址 0x6000 的第二页可能指向物理地址 0x4321000,一个完全不同的地方!
下面是一个示意图,可视化了在存在大量碎片(这通常是在 VRAM 非常低时的情况)时虚拟分配到物理分配的映射:
箭头显示第一个分配的不同段的页表映射到物理内存段。为了可读性,其他所有分配的箭头都被省略了。
如果你要分配显示扫描输出数据,这种碎片是不可接受的,因为物理内存必须是连续的。这与其他数据的驱逐产生了非常非常不幸的相互作用。让我们假设扫描输出数据已经被驱逐,但现在该数据需要被扫描输出,因此它必须被移回 VRAM。
简单地驱逐一个缓冲区是不够的,即使该缓冲区与显示扫描输出数据大小相同,因为驱逐它不会产生足够的连续物理空间来放置扫描输出数据!更糟糕的是,驱逐算法根本不考虑物理内存约束。它是一个非常简单的循环,大致是:
while (true) { evict(getLeastRecentlyUsedBuffer()) if (tryAllocate(newBuffer) == SUCCESS) break; }
使用这个算法(假设分配按 LRU 顺序排列),即使你驱逐前 3 个分配(绿色、蓝色和红色),也没有足够大的空间来容纳扫描输出缓冲区!即使最大的可能空闲空间也稍微小了一点,如这个图所示。要在我们的例子中找到足够大的物理连续内存区域,VRAM 中的每一个分配最终都会被驱逐!在真实场景中,我观察到多达 4GiB 的 VRAM 被清除,只是为扫描输出图像(每个图像约 32MiB 的 R11G11B10 像素数据)腾出空间。这会非常痛苦!仅仅将所有数据从 VRAM 移出的操作已经至少耗费约 130ms,根据之前估计的 PCIe 传输速率。
虽然扫描输出是这里最严重的失败案例,但这个问题更普遍:总是会有某些内存分配被反复移动到 VRAM,可能踢出一些应用可能希望保留在 VRAM 中的内存。抵制这种情况并试图将驱逐的内存移回很可能会适得其反。
尽管 dmem cgroup 保护不是这个问题的完整解决方案,但它大大缩小了问题范围。有了 cgroup 保护,你可以确信任何随机应用都不会随意踢出重要的游戏资源。任何被强制移回 VRAM 的内存很可能有充分的理由留在 VRAM。因此,即使有 dmem cgroup 保护,我们也应该小心,不要试图强制回收被驱逐的内存。
通过一些迭代测试,我认为我得出了一组启发式规则,在大多数游戏在野外遇到的情况下能合理工作(在重要系统分配驱逐东西时不要过于激进是一回事,但也需要相当快速地回收被驱逐的内存,例如当游戏暂停且 Steam 菜单运行时,驱逐大量游戏内存,然后游戏恢复)。
根据我的经验,这在不因过度激进的驱逐其他应用而搬起石头砸自己的脚,以及当大量内存突然被驱逐(例如因为游戏暂停且用户浏览 Steam 而不是玩游戏)时仍能合理快速恢复之间,取得了可接受的平衡。
有了这些启发式规则,让我们终于真正尝试调高设置。
我最终选择了《夺宝奇兵:大圆环》,因为它方便地暴露了流媒体池大小设置,你可以通过修改它来几乎直接调整 VRAM 消耗。
你瞧,即使将设置调到有点荒谬的程度,游戏请求 8GiB VRAM 中的 9GiB(即整整 1GiB 过度提交的游戏资源驻留在 CPU 内存中),性能也不再崩溃到遗忘!每帧 19.6ms 的平均值,我仍然会称之为完全可玩。
我还可以将设置调到更加荒谬的水平,并将过度提交的内存量加倍,游戏在这个 8GiB 系统上请求 10GiB 的 VRAM(因此有 2GiB 资源过度提交)。此时帧时间方差上升很多,超过 33.3ms 的尖峰频繁出现。整体平均值约为 29.8ms,并非最差,但尤其加上方差,这在游戏中会开始明显。
虽然这已经是巨大的进步,但我们还没有完全到位。VRAM 过度提交下的体验有时仍然有点好坏参半,帧时间可能会根据你在游戏中查看的对象而明显变化。
请记住,要获得真正良好的驱逐性能,被驱逐的内存如何被 GPU 使用非常重要。目前,这完全没有被考虑!如果我们能更多地根据应用访问与 CPU 内存的兼容性来做出驱逐决策,很多这种方差可能会直接消失。
关于应用内存访问模式的复杂之处在于,只有应用真正知道这些模式。因此,驱动程序无法按原样考虑它们。理想情况下,应该有一个 API,应用可以通过它向驱动程序提供关于特定内存分配适合被驱逐程度的提示。
正好有像 vkSetDeviceMemoryPriorityEXT 这样的东西!
VK_EXT_pageable_device_local_memory 扩展提供了我们需要的功能,允许应用为任何设备内存分配传达任何想要的优先级。只要应用通过此扩展提供合理的提示,在内核中实现优先级排序并利用应用提供的优先级,就有潜力大大稳定局面!
在内核中接入优先级排序其实比你预期的要容易得多。内核已经维护了一个最近最少使用(LRU)的内存分配列表,在驱逐时按顺序遍历。对于 LRU 列表上的每个条目,尝试驱逐,直到有足够的空闲空间来满足驱逐的需求。
这个 LRU 列表为应该先驱逐哪个应用的内存提供了一个很好的启发式。长时间未提交任何内容的应用不太可能很快需要内存,而且由于它们的内存不是最近使用的,它会出现在 LRU 列表的早期,并被首先驱逐。
当一个应用使用一组缓冲区时,这组缓冲区会一次性移动到 LRU 列表的末尾。然而,该批次内分配的顺序完全没有明确控制。这意味着一旦内核接近某个应用以驱逐其内存,具体哪些内存片段被驱逐或多或少是不确定的6。一个简化的可视化可能看起来像这样:
如果内核这样遍历 LRU 列表,它会首先驱逐优先级值为 2 的缓冲区,尽管 LRU 列表中其他地方有优先级低得多的缓冲区。如果只驱逐第一个优先级 2 的缓冲区,事情可能还好,但如果优先级 4 的高重要缓冲区最终也被驱逐,就很可能会出现问题。
鉴于我们已经知道各个分配的具体优先级,这个 LRU 列表是一个非常简单的集成位置。就像按优先级对单个应用内的列表条目排序一样简单7:
现在,当内核遍历 LRU 列表寻找要驱逐的对象时,它首先发现并尝试驱逐的是优先级最低的缓冲区。优先级最高的缓冲区在列表末尾,因此只有在驱逐所有较低优先级的缓冲区都不够时才会被驱逐。
不幸的是,并非所有应用都实际通过 VK_EXT_pageable_device_local_memory 设置优先级。至于原生 Vulkan 应用,我至少没有观察到任何 idTech 游戏直接使用该扩展 :/