15.2 性能的度量与剖析
站内 /graphics-mobile-rendering-optimization/ 那篇讲的是移动端实时渲染的性能:它的时间预算以毫秒计,一帧必须在 16.7 ms 内画完,于是所有优化都指向「把这一毫秒从哪儿抠出来」。离线渲染没有这条硬线,但它有另一个更朴素的问题——这一帧到底为什么这么慢。上一节算出「单帧 1.8 s、16 帧 29 s」,可那只是一个总数。总数不能告诉你该改哪里。这一节要把它拆开。
为什么不能只看秒表
最直觉的做法是「注释掉一块代码,看时间少多少」。这条路在本例里行不通,因为卷三基线的一帧只做一件事的下游:render_trace 逐像素发主光线,主光线命中后交给 shade,shade 里再对两盏灯各发一条阴影光线。这些步骤串在一起,任何一处「注释掉」都会让后面的步骤失去输入。
所以换个做法:写一个与 cgtrace.render_trace 逐字同式的渲染循环,只在四处分插计时器——
- 光线生成:每个像素算一次主光线方向(
v_norm那一串); - 主光线求交:主光线的 BVH 遍历;
- 命中处理:命中后的法线插值、命中点、偏移向量;
- 着色算术:环境光 + 方向光 + 点光源的求值;
- 阴影光线求交:两盏灯各一条阴影光线的 BVH 遍历。
计时器插在循环里,但循环本身与 render_trace 一模一样——像素遍历顺序、相机基取行、fdiv/fmul 的写法都不动。只有这样,拆出来的时间才对应真实的那一帧。
#!/usr/bin/env python3
# file: lantern/sim/cg_trace_15_2_profile.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_15_2_profile.py
"""15.2 性能的度量与剖析:把单帧耗时拆成可归因的几块,并用**计数**解释它。
在 320x240 的卷三基线上,写一个与 `cgtrace.render_trace` 逐字同式的渲染循环,只在
四处分插计时器:光线生成 / 主光线求交 / 着色算术 / 阴影光线求交。四块相加约等于总
耗时,差额是循环与函数调用的固定开销。
**四块的比例不是估的**:每块都配一组计数(主光线、命中像素、阴影光线、BVH 访问节点、
三角形测试),主光线与阴影光线的遍历计数用**两个独立统计器**分别累计——BVH 的代价
必须能拆到「谁在遍历」这一层。所有时间都是本机(Apple M1 Pro,单线程 Python)实测
墙钟,只做本机相对比较,不写「提升 N 倍」。
产出:out/cg_trace_15_2_profile.json
"""
import json
import os
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import cgtrace # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
perf = time.perf_counter
def c8(v):
"""8 位通道 -> Q16.16 的 [0, 1]。"""
return (v << 16) // 255
def make_shader(ctx):
"""直接光照 + 硬阴影(与卷三基线逐字同式),阴影光线的遍历走独立统计器。"""
spec = ctx["spec"]
tris, _ = cgtrace.build_scene_tris(spec)
nodes, order = cgtrace.build_bvh(tris)
ctx["tris"], ctx["bvh"] = tris, (nodes, order)
dl, pl = spec["directionalLight"], spec["pointLight"]
ldir, lcol = tuple(dl["dirQ16"]), [c8(v) for v in dl["colorQ8"]]
lint = dl["intensityQ16"]
ppos, pcol = tuple(pl["posQ16"]), [c8(v) for v in pl["colorQ8"]]
pint = pl["intensityQ16"]
pr2 = cglib.fmul(pl["rangeQ16"], pl["rangeQ16"])
amb, tmax, ss = spec["ambientQ16"], ctx["tmax"], ctx["shadow_stats"]
def shadow(so, dirv):
"""一条阴影光线:计时 + 计数,返回 True 表示被遮挡。"""
ctx["shadow_rays"] += 1
ta = perf()
blocked = cgtrace.bvh_intersect(nodes, order, tris, so, dirv, tmax, ss)
ctx["t_shadow"] += perf() - ta
return blocked is not None
def shade(p, off, n, albedo, depth):
t0 = perf()
alb = [c8(v) for v in albedo]
out = [cglib.fmul(amb, alb[i]) for i in range(3)]
so = cglib.v_add(p, off)
ndl = cglib.v_dot(n, ldir)
if ndl > 0:
if not shadow(so, ldir):
k = cglib.fmul(lint, ndl)
for i in range(3):
out[i] += cglib.fmul(k, cglib.fmul(lcol[i], alb[i]))
else:
ctx["shadow_hits"] += 1
to_l = cglib.v_sub(ppos, p)
d2 = cglib.v_dot(to_l, to_l)
if 0 < d2 < pr2:
lp = cglib.v_norm(to_l)
ndlp = cglib.v_dot(n, lp)
if ndlp > 0:
if not shadow(so, lp):
att = cglib.fdiv(pr2, pr2 + d2)
k = cglib.fmul(cglib.fmul(pint, ndlp), att)
for i in range(3):
out[i] += cglib.fmul(k, cglib.fmul(pcol[i], alb[i]))
else:
ctx["shadow_hits"] += 1
ctx["t_shade"] += perf() - t0
return tuple(cgtrace._clamp8(x) for x in out)
return shade
def trace_prof(ctx, orig, dirv):
"""与 `cgtrace.trace` 同构的探针版:把主光线求交与命中处理的时间拆出来。"""
nodes, order = ctx["bvh"]
tris = ctx["tris"]
t0 = perf()
hit = cgtrace.bvh_intersect(nodes, order, tris, orig, dirv, ctx["tmax"], ctx["prim_stats"])
ctx["t_primary"] += perf() - t0
if hit is None:
return ctx["background"]
t, ti, u, v = hit
rec = tris[ti]
t1 = perf()
w0 = cglib.ONE - u - v
n = cglib.v_norm((
cglib.fmul(rec[9], w0) + cglib.fmul(rec[12], u) + cglib.fmul(rec[15], v),
cglib.fmul(rec[10], w0) + cglib.fmul(rec[13], u) + cglib.fmul(rec[16], v),
cglib.fmul(rec[11], w0) + cglib.fmul(rec[14], u) + cglib.fmul(rec[17], v),
))
p = cglib.v_add(orig, (cglib.fmul(dirv[0], t), cglib.fmul(dirv[1], t), cglib.fmul(dirv[2], t)))
if cglib.v_dot(n, dirv) > 0:
n = (-n[0], -n[1], -n[2])
off = (cglib.fmul(n[0], cgtrace.EPSILON), cglib.fmul(n[1], cgtrace.EPSILON),
cglib.fmul(n[2], cgtrace.EPSILON))
ctx["t_interp"] += perf() - t1
ctx["hit_pixels"] += 1
return ctx["shade"](p, off, n, (rec[18], rec[19], rec[20]), 0)
def render_profiled(spec):
"""与 `cgtrace.render_trace` 逐字同式的循环,只在四处分插计时器。"""
w, h = spec["width"], spec["height"]
cam = spec["camera"]
eye = tuple(cam["eyeQ16"])
view = cglib.mat_look_at(eye, tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
right = (view[0], view[1], view[2])
up = (view[4], view[5], view[6])
back = (view[8], view[9], view[10])
tan_half = cam["tanHalfFovYQ16"]
tan_x = cglib.fmul(tan_half, cglib.fdiv(cglib.f_from_int(w), cglib.f_from_int(h)))
ctx = {"background": tuple(spec["backgroundQ8"]), "tmax": cam["farQ16"], "spec": spec,
"prim_stats": {"visited": 0, "tested": 0},
"shadow_stats": {"visited": 0, "tested": 0},
"t_gen": 0.0, "t_primary": 0.0, "t_interp": 0.0, "t_shade": 0.0, "t_shadow": 0.0,
"hit_pixels": 0, "shadow_rays": 0, "shadow_hits": 0}
ctx["shade"] = make_shader(ctx)
color = np.empty((h, w, 3), dtype=np.uint8)
half_w, half_h = w // 2, h // 2
t_start = perf()
for py in range(h):
sy = cglib.fdiv(cglib.f_from_int(2 * (half_h - py) - 1), cglib.f_from_int(h))
ay = cglib.fmul(sy, tan_half)
for px in range(w):
tg = perf()
sx = cglib.fdiv(cglib.f_from_int(2 * (px - half_w) + 1), cglib.f_from_int(w))
ax = cglib.fmul(sx, tan_x)
dirv = cglib.v_norm((
cglib.fmul(ax, right[0]) + cglib.fmul(ay, up[0]) - back[0],
cglib.fmul(ax, right[1]) + cglib.fmul(ay, up[1]) - back[1],
cglib.fmul(ax, right[2]) + cglib.fmul(ay, up[2]) - back[2],
))
ctx["t_gen"] += perf() - tg
color[py, px] = trace_prof(ctx, eye, dirv)
ctx["total_s"] = perf() - t_start
return color, ctx
def main():
spec = cglib.load_spec()
os.makedirs(OUT, exist_ok=True)
color, ctx = render_profiled(spec)
ps, ss = ctx["prim_stats"], ctx["shadow_stats"]
t_gen, t_prim, t_int = ctx["t_gen"], ctx["t_primary"], ctx["t_interp"]
t_shade, t_shadow, t_total = ctx["t_shade"], ctx["t_shadow"], ctx["total_s"]
t_light = t_shade - t_shadow
t_other = t_total - (t_gen + t_prim + t_int + t_shade)
pct = lambda x: round(100.0 * x / t_total, 2)
blocks = [("主光线求交", t_prim), ("阴影光线求交", t_shadow), ("着色算术", t_light),
("光线生成", t_gen), ("命中处理", t_int), ("循环/其他", t_other)]
rays = spec["width"] * spec["height"]
shadow_rays = ctx["shadow_rays"]
prim_vis, prim_te = ps["visited"], ps["tested"]
sh_vis, sh_te = ss["visited"], ss["tested"]
counts = {
"primaryRays": rays, "hitPixels": ctx["hit_pixels"], "shadowRays": shadow_rays,
"shadowHits": ctx["shadow_hits"], "bvhVisitedPrimary": prim_vis,
"bvhTestedPrimary": prim_te, "bvhVisitedShadow": sh_vis, "bvhTestedShadow": sh_te,
"bvhVisitedTotal": prim_vis + sh_vis, "bvhTestedTotal": prim_te + sh_te,
"visitedPerPrimaryRay": round(prim_vis / rays, 4),
"testedPerPrimaryRay": round(prim_te / rays, 4),
"visitedPerShadowRay": round(sh_vis / shadow_rays, 4),
"testedPerShadowRay": round(sh_te / shadow_rays, 4),
}
# 计数 -> 代价:每条光线的摊销耗时,以及「每访问一个节点」「每做一次三角形测试」的
# 摊销代价。**这两项不可相加**——三角形测试发生在叶子节点的遍历内部,二者不是独立
# 事件;相加会得到约两倍于实测的预测值(一个典型的错误模型)。
cost_model = {
"usPerPrimaryRay": round(t_prim * 1e6 / rays, 2),
"usPerShadowRay": round(t_shadow * 1e6 / shadow_rays, 2),
"nsPerVisitedNode": round((t_prim + t_shadow) * 1e9 / (prim_vis + sh_vis), 1),
"nsPerTriangleTest": round((t_prim + t_shadow) * 1e9 / (prim_te + sh_te), 1),
"note": "两项为同一段遍历的摊销,不可相加",
}
report = {"width": spec["width"], "height": spec["height"], "maxDepth": 1,
"totalMs": round(t_total * 1e3, 1), "sha256": cgtrace.digest(color),
"blocks": [{"name": n, "ms": round(v * 1e3, 1), "pct": pct(v)} for n, v in blocks],
"counts": counts, "costModel": cost_model}
with open(os.path.join(OUT, "cg_trace_15_2_profile.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("== 15.2 性能的度量与剖析 ==")
print(f"分辨率 {spec['width']}x{spec['height']} / max_depth 1 / 总耗时 {report['totalMs']} ms")
print(f"{'块':<12} {'ms':>9} {'占比%':>7}")
for n, v in blocks:
print(f"{n:<12} {round(v * 1e3, 1):>9} {pct(v):>7}")
print("计数(主光线与阴影光线用独立统计器):")
print(f" 主光线 {rays} 命中像素 {counts['hitPixels']} 阴影光线 {shadow_rays}"
f"(遮挡 {counts['shadowHits']})")
print(f" BVH 访问节点 主光线 {prim_vis}({counts['visitedPerPrimaryRay']}/条)"
f" + 阴影光线 {sh_vis}({counts['visitedPerShadowRay']}/条) = {counts['bvhVisitedTotal']}")
print(f" BVH 三角形测试 主光线 {prim_te}({counts['testedPerPrimaryRay']}/条)"
f" + 阴影光线 {sh_te}({counts['testedPerShadowRay']}/条) = {counts['bvhTestedTotal']}")
print(f"摊销代价 : 主光线 {cost_model['usPerPrimaryRay']} us/条 · 阴影光线"
f" {cost_model['usPerShadowRay']} us/条 · {cost_model['nsPerVisitedNode']} ns/访问节点"
f" · {cost_model['nsPerTriangleTest']} ns/三角形测试(后两项不可相加)")
print(f"sha256 : {report['sha256']}")
return 0
if __name__ == "__main__":
sys.exit(main())
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_15_2_profile.py
== 15.2 性能的度量与剖析 ==
分辨率 320x240 / max_depth 1 / 总耗时 6687.2 ms
块 ms 占比%
主光线求交 2633.9 39.39
阴影光线求交 3590.0 53.68
着色算术 153.6 2.3
光线生成 122.0 1.82
命中处理 99.3 1.49
循环/其他 88.4 1.32
计数(主光线与阴影光线用独立统计器):
主光线 76800 命中像素 43731 阴影光线 77211(遮挡 1357)
BVH 访问节点 主光线 1347708(17.5483/条) + 阴影光线 1890625(24.4865/条) = 3238333
BVH 三角形测试 主光线 463033(6.0291/条) + 阴影光线 656771(8.5062/条) = 1119804
摊销代价 : 主光线 34.3 us/条 · 阴影光线 46.5 us/条 · 1921.9 ns/访问节点 · 5558.0 ns/三角形测试(后两项不可相加)
sha256 : 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71
四块拆解:阴影光线比主光线还贵
| 块 | 毫秒 | 占比 |
|---|---|---|
| 主光线求交 | 2633.9 | 39.39% |
| 阴影光线求交 | 3590.0 | 53.68% |
| 着色算术 | 153.6 | 2.30% |
| 光线生成 | 122.0 | 1.82% |
| 命中处理 | 99.3 | 1.49% |
| 循环/其他 | 88.4 | 1.32% |
| 合计 | 6687.2 | 100% |
最反直觉的一行是第二行:阴影光线求交(53.68%)比主光线求交(39.39%)还贵。主光线才是画出画面的那条光线,为什么反而更便宜?计数给出了答案:
- 主光线 76800 条(320×240),阴影光线 77211 条——两者数量几乎相等(阴影光线只多 0.5%)。命中像素 43731,平均每个命中像素发 1.77 条阴影光线(43731 × 1.77 ≈ 77211)。
- 但每条阴影光线更贵:主光线 34.3 μs/条,阴影光线 46.5 μs/条,贵 36%。
- 贵在遍历深度:主光线平均访问 17.55 个节点、做 6.03 次三角形测试;阴影光线平均访问 24.49 个节点、做 8.51 次三角形测试。
原因是几何性的。主光线大多在离开场景的路上就早早命中(命中率 43731/76800 = 56.9%),一旦命中就不再深入;而阴影光线大多是一条「证明没被挡住」的失败查找——它必须一路走到底、确认沿途没有任何遮挡才算完成。77211 条里只有 1357 条(1.76%)真的被挡住,其余 98.24% 都是白跑。这正是硬阴影的固有代价:每多一盏灯,就要为每个可见点多发一条「大概率什么都不撞上」的光线。
这也解释了为什么 §7 的基线里「BVH 访问节点 3238333」这个数是主光线与阴影光线分开统计再相加的——如果只记一个总数,你就永远看不到「谁在遍历」这一层,也就找不到优化方向。所以剖析脚本用两个独立的统计器(prim_stats 与 shadow_stats)分别累计。
计数到代价:两项不可相加
拿到计数以后,很自然会想「把时间按计数摊下去」。可以摊,但不能加。脚本摊出来的结果是 主光线 34.3 us/条 · 阴影光线 46.5 us/条 · 1921.9 ns/访问节点 · 5558.0 ns/三角形测试。其中后两项是同一段遍历的两个侧面:三角形测试就发生在叶子节点的遍历内部,它们不是两个独立事件。如果按「访问节点数 × 每节点代价 + 测试数 × 每次代价」去预测总时间,会得到约两倍于实测的值——这是一个典型的错误模型。正确的用法是只用它们做相对比较:换一个叶子容量,看这两个摊销值怎么变。
P 层
P 层不重复计时(时间不能跨语言比),只断言计数与 S 层逐条相同——包括主光线与阴影光线分开的四个遍历计数:
// snippet: core/test/cg_trace_15_2_profile.test.ts(节选)
test("BVH 遍历计数(主光线 / 阴影光线分开)与 S 层相同", () => {
assert.equal(stats.visited, golden.counts.bvhVisitedPrimary);
assert.equal(stats.tested, golden.counts.bvhTestedPrimary);
assert.equal(shadowStats.visited, golden.counts.bvhVisitedShadow);
assert.equal(shadowStats.tested, golden.counts.bvhTestedShadow);
assert.equal(stats.visited + shadowStats.visited, golden.counts.bvhVisitedTotal);
assert.equal(stats.tested + shadowStats.tested, golden.counts.bvhTestedTotal);
});
node --test 'core/test/cg_trace_15_2_profile.test.ts' 跑出 3 项全绿:计数、BVH 分项计数、以及像素缓冲 sha256 213b4130… 与 S 层一致。
小结
- 单帧 6687.2 ms 拆成五块:主光线求交 39.39%、阴影光线求交 53.68%、着色算术 2.30%、光线生成 1.82%、命中处理 1.49%。
- 阴影光线比主光线还贵:数量几乎相等(77211 vs 76800),但每条贵 36%,因为大多要遍历到底去证明「没被挡住」。
- 剖析必须用计数:BVH 访问节点 3238333、三角形测试 1119804,且主光线与阴影光线分开统计。
- 摊销代价只能相对比较,不可相加——两项是同一段遍历的两个侧面。
知道了「时间花在阴影光线上」,下一个问题自然是「那我把参数调一调会怎样」。下一节用四条旋钮做一次系统的扰动,看质量与时间怎么换。
阅读导航:上一节:15.1 帧序列与动画输出 · 下一节:15.3 参数与质量的取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。