致密化三操作:一个判据管三个开关
判据:位置梯度的范数
致密化(densification)的全部触发条件只有一个量:高斯中心 μ \mu μ 的梯度范数
∥ ∂ L ∂ μ ∥ > τ pos = 2 × 10 − 4 \Big\|\frac{\partial L}{\partial \mu}\Big\| > \tau_{\text{pos}} = 2\times10^{-4} ∂ μ ∂ L > τ pos = 2 × 1 0 − 4
工程上不是看单步梯度,而是在一个致密化窗口内对各步梯度范数累加取平均 ,再和阈值比较。语义解读:损失想把这个高斯往某处推,推不动(一个高斯表达力不够),梯度就持续偏大——“这里很想要、但没学到”。
超阈之后,按大小分流
超阈只说明”需要增援”,往哪种方式增援看高斯的尺度(与原论文/INRIA 实现一致,以 0.01 E 0.01\,E 0.01 E 为大小分界,E E E 是场景尺度):
小高斯 → clone(克隆) :欠重建(under-reconstruction)——区域没盖住。原样复制一份,两份各自学习,等效于把该处的表达容量翻倍。
大高斯 → split(劈开) :过重建(over-reconstruction)——一个高斯盖过头了。一分为二,三条轴长各除以 φ = 1.6 \varphi = 1.6 φ = 1.6 ,例如 0.32 → 0.20 + 0.20 0.32 \to 0.20 + 0.20 0.32 → 0.20 + 0.20 。
无论大小,透明度 o < 0.005 o < 0.005 o < 0.005 的高斯直接 prune(剪枝) ;高斯总量封顶 300 万,防止显存失控。
符号约定
o o o 是透明度数值本身,即 o = σ ( logit ) o = \sigma(\text{logit}) o = σ ( logit ) ,σ \sigma σ 是 sigmoid。存储和优化都在 logit 空间,判据 o < 0.005 o<0.005 o < 0.005 比较的是 sigmoid 之后的值,别拿 logit 直接比。
τ pos = 0.0002 \tau_{\text{pos}}=0.0002 τ pos = 0.0002 、φ = 1.6 \varphi=1.6 φ = 1.6 、0.005 0.005 0.005 、300 万都来自 INRIA 原版实现的设定,是经验值,不是推导出来的常数。
容易看错的点
三个操作共用同一个判据 ,clone/split 的分流条件是尺度不是梯度大小。
prune 不看梯度 。一个梯度为零、透明度也见底的高斯照样被剪——判据是 o o o ,与 ∥ ∂ L / ∂ μ ∥ \|\partial L/\partial\mu\| ∥ ∂ L / ∂ μ ∥ 无关。
“超阈 = 欠拟合”只对高梯度成立 。梯度小且效果不好(过平滑)的情况不由致密化处理,靠正常优化收敛。
参考
clone / split 的实现细节
clone:复制,不是扰动出新样本
clone 把原高斯的全部参数 原样拷贝:位置 μ \mu μ 、旋转四元数 q q q 、对数尺度 s s s 、透明度 logit、SH 颜色系数。复制完父子瞬间完全重合,靠后续梯度自然分开——两份参数接收相同区域传来的梯度,等价于该处学习容量翻倍。
split:÷1.6 之后,位置是采样出来的
split 不是”左右各挪一点”的对称分裂。按原论文 §5.2 的做法:
子代三条轴长除以 φ = 1.6 \varphi = 1.6 φ = 1.6 :s ′ = s / 1.6 s' = s / 1.6 s ′ = s /1.6 ,即每个子高斯线性尺寸是父代的 62.5 % 62.5\% 62.5% ;
子代位置从父代高斯自身的概率分布 里采样:μ ′ ∼ N ( μ , Σ ′ ) \mu' \sim \mathcal{N}(\mu,\ \Sigma') μ ′ ∼ N ( μ , Σ ′ ) ,其中 Σ ′ = R d i a g ( s ′ 2 ) R ⊤ \Sigma' = R\, \mathrm{diag}(s'^2)\, R^{\top} Σ ′ = R diag ( s ′2 ) R ⊤ 。
所以两个子代落在父代椭球内部的概率最大,但具体落点是随机的——这正是动画里”向两侧探索”的来源,方向并不固定。
为什么是 1.6,不是 √2 或 2
φ = 1.6 \varphi=1.6 φ = 1.6 是经验值。可以做个体检:两个子代的总三维体积约为
2 × ( 1 1.6 ) 3 ≈ 0.49 2\times\left(\frac{1}{1.6}\right)^3 \approx 0.49 2 × ( 1.6 1 ) 3 ≈ 0.49
即劈开后总覆盖体积约为原来的一半。split 的目的本来就不是保体积,而是把一块盖过头的大高斯换成两颗更细的 ,牺牲覆盖换分辨率。φ \varphi φ 太大子代太碎、太小子代又盖回去,1.6 是 INRIA 调出的折中。
计数账
一轮致密化里三种事件的数量变化:clone 每个 + 1 +1 + 1 ,split 每个 + 1 +1 + 1 (一变二),prune 每个 − 1 -1 − 1 。曲线里 alive 列的净增量就是三者的合账,所以 alive 上涨不一定代表没有剪枝——只是新增多于删除。
容易看错的点
split 子代位置是随机采样,不是确定性偏移 。两次运行同样的致密化,子代落点不同。
clone 之后父子梯度相同只是第一步 。一旦参数因更新次序/数值噪声出现差异,两者就走上了各自的轨迹。
透明度 o o o 在 split 中被继承 ,不会因为一分为二而减半——每个子代仍然是”不透明度为 o o o 的完整个体”,覆盖变小了而已。
参考
extent 单源:一个 E 定全局
E 的定义
场景尺度 E E E (extent)从高斯位置的几何分布直接算出:
E = 1.1 × max i ∥ μ i − μ ˉ ∥ E = 1.1 \times \max_i \|\mu_i - \bar{\mu}\| E = 1.1 × max i ∥ μ i − μ ˉ ∥
其中 μ i \mu_i μ i 是第 i i i 个高斯的中心,μ ˉ \bar{\mu} μ ˉ 是所有中心的质心。系数 1.1 1.1 1.1 是 10 % 10\% 10% 的外扩余量(INRIA 原版对相机位置做同样的 1.1 × 1.1\times 1.1 × 处理)。本项目实测 E ≈ 29 E \approx 29 E ≈ 29 米。
从 E 派生出整张超参表
与”位移/尺度”量纲有关的超参全部乘以 E E E ,而不是写死绝对值:
pos lr : 1.6 × 10 − 4 E → 指数衰减 1.6 × 10 − 6 E \text{pos lr}:\quad 1.6\times10^{-4}\,E \;\xrightarrow{\text{指数衰减}}\; 1.6\times10^{-6}\,E pos lr : 1.6 × 1 0 − 4 E 指数衰减 1.6 × 1 0 − 6 E
代入 E = 29 E=29 E = 29 :
1.6 × 10 − 4 × 29 = 4.64 × 10 − 3 1.6\times10^{-4}\times 29 = 4.64\times10^{-3} 1.6 × 1 0 − 4 × 29 = 4.64 × 1 0 − 3
这个 4.64 × 10 − 3 4.64\times10^{-3} 4.64 × 1 0 − 3 与训练计划书从另一路径独立推导的初始位置学习率逐位一致 ——两条独立推导链在同一个数字上会合,是最便宜的正确性交叉验证。
为什么必须单源
位置学习率的物理含义是”每步最多走场景尺度的万分之一量级”。场景多大、步子多大:换成小房间场景,E E E 自动缩小,步长跟着缩小,无需重调。
反例:工程评审中抓到过”extent 三处不同源”——三处代码各自算自己的 E E E (数据源、外扩系数、取 max 还是取范数不一致),数值悄悄漂移,派生阈值之间失去共同基准。单源不是代码洁癖,是防止同一物理量在不同模块里长出不同数值 。
容易看错的点
E E E 不是包围盒对角线 。定义是”最远中心到质心的距离”乘 1.1 1.1 1.1 ,近似场景半径;拿对角线当 E E E 会大出一倍,所有派生步长跟着翻倍。
max 是对全体高斯取 ,不是逐高斯一个 E E E 。E E E 是标量,全场共享。
交叉验证一致 ≠ 公式正确 。它只证明两条推导链没有算术/拷贝错误;1.6 × 10 − 4 1.6\times10^{-4} 1.6 × 1 0 − 4 这个系数本身仍是 INRIA 的经验设定。
参考
三万步课程表的三条调度
位置学习率:指数衰减 + 前置 delay
位置 lr 在 T = 30000 T=30000 T = 30000 步内从 η 0 = 1.6 × 10 − 4 E \eta_0 = 1.6\times10^{-4}\,E η 0 = 1.6 × 1 0 − 4 E 指数衰减到 η T = 1.6 × 10 − 6 E \eta_T = 1.6\times10^{-6}\,E η T = 1.6 × 1 0 − 6 E :
η ( t ) = η 0 ( η T η 0 ) t / T = η 0 × 100 − t / T \eta(t) = \eta_0 \left(\frac{\eta_T}{\eta_0}\right)^{t/T} = \eta_0 \times 100^{-t/T} η ( t ) = η 0 ( η 0 η T ) t / T = η 0 × 10 0 − t / T
前期还要乘一个 delay 因子(INRIA 实现里初值约 0.01 0.01 0.01 ,随步数指数回升到 1 1 1 )——刚出生的高斯位置不可靠,先压住步子,等几轮优化后再放开。
不透明度:每 3000 步重置回 logit(0.01)
优化的是 logit(对数几率)ℓ \ell ℓ ,透明度 o = σ ( ℓ ) o = \sigma(\ell) o = σ ( ℓ ) 。每 3000 步把所有高斯的 logit 强制打回
ℓ ← log 0.01 1 − 0.01 ≈ − 4.595 \ell \leftarrow \log\frac{0.01}{1-0.01} \approx -4.595 ℓ ← log 1 − 0.01 0.01 ≈ − 4.595
即 o ≈ 0.01 o \approx 0.01 o ≈ 0.01 的”几乎透明”状态。为什么要这么做:训练久了,大量高斯的透明度会饱和到接近 1(“死硬”),梯度在 sigmoid 平坦区几乎传不动;周期重置等于定期给一次”重新学会透明”的机会,让本该退场的高斯在下个周期里被 prune 判据 o < 0.005 o<0.005 o < 0.005 自然筛掉。注意重置后 0.01 > 0.005 0.01 > 0.005 0.01 > 0.005 ,刚好在剪枝线之上——重置不是删除,是”留观”。
球谐:每 1000 步升一阶,3 阶封顶
SH(球谐函数,spherical harmonics)系数的学习是分阶段的:初始只学 0 阶(DC 分量,即视角无关的平均色),每 1000 步解锁一阶,依次到 1、2、3 阶封顶,SH 学习率 2.5 × 10 − 3 2.5\times10^{-3} 2.5 × 1 0 − 3 。
逻辑是课程学习(curriculum learning):低频基函数先稳住整体颜色,高频基函数(视角相关的光泽、高光)后进场。如果一开始就放开全部 45 个高阶系数,视角相关色会在几何还没成形时乱拟合,反而拖垮收敛。
容易看错的点
“重置回 0.01”是透明度值 0.01,落到存储上是 logit ≈ − 4.595 \text{logit}\approx-4.595 logit ≈ − 4.595 。直接把 − 4.595 -4.595 − 4.595 当透明度写进去就是另一个 bug。
SH 升阶升的是”启用阶数” ,系数张量本身全程都在,只是未解锁阶数的梯度被屏蔽。
三条调度相互独立 :opacity 重置(3000 步周期)与 SH 升阶(1000 步阶梯)周期不同,别把它们合并成一个事件。
参考
210 万 NaN 的机制:Adam 的 t=0 奇点
Adam 的偏差修正项
Adam 维护一阶、二阶动量 m t m_t m t 、v t v_t v t (初值都是 0 0 0 ),并用步数 t t t 做偏差修正(bias correction):
m ^ t = m t 1 − β 1 t , v ^ t = v t 1 − β 2 t \hat{m}_t = \frac{m_t}{1-\beta_1^{\,t}}, \qquad \hat{v}_t = \frac{v_t}{1-\beta_2^{\,t}} m ^ t = 1 − β 1 t m t , v ^ t = 1 − β 2 t v t
参数更新为 θ ← θ − η m ^ t / ( v ^ t + ϵ ) \theta \leftarrow \theta - \eta\,\hat{m}_t / (\sqrt{\hat{v}_t}+\epsilon) θ ← θ − η m ^ t / ( v ^ t + ϵ ) ,本项目 β 1 = 0.9 \beta_1=0.9 β 1 = 0.9 、β 2 = 0.999 \beta_2=0.999 β 2 = 0.999 、ϵ = 10 − 15 \epsilon=10^{-15} ϵ = 1 0 − 15 。
修正项在 t t t 小时放大动量、t t t 大时趋近 1 1 1 ,这是 Adam 对抗”动量从零开始偏小”的标准做法。
t=0 时分母精确为零
关键事实:任何 β ≠ 0 \beta \neq 0 β = 0 都有 β 0 = 1 \beta^0 = 1 β 0 = 1 ,所以
1 − β 2 0 = 1 − 1 = 0 1-\beta_2^{\,0} = 1-1 = 0 1 − β 2 0 = 1 − 1 = 0
新出生的高斯槽位 m = 0 m=0 m = 0 、v = 0 v=0 v = 0 ,于是
v ^ 0 = 0 0 = N a N \hat{v}_0 = \frac{0}{0} = \mathrm{NaN} v ^ 0 = 0 0 = NaN
这不是”除数很小”的下溢,是精确的 0 / 0 0/0 0/0 ,IEEE 754 规定其结果为 NaN。ϵ = 10 − 15 \epsilon=10^{-15} ϵ = 1 0 − 15 救不了它——ϵ \epsilon ϵ 加在 v ^ \sqrt{\hat{v}} v ^ 之后,而 v ^ \hat{v} v ^ 本身已经是 NaN 了。
出生登记晚一步,首步恰好落在 t=0
致密化产生新高斯时登记的出生时刻是 born = iter + 1 \text{born} = \text{iter}+1 born = iter + 1 (下一次迭代才算它”存在”)。而 Adam 按 t = iter − born + 1 t = \text{iter} - \text{born} + 1 t = iter − born + 1 计算该槽位的步数:致密化之后的第一次更新,t = iter − ( iter + 1 ) + 1 = 0 t = \text{iter} - (\text{iter}+1) + 1 = 0 t = iter − ( iter + 1 ) + 1 = 0 ——首步精确踩中奇点。
为什么一次染一片
NaN 的传染性是算术级的:任何数与 NaN 运算都得 NaN。新槽位的 v ^ = N a N \hat v = \mathrm{NaN} v ^ = NaN → 更新量 NaN → 参数变 NaN → 前向渲染该高斯的像素全部 NaN → 损失 NaN → 反向梯度把 NaN 带回所有相关参数。实测 210 210 210 万个 NaN 同时在场:不是”崩了一次”,而是每个新生参数槽位各自崩了一次 ,再经损失函数互相染色,几个迭代内扩散到整屏。
容易看错的点
NaN 不来自梯度爆炸 。m = v = 0 m=v=0 m = v = 0 时梯度完全可以是正常的,炸的是修正项的分母。
加 eps 不能修这类 bug 。ϵ \epsilon ϵ 防的是 v ^ \sqrt{\hat v} v ^ 过小,防不了 v ^ \hat v v ^ 本身是 NaN。
“第一个高斯没事”不代表安全 。初始高斯从 t = 1 t=1 t = 1 开始走,奇点只在动态新增的槽位上触发——这正是它在致密化开闸前一切正常的原因。
参考
gradcheck 的盲区:静态检查抓不到动态 bug
gradcheck 实际在做什么
数值梯度检查(gradient checking)用中心差分逼近每个参数的偏导,和解析梯度逐参数对拍:
∂ L ∂ θ i ≈ L ( θ i + h ) − L ( θ i − h ) 2 h , rel-err < 10 − 3 \frac{\partial L}{\partial \theta_i} \approx \frac{L(\theta_i + h) - L(\theta_i - h)}{2h}, \qquad \text{rel-err} < 10^{-3} ∂ θ i ∂ L ≈ 2 h L ( θ i + h ) − L ( θ i − h ) , rel-err < 1 0 − 3
差分步长 h = 10 − 6 h = 10^{-6} h = 1 0 − 6 (位置参数 2 × 10 − 6 2\times10^{-6} 2 × 1 0 − 6 )。因为差分信号的量级就在 h h h 附近,差分基准必须是全双精度 CPU 复刻 的前向/反向——GPU f32 的舍入误差约 10 − 7 10^{-7} 1 0 − 7 ,会把 10 − 6 10^{-6} 1 0 − 6 步长的差分信号淹没。门禁跑 6 个用例(isolated / overlap / clamp / earlyexit / ssim / boundary),覆盖边界分支,全参数逐一对拍。
这道门禁能抓住的错:链式法则漏项、符号写反、分支漏处理——都是静态公式 的错,跑一遍前向+反向就现形。
这只 bug 在测试空间之外
NaN bug 的错误不在任何一条梯度公式里,而在参数生命周期 里:致密化动态新增参数,新增槽位的优化器状态(m m m 、v v v 、t t t )登记方式出错。而 6 个 gradcheck 用例的参数集全部是静态的——构造好一批高斯,算前向、算反向、对拍,结束。没有任何一个用例会”在测试中生出新参数” ,优化器的状态迁移路径从未被执行过。
类比:单元测试验证了”刹车距离公式正确”,但 bug 是”新车下线时没装刹车”——公式对拍永远路过不到这条路径。
每层验证都有盲区
数值梯度对拍:验证公式,不验证生命周期;
短训练 smoke test:致密化未到触发步数(通常几百上千步才开闸),不暴露;
只有跑到致密化开闸的长训练 才会踩中。
所以工程上的推论不是”gradcheck 没用”,而是:验证手段的覆盖维度要和 bug 的存在维度对齐——动态参数的 bug,需要动态参数进入测试用例。
容易看错的点
“门禁全绿”只证明被测路径无误 ,不代表没被测的路径安全。读验证报告先看用例覆盖了哪些行为维度。
rel-err 阈值和差分步长是耦合的 :h h h 太小被舍入噪声淹没,太大截断误差 O ( h 2 ) O(h^2) O ( h 2 ) 超标。10 − 6 10^{-6} 1 0 − 6 配双精度基准是这对矛盾的折中点。
修法三件套:born=iter、per-slot t、guard
① born 改成 iter:让首步落在 t=1
Bug 的根源是出生登记晚一步:born = iter + 1 \text{born}=\text{iter}+1 born = iter + 1 使首步 t = iter − born + 1 = 0 t=\text{iter}-\text{born}+1=0 t = iter − born + 1 = 0 。修法是让致密化排在本迭代的 Adam 步之前 执行,新高斯当步即登记 born = iter \text{born}=\text{iter} born = iter ,首步
t = iter − iter + 1 = 1 t = \text{iter} - \text{iter} + 1 = 1 t = iter − iter + 1 = 1
奇点根本不触发。t = 1 t=1 t = 1 时修正项 1 − β 2 1 = 10 − 3 1-\beta_2^1 = 10^{-3} 1 − β 2 1 = 1 0 − 3 ,虽小但非零,正是 Adam 设计的正常工作点。
② per-slot t:每个高斯自己的时间戳
全局共用一个步数 t t t 的前提是”所有参数同时出生”。致密化打破了这个前提,所以 t t t 必须改成每个高斯槽位各存一个 :t i = iter − born i + 1 t_i = \text{iter} - \text{born}_i + 1 t i = iter − born i + 1 。老高斯带着几千步的时间戳,子代从 t = 1 t=1 t = 1 开始数,互不干扰。
随之而来的是子代动量 m m m 、v v v 从零预热——这是特性不是缺陷 :动量是对该参数历史梯度的指数平均,子代的梯度历史就是零,继承父辈的动量等于让新兵背着老兵的记忆上战场,方向可能是错的。clone 继承全部几何/外观参数,但 m m m 、v v v 、born \text{born} born 都重置。
③ guard:给未来上保险丝
Adam 着色器里加一道钳位(本项目 adam.comp:23):
t = max (iter - born + 1 , 1 ); // 钳位:分母 1-β^t 永不为零
即使将来谁又写错了出生时刻,t t t 最小也是 1 1 1 ,0 / 0 0/0 0/0 在算术上不可能再出现。前两件是”把雷排掉”,这一件是”让同类雷永远炸不响”——防御纵深(defense in depth):修当前 bug 靠①②,防未来回归靠③。
三件套为什么一件都不能省
只有①没有②:新生高斯共享全局 t t t ,动量语义混乱,且若致密化调度变动仍会踩 t = 0 t=0 t = 0 ;
只有②没有①:per-slot 的 t t t 照样从 0 0 0 起算,奇点照炸;
只有③没有①②:不炸,但子代动量/时间戳语义全是错的,属于”把仪表盘的警报灯拆了”。
容易看错的点
t t t 的语义是”该参数经历的优化步数”,不是全局迭代数 。两者只在参数全程静态时相等。
guard 不是替代正确登记 。max ( ⋅ , 1 ) \max(\cdot,1) max ( ⋅ , 1 ) 只能兜底分母,救不了错误的动量统计。
参考
通用规则:动态参数必须带优化器状态
修后怎么看账
单看 alive(存活高斯数)上涨不能证明修复成功——NaN 修复前数量也在涨,涨的是坏参数。判据是两条线同步 :真实训练曲线里 alive 从 100000 100000 100000 涨到 130306 130306 130306 的同时,PSNR 同步上行。含义是每一批致密化新增的高斯都带来了净增益:新容量被用于降低损失,而不是稀释或破坏已有表达。
修复前的对照形态:致密化一开闸,PSNR 曲线立刻断头(NaN 染开后损失失去意义)。所以”alive 涨 + PSNR 涨”是必要条件对,缺一条都不算修好了。
规则的一般形式
Adam 的每个参数槽位绑着三件状态:一阶动量 m m m 、二阶动量 v v v 、时间戳 t t t 。参数集合一旦动态变化(新增、删除),优化器状态必须同步迁移:
新增 :m m m 、v v v 置零,t t t (或 born \text{born} born )按本槽位独立登记;
删除 :对应槽位状态一并移除,张量形状保持对齐。
PyTorch 系的 INRIA 原版用 cat_tensors_to_optimizer 做这件事:把新参数拼进优化器的 exp_avg / exp_avg_sq 状态张量,新增段填零。本项目在 compute shader 管线里等价实现为 per-slot 状态数组的扩容与重置。
一句话:m m m 、v v v 、t t t 是参数生命周期的一部分,不是优化器的私有财产 ——参数出生的瞬间,这三样的出生登记一个都不能少。
容易看错的点
只迁移 m m m 、v v v 不够 。t t t 漏登记,m / v m/v m / v 置零反而是对的也照炸(t = 0 t=0 t = 0 的 0 / 0 0/0 0/0 )——本案正是这个组合。
删除路径同样危险 。prune 删参数不删状态,槽位错位后 m / v m/v m / v 张冠李戴,是另一类慢性 bug。
“置零”只对新增成立 。split/clone 的子代重置 m / v m/v m / v 是语义正确;但若实现做的是参数重排(如 compaction),已有槽位的状态必须跟着搬,不能清零。
参考