Bits, pixels, cycles and more Arseny Kapoulkine (email, , )


Measuring acceleration structures

2025年3月31日

DirectX 12 和 Vulkan 支持的硬件加速光线追踪依赖于一种抽象数据结构,用于存储场景几何体,被称为 "acceleration structure",通常也称为 "BVH" 或 "BLAS"。 与光栅化几何体表示不同,渲染引擎无法自定义数据布局; 与纹理格式不同,该布局在供应商之间没有标准化。

这似乎是一个微不足道的问题 - 肯定到 2025 年,所有实现方案在内存消耗方面都已接近,主要的竞争在于光线遍历性能和新的光线追踪功能? 让我们拭目以待。

实验设置

在这里做出任何概括性的声明都将是困难的; 并且测试它需要使用许多不同供应商的许多不同的 GPU,这非常耗时。 因此,就本文而言,我们将只关注一个场景 - Amazon Lumberyard Bistro,或者更具体地说,是 Nvidia 对其进行的某种自定义变体,该变体使用的实例化比默认 FBX 下载更多。

结果是通过运行 niagara 渲染器获得的; 如果您想跟着一起操作,您将需要 Vulkan 1.4 SDK 和驱动程序,以及类似以下内容:

git clone https://github.com/zeux/niagara --recursive
cd niagara
git clone https://github.com/zeux/niagara_bistro bistro
cmake . && make
./niagara bistro/bistro.gltf

代码 将解析 glTF 场景,转换网格以使用 fp16 位置,为每个网格构建一个 BLAS1,使用 VK_KHR_acceleration_structure 扩展的相关部分对其进行压缩,并打印生成的压缩大小。 虽然在场景加载时构建了许多细节级别,但只有原始几何体会进入 acceleration structures,总共有 1.754M 个三角形2

构建使用 PREFER_FAST_TRACE 构建模式; 在某些驱动程序上,使用 LOW_MEMORY 标志可以进一步减小 BLAS 大小,但会以一些遍历性能为代价,我们现在将忽略这一点。

实验结果

在所有相应供应商的最新(截至 3 月底)驱动程序上,在一系列不同的 GPU 上运行此程序,我们得到以下结果; 总 BLAS 大小与近似的“字节/三角形”数一起呈现 - 这实际上是不正确的计算方式,但我们无论如何都会这样做。

GPU | BLAS 大小 | 字节/三角形 ---|---|--- AMD Ryzen 7950X (RDNA2 iGPU) | 100 MB | 57.0 AMD Radeon 7900 GRE (RDNA3) | 100 MB | 57.0 AMD Radeon 9070 (RDNA4) | 84 MB | 47.9 NVIDIA GeForce RTX 2080 | 46 MB | 26.5 NVIDIA GeForce RTX 3050 | 45 MB | 25.7 NVIDIA GeForce RTX 4090 | 45 MB | 25.7 NVIDIA GeForce RTX 5070 | 33 MB | 18.8 Intel Arc B580 | 79 MB | 45.0

哇,差距真大! 早期 AMD GPU 和最新 NVIDIA GPU 之间的差异为 3 倍; 比较最新的 AMD 和 NVIDIA GPU,我们仍然看到内存消耗方面存在 2.5 倍的差异。 Intel3 略领先于 RDNA4,BLAS 比 NVIDIA 大 2.4 倍。

现在,此表将每个 BLAS 内存消耗表示为 GPU 的函数 - 显然,GPU 代系对内存消耗有一些影响。 然而,另一个重要的促成因素是软件,或者更具体地说是驱动程序。 对于 AMD,我们可以比较去年各种驱动程序版本的结果,以及同一 GPU - Radeon 7900 GRE 上的备用驱动程序 radv4

驱动程序 (RDNA3) | BLAS 大小 | 字节/三角形 ---|---|--- AMDVLK 2024.Q3 | 155 MB | 88.4 AMDVLK 2024.Q4 | 105 MB | 59.9 AMDVLK 2025.Q1 | 100 MB | 57.0 radv (Mesa 25.0) | 241 MB | 137.4

