15.2 性能的度量与剖析

一帧 6.7 秒,时间到底花在哪?本节把 320x240 基线的单帧耗时拆成主光线求交、阴影光线求交、着色算术、光线生成、命中处理五块,用计数而不是直觉解释它;实测阴影光线求交独占 53.68%,比主光线求交还贵,因为阴影光线大多要遍历到底去证明这一点没被挡住。

15.2 性能的度量与剖析

站内 /graphics-mobile-rendering-optimization/ 那篇讲的是移动端实时渲染的性能:它的时间预算以毫秒计,一帧必须在 16.7 ms 内画完,于是所有优化都指向「把这一毫秒从哪儿抠出来」。离线渲染没有这条硬线,但它有另一个更朴素的问题——这一帧到底为什么这么慢。上一节算出「单帧 1.8 s、16 帧 29 s」,可那只是一个总数。总数不能告诉你该改哪里。这一节要把它拆开。

为什么不能只看秒表

最直觉的做法是「注释掉一块代码,看时间少多少」。这条路在本例里行不通,因为卷三基线的一帧只做一件事的下游:render_trace 逐像素发主光线,主光线命中后交给 shade,shade 里再对两盏灯各发一条阴影光线。这些步骤串在一起,任何一处「注释掉」都会让后面的步骤失去输入。

所以换个做法:写一个与 cgtrace.render_trace 逐字同式的渲染循环,只在四处分插计时器——

  1. 光线生成:每个像素算一次主光线方向(v_norm 那一串);
  2. 主光线求交:主光线的 BVH 遍历;
  3. 命中处理:命中后的法线插值、命中点、偏移向量;
  4. 着色算术:环境光 + 方向光 + 点光源的求值;
  5. 阴影光线求交:两盏灯各一条阴影光线的 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.939.39%
阴影光线求交3590.053.68%
着色算术153.62.30%
光线生成122.01.82%
命中处理99.31.49%
循环/其他88.41.32%
合计6687.2100%

最反直觉的一行是第二行:阴影光线求交(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 参数与质量的取舍 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配