简并奇异值
主线位置:TRG 主线的「分段求导得内能」,反向穿过每一步的两次 SVD;本页看奇异值成对相等时,各家的 SVD 求导规则给出什么。
版本:JAX 0.12.0.1(CPU,x64)、PyTorch 2.15.0(CPU,
torch.set_num_threads(1))、tenferro-rs 0.8.0(CPU,native,单线程)
flowchart TB s["s_i 等于 s_j"] --> d["平方差为 0"] d --> jx["JAX torch 1/d"] d --> tf["tenferro 展宽"] jx --> inf["inf"] inf --> nanv["乘 0 得 NaN"] tf --> zero["F 为 0"] zero --> ok["梯度有限"]
SVD 的反向规则里有一个奇异值两两耦合的系数矩阵 F。教科书写法是 F[i,j] = 1/(s_j² − s_i²),两个奇异值相等时除以 0。JAX 内置规则用的是这个写法;PyTorch 的反向是 C++ 写的,这里没有读源码,但下面的实验里它的表现和 1/d 一致(逐位相等时 NaN,差一个 ulp 时有限);tenferro 默认把它展宽成 (s_j² − s_i²)/((s_j² − s_i²)² + ε²),ε = 1e-12,相等时恰好是 0。展宽的公式和它在 tenferro 里的实测见 线性代数 的「SVD 的反向规则」,这里只做对照。
实验代码:trg_onset.py、degenerate.py、fma_probe.py、fma_trg.py(JAX、PyTorch),tenferro 一侧是 bin degenerate 和 trg_staged(release,CpuEngine::serial())。M2 Max、macOS 27、rustc 1.99.0。
现象:TRG 第 5 步起梯度是 NaN
TRG 主线,χ = 16,截断数按秩取(k = 2, 4, 4, 16, …),β = 0.4,对 lnZ/N 求导,步数从 1 加到 8。JAX 一侧三种 SVD:内置 jnp.linalg.svd;custom_jvp 写的和 tenferro 相同的展宽规则(下文);PyTorch 的 torch.linalg.svd 加 backward。
jax 0.12.0.1 torch 2.15.0 chi=16 rank schedule beta=0.4
steps=1 ks=[2] grad builtin=1.843337108813 lorentz=1.843337108813 torch=1.843337108813
steps=2 ks=[2, 4] grad builtin=1.602167517441 lorentz=1.602167517441 torch=1.602167517441
steps=3 ks=[2, 4, 4] grad builtin=1.499230514558 lorentz=1.499230514558 torch=1.499230514558
steps=4 ks=[2, 4, 4, 16] grad builtin=1.379116482259 lorentz=1.379116482259 torch=1.379116482259
steps=5 ks=[2, 4, 4, 16, 16] grad builtin=nan lorentz=1.295053391807 torch=nan
steps=6 ks=[2, 4, 4, 16, 16, 16] grad builtin=nan lorentz=1.222308598298 torch=nan
steps=7 ks=[2, 4, 4, 16, 16, 16, 16] grad builtin=nan lorentz=1.167575613423 torch=nan
steps=8 ks=[2, 4, 4, 16, 16, 16, 16, 16] grad builtin=nan lorentz=1.131237714886 torch=nan
tenferro 不需要任何改动,用的就是默认的 a.svd()(分段写法见 分段):
tenferro 0.8.0 staged chi=16 steps=5 schedule=rank width=1 ks=[2, 4, 4, 16, 16]
u=-1.295053391806669 exact=-1.106079203745793 err=-1.890e-1 | grad=1.295053391806669 fd=1.295053391542345 |grad-fd|=2.643e-10
tenferro 0.8.0 staged chi=16 steps=8 schedule=rank width=1 ks=[2, 4, 4, 16, 16, 16, 16, 16]
u=-1.131237714886462 exact=-1.106079203745793 err=-2.516e-2 | grad=1.131237714886462 fd=1.131237716411881 |grad-fd|=1.525e-9
(这里 u 和 Onsager 精确解差得多,是因为只做了 5 步、8 步,格子还小;20 步的对照见 动手:Ising 模型的 TRG。)
- 前 4 步三家逐位相同(打印的 12 位),JAX 内置规则和展宽规则在这里没有区别。
- 第 5 步起 JAX 内置和 PyTorch 都是
nan,之后每一步都是。 - tenferro 的梯度和 JAX 展宽规则在打印的 12 位上相同,和中心差分差 1e-10 到 1e-9。20 步全程也是这样,见 动手。
为什么是第 5 步
打印每一步被分解的矩阵 ma 的奇异值,看有没有恰好相等的两个:
step 0: n=4 min s=7.624e-18 exactly equal pairs=0 min|s_j^2-s_i^2| off-diag=4.075e-34 1/that=2.454e+33
step 1: n=4 min s=6.037e-02 exactly equal pairs=0 min|s_j^2-s_i^2| off-diag=6.772e-03 1/that=1.477e+02
step 2: n=16 min s=1.632e-19 exactly equal pairs=0 min|s_j^2-s_i^2| off-diag=6.665e-38 1/that=1.500e+37
step 3: n=16 min s=4.563e-05 exactly equal pairs=0 min|s_j^2-s_i^2| off-diag=3.474e-09 1/that=2.878e+08
step 4: n=256 min s=4.261e-17 exactly equal pairs=25878 min|s_j^2-s_i^2| off-diag=0.000e+00 1/that=inf
step 5: n=256 min s=1.970e-17 exactly equal pairs=1 min|s_j^2-s_i^2| off-diag=0.000e+00 1/that=inf
第 5 步(下标 4)的矩阵是 256×256,只留前 16 个奇异值,其余 240 个在舍入意义上是 0。LAPACK 把其中 228 个算成了同一个数 7.124e-17,两两配对就是 25878 对恰好相等的奇异值,s_j² − s_i² 恰好是 0。前几步也有 1e-34、1e-38 这样小的差,1/d 到 1e33、1e37,大,但是有限,没有出 NaN。
JAX 0.12.0.1 的 _svd_jvp_rule(jax/_src/lax/linalg.py)这样算 F:
s_diffs = (s_dim + _T(s_dim)) * (s_dim - _T(s_dim))
s_diffs_zeros = lax._eye(s.dtype, (s.shape[-1], s.shape[-1])) # jnp.ones((), dtype=A.dtype) * (s_diffs == 0.) # is 1. where s_diffs is 0. and is 0. everywhere else
s_diffs_zeros = lax.expand_dims(s_diffs_zeros, range(s_diffs.ndim - 2))
F = 1 / (s_diffs + s_diffs_zeros) - s_diffs_zeros
注释里写的是「s_diffs 为 0 的地方是 1」,代码里换成了单位矩阵,只挡住了对角线。非对角线上两个奇异值相等时,F 是 inf。这些奇异向量都被截断丢掉了,它们收到的余切是 0,可 inf · 0 是 NaN,经过 U @ (F ∘ …) 的矩阵乘扩散到整个梯度,再一路传回 β。PyTorch 在这条链上也是 NaN。tenferro 的 F 在 d = 0 时是 0/(0 + ε²) = 0,这些耦合直接消失。
最小复现:4×4,奇异值 (3, 2, 2, 1)
两个矩阵:对角的 diag(3, 2, 2, 1),和用两个 Householder 反射转过的同一组奇异值。三个损失都和简并块里怎么选基无关,在这一点可导:Σ wᵢsᵢ(重复的两个权重相同),最大奇异向量的投影 P₁ = u₁u₁ᵀ 的加权和(3 和 2 之间有间隙),前三个奇异向量张成的投影 P₃ 的加权和(整个简并块在里面)。
== diag(3,2,2,1): LAPACK s = [3.0, 2.0, 2.0, 1.0], s1 - s2 = 0.000e+00
loss sum w*s
jax builtin : nan= 0 max|g|=1.000e+00 max|g-fd|=1.398e-10
jax builtin jit : nan= 0 max|g|=1.000e+00 max|g-fd|=1.398e-10
torch : nan= 0 max|g|=1.000e+00 max|g-fd|=1.398e-10
jax lorentz jit : nan= 0 max|g|=1.000e+00 max|g-fd|=1.398e-10
loss P1
jax builtin : nan=16 max|g|=nan max|g-fd|=nan
jax builtin jit : nan=16 max|g|=nan max|g-fd|=nan
torch : nan=16 max|g|=nan max|g-fd|=nan
jax lorentz jit : nan= 0 max|g|=6.419e-01 max|g-fd|=7.816e-11
loss P3
jax builtin : nan=16 max|g|=nan max|g-fd|=nan
jax builtin jit : nan=16 max|g|=nan max|g-fd|=nan
torch : nan=16 max|g|=nan max|g-fd|=nan
jax lorentz jit : nan= 0 max|g|=5.493e-01 max|g-fd|=6.610e-11
== rotated (3,2,2,1): LAPACK s = [2.9999999999999996, 2.000000000000001, 2.000000000000001, 1.0000000000000002], s1 - s2 = 0.000e+00
loss sum w*s
jax builtin : nan= 0 max|g|=8.568e-01 max|g-fd|=1.891e-09
jax builtin jit : nan= 0 max|g|=8.568e-01 max|g-fd|=1.891e-09
torch : nan= 0 max|g|=8.568e-01 max|g-fd|=1.891e-09
jax lorentz jit : nan= 0 max|g|=8.568e-01 max|g-fd|=1.891e-09
loss P1
jax builtin : nan=16 max|g|=nan max|g-fd|=nan
jax builtin jit : nan=16 max|g|=nan max|g-fd|=nan
torch : nan= 0 max|g|=2.664e-01 max|g-fd|=3.615e-10
jax lorentz jit : nan= 0 max|g|=2.664e-01 max|g-fd|=3.615e-10
loss P3
jax builtin : nan= 9 max|g|=nan max|g-fd|=nan
jax builtin jit : nan= 9 max|g|=nan max|g-fd|=nan
torch : nan= 0 max|g|=7.210e-01 max|g-fd|=1.498e-09
jax lorentz jit : nan= 0 max|g|=7.210e-01 max|g-fd|=1.498e-09
# tenferro,默认 a.svd()
== diag(3,2,2,1): faer s = [3.0, 2.0, 2.0, 1.0]
loss sum w*s tenferro default: nan= 0 max|g|=1.000e0 max|g-fd|=3.273e-10
loss P1 tenferro default: nan= 0 max|g|=6.419e-1 max|g-fd|=7.816e-11
loss P3 tenferro default: nan= 0 max|g|=5.493e-1 max|g-fd|=6.610e-11
== rotated (3,2,2,1): faer s = [3.0, 2.0, 1.9999999999999998, 1.0]
loss sum w*s tenferro default: nan= 0 max|g|=8.568e-1 max|g-fd|=9.213e-10
loss P1 tenferro default: nan= 0 max|g|=2.664e-1 max|g-fd|=2.345e-10
loss P3 tenferro default: nan= 0 max|g|=7.210e-1 max|g-fd|=1.213e-7
- 只用奇异值(
Σ wᵢsᵢ)四家都对:ds = diag(Uᵀ dA V),这个损失的梯度不经过F。 - 用到奇异向量,只要两个奇异值逐位相等,JAX 内置规则就是 NaN,
jit不jit一样;PyTorch 在对角矩阵上也是 NaN。tenferro 和 JAX 的展宽规则都和差分一致。 - tenferro 和 JAX 展宽规则的梯度逐元素比:对角矩阵上差 0 到 4e-18;转过的矩阵上
Σ wᵢsᵢ、P₁差 1e-15 量级,P₃差 1.2e-7。后者出在 faer 算出的两个 2 差一个 ulp(2.2e-16):s_j² − s_i²约 9e-16,远小于 ε,F ≈ d/ε² ≈ 9e8,这个大系数很可能在对称的两项之间没有抵消干净;LAPACK 那边两个值逐位相等,F恰好是 0。这个量级的分析见 线性代数 的动脑筋答案。
问: 转过的矩阵上 PyTorch 没有 NaN,梯度还是对的,JAX 内置却是 NaN。PyTorch 是不是用了更稳的规则?
答: 不是规则的区别,是奇异值本身的区别。同一个矩阵,三家的分解给出:
torch s ['3.0', '2.000000000000001', '2.0', '0.9999999999999999'] diff 8.881784197001252e-16
jax s ['3.0', '2.000000000000001', '2.000000000000001', '1.0000000000000002'] diff 0.0
numpy s ['3.0', '2.000000000000001', '2.000000000000001', '1.0000000000000002']
PyTorch 的两个 2 差 8.9e-16,F 是一个约 2.8e14 的有限数,不是 inf,也就没有 inf · 0;结果和差分一致(误差 1e-9 量级)。JAX 拿到两个逐位相等的数,F 是 inf。所以用 1/d 规则时,出不出 NaN 取决于分解在最后一位上怎么舍入;对角输入这种奇异值精确相等的情形,JAX 和 PyTorch 都会撞上(上面对角矩阵的输出)。
JAX 里补上:custom_jvp
试验阶段的 JAX 参考实现用 jax.custom_jvp 包了一层 SVD,把 F 换成 tenferro 的展宽形式。规则只写 JVP,grad 由 JAX 转置得到(TRG 里的矩阵都是实方阵):
EPS = 1e-12 # tenferro 的 DEFAULT_DECOMPOSITION_DERIVATIVE_EPS
@jax.custom_jvp
def lorentz_svd(a):
return jnp.linalg.svd(a, full_matrices=False)
@lorentz_svd.defjvp
def lorentz_svd_jvp(primals, tangents):
(a,), (da,) = primals, tangents
u, s, vh = lorentz_svd(a)
v = vh.T
dp = u.T @ da @ v
ds = jnp.diagonal(dp)
d = (s[None, :] - s[:, None]) * (s[None, :] + s[:, None])
f = jnp.where(jnp.eye(s.shape[0]) > 0, 0.0, d / (d * d + EPS * EPS))
dss = dp * s[None, :]
sds = s[:, None] * dp
du = u @ (f * (dss + dss.T))
dv = v @ (f * (sds + sds.T))
return (u, s, vh), (du, ds, dv.T)
tenferro 一侧什么都不用写:a.svd() 默认就是这个规则,ε 用 a.svd_with_options(SvdOptions::default().derivative_eps(e)) 改。上面这段比 tenferro 的规则少两样东西:复数时的规范修正项,和 m ≠ n 时的投影修正项,所以只适用于实方阵。
有两处写法不是随手的:
d写成(s_j − s_i)(s_j + s_i),不写成s_j**2 - s_i**2。原因在下一节。JAX 自己的内置规则也是这么写的(前面引的s_diffs)。- 对角线用
where强制为 0。F[i,i] = 0/(0 + ε²)本来就是 0,但它对d的斜率是1/ε²,反向套反向求二阶导数时会把结果带偏,tenferro 默认规则没有这一步,grad(grad)偏 6%;这是 高阶导数 的话题。一阶导数上,where还顺带挡住了下一节的问题。
jit 的 FMA 陷阱
把 d 写成平方差、对角线不屏蔽,F 的对角线应当是 0/(0 + ε²) = 0。在 jit 里,它不是 0。TRG 的梯度(β = 0.4):
steps=5 fd=1.295053391531
squares masked=False jit grad=-3.050596362613e+20 eager grad=1.295053391807e+00
product masked=False jit grad=1.295053391807e+00 eager grad=1.295053391807e+00
squares masked=True jit grad=1.295053391807e+00 eager grad=1.295053391807e+00
product masked=True jit grad=1.295053391807e+00 eager grad=1.295053391807e+00
steps=20 fd=1.106045408322
squares masked=False jit grad=1.396314156080e+120
product masked=False jit grad=1.106045407614e+00
squares masked=True jit grad=1.106045407614e+00
product masked=True jit grad=1.106045407614e+00
平方差加 jit:5 步时梯度 −3e20,20 步时 1.4e120;不 jit 就是对的。换成乘积形式、或者屏蔽对角线,任何一个都够。
看对角线上到底是什么。在同一个 jit 里做 SVD 和平方差,把 d 的对角线和 s 一起返回:
def sq(a):
s = jnp.linalg.svd(a, compute_uv=False)
return s[None, :] ** 2 - s[:, None] ** 2, s
d_j, s_j = jax.jit(sq)(a)
print([math.fma(x, x, -(x * x)) for x in s_j]) # Python 3.14 的 math.fma
s (jit) = [3.0348734488811724, 1.6885475440472182, 1.1555909458038691, 0.3182721502403828] same as eager: True
eager diag(s_j**2 - s_i**2) = [0.0, 0.0, 0.0, 0.0]
jit diag(s_j**2 - s_i**2) = [-5.712212290647009e-16, -1.2505230706026656e-16, 8.384298537456344e-17, -3.484010694261065e-18]
math.fma(s, s, -(s*s)) = [-5.712212290647009e-16, -1.2505230706026656e-16, 8.384298537456344e-17, -3.484010694261065e-18]
jit diag((s_j-s_i)(s_j+s_i)) = [0.0, 0.0, 0.0, 0.0]
eps=1e-12: jit diag F = d/(d^2+eps^2) = [-571221042.6788777, -125052305.10468869, 83842984.78517693, -3484010.6942187757]
eps=1e-06: jit diag F = d/(d^2+eps^2) = [-0.0005712212290647009, -0.00012505230706026657, 8.384298537456344e-05, -3.4840106942610654e-06]
jit 下的对角线和 fma(s, s, −fl(s·s)) 逐位相同:XLA 把 s_j·s_j − s_i·s_i 编成了一条融合乘加,一边是精确的 s_j²,另一边是舍入过的 s_i²,差就是 s_i² 的舍入误差,1e-16 量级。ε = 1e-12 时 F 的对角线就是 d/ε²,到 5.7e8;正常的非对角元是 0.1 量级。这个大系数乘上 dP 的对角线进入 dU,一步步放大到 1e120。ε = 1e-6 时对角线只有 5.7e-4,前面实验里也只偏了一点。乘积形式 (s_j − s_i)(s_j + s_i) 的第一个因子在对角线上是 s − s,精确的 0,乘什么都是 0。
tenferro 的规则也是乘积形式(s_gap = s_sum * s_diff,见 线性代数),对角线上的 s_diff 是精确的 0。怎么读这一卷 里看到的 1 ulp 差别,就是同一种融合。
jit 外面,只把 s 当参数传进 jit(lambda s: s[None, :] ** 2 - s[:, None] ** 2),对角线还是那四个 1e-16 量级的数吗?所以写自定义的 SVD 规则,凡是「数学上应当恰好为 0」的量,都写成精确的形式(s − s 再乘),或者用 where 明确置 0;不要指望 x*x - y*y 在 x == y 时得到 0。
动脑筋答案
s as jit argument: diag = [0.0, 0.0, 0.0, 0.0]
- 是 0。同一个表达式,
s是jit的参数时 XLA 没有生成那条乘加,s和平方差在同一个jit里由 SVD 算出来时才生成。融合不融合由编译器在整个计算里的安排决定,不能从这一行代码看出来。 - 所以这个坑很难用小例子提前发现:把规则单独拿出来测,
s往往是参数,测不出问题;放进 TRG 的jit里才出现。写成乘积形式或者屏蔽对角线,就不依赖编译器怎么安排。
小结
- TRG 第 5 步的 256×256 矩阵里,LAPACK 把 228 个舍入意义上的零奇异值算成同一个数,
1/(s_j² − s_i²)是inf,乘被截断方向上的零余切得到 NaN。JAX 内置jnp.linalg.svd和 PyTorch 从这一步起梯度全是 NaN。 - tenferro 默认的展宽
F = d/(d² + ε²)(ε = 1e-12)在d = 0时给 0,TRG 的梯度有限且和差分一致,和 JAX 补上同样规则后的结果 12 位相同。 - 损失只用奇异值时,内置规则没事;用到奇异向量、又有逐位相等的奇异值时才 NaN。PyTorch 在转过的矩阵上没出 NaN,是因为它的两个奇异值碰巧差一个 ulp。
- JAX 里用
custom_jvp把F换成展宽形式;d写成(s_j − s_i)(s_j + s_i),对角线用where置 0。写成s_j**2 - s_i**2时,jit会生成 FMA,对角线变成 1e-16 量级,TRG 的梯度到 1e120。