正如我们所看到的,在过去的 9 个月中,同一 AMD GPU 和同一驱动程序代码库上的 BLAS 内存消耗逐渐提高了 1.5 倍5,而如果您使用 radv,您的 BLAS 消耗现在比官方 AMD 驱动程序大 2.4 倍,更不用说最新的 NVidia GPU6

嗯……这当然是很多不同的数字。 让我们尝试理解至少其中的一些。

心理模型

让我们尝试构建一些模型来帮助我们理解我们应该期望什么。 100 MB 对于 1.754M 个三角形来说好吗? 241 MB 不好吗? 现在是讨论 BVH 实际上是什么的时候了。

首先,让我们结合我们正在输入多少数据来对此进行上下文化。 Vulkan / DX12 API 的工作方式是,应用程序向驱动程序提供几何体描述,该描述可以是三角形的平面列表,也可以是顶点索引缓冲区对。 与光栅化不同,顶点可以携带应用程序希望以某种方式打包的各种属性,而对于光线追踪,您只需指定每个顶点的位置,并且格式的指定更为严格。 如上所述,在这种情况下,我们正在向驱动程序提供 fp16 数据 - 这很重要,因为在 fp32 数据上,您可能会看到不同的结果,并且供应商之间的差异不太明显。7

索引缓冲区是您通常期望在光栅化中看到的 32 位或 16 位数据; 然而,在大多数或可能所有情况下,索引缓冲区只是一种将您的几何体传递给驱动程序的方式 - 与光栅化不同,其中索引和顶点缓冲区的效率至关重要,在这里,驱动程序通常会构建 acceleration structure 而不考虑显式索引信息。

然后,一个平面三角形位置列表将占用每个三角形角 6 个字节 * 每个三角形 3 个角 * 1.754M 个三角形 = 31.5 MB。 这不是最具内存效率的存储方式:此场景使用 1.672M 个唯一顶点,因此使用 16 位索引缓冲区将需要大约 10 MB 用于顶点位置和大约 10.5 MB 用于索引,并且一些 meshlet 压缩方案可以低于该值8; 但无论如何,我们对一个效率不高的几何体存储的基线可能在 20-30 MB 左右,或每个三角形最多 18 个字节。

一个平面三角形列表没有用 - 驱动程序需要构建可以用来有效地针对光线追踪的 acceleration structure。 这些结构通常被称为“BVH” - bounding volume hierarchy - 表示一个具有低分支因子的树,其中中间节点定义为边界框,叶节点存储三角形。 我们将在下一节中介绍具体示例。

通常,您希望此结构具有高内存局部性 - 当在该数据结构中遇到三角形时,您不希望必须在内存中的其他位置访问三角形的顶点数据。 此外,Vulkan 和 DX12 允许访问光线命中的三角形 ID(必须与原始提供的数据中三角形的索引匹配); 此外,多个网格几何体可以组合在一棵树中,并且对于光线追踪性能,将几何体分成单独的子树是不经济的,因此三角形信息还必须携带几何体索引。 综上所述,我们得到如下所示的内容:

struct BoxNode
{
	float3 aabb_min[N];
	float3 aabb_max[N];
	uint32 children[N];
};
struct TriangleNode
{
	float3 corners[3];
	uint32 primid;
	uint32 geomid;
};

N 是分支因子; 虽然理论上 2(对于二叉树)和像 32 这样极其大的数字之间的任何数字都是可能的,但在实践中,我们应该期望一个小的数字,该数字允许硬件快速测试合理数量的 AABB 以对抗光线; 我们现在假设 N=4。9

如果 N=4 并且所有地方都使用 fp32 坐标,则 BoxNode 为 112 字节,TriangleNode 为 44 字节。 如果两个结构都改用 fp16,我们将获得 64 字节用于框,26 字节用于三角形。 我们知道(主要……)我们应该有多少个三角形节点 - 每个输入三角形一个 - 但是有多少个框?

