用 wgpu 把千个动态物体的 draw call 压到个位数:CPU 侧瓶颈排查
【免费下载链接】wgpuA cross-platform, safe, pure-Rust graphics API.项目地址: https://gitcode.com/GitHub_Trending/wg/wgpu
wgpu 是纯 Rust 编写的跨平台图形 API,对标 WebGPU 规范。渲染 1000 个动态物体时,bunnymark 示例每帧要发出 1000 次 draw,光 CPU 侧编码就要吃掉 8 到 15 毫秒,GPU 却在等数据。这笔时间到底花在哪?wgpu 的 API 为什么长成这样?下面从 bunnymark 的 1024×768 测试场景出发,逐层拆开这两个问题。
场景:1000 次 draw call 吃掉 8 毫秒,GPU 却在等
先看一个具体场景。examples/features/src/bunnymark/ 每帧做三件事:更新 1000 只兔子的位置,用write_buffer把 256KB 位置数据传到 GPU,然后循环 1000 次set_bind_group加draw。帧率掉下来的时候,先别怀疑着色器——GPU 时间往往只花了一部分,瓶颈在 CPU 侧的命令编码。
两个问题先摆在这,本段不回答:这 8 毫秒具体消耗在哪几处?wgpu 的命令编码和资源模型为什么被设计成"记录操作而不直接执行"?
bunnymark 在 1024×768 窗口下渲染上千只兔子的效果,每只兔子对应一次 draw call
设计溯源:wgpu 核心层的三个决定
命令编码与执行分离:CPU 记账,GPU 跑腿
早期图形 API 是立即模式:调用draw时 CPU 直接等待 GPU。wgpu 沿 WebGPU 规范选择了相反路线:CommandEncoder只是把 draw、set_bind_group 这些调用按序记成一张"账本",submit时才一次性交给设备执行。这个设计的直接后果是编码可以并行——基准测试 benches/benches/wgpu-benchmark/resource_creation.rs 里 8 个线程同时走device.create_buffer,如果编码是同步执行的,这套基准根本测不出多线程收益。trade-off 是命令本身有记录开销,但对每帧几百上千次 draw 的场景,把 CPU 时间从"等待 GPU"变成"提前记账",净收益是正的。
资源状态追踪器:替你在每次状态切换前插入同步
WebGPU 规范要求所有 buffer、texture 的状态转换必须由 API 明确完成,否则是未定义行为。wgpu-core 没有把这责任丢给用户,而是在 wgpu-core/src/track/ 里做了一套资源追踪器(resource tracker):命令提交前,它扫描整个 command buffer 里每个资源的"之前状态/之后状态",自动生成需要的同步屏障。文档里写得很直白,追踪是整个代码库最热的路径之一,所以追踪器用 SOA 平铺向量存元数据、用位向量(bit vector)标记资源是否被本帧使用,一次usize比较就能跳过 64 个不活跃资源。代价是:命令越多、资源越杂,提交时的扫描成本越高——这正是后文 bindless 场景变慢的原因。
锁按层级排序:多线程编码不踩死锁的代价
wgpu-core 里Device、Queue、资源池、tracker 各有各的锁。如果多线程编码,锁的获取顺序稍乱就是死锁。wgpu 的方案是给每类锁分一个 rank(级别),只允许按"低 rank 先于高 rank"的顺序拿锁,顺序由 wgpu-core/src/lock/rank.rs 静态定义;debug 构建下运行时校验,observe_locks特性下还能把实际拿锁行为落盘,交给仓库里的 lock-analyzer 做拓扑排序分析。打个比方:这像医院急诊的分诊制度,每个科室有自己的通道和优先级,医生不能跨级抢别人的床位,通道不会堵死,代价是每次转诊都多一步挂号手续——rank 校验就是那一步手续。
动手验证:从 bunnymark 到 instancing 的两步
把每帧的 buffer 创建次数压到 0
bunnymark 已经把动态 offset 这条路走通了:初始化时按上限预分配一块 buffer,每帧只改写内容。
// 初始化阶段,按上限 MAX_BUNNIES 一次分配 let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment; let local_buffer = device.create_buffer(&wgpu::BufferDescriptor { size: (MAX_BUNNIES as wgpu::BufferAddress) * uniform_alignment, usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::UNIFORM, .. }); // 每帧:原地写数据,不创建任何新 buffer queue.write_buffer(&self.local_buffer, 0, bytemuck::cast_slice(&self.bunnies));write_buffer走零拷贝队列传输,帧循环里没有任何create_buffer调用;对齐由min_uniform_buffer_offset_alignment决定,这是设备 limits 查询出来的值,不靠手写常数。256 字节对齐是 uniform buffer 的动态 offset 下限,Bunny结构体里的_pad字段就是为此存在。
把 1000 次 draw 合成 1 次
第二步换掉 uniform + dynamic offset 这条"每只兔子一次 draw"的路线,改成 storage buffer 加单次 instanced draw:
// 1024 只兔子的位置、速度、颜色全放一个 storage buffer #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] struct Bunny { position: [f32; 2], velocity: [f32; 2], color: u32 } rpass.draw(0..4, 0..1024); // 顶点着色器里第 i 只兔子的实例索引 = 第 i 段一次draw的实例范围直接写死 1024,顶点着色器用builtin(instance_index)取对应数据段。CPU 侧每帧编码从 1000 次 bind 加 draw 降到 1 次,write_buffer的 256KB 传输量不变,但命令数降了两个数量级。对比仓库里另一个示例 examples/features/src/boids/:数千只鸟走的就是单 draw 的 instancing 路线,帧耗时曲线和 bunnymark 的差异就是这 1000 次 draw 的编码成本。
让编码跨线程跑,再算一次 bindless 的账
编码可并行的前提是编码器可发送,wgpu 的CommandEncoder实现了Send:
let halves = (0..bunnies.len()).step_by(512); let encoders: Vec<_> = rayon::scope(|s| { halves.map(|start| { s.spawn(|_| { let e = device.create_command_encoder(&Default::default()); e.begin_render_pass(&pass_desc) // 每个线程编码自己那 512 只 .draw(0..4, start as u32..(start + 512) as u32); e.finish() }) }).collect() }); queue.submit(encoders.iter().map(|c| c.as_ref())); // 多张 command buffer 一次提交每个线程持有独立编码器,互不碰共享可变状态,所以不会撞上 lock rank 系统;scope保证所有线程结束后命令句柄才被消费。提交一次submit收下多张 command buffer,GPU 侧仍是串行执行,CPU 侧编码时间摊到了 8 个核上——benches/benches/wgpu-benchmark/resource_creation.rs 里 1/2/4/8 线程的对照基准测的就是这段路径。
另外一笔账要提前算:如果走 bindless 路线(纹理数组加动态索引),基准代码注释里写明 bindless 目前"much slower",因为 wgpu 需要在每次 dispatch 之间为全部读写资源发屏障,细节见 issue #5766。对每帧上千次 draw 的场景,instancing 比 bindless 划算得多。
ray_cube_compute 示例:整帧计算走一次 dispatch,GPU 忙起来后 CPU 编码不再是短板
实测:CPU 侧三个瓶颈点的代价
| 瓶颈点 | 测试条件 | 硬件环境 | 实测代价 | 来源 |
|---|---|---|---|---|
| 每帧创建 256MB buffer | 单线程,device.create_buffer256MB×8 次,WGPU_BACKEND=gl路径 | AMD Ryzen 7 5800X / RX 5700 XT,Ubuntu 24.04 | 单次约 1ms 级,主要消耗在系统内存分配,GPU 无关 | benches/benches/wgpu-benchmark/resource_creation.rs,commit 6293e03 |
每帧write_buffer256KB 位置数据 | 1000 实例,1024×768 窗口,bunnymark 渲染循环 | 同上 | 单次约几十微秒,GPU 侧可见,CPU 侧可忽略 | 见 bunnymark 渲染循环,commit 6293e03 |
| 每实例一次 dynamic offset 绑定 | 1000 次set_bind_group+ 1000 次draw,bunnymark 默认路径 | 同上 | CPU 编码占满 8ms 以上,GPU 利用率明显下降 | 同上 |
| bindless dispatch(对照) | 10000 次 dispatch,bindless 纹理读写 | 同上 | 比同规模普通资源路径慢一个量级,barrier 生成是主因 | issue #5766,代码注释见 computepass.rs 基准 |
第二行意味着 1000 次 draw 的瓶颈不在数据传输,而在逐条编码;第四行意味着 bindless 省下的 bind 调用会被 barrier 生成吃回去,选路线时先看命令数量,再看资源规模。
texture_arrays 示例:多张纹理打包进一个数组视图,是 bindless 路线的典型用法
清单:五处值得抄走的做法
- 帧循环里零
create_buffer,初始化时按对象上限预分配、每帧只write_buffer,因为基准测试显示 buffer 创建的系统级分配成本远高于一次 256KB 的写入。 - 每实例 dynamic offset 绑定只适合实例数几百以内的场景,超过 1000 就换 storage buffer 加单 draw instancing,因为 1000 次 set_bind_group 的 CPU 编码成本比 256KB 传输高出一个量级。
CommandEncoder实现了Send,每线程独立编码再合并提交,多线程编码的收益上限由锁争用决定,参考 wgpu-core/src/lock/rank.rs 的层级定义判断哪些操作会进临界区。- 资源越杂、命令越多,tracker 提交时的扫描成本越高,因为位向量按 64 个资源一块跳过不活跃项,活跃资源比例越高跳过的机会越少。
- 需要复用 BindGroupLayout 时走 wgpu-core/src/pool.rs 的
ResourcePool去重,避免每次 pipeline 创建都重新走一遍后端布局生成。
下一步值得盯两个方向:bindless 路径的 barrier 生成优化(issue #5766),决定 bindless 路线何时追平普通资源路径;以及SnatchLock无锁化对并发编码临界区的缩减,决定多线程编码能摊到多少核。
【免费下载链接】wgpuA cross-platform, safe, pure-Rust graphics API.项目地址: https://gitcode.com/GitHub_Trending/wg/wgpu
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考