尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用 wgpu 把千个动态物体的 draw call 压到个位数:CPU 侧瓶颈排查

用 wgpu 把千个动态物体的 draw call 压到个位数:CPU 侧瓶颈排查 用 wgpu 把千个动态物体的 draw call 压到个位数CPU 侧瓶颈排查【免费下载链接】wgpuA cross-platform, safe, pure-Rust graphics API.项目地址: https://gitcode.com/GitHub_Trending/wg/wgpuwgpu 是纯 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 创建次数压到 0bunnymark 已经把动态 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实现了Sendlet 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 bufferGPU 侧仍是串行执行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 示例整帧计算走一次 dispatchGPU 忙起来后 CPU 编码不再是短板实测CPU 侧三个瓶颈点的代价瓶颈点测试条件硬件环境实测代价来源每帧创建 256MB buffer单线程device.create_buffer256MB×8 次WGPU_BACKENDgl路径AMD Ryzen 7 5800X / RX 5700 XTUbuntu 24.04单次约 1ms 级主要消耗在系统内存分配GPU 无关benches/benches/wgpu-benchmark/resource_creation.rscommit 6293e03每帧write_buffer256KB 位置数据1000 实例1024×768 窗口bunnymark 渲染循环同上单次约几十微秒GPU 侧可见CPU 侧可忽略见 bunnymark 渲染循环commit 6293e03每实例一次 dynamic offset 绑定1000 次set_bind_group 1000 次drawbunnymark 默认路径同上CPU 编码占满 8ms 以上GPU 利用率明显下降同上bindless dispatch对照10000 次 dispatchbindless 纹理读写同上比同规模普通资源路径慢一个量级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),仅供参考
返回列表