嗯,对于分支因子为 4 的树,如果我们有 1.754M 个叶节点(三角形),我们希望在下一层获得 1.754M/4 = 438K 个框节点,在下一层获得 438K/4 = 109K 个框节点,在再下一层获得 109K/4 = 27K 个框节点,在那之后获得 27K/4 = 6.7K 个框节点,以此类推,直到我们到达根节点 - 这给了我们大约 584K 个框节点。 如果您不想一次一步地使用枯燥的除法,这大约是三角形节点的三分之一,这是 阿基米德在大约 2250 年前发现的

方便的是,这意味着 N 个三角形应占用大约 N*sizeof(TriangleNode) + (N/3)*sizeof(BoxNode) 内存,或每个三角形 sizeof(TriangleNode) + sizeof(BoxNode)/3 字节。 对于 fp32 坐标,这为我们提供了每个三角形约 81.3 个字节,对于 fp16,则为约 47.3 个字节。

由于多种原因,此分析是不精确的。 它忽略了不平衡树的可能性(并非所有框都可能使用 4 个子节点来实现最佳空间分割); 它忽略了各种硬件因素,例如内存对齐和额外数据; 它假设了一组特定的节点大小; 并且它假设叶(三角形)节点的数量等于输入三角形计数。 让我们在尝试了解 BVH 实际 如何工作时重新审视这些假设。

radv

由于 BVH 的内存布局最终取决于特定供应商的硬件和软件,并且我不想过度概括这一点,因此让我们专注于 AMD。

AMD 具有拥有多个版本的 RDNA 架构的优势 - 尽管 RDNA2 和 RDNA3 之间没有会影响内存大小的更改 - 并且拥有文档以及开源驱动程序。 现在,一个需要注意的是,AMD 实际上没有正确记录 BVH 结构(预期的节点内存布局_应该_是 RDNA ISA 的一部分,但事实并非如此 - AMD,请修复此问题),但是在两个开源驱动程序之间,应该有足够的详细信息可用。 相比之下,关于 NVidia 几乎一无所知,但他们显然在这里具有显着的竞争优势,因此也许他们有什么要隐藏的。10

AMD 实现光线追踪的方式如下:硬件单元(“光线加速器”)可以通过类似于纹理获取的指令访问着色器核心; 每个指令都给出指向单个 BVH 节点和光线信息的指针,并且可以自动针对节点中的所有框或三角形执行光线框或光线三角形测试并返回结果。 然后,驱动程序负责:

虽然 RT 格式的官方文档很缺乏,但我们不必对此进行逆向工程,因为我们有两个带有源代码的单独驱动程序。

radv,非官方驱动程序,它是 Linux 和 SteamOS 上的默认驱动程序,具有非常干净且易于阅读的代码库,它将结构定义如下:

struct radv_bvh_triangle_node {
  float coords[3][3];
  uint32_t reserved[3];
  uint32_t triangle_id;
  /* flags in upper 4 bits */
  uint32_t geometry_id_and_flags;
  uint32_t reserved2;
  uint32_t id;
};
struct radv_bvh_box16_node {
  uint32_t children[4];
  float16_t coords[4][2][3];
};
struct radv_bvh_box32_node {
  uint32_t children[4];
  vk_aabb coords[4];
  uint32_t reserved[4];
};

这些应该是不言自明的(vk_aabb 有 6 个浮点数来表示最小值/最大值),并且主要映射到我们之前的草图。 从此,我们可以推断出 RDNA GPU 支持 fp16/fp32 框节点,但需要对三角形节点进行完整的 fp32 精度。 此外,此处的三角形节点为 64 字节,fp16 框节点为 64 字节,fp32 框节点为 128 字节:也许不足为奇的是,GPU 喜欢对齐,这反映在这些结构中。

