在 M1 Max 上 Profiling 一个小型 Path Tracer
这篇记录整理了一次针对 bunny 场景的 CPU profiling 和优化过程。目标不是把渲染器彻底重写,而是用 Instruments 找到真实瓶颈,然后做一些小而可验证的改动。
当时使用的 profiling 配置尽量保持简单:
1 |
我先从单线程开始,并且把 useBVH 设为 false。这样 profile 主要集中在 bunny mesh 自己的内部 BVH,也就是 BLAS。这个场景的顶层 object 数量很少,如果打开 scene-level BVH,TLAS 的遍历开销反而可能掩盖真正想看的 mesh traversal 成本。
Baseline
初始结果:
1 | Rendering time: 15.2775 seconds |
第一个意外发现是,最热的部分并不是 BVH traversal,而是 sampler。Owen-scrambled Halton sampling 在单线程 profile 里占了很大比例,热点主要来自 gl::permutationElement() 和 gl::owenScrambledRadicalInverse()。
把 Owen scrambling 关掉之后,CPU baseline 干净了很多:
1 | Rendering time: 11.3048 seconds |
这不代表 RandomStrategy::None 适合作为最终画质设置。它会带来一些 banding 或 structured noise。但对性能分析来说,先移除昂贵的 sampler,可以更清楚地看到几何求交和 BVH traversal 的瓶颈。
Ray 和 AABB 优化
接下来明显的热点是 AABB::intersect(),其中不少时间被归因到 gl::vec3::operator[]。原始的 AABB slab test 在循环里反复调用 ray.getOrigin() 和 ray.getDirection()。早期这些 getter 是按值返回的,所以 hot path 里会发生不必要的 vec3 拷贝。
第一步 cleanup 是让 ray 的 origin / direction 按 const reference 返回,并且在 Ray 里缓存每条 ray 的 inverse direction:
1 | Ray stores: |
这个优化有效的原因是:一条 ray 会测试很多 BVH node,但 1.0f / direction 对这条 ray 来说是不变的。把 inverse direction 放到 Ray::setDirection() 里维护,就可以避免每次 AABB intersect 都重新做除法。
这个 ray-side 优化之后:
1 | Rendering time: 10.7534 seconds |
之后我又把 AABB slab test 从:
1 | for (int i = 0; i < 3; ++i) |
展开成显式的 x/y/z 三段计算。这个改动主要减少 operator[] 的采样成本。在测试 trace 里,operator[] 大概从 1.0s 降到了 0.5s 左右。总时间变化不算大,但 profile 变得更干净,indexed vector access 的热点也明显降低了。
TLAS 和 BLAS
这次 profiling 里一个很有用的观察是:对当前这个 bunny 场景来说,打开 scene-level BVH 不一定更快。
因为场景顶层只有一个 bunny,再加几个墙面和灯。bunny mesh 自己内部已经有 BVH。所以:
1 | useBVH=false: |
也就是说,useBVH 控制的是 TLAS,而 TriangleMesh 内部的 MeshBVH 是 BLAS。对于这个小场景,TLAS 虽然能减少 primitive hit count,但额外的 AABB traversal 和 pointer chasing 可能抵消掉收益,甚至变慢。
Near-First BVH Traversal
原来的 mesh BVH traversal 是固定顺序:
1 | 先 intersect left subtree |
这个顺序是正确的,但不一定高效。如果 ray 实际上先命中右边 subtree 里更近的 triangle,那么先遍历左边就可能做很多无用工作。
于是 traversal 改成先测试左右 child 的 AABB,拿到各自的 t_enter,再优先访问更近的 child:
1 | left_t = ray 进入 left child box 的 t |
这只是一个 heuristic。更早进入某个 box,并不保证最近 triangle 一定在那个 box 里。但它经常能更早找到较小的 closest,从而让后面的 subtree 更容易被剪枝。
Flatten Mesh BVH
原来的 mesh BVH 是 pointer tree:
1 | std::unique_ptr<MeshBVHNode> left; |
这种结构会带来递归函数调用和 pointer chasing。为了改善 locality,我把 recursive BVH flatten 成一个数组:
1 | struct LinearMeshBVHNode { |
这不是 heap layout。也就是说 child 不是通过 2*i+1 和 2*i+2 找到的,因为 BVH 不是 perfect binary tree。每个 linear node 会显式保存自己 child 在数组里的 index。
Leaf triangle id 单独存放在:
1 | linear_bvh_tri_indices |
每个 leaf 只保存:
1 | first_tri |
它们指向 linear_bvh_tri_indices 里的一段连续区间。这些值仍然只是 triangle index,真正的三角形数据还在 mesh data 里。
运行时 traversal 也从递归改成 iterative:
1 | push root node index |
一开始我用 std::vector 作为 traversal stack,但 profile 里出现了明显的 allocation 开销。改成固定大小的 local stack 后:
1 | std::array<StackEntry, 128> stack; |
flat traversal 变快了一些。这个收益不是质变,但它减少了递归开销和 pointer chasing,也让 BVH 表达更适合后续做 SoA 或 SIMD 实验。
时间最后花在哪里
完成这些改动之后,主要热点仍然集中在:
1 | TriangleMesh::intersectFlat |
这很合理。当前 flat BVH 改善的是 node traversal locality,但 triangle data 本身仍然是间接访问:
1 | leaf -> linear_bvh_tri_indices -> mesh.indices -> mesh.positions |
所以后面真正有意义的优化,大概率不是继续 micro-opt traversal,而是改善 triangle data 的布局。
后续方向
比较自然的下一步有三个。
第一,预计算 triangle data:
1 | p0 |
这样可以减少 hitTriangleFlat() 里每次重复计算 edge 和 normal 的成本。
第二,按 BVH leaf 顺序重排 triangle:
1 | 让同一个 leaf 里的 triangles 在内存里连续 |
这可以减少 leaf intersection 时的随机访问。
第三,再考虑 SIMD。
单个 AABB SIMD,也就是 one ray vs one box 的 x/y/z 三轴并行,是可以做的,但不一定划算。更好的 SIMD 目标通常是 one ray vs multiple boxes,比如 4-wide BVH node,也就是 QBVH。这需要把 child AABB 组织成更适合 SIMD 的布局。
总结
这次优化最大的收获是:先 profile 很重要。一开始真正的 hotspot 是 sampler,不是 BVH。把 profiling noise 移除后,几何求交的成本才变得清楚。之后的 ray inverse-direction cache、near-first traversal、flat BVH、fixed stack、AABB unroll 都是小步优化,但它们让代码更接近适合后续 data layout 和 SIMD 优化的形态。