在 M1 Max 上 Profiling 一个小型 Path Tracer

在 M1 Max 上 Profiling 一个小型 Path Tracer

May 11, 2026

这篇记录整理了一次针对 bunny 场景的 CPU profiling 和优化过程。目标不是把渲染器彻底重写,而是用 Instruments 找到真实瓶颈,然后做一些小而可验证的改动。

当时使用的 profiling 配置尽量保持简单:

1
2
3
4
5
6
7
8
9
10
11
#define LIGHT_SAMPLE_X 1
#define LIGHT_SAMPLE_Y 1
#define MAX_RAY_DEPTH 10
#define SPP_X 3
#define SPP_Y 3
#define WIDTH 500
#define HEIGHT 500
#define useBVH false // 关闭 scene-level BVH / TLAS
#define USE_MAXDEPTH_NEE
#define OVERRIDE_LOCAL_RENDER_VAL TRUE
#define NUM_THREADS 1

我先从单线程开始,并且把 useBVH 设为 false。这样 profile 主要集中在 bunny mesh 自己的内部 BVH,也就是 BLAS。这个场景的顶层 object 数量很少,如果打开 scene-level BVH,TLAS 的遍历开销反而可能掩盖真正想看的 mesh traversal 成本。

Baseline

初始结果:

1
2
Rendering time: 15.2775 seconds
Total hit count: 717420294

第一个意外发现是,最热的部分并不是 BVH traversal,而是 sampler。Owen-scrambled Halton sampling 在单线程 profile 里占了很大比例,热点主要来自 gl::permutationElement()gl::owenScrambledRadicalInverse()

把 Owen scrambling 关掉之后,CPU baseline 干净了很多:

1
2
Rendering time: 11.3048 seconds
Total hit count: 193636618

这不代表 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
2
3
4
Ray stores:
origin
direction
inv_direction

这个优化有效的原因是:一条 ray 会测试很多 BVH node,但 1.0f / direction 对这条 ray 来说是不变的。把 inverse direction 放到 Ray::setDirection() 里维护,就可以避免每次 AABB intersect 都重新做除法。

这个 ray-side 优化之后:

1
2
Rendering time: 10.7534 seconds
Total hit count: 193642185

之后我又把 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
2
3
4
5
6
7
useBVH=false:
ObjectList 线性测试少量 scene objects
TriangleMesh 内部仍然使用 MeshBVH

useBVH=true:
ray 先遍历 scene-level BVH
然后 TriangleMesh 内部仍然使用 MeshBVH

也就是说,useBVH 控制的是 TLAS,而 TriangleMesh 内部的 MeshBVH 是 BLAS。对于这个小场景,TLAS 虽然能减少 primitive hit count,但额外的 AABB traversal 和 pointer chasing 可能抵消掉收益,甚至变慢。

Near-First BVH Traversal

原来的 mesh BVH traversal 是固定顺序:

1
2
先 intersect left subtree
再 intersect right subtree

这个顺序是正确的,但不一定高效。如果 ray 实际上先命中右边 subtree 里更近的 triangle,那么先遍历左边就可能做很多无用工作。

于是 traversal 改成先测试左右 child 的 AABB,拿到各自的 t_enter,再优先访问更近的 child:

1
2
3
4
5
left_t  = ray 进入 left child box 的 t
right_t = ray 进入 right child box 的 t

优先访问 t_enter 更小的 child
如果先找到更近 triangle,则 far_t > closest 的 child 可以跳过

这只是一个 heuristic。更早进入某个 box,并不保证最近 triangle 一定在那个 box 里。但它经常能更早找到较小的 closest,从而让后面的 subtree 更容易被剪枝。

Flatten Mesh BVH

原来的 mesh BVH 是 pointer tree:

1
2
std::unique_ptr<MeshBVHNode> left;
std::unique_ptr<MeshBVHNode> right;

这种结构会带来递归函数调用和 pointer chasing。为了改善 locality,我把 recursive BVH flatten 成一个数组:

1
2
3
4
5
6
7
struct LinearMeshBVHNode {
AABB box;
int left;
int right;
int first_tri;
int tri_count;
};

这不是 heap layout。也就是说 child 不是通过 2*i+12*i+2 找到的,因为 BVH 不是 perfect binary tree。每个 linear node 会显式保存自己 child 在数组里的 index。

Leaf triangle id 单独存放在:

1
linear_bvh_tri_indices

每个 leaf 只保存:

1
2
first_tri
tri_count

它们指向 linear_bvh_tri_indices 里的一段连续区间。这些值仍然只是 triangle index,真正的三角形数据还在 mesh data 里。

运行时 traversal 也从递归改成 iterative:

1
2
3
4
5
6
push root node index
while stack not empty:
pop node index
test AABB
if leaf: test triangles
else: push children

一开始我用 std::vector 作为 traversal stack,但 profile 里出现了明显的 allocation 开销。改成固定大小的 local stack 后:

1
std::array<StackEntry, 128> stack;

flat traversal 变快了一些。这个收益不是质变,但它减少了递归开销和 pointer chasing,也让 BVH 表达更适合后续做 SoA 或 SIMD 实验。

时间最后花在哪里

完成这些改动之后,主要热点仍然集中在:

1
2
3
4
TriangleMesh::intersectFlat
AABB::intersect
gl::vec3::operator[]
TriangleMesh::hitTriangleFlat

这很合理。当前 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
2
3
4
p0
e1 = p1 - p0
e2 = p2 - p0
geometric normal

这样可以减少 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 优化的形态。