13.2 累积的确定性

一张图是几万次累加拼出来的:整数加法三序零差异,破坏结合律的是 fmul 的截断(结合律反例 1005/4096)。重排三角形重建 BVH 后图像摘要一字不变而访问节点数变了;全局 RNG 流下换遍历顺序 3049/3072 像素改变,逐像素流则逐字节相同。

13.2 累积的确定性

站内 /posts/books/渲染引擎/ 那套讲的是 TypeScript 引擎架构:RHI 抽象、帧图调度、可见性剔除,关心的是结构怎么摆、每帧的代价怎么摊。本节换一个尺度——架构不变、算法不变,只问一件事:同一份输入,能不能每次拼出同一个字节。13.1 管的是一条光线;本节管的是一批光线拼出来的那张图。

累加顺序:加法可结合,乘法的截断不可结合

第一层是好消息。4096 个 Q16.16 采样分别用正序、逆序、两两配对(树形归约)求和:

  • 三个和完全相同:都是 135287899,差异 0 处。

整数加法是可结合的,这一层没有不确定性。破坏结合律的是乘法的截断。同一批采样,一种口径是「先精确求和、最后降一次精度」(Σ x) >> 12,另一种是「逐个降精度再累加」Σ (x >> 12):

  • 先累加后移位 33029,逐个移位 30958,差 2071。
  • 逐个移位那一侧对顺序不敏感(逆序同样是 30958)——移位是逐元素操作;而「先累加后移位」把顺序的全部差异压进了那一次移位里。

fmul 本身的两个反例计数:交换律 0 组、结合律 1005 / 4096 组。第 4.2 节在同一台机器上量过 979/4096;两次的种子与样本集不同,所以数字不同,但结论完全一致——(a * b) >> 16 的向下取整在不同分组点截断,少截断一次就落回另一侧。