更仔细地查看源代码,您可以发现一些额外的内存,这些内存分配用于存储“父链接”:对于整个 BVH 的每 64 个字节,驱动程序分配一个 4 字节的值,该值将存储与此 64 字节块关联的节点的父索引(由于对齐,每个 64 字节对齐的块只是一个节点的一部分)。 这对于遍历很重要:着色器使用一个小堆栈进行遍历,该堆栈保留当前正在遍历的节点的索引,但该堆栈可能不足以满足大型树的完整深度。 为了解决这个问题,可以退回到使用这些父链接 - 可以以完全无堆栈的形式实现递归遍历,但是对于每个步骤从内存中读取额外的父指针可能会非常昂贵。

另一个更关键的观察结果是,在撰写本文时,radv 不支持 fp16 框节点 - 发出的所有框节点都是 fp32。 因此,我们可以尝试使用 radv 结构重做我们之前的分析:

……我们预计 radv 总共约为 114 字节/三角形。 现在,radv 的_实际_数据为 137 字节/三角形 - 未知原因多出 23 个字节! 现在是提及以下内容的好时机:虽然我们希望该树是完全平衡的并且分支因子确实为 4,但实际上我们期望会有一定程度的不平衡 - 既由于构建这些树的算法的性质,这些算法本质上是高度并行的并且并非总是达到最佳状态,而且由于某些几何体配置只是需要在树的某些部分中进行某种程度的不均匀分割,以实现最佳遍历性能11

AMDVLK

鉴于 BVH 节点的硬件格式是固定的,因此 BVH 可以占用多少内存似乎没有_那么_多的回旋余地。 对于 fp32 框节点,我们估计 BVH 在 AMD 硬件上至少需要 114 字节/三角形,但即使我们从官方驱动程序中看到的最大数字也是 88.4 字节/三角形。 这里发生了什么?

现在是查阅 AMD 的官方 光线追踪实现 的时候了。 它或多或少是在 Windows 和 Linux 版本的 AMD 驱动程序中运行的; 它可能应该被视为明确的来源,尽管不幸的是它比 radv 更难遵循。

特别是,它不包含 BVH 节点的 C 结构定义:那里的大部分代码都在 HLSL 中,并且它使用带有宏偏移的单个字段写入。 也就是说,对于 RDNA2/3,我们需要更仔细地查看三角形节点

// Note: GPURT limits triangle compression to 2 triangles per node. As a result the remaining bytes in the triangle node
// are used for sideband data. The geometry index is packed in bottom 24 bits and geometry flags in bits 25-26.
#define TRIANGLE_NODE_V0_OFFSET 0
#define TRIANGLE_NODE_V1_OFFSET 12
#define TRIANGLE_NODE_V2_OFFSET 24
#define TRIANGLE_NODE_V3_OFFSET 36
#define TRIANGLE_NODE_GEOMETRY_INDEX_AND_FLAGS_OFFSET 48
#define TRIANGLE_NODE_PRIMITIVE_INDEX0_OFFSET     52
#define TRIANGLE_NODE_PRIMITIVE_INDEX1_OFFSET     56
#define TRIANGLE_NODE_ID_OFFSET 60
#define TRIANGLE_NODE_SIZE   64

所以仍然是 64 个字节; 但是这个“NODE_V3”字段是什么,以及这个三角形压缩是什么? 实际上,radv_bvh_triangle_node 结构在 coords 数组之后有一个字段 uint32_t reserved[3];; 事实证明,AMD HW 格式的 64 字节三角形节点最多可以存储 2 个三角形,而不是仅仅存储一个。

AMD 文档将此称为“三角形压缩”或“对压缩”。 同样的概念也可以在 Intel 的硬件中看到,称为“QuadLeaf”。 在任何一种情况下,该节点都可以存储共享边的两个三角形,这只需要 4 个顶点。 三角形_不必_共面; 硬件交叉引擎将忠实地与两者相交,并根据需要返回一个或两个交点。

