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.contents在Managed或Sharedstorage 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* meshes和constant 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
- Discover Metal 3 — Metal 3 总览,适合先了解这一年图形与工具链更新的全貌。
- Maximize your Metal ray tracing performance — 深入补充本 session 提到的光线追踪性能和 acceleration structure 更新。
- Target and optimize GPU binaries with Metal 3 — 讲解 Metal 3 如何把 GPU binary 生成前移到构建阶段。
- Load resources faster with Metal 3 — 讲解图形和游戏资源如何通过 Fast Resource Loading 异步进入 Metal 资源。
- Program Metal in C++ with metal-cpp — 补充 session 中提到的 Metal object lifecycle 和 C++ API 使用方式。
评论
GitHub Issues · utterances