Rust in Everything
基于 burn 0.22.1

linalg::svd: gradients are not implemented

主线位置:张量主线的「计算」一步,一个线性代数算子:形状和 PyTorch 不同,又不能放进训练主线的 backward。

版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备,linalg feature;求导部分加 autodiff)

flowchart TB
  a["A 形状 3x2"] -->|torch svd| fu["U 形状 3x3"]
  a -->|burn svd| ru["U 形状 3x2"]
  fu -->|"U[:, 2:]"| c1["补空间 3x1"]
  ru -->|"s![.., 2..]"| c0["空张量 3x0"]
  c0 --> z["投影静默为 0"]
  ru --> proj["I - U Ut"]
  w["要梯度的 w"] -->|burn svd| pn["panic"]

实验 crate pita-t 的 p13(feature 里带 linalg,没有它就没有 burn::linalg,见为什么要转的冷知识),PyTorch 侧 p13_svd.py。debug 构建。平台 M2 Max、macOS 27、rustc 1.99.0。panic 行里的本地路径前缀换成了 ...。

PyTorch 的写法

求一个矩阵列空间的正交补,最直接的写法是取完整 U 的后几列:

A = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
U, S, Vh = torch.linalg.svd(A)
comp = U[:, 2:]                         # orthogonal complement of col(A)
x = torch.tensor([1.0, 0.0, 0.0])
print("residual of x off col(A):", (comp @ (comp.T @ x)).tolist())
W = A.clone().requires_grad_(True)
torch.linalg.svd(W).S.sum().backward()
U [3, 3] S [2] Vh [2, 2]
U[:, 2:] [3, 1]
residual of x off col(A): [0.1666661649942398, -0.33333292603492737, 0.16666653752326965]
full_matrices=False: U [3, 2]
(I - Ur Ur^T) x: [0.16666585206985474, -0.3333330452442169, 0.16666637361049652]
grad of S.sum(): [-0.5510035753250122, 0.7278250455856323, 0.13615891337394714, 0.5610649585723877, 0.8233200311660767, 0.3943061828613281]

两个习惯:torch.linalg.svd 默认 full_matrices=True,U 是 [m, m];SVD 可以放在要求导的计算里(谱归一化、低秩约束)。

搬到 burn 的现象

let (u, s, vt) = svd::<2, 1>(a, 10);
let comp = u.clone().slice(s![.., 2..]);
let x = Tensor::<2>::from_floats([[1.0], [0.0], [0.0]], &device);
let r = comp.clone().matmul(comp.transpose().matmul(x.clone()));
U [3, 2] S [2] Vt [2, 2]
U.slice(s![.., 2..]) [3, 0]
residual via U[:, 2..]: [3, 1] [0.0, 0.0, 0.0]

U 只有两列,2.. 越界被截成空的一维(切片越界不报错),矩阵乘法照样能做,结果全是 0。x 明明有一部分在列空间之外,算出来却是「全在列空间里」。

求导:

let w = Tensor::<2>::from_floats(rows, &ad).require_grad();
let (_u, s, _vt) = svd::<2, 1>(w.clone(), 10);
input is_require_grad=true is_tracked=true

thread 'main' (8823752) panicked at .../burn-linalg-0.22.1/src/functions/svd.rs:98:9:
linalg::svd: gradients are not implemented; detach the input tensor first

照报错说的先 detach():

## svd(w.clone().detach())
input is_require_grad=true is_tracked=true

thread 'main' (8823778) panicked at .../burn-linalg-0.22.1/src/functions/svd.rs:98:9:
linalg::svd: gradients are not implemented; detach the input tensor first

还是同一个 panic。换成一个不要梯度的中间结果(w.clone() * 1.0),或者 autodiff 设备上一个从没 require_grad 过的张量,换了一种 panic:

## svd(w.clone() * 1.0)
input is_require_grad=false is_tracked=true

thread 'main' (8823835) panicked at .../burn-linalg-0.22.1/src/ops.rs:6:1:
not implemented: Backend not supported for custom op `svd`
## svd(Tensor::from_floats(rows, &ad))
input is_autodiff=true is_require_grad=false is_tracked=false

thread 'main' (8824875) panicked at .../burn-linalg-0.22.1/src/ops.rs:6:1:
not implemented: Backend not supported for custom op `svd`

原因

形状:svd 的文档写着「with k = min(m, n) (reduced decomposition, matching torch.linalg.svd)」,返回 U 是 [..., m, k]、Vt 是 [..., k, n]。它对应的是 torch.linalg.svd(A, full_matrices=False)(上面 PyTorch 输出的第四行),不是 PyTorch 的默认值,也没有参数可以要完整的 U。