再看一个真实估计器:递推均值 m += fmul(x - m, alpha)(alpha = 1/8)。

  • 正序 34326、逆序 32320,差 2006。
  • 批量均值(先精确求和再 // n)是 33029。

三种算法算的是同一个实数,得到三个不同的整数。这不是 bug,是估计器的路径依赖:每一步都在一个截断过的中间值上继续。所以「可复现」的准确含义是路径固定,不是「算法等价」。

遍历顺序:结果不变,代价变

把 896 个三角形按确定性 Fisher–Yates 重排,重建 BVH,再跑一遍主光线:

  • 命中像素数:43731 → 43731(不变)。
  • t 序列摘要:c030dd7c… → c030dd7c…(不变)。
  • BVH 访问节点数:1347708 → 1347210;三角形测试:463033 → 462926(都变了)。

代价少了 498 个节点、107 次测试,图像一个字节没变。原因是最近命中唯一:暴力扫描 1024 条抽样光线,t 并列 0 条。只要没有两条三角形在同一个 t 上打平,「取最小」就是与顺序无关的运算。反过来,一旦并列,r[0] < best_t 里的严格小于号会让先被访问到的那条获胜——顺序就从后门进来了。这也解释了为什么本节要专门去数并列:顺序无关不是「显然的」,它有一个可检验的前提。

口径说明:上表里的访问 / 测试只统计主光线 76800 条;本卷基线记的 3238333 是主光线加阴影光线的合计,两者不可直接比。

RNG 流结构:可复现不等于可并行

换成一张 64×48 的小图,每像素抽 4 个 Q16.16 采样求平均,两种随机流:

  • 全局流(一个状态按遍历顺序推进):按行扫和按列扫得到两张图,3049 / 3072 个像素不同。
  • 逐像素流(状态由像素坐标派生):两张图逐字节相同,摘要 00899158…。

「同一个种子跑两次结果一样」是最弱的可复现——单线程顺序执行时,全局流也满足。真正要的是与调度无关:按瓦片并行时,全局流会随线程调度改变每个像素取到的采样序号,图就变了;逐像素流把随机性绑死在坐标上,调度怎么变都一样。第 5 章的分层采样、第 12 章的收敛曲线都依赖这条性质。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_13_2_accumulation.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_13_2_accumulation.py
"""13.2 累积的确定性:同种子可复现、累加顺序、遍历顺序与 RNG 流结构。

13.1 把**一条**光线的误差关进闭式界;本节换到**一批**光线。图像是几万次累加拼出来的,
问的是同一份输入能不能每次拼出同一个字节。四组实验:

  1. **累加顺序**。整数加法本身可结合(三序零差异);破坏结合律的是**乘法的截断**:
     `(Σ x) >> k` 与 `Σ (x >> k)` 不等价,`fmul` 的结合律反例复现第 4.2 节的同一现象,
     递推均值 `m += fmul(x - m, alpha)` 与批量均值也不等价。
  2. **遍历顺序**。重排三角形 -> 重建 BVH -> 遍历顺序随之改变:图像摘要与命中像素数
     不变,BVH 访问节点数变。顺序无关的前提是**最近命中唯一**,故暴力扫描统计 t 并列。
  3. **RNG 流结构**。全局流下按行 / 按列遍历会得到两张不同的图;逐像素流下两张图逐字节
     相同——「可复现」不等于「可并行」。

产物:out/cg_trace_13_2_accumulation.json
"""

import hashlib
import json
import os
import struct
import sys

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")
ONE = cglib.ONE
FMUL, FDIV = cglib.fmul, cglib.fdiv
BASELINE_SHA = "213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71"
SAMPLES, SEED, SHIFT = 4096, 20261016, 12
GW, GH = 64, 48                    # RNG 流实验的小图(1/25 面积,够看出结构)


def draw(n, seed):
    st, xs = seed, []
    for _ in range(n):
        st, q = cgtrace.rng_q16(st)
        xs.append(q)
    return xs


def sum_seq(xs):
    a = 0
    for x in xs:
        a += x
    return a


def sum_pair(xs):                  # 两两配对求和(树形归约)
    cur = list(xs)
    while len(cur) > 1:
        nxt = [cur[i] + cur[i + 1] for i in range(0, len(cur) - 1, 2)]
        if len(cur) % 2:
            nxt.append(cur[-1])
        cur = nxt
    return cur[0]


def exp_accumulation(xs):
    """累加顺序:加法可结合,乘法的截断不可结合。"""
    seq, rev, pair = sum_seq(xs), sum_seq(list(reversed(xs))), sum_pair(xs)
    sh = [x >> SHIFT for x in xs]
    after, before, before_rev = seq >> SHIFT, sum_seq(sh), sum_seq(list(reversed(sh)))
    st, comm, assoc = SEED, 0, 0                   # fmul 的交换律 / 结合律
    for _ in range(SAMPLES):
        st, a = cgtrace.rng_q16(st)
        st, b = cgtrace.rng_q16(st)
        st, c = cgtrace.rng_q16(st)
        comm += FMUL(a, b) != FMUL(b, a)
        assoc += FMUL(FMUL(a, b), c) != FMUL(a, FMUL(b, c))
    alpha = ONE >> 3                               # 递推均值:alpha = 1/8
    fwd = revm = 0
    for x in xs:
        fwd += FMUL(x - fwd, alpha)
    for x in reversed(xs):
        revm += FMUL(x - revm, alpha)
    return {
        "seq": seq, "rev": rev, "pair": pair, "orderDiffAdd": int(seq != rev) + int(seq != pair),
        "afterShift": after, "beforeShift": before, "beforeShiftRev": before_rev,
        "shiftDiffFwd": abs(after - before), "shiftDiffRev": abs(after - before_rev),
        "fmulCommDiff": comm, "fmulAssocDiff": assoc, "alpha": alpha,
        "runningMeanFwd": fwd, "runningMeanRev": revm, "runningMeanDiff": abs(fwd - revm),
        "batchMean": seq // len(xs),
    }


def permutation(n, seed):          # 确定性 Fisher–Yates(rng_below 拒绝采样,无取模偏置)
    idx = list(range(n))
    for i in range(n - 1, 0, -1):
        seed, j = cgtrace.rng_below(seed, i + 1)
        idx[i], idx[j] = idx[j], idx[i]
    return idx


def primary_pass(spec, tris, nodes, order, keep_dirs=False):
    """主光线最近命中一遍:返回 (t 序列摘要, stats, 方向表)。"""
    cam = spec["camera"]
    eye = tuple(cam["eyeQ16"])
    view = cglib.mat_look_at(eye, tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
    right, up, back = view[0:3], view[4:7], view[8:11]        # 相机基取**行**
    w, h = spec["width"], spec["height"]
    th, tmax = cam["tanHalfFovYQ16"], cam["farQ16"]
    tx = FMUL(th, FDIV(cglib.f_from_int(w), cglib.f_from_int(h)))
    stats = {"visited": 0, "tested": 0, "rays": 0, "hits": 0}
    buf, dirs = bytearray(), []
    for py in range(h):
        ay = FMUL(FDIV(cglib.f_from_int(2 * (h // 2 - py) - 1), cglib.f_from_int(h)), th)
        for px in range(w):
            ax = FMUL(FDIV(cglib.f_from_int(2 * (px - w // 2) + 1), cglib.f_from_int(w)), tx)
            d = cglib.v_norm((FMUL(ax, right[0]) + FMUL(ay, up[0]) - back[0],
                              FMUL(ax, right[1]) + FMUL(ay, up[1]) - back[1],
                              FMUL(ax, right[2]) + FMUL(ay, up[2]) - back[2]))
            stats["rays"] += 1
            hit = cgtrace.bvh_intersect(nodes, order, tris, eye, d, tmax, stats)
            if hit is None:
                buf += struct.pack("<q", -1)
            else:
                stats["hits"] += 1
                buf += struct.pack("<q", hit[0])
            if keep_dirs:
                dirs.append(d)
    return hashlib.sha256(bytes(buf)).hexdigest(), stats, dirs


def tie_scan(eye, dirs, tris, step):
    """暴力扫描抽样光线:统计「最小 t 被多个三角形同时达到」的光线数。"""
    checked = ties = 0
    for i in range(0, len(dirs), step):
        d, best, cnt = dirs[i], None, 0
        for rec in tris:
            r = cgtrace.intersect_triangle(eye, d, (rec[0], rec[1], rec[2]),
                                           (rec[3], rec[4], rec[5]), (rec[6], rec[7], rec[8]))
            if r is None:
                continue
            if best is None or r[0] < best:
                best, cnt = r[0], 1
            elif r[0] == best:
                cnt += 1
        checked += 1
        ties += cnt > 1
    return checked, ties


def stream_img(col_major=None):
    """全局流(col_major 给出时按列推进)/ 逐像素流(col_major=None,与顺序无关)。"""
    img = np.zeros((GH, GW), dtype=np.uint8)
    coords = ([(px, py) for px in range(GW) for py in range(GH)] if col_major else
              [(px, py) for py in range(GH) for px in range(GW)])
    st = SEED
    for px, py in coords:
        if col_major is None:                      # 逐像素种子:由坐标派生,与遍历顺序无关
            st = (SEED + (py * GW + px) * cgtrace.RNG_GOLDEN) & cgtrace.RNG_MASK
        s = 0
        for _ in range(4):
            st, q = cgtrace.rng_q16(st)
            s += q
        img[py, px] = (s >> 2) >> 8
    return img


def main():
    spec = cglib.load_spec()
    tris, _ = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)

    acc = exp_accumulation(draw(SAMPLES, SEED))

    d0, s0, dirs = primary_pass(spec, tris, nodes, order, keep_dirs=True)
    d0b, _, _ = primary_pass(spec, tris, nodes, order)            # 同顺序再跑一遍
    perm = permutation(len(tris), SEED ^ 0x5DEECE66D)
    tris_p = [tris[i] for i in perm]
    nodes_p, order_p = cgtrace.build_bvh(tris_p)
    d1, s1, _ = primary_pass(spec, tris_p, nodes_p, order_p)      # 重排 + 重建 BVH
    eye = tuple(spec["camera"]["eyeQ16"])
    checked, ties = tie_scan(eye, dirs, tris, 75)

    gr, gc, pp = stream_img(False), stream_img(True), stream_img(None)
    sha = cgtrace.digest
    g_diff = int(np.count_nonzero(gr != gc))

    print("== 13.2 累积的确定性 ==")
    print(f"累加顺序          : seq/rev/pair = {acc['seq']} / {acc['rev']} / {acc['pair']}"
          f"(差异 {acc['orderDiffAdd']} 处:整数加法可结合)")
    print(f"先累加后移位      : {acc['afterShift']} vs 逐个移位 {acc['beforeShift']}"
          f"(差 {acc['shiftDiffFwd']},逆序差 {acc['shiftDiffRev']})")
    print(f"fmul 归因         : 交换律反例 {acc['fmulCommDiff']} / 结合律反例 "
          f"{acc['fmulAssocDiff']}({SAMPLES} 组三元组)")
    print(f"递推均值 alpha=1/8: 正序 {acc['runningMeanFwd']} / 逆序 {acc['runningMeanRev']}"
          f"(差 {acc['runningMeanDiff']},批量均值 {acc['batchMean']})")
    print(f"\n遍历顺序(原始)  : 命中 {s0['hits']} 像素,访问 {s0['visited']} 节点,"
          f"测试 {s0['tested']} 次")
    print(f"遍历顺序(重排)  : 命中 {s1['hits']} 像素,访问 {s1['visited']} 节点,"
          f"测试 {s1['tested']} 次")
    print(f"两次摘要           : {'相同' if d0 == d1 else '不同'}"
          f"(同顺序再跑 {'相同' if d0 == d0b else '不同'})")
    print(f"最近命中唯一性     : 暴力扫描 {checked} 条光线,t 并列 {ties} 条")
    print(f"\n== RNG 流结构 ==\n全局流 行/列       : 不同像素 {g_diff} / {GW * GH}"
          f"(摘要 {'相同' if sha(gr) == sha(gc) else '不同'})")
    print(f"逐像素流           : 不同像素 0(摘要 {sha(pp)[:8]}… 可重复)")

    os.makedirs(OUT, exist_ok=True)
    payload = {
        "samples": SAMPLES, "seed": SEED, "shift": SHIFT, "baselineSha": BASELINE_SHA,
        "accumulation": acc, "orderSameSha": d0 == d1, "repeatSameSha": d0 == d0b,
        "shaOriginal": d0, "shaPermuted": d1, "statsOriginal": s0, "statsPermuted": s1,
        "tieRaysChecked": checked, "tieRays": ties, "gridW": GW, "gridH": GH,
        "globalRowSha": sha(gr), "globalColSha": sha(gc), "perPixelSha": sha(pp),
        "globalDiffPixels": g_diff, "perPixelDiffPixels": 0,
    }
    with open(os.path.join(OUT, "cg_trace_13_2_accumulation.json"), "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)
        f.write("\n")
    print("\n落盘 out/cg_trace_13_2_accumulation.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())
== 13.2 累积的确定性 ==
累加顺序          : seq/rev/pair = 135287899 / 135287899 / 135287899(差异 0 处:整数加法可结合)
先累加后移位      : 33029 vs 逐个移位 30958(差 2071,逆序差 2071)
fmul 归因         : 交换律反例 0 / 结合律反例 1005(4096 组三元组)
递推均值 alpha=1/8: 正序 34326 / 逆序 32320(差 2006,批量均值 33029)

遍历顺序(原始)  : 命中 43731 像素,访问 1347708 节点,测试 463033 次
遍历顺序(重排)  : 命中 43731 像素,访问 1347210 节点,测试 462926 次
两次摘要           : 相同(同顺序再跑 相同)
最近命中唯一性     : 暴力扫描 1024 条光线,t 并列 0 条

== RNG 流结构 ==
全局流 行/列       : 不同像素 3049 / 3072(摘要 不同)
逐像素流           : 不同像素 0(摘要 00899158… 可重复)

落盘 out/cg_trace_13_2_accumulation.json

P 层

// snippet: 节选自 lantern/core/test/cg_trace_13_2_accumulation.test.ts
// verify: node --test 'core/test/cg_trace_13_2_accumulation.test.ts'
  assert.equal(g.orderDiffAdd, 0, "整数加法三序必须零差异");
  assert.ok(assoc > 0, "结合律必须有反例,否则定点语义变了");
  assert.notEqual(base.stats.visited, permBase.stats.visited, "代价必须随顺序变");
  assert.equal(base.sha, permBase.sha, "最近命中唯一时,遍历顺序不应改变图像");

两条断言的方向相反,这是刻意的:摘要必须相等,代价必须不等。如果哪天有人把 BVH 遍历「优化」成不依赖顺序的固定代价,第三条会失败;如果有人改坏了最近命中的选择(比如把 < 改成 <=),第一条会失败。P 层在这里守的不是「跑得快」,而是这两件事必须仍然分开——结果确定,代价可以不确定。

小结

  • 整数加法三序同值(135287899,差异 0);(Σ x) >> 12 与 Σ (x >> 12) 差 2071。
  • fmul 交换律 0 反例、结合律 1005 / 4096 反例(4.2 节同现象量到 979/4096,样本不同)。
  • 递推均值路径依赖:正序 34326、逆序 32320、批量均值 33029。
  • 重排三角形重建 BVH:命中 43731 与摘要不变,访问节点 1347708 → 1347210;前提是 t 并列 0 条(1024 条抽样)。
  • 全局 RNG 流下换遍历顺序 3049 / 3072 像素改变;逐像素流下逐字节相同——可复现 ≠ 可并行。

单机内部这一层确定性理清了,下一节把它抬到语言边界上:同一份 scene_spec.json 交给 Python 和 TypeScript,像素缓冲的 sha256 能不能逐字节相同。

阅读导航:上一节:13.1 求交的容差边界 · 下一节:13.3 跨语言对照 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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