门禁阶梯 M0–M3:为什么每级只认一个硬数字
门禁的设计逻辑
四级台阶按成本递增、拦截面互补排列:便宜的检查放前面,挡住低级错误,昂贵的训练放后面。每级绑定一个可判定的数字阈值——“不过不升级”,避免”感觉差不多了”式的灰区放行。
M0:字节级与 1e-7 对拍
- 黄金字节:确定性路径(排序结果等)与参考输出逐字节比对。GPU 上只要调度确定,结果应位级一致;差一个字节就是逻辑变了。
- 前向 CPU 对拍 1e-7:同一份输入,CPU 双精度复刻 vs GPU f32 着色器,逐值比对。10−7 正好卡在 f32 机器精度 ε=2−24≈6×10−8 的量级——差异只能来自舍入,不能来自公式。
M1:gradcheck,rel-err < 1e-3
有限差分(finite difference)验证反向:对每个参数 θ 比较解析梯度与数值梯度
rel-err=max(∥ganalytic∥, ∥gnumeric∥, ϵ)∥ganalytic−gnumeric∥
关键细节在 FACTS 里写得很清楚:差分基准必须是经对拍的全双精度 CPU 复刻。若直接在 GPU f32 上做差分,步长 10−6 引起的函数值变化(∼10−6 量级)会被 f32 舍入噪声(∼10−7 相对,经整条前向放大后)淹没,差分信号根本不可信。六个用例(isolated / overlap / clamp / earlyexit / ssim / boundary)分别覆盖不同的代码分支——单用例通过不代表全路径正确。
M2:单视角 overfit 48.5 dB
3000 迭代、只背三张训练图,PSNR(峰值信噪比,Peak Signal-to-Noise Ratio)需达 48.5 dB,其定义为
PSNR=10log10MSE1,MSE=N1∑i(yi−y^i)2, y∈[0,1]
48.5 dB 对应 MSE≈1.4×10−5,即平均每像素误差约 1/255 量级——视觉上无法区分。这一级测的是可表达性:模型容量、梯度通路、优化器三者合起来,连三张图都背不动,说明链路必有断裂。
M3:全量 23 dB,门禁 28 dB
全量训练未达标。门禁的价值恰在此处显形:如实记录”没到”,而不是调低门槛放行。数字不说谎,前提是数字测量的是对的东西——这正是后面 B204 大案要拆穿的盲区。
参考
三层能力矩阵:每层抓什么、盲什么
为什么一层不够
三种验证工具检查的是三个不同的命题,命题之间互不蕴含,所以每层都有自己的盲区:
| 层 | 检查的命题 | 盲区 |
|---|
| ① 有限差分 | “我的反向是我前向的正确导数吗” | 前向本身语义对不对 |
| ② 单例 overfit | “我的模型+优化器背得动目标吗” | 目标本身对不对 |
| ③ 交叉对拍 | “我的输出和独立参照一致吗” | 没有参照可用时 |
第一层:有限差分抓”公式错”
gradcheck 比较的是同一个实现内部的前向与反向:数值梯度用前向 f(θ+δ)−f(θ−δ) 扰动出来,解析梯度来自手写的 backward。两者一致,只证明 backward 是 forward 的正确导数。
漏链(某项没传梯度)、符号反、系数错——这类”局部公式错”会被立刻抓住。但若前向公式本身就写错了,backward 是错误前向的正确导数,gradcheck 照样全绿。公式全对、含义全错,它管不着。
第二层:单例 overfit 抓”可表达性”
把训练集缩到几张图、跑几千迭代,看模型能否把它背下来。这隔离掉数据复杂度,单独考验:参数化够不够用、梯度能不能流到每个参数、优化器能不能推动损失下降。
盲区是自洽的错误:如果前向写错,优化器会调整参数去补偿这个错误——错的前向配错的参数,输出照样贴合训练图。overfit 只问”背得动吗”,不问”背的是对的东西吗”。本案的 +0.5 bug 正是从这条缝里穿过去的(机制见 B205)。
第三层:交叉对拍抓”语义漂移”
换一个独立实现当裁判:黄金字节对拍、与 gsplat 交叉比 PSNR、外部查看器渲染同一份权重。因为参照的实现路径不同,系统性语义错误(颜色约定、坐标约定、归一化约定)无法两边同时犯得一样,必然暴露。
代价是依赖外部锚点:训练器早期没有可对拍的参照时,这一层是空的。三层合起来才闭环——层数即安全边际。
容易误解的点
- 三层不是”越来越严格的同一个检查”。它们测不同命题,不能用 overfit 取代 gradcheck,反之亦然。
- “全绿”只说明被测命题成立。报指标时先问:这个数字是哪个系统、和哪个参照算出来的?
内部自洽指标:为什么 48.5 dB 会撒谎
自洽不等于正确
内部自评 PSNR 的计算链路是:
PSNR(f(θ), y)其中 f 是自己的前向
问题在于,f 既是被测对象、又是测量工具的一部分。若 f 含系统性错误,训练会驱动参数 θ 向补偿方向移动,最终找到 θ′ 使
f错(θ′)≈y
内部 PSNR 看到的是 f错(θ′) 与 y 的距离——错误已经在参数里被”消化”掉了,指标自然全绿。PSNR 度量的是同一系统两个输出之间的距离,系统性错误在这个距离里会自相抵消。
外部查看器为什么一眼穿帮
外部查看器(正确的前向 f对)拿到的是权重 θ′,而不是渲染结果:
f对(θ′)=y
θ′ 是为错误前向定制的补偿解,放进正确前向里,补偿就变成了过冲——于是全白雾。同一组权重,内部 48.5 dB,外部一片白,差别只在”谁来执行前向”。
普适教训
- 指标与实现同源,则指标不可信。渲染器自评、编译器自举测试、用同一套解析器校验自己生成的文件,都是同构陷阱。
- 导出物(权重、PLY)的正确性只能在导出物的消费者处验证。权重好不好,问查看器,别问训练器。
- 这类 bug 的隐蔽性在于:每一步单独看都”合理”——前向是自洽的、损失在降、指标在涨。破案只能靠一个不共享错误假设的外部锚点。
机制链:一个 0.5 如何让系统自己骗自己
起点:SH 零阶项的正确公式
3DGS 的视角相关颜色用球谐(SH, Spherical Harmonics)系数存储。只看零阶(DC 项),每个通道的颜色是
c=0.5+C0⋅dc,C0=2π1≈0.28209479
dc 是 PLY 里的 f_dc 系数,C0 是零阶球谐基函数值,0.5 是人为加的平移项——让 dc≈0 对应中灰,系数围绕 0 对称分布,利于初始化和学习率设定。
案发代码(forward.comp)漏掉了这个平移项:
// 错误:少了 +0.5
vec3 col = C0 * dc;
// 正确:
vec3 col = 0.5 + C0 * dc;
第二步:1.77 不是巧合,是解出来的
损失压着输出逼近目标颜色 c∗,前向少给 0.5,优化器只能动 dc。设补偿后系数为 dc′,要求
C0⋅dc′=0.5+C0⋅dc
解出
dc′=dc+C00.5=dc+0.282094790.5≈dc+1.7725
实测直流系数平均上移 +1.77——正是 0.5/C0。这个数字的出现本身就是诊断线索:补偿量精确等于缺失项除以基函数系数,说明补偿发生在 DC 系数上、缺失项恰好是 0.5。
第三步:自洽闭环
错误前向 + 补偿后的参数,输出与训练图吻合,自评 48.5 dB。此时系统内部没有任何信号能暴露错误——前向、损失、指标三者互相咬合,全部”正确”。这是机制链里最危险的一环:错误看起来和成功一模一样。
第四步:外部崩溃的算术
权重交给正确实现(外部查看器 / gsplat),它执行的是完整公式:
c外部=0.5+C0⋅dc′=正确颜色0.5+C0⋅dc+0.5
每个通道整体抬高 0.5。颜色范围 [0,1] 内大面积溢出、截断到 1——整幅画面糊成全白雾。几何、不透明度和排序都没坏,只坏在一个常数项上。
易踩的坑
- 补偿量反推 bug:训练后参数出现异常的整体偏移时,先算”偏移量 × 基函数 = 多少”,往往能直接读出前向漏掉了哪个常数。
- SH 系数不是颜色。
f_dc 要过 0.5+C0⋅dc 才是 RGB;PLY 里存的是系数,直接当颜色用会差出一个平移加缩放。
参考
单像素手工追踪:指标会骗人,算术不会
为什么聚合指标定不了案
PSNR 是百万像素的平均:MSE=N1∑(yi−y^i)2。系统性错误在平均里只有两种下场——要么被优化器补偿掉(本案),要么被海量正常像素稀释。聚合指标回答”整体像不像”,回答不了”这一步算得对不对”。
手工追踪反过来做:把系统缩到最小可复算单元——一个像素、一个高斯,跳过排序、混合、致密化的干扰,让整条链路短到可以用纸笔复算。
两个 0.37x 的差距意味着什么
| 来源 | 值 | 含义 |
|---|
| CPU 语义参考 | 0.372 | 按正确公式 0.5+C0⋅dc,双精度手算 |
| GPU 实际读数 | 0.375 | 着色器真实执行路径的单像素输出 |
差 0.003。这个差距的量级本身就是证据:f32 的机器精度是 2−24≈6×10−8,即使沿前向链累积放大,舍入误差也只能到 10−6∼10−5 量级。0.003 比舍入噪声高两个数量级以上——这不是精度问题,是公式不同,因此”不容抵赖”。
手工追踪的工程要点
- 先冻结随机性:固定初始化、固定迭代步数、关掉致密化,保证两次运行逐比特可复现。
- 单高斯隔离:一个高斯就不存在排序先后、alpha 混合次序的歧义,前向退化为单点求值。
- 双精度做裁判:CPU 参考实现用 float64 按公式语义算,把舍入从嫌疑名单里划掉,剩下的差异只可能来自公式。
- 中间量逐点比对:不只看最终颜色,conic、power、α、T 逐步对,第一个分叉点就是 bug 位置。
这个方法论的普适形式:当一个复杂系统”指标正常但行为可疑”时,把它分解到最小确定性单元,用独立算术复算——指标是统计,算术是事实。
0.5 的三张脸:同一数字,三种语义
脸一:SH 前向基础色的平移项(本案罪犯)
c=0.5+C0⋅dc,C0=2π1≈0.28209
这个 0.5 是颜色映射的偏置:让零阶 SH 系数 dc=0 时输出中灰 0.5,系数以零为中心对称取值。丢掉它,所有颜色整体暗 0.5;训练会把 dc 抬高 0.5/C0≈1.77 补回来——内部看不出来,导出即翻车(完整机制见 B205)。
脸二:像素中心约定 +0.5(EP02,无辜但高危)
像素 (i,j) 不是点,是方格 [i,i+1)×[j,j+1),其几何中心在
(u,v)=(i+0.5, j+0.5)
投影一个高斯中心到屏幕、或从像素反引射线时,必须用格心坐标。写成 (i,j) 就产生统一的半像素偏移:单独看只是”糊了一点”,与真值对比时细结构出现重影。这类约定错最阴险——它不产生 NaN、不崩溃,只是安静地错半个像素。
脸三:抗混叠膨胀 +0.3(EP03,本案复核后无罪)
EWA(椭圆加权平均,Elliptical Weighted Average)投影得到的二维协方差,对角线上加一个亚像素量:
cov2d′=cov2d+0.3I
含义是把每个屏幕高斯人为膨胀一点:当投影后的高斯比像素还小时,采样会严重走样(闪烁、锯齿);加 0.3 保证其方差下限约等于可采样尺度。注意它是 0.3 不是 0.5——“三张脸”里最像凶手的一位,恰恰是清白的。本案排查时对它做了连带复核:核对系数值、确认只加在对角线(只膨胀、不旋转),排除嫌疑。
审计方法论
同一个字面量出现在代码三处、语义各不相同,这就是”魔法数字审计”的典型场景。办案副产品是一次全链路常量审计:对每个常数问三个问题——推导来源是什么(哪个公式)、量纲是什么(颜色/像素/方差)、改动谁会被波及。常量没有注释里写不出的来历,就不该出现在渲染器里。
参考
评审总览柜:六个 bug 的根因速写
六个 bug 全部来自”规划-对抗评审”流程,每一类对应一种典型的数值/链路错误模式。
32 位排序键全分辨率溢出(EP10)
排序键把 tile 编号和深度打包进 32 bit。tile 段位数取自适应值 tileBits=32−clz(ntiles−1);若按低分辨率场景预留键宽,全分辨率下 tile 数超界,低位深度比特溢出进高位 tile 比特——键值不再是”先 tile 后深度”的字典序,排序整体错乱。教训:打包键的位宽必须由运行时数据推导,不能按测试场景的尺度硬编码。
反向缺 n_contrib(EP05)
前向 alpha 混合带早退:T<10−4 时停止,并用 n_contrib 记录实际参与的高斯数。反向按逆序重放 T 链,Ti=T/(1−αi) 逐步恢复。漏传 n_contrib,反向就会多回放量到被早退截掉的高斯,T 链整体错位一个透射因子 (1−α)——梯度每个都”差不多对”,但全部系统性偏移。前向的任何数据依赖的分支/截断,反向必须拿到同款书签。
opacity 梯度用错 α(EP05)
opacity 以 logit 形式存储,α=σ(o),对 logit 求导必须带 sigmoid 导数因子 α(1−α)。代码混淆了激活前后的量,因子写错,实测全体 opacity 梯度偏离正确值 3.5 倍。logit 参数化 + sigmoid 的组合里,“梯度对哪个变量”是最常见的出错点。
μ3D 缺二阶 VJP(EP05)
屏幕坐标 μ2D 由 μ3D 经投影雅可比 J 得到,而 J 本身含透视除法、也依赖 μ3D。VJP(向量-雅可比积,vector-Jacobian product)只写一阶项 W⊤J⊤⋅dL/dμ2D 会丢掉 J 对 μ3D 的二阶贡献(经 K′=HJVrk 路径回流)。症状不暴烈——收敛永远差一口气——这类”缺一项”的反向 bug 只能靠 gradcheck 全参数比对抓出来。
extent 三处不同源(EP07)
场景尺度 E=1.1×max∥c−质心∥≈29 是学习率、致密化阈值、裁剪阈值的共同基准。三处代码各自计算/传入了不同来源的 extent,阈值随数据来源悄悄漂移。单源化(single source of truth)不是代码洁癖——派生参数必须只有一个计算点。
致密化 NaN:0/0(EP06–07)
Adam 的偏差修正含 1−βt 分母,t 是该参数已走过的步数。致密化新生的子代高斯记 born=iter+1,首次更新时 t=iter−born+1=0,于是 1−β0=0,0/0=NaN——实测 210 万参数被污染。修复三件套:
// adam.comp:23 —— t 下限保护
int t = max(iter - bornIter + 1, 1);
加上 born=iter(致密化先于本迭代 Adam 步)与 per-slot t(每个参数槽独立计步)。动态增删参数的训练器里,优化器状态的生命周期管理和参数本身同等重要。
参考