现在,这种类型的共享并非总是可能的。 例如,如果输入由不相交的三角形组成的三角形汤组成,那么我们将遇到每个叶节点一个三角形的最坏情况。 在某些情况下,即使可以合并两个三角形,如果其中一个三角形大得多,这样做也可能会损害 SAH 指标。 但是,一般来说,我们希望很多三角形成对分组。

这会显着改变我们的分析:

这使得总数达到 57 字节/三角形……假设理想条件:所有三角形都可以成对合并,所有节点的分支因子均为 4(我们知道根据 radv 结果,这可能不正确)。 实际上,这是 AMD 驱动程序在 2024.Q3 驱动程序中使用的配置,并且它的值为 88 字节/三角形 - 比预期多出 31 字节 - 这可能是比我们期望的更多的框节点的组合,以及不完美的三角形配对。 这里的另一个怪癖是 AMDVLK 驱动程序实现了所谓的 SBVH:单个三角形可以跨多个 BVH 节点“拆分”,从而有效地多次出现在树中。 这有助于提高长三角形的光线追踪性能,并且可能会进一步扭曲我们的统计数据,因为存储在叶节点中的三角形数量确实可能大于提供的输入!12

radv 目前未实现任何优化; 重要的是,除了这会显着影响内存消耗之外,我还预计这也会对光线追踪成本产生重大影响 - 事实上,我的测量结果表明,radv 在此场景中明显慢于官方 AMD 驱动程序,但这是另一个时间的故事。

那么,AMD 的 2024.Q4 版本发生了什么? 如果我们仔细跟踪 源代码更改(这并非易事,因为提交结构已从源代码转储中删除,但我很高兴我们至少拥有这么多!),很明显发生的事情是,默认情况下现在启用了 fp16 框节点。 在此之前,默认情况下框节点使用 fp32,并且通过此更改,许多框节点将改用 fp16。

在这种情况下,会有一些特定条件 - 如果您从 radv 结构中注意到,fp32 框节点还有一个 reserved 字段,而 fp16 框节点没有 - 实际上,此字段用于存储一些额外的信息,这些信息在某些情况下可能被认为是每个节点的重要信息13。 但无论如何,RDNA2/3 系统的_完美_配置似乎是:

……总共为 46 字节/三角形。 这是绝对最好的情况,正如我们之前所见,对于像 Bistro 这样复杂的几何体,期望这是不现实的; AMD 驱动程序中的最佳结果使用 57 字节/三角形,比理论最佳值多出 11 字节。14

值得注意的是,2025.Q1 版本将内存消耗从大约 60 字节/三角形减少到大约 57 字节/三角形。 鉴于我们知道与最佳状态相比,结果结构的各种低效率会损失一些内存,因此将来可能会从中榨取更多的汁液 - 但是鉴于硬件单元需要固定格式,并且如果您需要保持良好的跟踪性能,不可避免地会损失一些效率,因此剩余的收益将受到限制。

RDNA4

……直到下一次硬件修订。

虽然 RDNA3 在很大程度上保留了 RDNA2 的 BVH 格式(以前保留的一些位现在用于各种剔除标志,但这只是一个不会影响内存消耗的次要更改),但 RDNA4 似乎完全重新设计了存储格式。 据推测,所有以前的节点类型仍然受支持,因为 radv 无需更改即可工作,但 gpurt 实现了两个主要的新节点类型:

顾名思义,BVH8 节点存储 8 个子节点; 它没有使用 fp16 作为框边界,而是以一种特殊的格式存储框角15,该格式具有 12 位尾数和在所有角之间共享的 8 位指数,以及一个完整的 fp32 原点角。 这加起来为 128 个字节 - 从内存的角度来看,它与 RDNA2/3 的两个 fp16 BVH4 节点一样多,但它应该允许边界框值的完整 fp32 范围 - fp16 框节点无法表示坐标超出 +-64K 的几何体! - 因此我预计 RDNA4 BVH 数据不需要使用任何 BVH4 节点,这允许 AMD 将其他类型的数据嵌入到框节点中,例如用于他们的新旋转支持的 OBB 索引和父指针(正如您所回忆的那样,之前必须单独分配该指针)。

