WWDC Quick Look 💓 By SwiftGGTeam
Go bindless with Metal 3

Go bindless with Metal 3

观看原视频

Highlight

Metal 3 让 bindless 渲染可以直接写入 Argument Buffer 和无界数组、从 Heap 分配光线追踪加速结构,并通过 shader validation 与 Xcode 14 工具定位缺失驻留和错误资源访问。

核心内容

传统绑定模型会把资源逐个绑定到 pipeline 的固定槽位。场景复杂以后,shader 要访问网格、材质、纹理、天空盒和光线追踪数据,CPU 端就要维护大量绑定调用。

Bindless rendering(无绑定渲染)换了一种组织方式:先把资源引用聚合到内存里,再只绑定一个 buffer。shader 可以沿着 Argument Buffer(参数缓冲区)里的指针和资源 ID 找到需要的数据。(00:59

这对光线追踪很重要。反射 shader 需要访问地板、卡车、材质和天空等大量资源。把这些资源放进 Argument Buffer 后,光线追踪 shader 就能在运行时按需取数据。(01:32

Metal 3 解决的是采用 bindless 时的工程成本。它让 Argument Buffer 像 C 结构体一样写;让无界数组像普通结构体数组一样填;允许从 MTLHeap 分配 ray tracing acceleration structure(光线追踪加速结构);还让 shader validation layer 在资源没有驻留 GPU 内存时直接报错。(02:02

详细内容

直接写 Argument Buffer

04:00)Metal 2 时代,写 Argument Buffer 通常要先创建 Argument Encoder。这个 encoder 可以通过 shader function reflection 创建,也可以手写 struct member 描述。多线程使用时还要格外小心。

Metal 3 把这一步简化成写 C 结构体。CPU 端拿到 buffer contents,把它 cast 成结构体指针,然后写入资源的 GPU address 或 Resource ID。Metal 会理解这些引用,效果等同于过去用 Argument Encoder 编码引用。(04:09

// Write argument buffers in Metal 3

struct Mesh
{
   uint64_t normals; // 64-bit uint for constant packed_float3*
};

NSUInteger meshArgumentSize = sizeof(struct Mesh);

id<MTLBuffer> meshArgumentBuffer = [device newBufferWithLength:meshArgumentSize
                                                       options:storageMode];

struct Mesh* meshes = (struct Mesh *)(meshArgumentBuffer.contents); 
 
meshes->normals = normalBuffer.gpuAddress + normalBufferOffset;

关键点:

  • uint64_t normals 对应 shader 里的 constant packed_float3* normals,CPU 端保存的是 64 位 GPU 地址。
  • sizeof(struct Mesh) 直接给出单个 Argument Buffer 结构体大小,不再需要 Argument Encoder 计算布局。
  • newBufferWithLength:options: 创建实际承载 Argument Buffer 内容的 MTLBuffer
  • meshArgumentBuffer.contentsManagedShared storage mode 下提供 CPU 可写指针。
  • normalBuffer.gpuAddress + normalBufferOffset 把目标 buffer 的 GPU 地址写进结构体成员;offset 需要按 GPU 内存要求对齐。

这个能力要求设备支持 Argument Buffers Tier 2。session 给出的范围是 2016 年或更新的 Mac,以及 A13 Bionic 或更新的 iOS 设备。(04:37

用共享头文件保持 CPU/GPU 结构一致

06:32)Argument Buffer 结构体在 shader 端和 host 端长得不完全一样。shader 端要写指针类型,host 端要写 uint64_t。Metal 3 保证 clang 和 Metal shader compiler 下结构体大小与对齐匹配,但声明本身仍然容易写出两份。

官方示例先展示两份声明:

// Shader struct:

struct Mesh
{
   constant packed_float3* normals;
};

// Host-side struct:

struct Mesh
{
    uint64_t normals;
};

关键点:

  • shader 端的 constant packed_float3* 是 GPU 访问法线数组时使用的指针类型。
  • host 端的 uint64_t 是 CPU 写入 Argument Buffer 时使用的地址表示。
  • 两边字段顺序必须一致,否则 shader 读取到的地址就会错位。

更稳妥的做法是把声明放进共享头文件,用 __METAL_VERSION__ 区分编译环境。(06:53

// Shared struct:

#if __METAL_VERSION__
#define CONSTANT_PTR(x) constant x*
#else
#define CONSTANT_PTR(x) uint64_t
#endif

struct Mesh
{
    CONSTANT_PTR(packed_float3) normals;
};

关键点:

  • __METAL_VERSION__ 只在编译 Metal shader 代码时定义。
  • shader 编译时,CONSTANT_PTR(packed_float3) 展开成 constant packed_float3*
  • host 编译时,宏展开成 uint64_t
  • Mesh 只维护一份字段列表,减少 CPU/GPU 两边结构漂移的概率。

无界数组就是结构体数组

07:18)Bindless 场景通常不会只写一个 mesh。你要把整场景的 mesh 数组、material 数组、texture 数组交给 shader。Metal 3 对无界数组的写法也做了同样简化:分配足够大的 buffer,然后循环填结构体数组。

// Write unbounded arrays of resources in Metal 3

struct Mesh
{
   uint64_t normals; // 64-bit uint for constant packed_float3*
};

NSUInteger meshArgumentSize = sizeof(struct Mesh) * meshes.count;

id<MTLBuffer> meshArgumentBuffer = [device newBufferWithLength:meshArgumentSize
                                                        options:storageMode];

struct Mesh* meshes = (struct Mesh *)(meshArgumentBuffer.contents); 

for ( NSUInteger i = 0; i < meshes.count; ++i )
{
   meshes[i].normals = normalBuffers[i].gpuAddress + normalBufferOffsets[i];
}

关键点:

  • sizeof(struct Mesh) * meshes.count 决定无界数组实际占用的空间。
  • shader 不需要在声明里写死数组大小。
  • struct Mesh* meshes 把 buffer contents 当成结构体数组来访问。
  • for 循环逐个写入每个 mesh 的法线 buffer 地址。
  • 每个元素都可以指向不同的 buffer 和 offset,适合场景级资源表。

shader 端可以把这个无界数组作为 buffer 参数传入。(09:03

// Metal shading language:

struct Mesh
{
   constant packed_float3* normals;
};


fragment half4 fragmentShader(ColorInOut v          [[stage_in]],
                              constant Mesh* meshes [[buffer(0)]] )
{
    /* determine mesh to read, e.g. geometry_id */

    packed_float3 n0 = meshes[ geometry_id ].normals[0];
    packed_float3 n1 = meshes[ geometry_id ].normals[1];
    packed_float3 n2 = meshes[ geometry_id ].normals[2];

    /* interpolate normals and calculate shading */
}

关键点:

  • constant Mesh* meshes [[buffer(0)]] 把 Argument Buffer 当成 Mesh 数组传给 shader。
  • geometry_id 决定当前片元要访问哪个 mesh。
  • meshes[geometry_id].normals[0] 先索引 mesh 数组,再沿着其中的 GPU 指针读取法线。
  • shader 对这个数组的访问方式和 C 数组一致。

另一种写法是把多个无界数组放进一个 Scene 结构体,让 shader 只绑定一个 scene buffer。(09:25

// Metal shading language:
struct Mesh
{
   constant packed_float3* normals;
};

struct Scene
{
   constant Mesh*     meshes;     // mesh array
   constant Material* materials;  // material array
};

fragment half4 fragmentShader(ColorInOut v          [[stage_in]],
                              constant Scene& scene [[buffer(0)]] )
{
    /* determine mesh to read, e.g. geometry_id */
    packed_float3 n0 = scene.meshes[ geometry_id ].normals[0];
    packed_float3 n1 = scene.meshes[ geometry_id ].normals[1];
    packed_float3 n2 = scene.meshes[ geometry_id ].normals[2];
    /* interpolate normals and calculate shading */
}

关键点:

  • Scene 聚合 mesh array 和 material array。
  • shader 入口只接收 constant Scene& scene
  • 访问路径变成 scene.meshes[geometry_id],资源入口更集中。
  • 这种组织方式适合把场景资源表长期保存在一个 Argument Buffer 里。

从 Heap 分配光线追踪加速结构

10:12)Metal 3 允许 ray tracing acceleration structure 从 MTLHeap 分配。这样 acceleration structures 可以和 buffer、texture 一起聚合管理。

好处在驻留管理上更直接。把一组 acceleration structures 放进 heap 后,可以对整个 heap 调一次 useHeap:,让这些资源一起驻留。session 明确说,这比逐个资源调用 useResource 更快,可以减少 render thread 上的 CPU 成本。(10:26

heapAccelerationStructureSizeAndAlignWithDescriptor:

关键点:

  • 从 heap 分配 acceleration structure 前,要查询它在 heap 中的 size 和 alignment。
  • 使用的是 MTLDevice 上的 heapAccelerationStructureSizeAndAlignWithDescriptor:
  • 这个查询不同于普通的 accelerationStructureSizesWithDescriptor:
  • 如果 heap 没有开启 hazard tracking,Metal 不会自动阻止 heap 内资源的 race condition;应用需要自己同步 acceleration structure build 和 ray tracing work。

Shader Validation Layer 检查缺失驻留

12:06)Bindless 的一个常见 bug 是忘记让间接访问资源驻留。资源没有驻留时,背后的内存页可能在渲染时不可用,结果可能是 command buffer failure、GPU restart,或图像损坏。

Metal 3 的 shader validation layer 会在 command buffer 执行期间报告这类错误。错误信息会包含触发问题的 shader function、pass 名称、Metal 文件和行号、buffer label、buffer size,以及资源没有驻留这一事实。(14:53

session 的例子来自 Hybrid Rendering app。反射有时显示错误,最后发现是 index buffer 写进了 Argument Buffer,却没有加入驻留资源集合。(13:01

// Argument buffer loading
for (NSUInteger i = 0; i < mesh.submeshes.count; ++i) {

    Submesh*      submesh     = mesh.submeshes[i];
    id<MTLBuffer> indexBuffer = submesh.indexBuffer;
    NSArray*      textures    = submesh.textures;

    // Copy index buffer into argument buffer
   submeshAB[i].indices = indexBuffer.gpuAddress;

    // Copy material textures into argument buffer
   for (NSUInteger m = 0; m < textures.count; ++m) {
        submeshAB[i].textures[m] = textures[m].gpuResourceID;
    }

    // Remember indirect resources
    [sceneResources addObject:indexBuffer];
    [sceneResources addObjectsFromArray:textures];
}

关键点:

  • indexBuffer.gpuAddress 被写进 submeshAB[i].indices,shader 会通过 Argument Buffer 间接访问它。
  • textures[m].gpuResourceID 被写进材质纹理数组,shader 同样会间接访问这些 texture。
  • sceneResources 记录所有没有由 heap 承载的间接资源。
  • [sceneResources addObject:indexBuffer] 是这个 bug 的修复点:index buffer 也必须被记录,渲染时才能通过 useResource 标记驻留。
  • [sceneResources addObjectsFromArray:textures] 让材质纹理在 ray tracing dispatch 前驻留。

这段代码还给出一个调试习惯:给 Metal object 设置 label。validation error 会显示 label,Xcode 也能用它帮助定位具体资源。(15:20

长生命周期资源可以关闭自动 retain

16:44)Metal command buffer 默认会强引用它使用的资源,防止 CPU 在 GPU 还没用完时释放对象。这个安全保证有 CPU 成本。

Bindless 应用常把资源聚合到 heap,生命周期也常跟关卡或场景一致。如果应用已经能保证资源生命周期,就可以创建使用 unretained references 的 command buffer,让 Metal 不再为这些引用建立额外强引用。(18:41

session 给出的结论很具体:在一个 microbenchmark 里,只切换到 unretained references command buffer,就让 command buffer 生命周期里的 CPU usage 降低 2%;节省的时间来自不再创建和销毁不必要的强引用。(19:19

这是一个前提很强的优化。只有当应用自己能保证资源不会在 GPU 使用期间释放时,才适合使用。这个设置的粒度是整个 command buffer:要么保留所有 referenced resources,要么一个都不保留。(19:09

Untracked resources 减少 heap 的 false sharing

19:46)Metal 默认可以跟踪资源 hazard,自动插入 GPU timeline 上的同步,避免 read-after-write 或 write-after-write 问题。

Heap 会把多个子资源聚合起来。Metal 看到的是同一个 heap 资源,可能会保守地认为两个 pass 之间有依赖,即使它们实际访问的是 heap 里的不同子资源。这种情况叫 false sharing,会增加 GPU work 的 wall-clock time。(21:38

解决办法是把资源描述符的 hazardTracking 设为 Untracked,再由应用显式表达细粒度依赖。session 提到,heap 默认就是 untracked,目的是让 GPU 有更多机会并行执行工作。(22:10

可用同步原语按场景选择:

  • MTLFence:适合单个 command queue 内、producer 先于 consumer 提交或 enqueue 的常见场景,开销最低。(25:51
  • MTLEvent:适合提交顺序不能保证,或需要跨多个 command queue 同步的场景。(26:04
  • MTLSharedEvent:适合跨 Metal device,或 GPU 和 CPU 之间同步。(26:11
  • Memory Barrier:适合 pass 内同步;fragment stage 之后不要用 barrier,Apple GPU 上会触发 validation error,建议用 Fence。(26:20

Xcode 14 的依赖视图和资源列表

27:50)写 bindless 代码只完成了一半。另一半是确认 GPU 实际看到了什么资源,以及哪些 pass 被同步卡住。

Xcode 14 的 Metal Debugger 增加了 dependency viewer。它用图展示 workload:节点表示 pass 和输出资源,边表示 pass 之间的 resource dependency。实线表示 data flow,虚线表示 synchronization。(28:08

这个工具可以直接定位 tracked heap 带来的 false sharing。session demo 中,dependency viewer 显示一个 tracked heap 在两个 pass 之间加入了同步,但 compute encoder 实际没有使用前面 encoder 的资源。把 app 改成 untracked heap,并在真正需要的地方插入 Fence 后,两个 pass 就能并行执行。(29:37

Metal Debugger 还新增 resource list。对一个 draw call 打开 Accessed 模式后,只显示这个 draw call 实际访问过的资源,以及访问类型。bindless 场景里,GPU 同时能看到成百上千个资源,这个筛选能把问题缩小到 shader 真的碰过的那几个。(30:55

如果访问了意外资源,可以继续进入 shader debugger。它会逐行显示 shader 执行过程和资源访问,适合定位 shader 访问了错误 Argument Buffer 元素的问题。(32:01

核心启发

  • 做什么:把材质纹理表改成 bindless Argument Buffer。
    为什么值得做:shader 可以通过 material index 访问纹理资源,CPU 不需要为每个 draw 逐个绑定材质 texture。
    怎么开始:定义共享的 material struct,CPU 端写入 gpuResourceID,shader 端通过 constant Material* 读取。

  • 做什么:给光线追踪反射 pass 建立场景级 Scene 结构。
    为什么值得做:反射 shader 需要访问 mesh、material、texture 和 sky 等资源,单个 scene buffer 能集中管理这些入口。
    怎么开始:把 constant Mesh* meshesconstant Material* materials 放进 Scene,将 Scene 作为 [[buffer(0)]] 传给 ray tracing 或 fragment shader。

  • 做什么:把 acceleration structures 放进同一个 MTLHeap
    为什么值得做:可以用一次 useHeap: 标记整组资源驻留,减少逐个 useResource 的 CPU 开销。
    怎么开始:先用 heapAccelerationStructureSizeAndAlignWithDescriptor: 查询 size/alignment,再按 heap 的同步策略安排 build 和 ray tracing work。

  • 做什么:为 bindless 资源驻留建立自动检查清单。
    为什么值得做:遗漏一个 index buffer 或 texture 都可能造成反射缺失、command buffer failure 或图像损坏。
    怎么开始:在写 Argument Buffer 的同一段代码里,把每个间接访问资源加入 NSMutableSet,渲染前统一 useResource;开发阶段打开 shader validation layer。

  • 做什么:用 dependency viewer 检查 heap 是否造成无效同步。
    为什么值得做:tracked heap 可能因为 false sharing 让本可并行的 pass 串行执行。
    怎么开始:在 Xcode 14 Metal Debugger 中只查看 synchronization dependency,定位 tracked heap 的虚线依赖,再考虑改用 untracked heap 和 MTLFence

关联 Session

评论

GitHub Issues · utterances