求导:svd 开头先检查 tensor.is_require_grad(),是就 panic,这就是第一条报错。detach() 对要梯度的叶子保留 require_grad(detach-still-tracked),所以照报错去 detach 一个参数,检查照样不过。过了这道检查之后,计算交给 LinalgOps 的 svd 方法。LinalgOps 是一个用 #[backend_extension(..)] 声明的扩展 trait,列出的后端是 Flex、Cube、NdArray、LibTorch、Remote、Capture,没有 Autodiff。只要张量在 autodiff 设备上,不管追不追踪,分派都找不到实现,报第二条错。

origin/main(2026-10-28)上 functions/svd.rs 没有变化,ops.rs 只是从列表里删掉了 NdArray 和 LibTorch,仍然没有 Autodiff;同一个 crate 里新加的 solve(#5856,还没有进发布版)的扩展 trait 列了 Autodiff。我认为第二条是 0.22.1 的上游问题:报错建议「detach the input tensor first」,可是在 autodiff 设备上 detach 之后仍然不能调用,能用的只有 without_autodiff();最小复现是 features = ["std", "flex", "autodiff", "linalg"] 下的 svd::<2, 1>(Tensor::<2>::from_floats([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], &Device::flex().autodiff()), 10)。

📝NOTE
冷知识:「matching torch.linalg.svd」这句话从第一版就在

svd 是 #5259「feat(tensor): add batched SVD decomposition to linalg」(2026-09-02,首次发布于 v0.22.0-pre.4)加进来的。提交说明里写的是「batched [..., m, n] input; reduced SVD matching torch.linalg.svd (k = min(m,n), singular values sorted descending)」,测试对照的是「torch.linalg.svd reference values (sign-aligned factors, 5e-4 tolerance)」。上面那个 is_require_grad 检查也是这一次加的。当时的算法是 one-sided Jacobi;0.22.1 的文档写的是 Householder 双对角化加隐式移位 QR 迭代,函数第二个参数 sweeps 是迭代次数的上限。「精简分解」和「不能求导」都是从第一版起就这样,不是后来的回归。

改法

补空间:不需要完整的 U。正交补上的投影是 I - U Uᵀ,用精简的 U 就能算:

let p = Tensor::<2>::eye(3, &device) - u.clone().matmul(u.transpose());
let r = fv(p.matmul(x).flatten::<1>(0, 1));
(I - U U^T) x: [0.16666728, -0.33333364, 0.16666685]

和 PyTorch 用完整 U 算的 comp @ comp.T @ x 对比,compare.py 容差 1e-5:

residual                 n=  3  max|diff|=1.12e-06
worst=1.12e-06 tol=1e-05 OK

差 1.12e-6,不是 0:两边的 SVD 是不同的迭代算法,f32 下奇异向量本身只精确到这个量级(PyTorch 自己用完整 U 和精简 U 两种调用算出来也差 3.1e-7,见上面 PyTorch 输出的第三、五行);奇异值的差别在为什么要转里量过。真的要一组补空间的基,在 0.22.1 里得自己做:比如对 I - U Uᵀ 再分解一次,取奇异值接近 1 的那些列。

求导:0.22.1 里没有办法让梯度穿过 svd。只要结果,输入先 without_autodiff():

let (_u, s, _vt) = svd::<2, 1>(w.clone().without_autodiff(), 10);
svd(w.without_autodiff()): S [9.5255165, 0.5143005] is_autodiff=false

结果在普通设备上,再参与 autodiff 的运算时被当作常量,梯度不会经过它。训练里确实需要 SVD 的梯度时,要自己写反向(burn 卷一个算子的反向讲了反向是怎么接进图的),或者换不需要 SVD 的写法。

怎么检查

  • svd 之后打印一次 u.dims(),和 PyTorch 代码里假设的 [m, m] 对一下。
  • 对 U 做切片之前断言列数够:assert!(u.dims()[1] > k),否则 s![.., k..] 会静默得到空张量。
  • 训练代码里搜 linalg:::svd 的输入要么不在 autodiff 设备上,要么先 without_autodiff()。

小结

  • burn 的 svd 是精简分解(full_matrices=False),U 是 [m, min(m, n)];按完整 U 写的 U[:, k:] 切出空张量,结果静默为 0。补空间用 I - U Uᵀ,和 PyTorch 差 1.12e-6。
  • svd 不能求导:要梯度的输入 panic,detach() 叶子没用;autodiff 设备上的任何张量 panic「Backend not supported for custom op svd」,origin/main 上也一样。
  • 输入用 without_autodiff();需要梯度就得自己写反向。

💬 关联讨论

发起讨论
加载中...