struct ChildInfo
{
  uint32_t minX     : 12;
  uint32_t minY     : 12;
  uint32_t cullingFlags : 4;
  uint32_t unused    : 4;
  uint32_t minZ     : 12;
  uint32_t maxX     : 12;
  uint32_t instanceMask : 8;
  uint32_t maxY   : 12;
  uint32_t maxZ   : 12;
  uint32_t nodeType : 4;
  uint32_t nodeRange : 4;
};
struct QuantizedBVH8BoxNode
{
  uint32_t internalNodeBaseOffset;
  uint32_t leafNodeBaseOffset;
  uint32_t parentPointer;
  float3 origin;
  uint32_t xExponent : 8;
  uint32_t yExponent : 8;
  uint32_t zExponent : 8;
  uint32_t childIndex : 4;
  uint32_t childCount : 4;
  uint32_t obbMatrixIndex;
  ChildInfo childInfos[8];
};

图元节点在某种程度上类似于三角形节点,但它更大(128 字节)并且用途更广泛:它可以存储每个节点可变数量的三角形对,并且使用类似于微网格格式的格式执行此操作,其中三角形对使用顶点索引,128 字节数据包的单独部分存储顶点位置 - 每个顶点使用可变数量的位来存储位置。

对于位置存储,单个节点内坐标的所有位都分为三个部分:前缀(对于同一轴的所有浮点数必须相同)、值、尾随零; 对于同一轴,所有部分的位宽对于节点中的所有顶点都相同。 对于 fp16 源位置,我预计前缀存储会删除位置之间共享的初始位段,这些位置在空间中非常接近,并且大多数尾随 fp32 位为零。 在该设置下,平均而言,每个顶点大约 30-33 位(3 * 10 位尾数,其中大多数指数位共享并删除尾随零)可能是合理的。

三角形对顶点索引使用每个索引 4 位进行编码,其中一些其他位用于其他字段; 图元索引存储为与图元节点内单个基本值的增量,类似于位置。 值得注意的是,三角形对对于每个三角形的三个角具有三个独立的索引 - 因此看起来该对不一定需要共享几何边,这可能会提高将几何体转换为此格式的效率,但代价是每个其他三角形增加 8 个额外位16。 每个节点的对数限制为 8 个对,或 16 个三角形。

似乎这种索引存储格式与帖子前面提到的内容相矛盾:如果驱动程序在 BLAS 构建期间丢弃初始索引缓冲区,它如何在此处使用索引? 答案是 BVH 构建像以前一样进行,并且一些子树被打包到图元节点中。 在此打包过程中,使用顶点角之间的按位相等性来机会性地识别共享顶点 - 因此源几何体是否已索引并不重要,只要三角形角位置完全相等即可。

所有这些都使得难以正确估计此类节点的最佳存储容量。 由于限制为 16 个三角形,我们理想情况下希望能够打包一个 3x5 顶点网格(15 个顶点,8 个四边形)17。 如果每个顶点大约 30 位用于位置存储是准确的,那么 15 个顶点将占用 57 字节的存储空间。 由于每个三角形对占用 29 位,因此 8 个对将占用 29 字节,总共 86 字节。 还需要一些额外的字节用于标头、用于重建位置和图元索引的各种锚点以及每个三角形的一些位用于图元索引,假设空间上连贯的输入三角形 - 这可能是合理的,预计适合。 因此,一个密集的网格可能能够打包到每个节点 16 个三角形或大约 8 字节/三角形。

由于 BVH 节点为 8 宽,这也成比例地减少了预期框节点总数,从图元节点的 1/3 减少到仅 1/718。 鉴于父指针已经嵌入到框节点中,这为我们提供了一个最佳情况理论界限:

……总共为 9.2 字节/三角形。 现在,通过_