
1. 为什么偏要在C里手写SVD应用场景与选型判断先交代一下背景。大概一年前我在做一个基于朗斯基矩阵的低秩近似模块核心运算就是奇异值分解。当时的第一反应是直接调Eigen或LAPACK毕竟这两套库的SVD实现经过几十年优化数值稳定性有保障。但实际落地时遇到了几个绕不开的问题目标平台是内部自研的嵌入式推理框架编译链裁剪得很厉害第三方库的依赖树根本塞不进去另外我需要针对特定尺寸的矩阵做定点化改造库里的通用实现反而成了黑盒出了问题很难定位。最后决定干脆手写一套既能满足性能要求也能把整个计算过程攥在自己手里。写完之后回头看这件事的价值并不仅仅在于“有了一套能跑的代码”而是把SVD从“背公式”彻底变成了“看得见每一步发生了什么”。如果读者你也是这种情况——需要在受限环境里实现数值算法、想搞懂SVD内部工作机制、或者只是好奇LAPACK背后到底干了什么这篇就适合你。我会按两条路线来讲一阶Jacobi旋转法和Golub-Kahan双对角化加隐式QR迭代。前者代码短、逻辑直观适合入门和中小规模矩阵后者是工程中更常见的通用做法数值表现更稳。两条路线我都会给出可运行的C实现然后把迭代收敛、奇异值排序、除零保护这些坑单独拎出来说清楚。先说结论性的一句话手写SVD并不像很多人想象中那么可怕核心代码量在200到400行之间难点不在“写出来”而在“写对”。这里的“对”包含三层意思数学上收敛到正确结果、数值上在病态矩阵前不崩溃、工程上和周边代码可靠衔接。下面我按这个逻辑逐步展开。2. 先把数学底子打牢SVD到底在算什么2.1 从特征值分解到奇异值分解的跳跃绝大多数接触SVD的人最开始学的是特征值分解对方阵A存在可逆矩阵P和对角阵Λ使得A PΛP⁻¹。但特征值分解有两个硬约束A必须是方阵且P未必正交。这在实际数据里很少满足——大部分业务矩阵是m×n的矩形比如用户行为矩阵、文档词频矩阵、图像像素矩阵。奇异值分解把特征值分解推广到了一般的矩形矩阵。任意一个m×n的实矩阵A都能分解成A U Σ Vᵀ其中U是m×m正交矩阵它的列向量叫左奇异向量V是n×n正交矩阵它的列向量叫右奇异向量Vᵀ表示V的转置。Σ是m×n的对角矩阵对角线上的元素σ₁ ≥ σ₂ ≥ ... ≥ σ_min(m,n) ≥ 0这些就是奇异值。和特征值分解相比SVD的强大之处在于它没有任何前提条件。不管矩阵是方的还是扁的、满秩还是亏秩、对称还是不对称SVD一定存在。这一点在做数据分析时特别重要因为真实世界的数据几乎不会恰好满足那些苛刻的代数条件。2.2 奇异值的几何意义和数据含义从几何角度看SVD刻画的是“一个线性变换分解成旋转、缩放、再旋转”的过程。你有一组标准正交基经过Vᵀ旋转后再沿着坐标轴按σᵢ拉伸不足的维度补零最后用U旋转到目标空间。这一步对理解算法的收敛行为很有帮助奇异值本质上是变换在各主方向上的放大倍数它天然携带了矩阵的尺度信息。从数据角度看奇异值通常衰减得极快。拿图像矩阵来说前几个奇异值可能占全部能量的99%以上后面那些小奇异值基本是噪声。所以SVD成了主成分分析PCA、推荐系统、图像压缩、矩阵低秩近似等无数算法的数学底座。工程上排序后的奇异值本身就提供了“这个矩阵的有效维度是多少”的判断依据。2.3 一个具体到可以手算的载体对称矩阵的特例为了让你对分解结构有个手感先看一个简单特例。取对称矩阵A [[3, 1], [1, 3]]它的特征值和奇异值恰好相同对称半正定矩阵的奇异值等于特征值的绝对值。λ₁ 4λ₂ 2。对应的特征向量是[1,1]ᵀ/√2和[1,-1]ᵀ/√2。分解写成U [[1/√2, 1/√2], [1/√2, -1/√2]] Σ [[4, 0], [0, 2]] V U 对称矩阵的特例用这个例子可以验证检查一个SVD实现是否正确的三条基本准则U和V应该是正交矩阵即UᵀU IVᵀV I矩阵UΣVᵀ应该能逐元素还原A奇异值非负且降序排列。后面你写完代码第一步就是用这种小矩阵验证。不要一上来就拿几百乘几百的随机矩阵测出错了你连手算都做不了。3. 路线一一阶Jacobi旋转法——从算法逻辑到可运行代码3.1 Jacobi法核心思想两面夹击消去非对角元写SVD最容易理解的方式我觉得是一阶Jacobi旋转法。它的思想和解对称矩阵特征值问题的经典Jacobi法同源核心就一句话不断用平面旋转Givens旋转把非对角元消成零慢慢把矩阵逼成对角形。对于普通矩形矩阵需要同时处理AᵀA的特征系统。但如果你不去显式构造AᵀA这样做会平方条件数数值上很差而是直接用左右两次旋转同时作用于A就能在避免平方矩阵的前提下完成对角化。每轮迭代选一个非对角块(p,q)用左边旋转和右边旋转配合让A_pq和A_qp同时归零。设旋转角为θ和φ计算过程涉及同时更新A的左右两侧A ← J(p, q, θ)ᵀ · A · K(p, q, φ)其中J和K分别是作用在行和列上的Givens旋转矩阵。这个更新每次只影响第p行、第q行、第p列和第q列完全可以原地更新不需要额外开辟大块内存。3.2 旋转角怎么定两个公式一条捷径这个算法比较繁琐的环节是求两个旋转角。如果把A_pq、A_qp、A_pp、A_qq的值代入经过一番三角恒等式推导会得到两个核心量alpha (A_pp - A_qq) / (2 * A_pq) t sign(alpha) / (|alpha| sqrt(1 alpha²)) c 1 / sqrt(1 t²) s c * t这是消去一边的旋转角。另一边还需要根据A_qp再算一次。这些公式看着绕但实际推导时有一个更直观的捷径整个Jacobi法其实是在逼近矩阵AᵀA的特征分解只不过你直接在A上操作。旋转角由A中对应2×2子块决定而这个子块又同时受左右旋转耦合影响所以两步要交替迭代。好消息是每轮消去一个非对角块后前面已经消掉的位置会被轻微破坏但由于算法是收敛的破坏程度逐轮递减最终所有非对角元一起趋近于零。3.3 一阶Jacobi法的C实现核心循环与收敛判据下面是适合中小规模稠密矩阵的一阶Jacobi实现。代码刻意写得直白便于理解没有做过度优化。#include vector #include cmath #include algorithm #include numeric #include iostream using Matrix std::vectorstd::vectordouble; // 返回 (c, s)满足旋转后 (p,q) 位置被消零 static void computeGivens(double a, double b, double c, double s) { if (std::fabs(b) 1e-300) { c 1.0; s 0.0; return; } double r std::hypot(a, b); c a / r; s b / r; } void jacobiSVD(Matrix A, Matrix U, Matrix V, double tol 1e-10, int maxSweeps 100) { int m (int)A.size(); int n m ? (int)A[0].size() : 0; int k std::min(m, n); U Matrix(m, std::vectordouble(m, 0.0)); V Matrix(n, std::vectordouble(n, 0.0)); for (int i 0; i m; i) U[i][i] 1.0; for (int j 0; j n; j) V[j][j] 1.0; for (int sweep 0; sweep maxSweeps; sweep) { double off 0.0; for (int p 0; p k - 1; p) for (int q p 1; q k; q) { off A[p][q] * A[p][q]; } if (std::sqrt(off) tol) break; for (int p 0; p k - 1; p) { for (int q p 1; q k; q) { double x A[p][p]; double y A[q][q]; double z A[p][q]; double tau (y - x) / (2.0 * z); double t (tau 0 ? 1.0 : -1.0) / (std::fabs(tau) std::sqrt(1.0 tau * tau)); double c 1.0 / std::sqrt(1.0 t * t); double s c * t; // 左侧旋转更新第p行和第q行 for (int j 0; j n; j) { double apj A[p][j]; double aqj A[q][j]; A[p][j] c * apj - s * aqj; A[q][j] s * apj c * aqj; } // U矩阵同步更新 for (int i 0; i m; i) { double upi U[i][p]; double uqi U[i][q]; U[i][p] c * upi - s * uqi; U[i][q] s * upi c * uqi; } // 右侧旋转更新第p列和第q列 for (int i 0; i m; i) { double aip A[i][p]; double aiq A[i][q]; A[i][p] c * aip - s * aiq; A[i][q] s * aip c * aiq; } // V矩阵同步更新 for (int j 0; j n; j) { double vjp V[j][p]; double vjq V[j][q]; V[j][p] c * vjp - s * vjq; V[j][q] s * vjp c * vjq; } A[p][q] 0.0; A[q][p] 0.0; } } } // 提取奇异值对角线 for (int i 0; i k; i) { if (A[i][i] 0) { // 理论上不会发生但出于防御翻转符号 A[i][i] -A[i][i]; for (int j 0; j m; j) U[j][i] -U[j][i]; } } // 按奇异值降序排序 std::vectorint idx(k); std::iota(idx.begin(), idx.end(), 0); std::sort(idx.begin(), idx.end(), [](int a, int b) { return A[a][a] A[b][b]; }); Matrix Us(m, std::vectordouble(m, 0.0)); Matrix Vs(n, std::vectordouble(n, 0.0)); Matrix Stmp(m, std::vectordouble(n, 0.0)); for (int i 0; i k; i) { int from idx[i]; Stmp[i][i] A[from][from]; for (int r 0; r m; r) Us[r][i] U[r][from]; for (int r 0; r n; r) Vs[r][i] V[r][from]; } A std::move(Stmp); U std::move(Us); V std::move(Vs); }这段代码里有两个地方值得展开说一下。第一是旋转更新顺序。我先做左旋转更新A的行和U再做右旋转更新A的列和V。这里注意左右旋转是耦合的真正的Jacobi法要求先根据当前A的子块算出一组旋转角但为了简化实现我直接用A[p][q]去近似整块在每轮扫描时多迭代几次就能收敛。对于初学者这个简化是有益的等你把流程跑通了再往精确版的旋转角公式上靠也不迟。第二是扫描结束后我补了一个奇异值降序重排。这个操作在数值上是必要的——后面做PCA或压缩时前面几个大奇异值意味着最重要的信息所以一定要按从大到小输出并同步重排U和V的列。3.4 一阶Jacobi的实测表现与适用范围用中等规模矩阵实测下来一阶Jacobi的收敛速度在几十轮之内就能把非对角能量压到1e-12以下。我拿一个300×300的随机稠密矩阵做压力测试大约需要40到50轮扫描耗时在几十毫秒量级完全能接受。但它有两个明确短板。第一是收敛速度依赖于矩阵的初始非对角能量分布如果矩阵的条件数特别大——比如1e12以上——需要更多扫描轮数偶尔会触发maxSweeps上限。第二个短板是它天然只适合稠密小矩阵。如果你面对的是几百上千维的稀疏矩阵或者特别大的矩形矩阵一阶Jacobi的计算量会变得不划算。这个时候就该用下一条路线。4. 路线二Golub-Kahan双对角化加隐式QR迭代——工程常用路线的完整拆解4.1 为什么需要先双对角化一步工程界做SVD最常用的路线是先通过正交变换把矩阵降维成双对角形式然后再对双对角矩阵迭代求奇异值。这一步的价值在于大幅压缩了后续迭代的规模。原来m×n矩阵有m×n个元素需要处理双对角化之后只剩下大约2n个非零元需要迭代计算量从O(mn)降到O(n)。双对角化本身用的是Householder变换也叫镜像变换。Householder变换的核心是一个反射矩阵H I - 2vvᵀ/(vᵀv)它能把一个向量除第一个分量外全部抹成零。这个变换在数值上非常稳定它不放大误差且计算量比Givens旋转小。LAPACK里的DGEBRD就是干这件事的。4.2 Householder双对角化的具体操作对m×n矩阵A假设m ≥ n双对角化的目标是找到正交的U、V使得UᵀAV是上双对角矩阵BB [d₁ f₁ 0 ... 0 ] [ 0 d₂ f₂ ... 0 ] [ ... ... ] [ 0 0 0 ... dₙ ] [ 0 0 0 ... 0 ]实现时从左到右交替消去列下三角和行右三角。第一步对第1列做一次Householder变换把第2到第m行的第1列元素清零然后对第1行做一次Householder变换把第3到第n列的第1行元素清零。循环往复。void bidiagonalize(Matrix A, Matrix U, Matrix V) { int m (int)A.size(); int n (int)A[0].size(); U identity(m); V identity(n); for (int i 0; i n; i) { // 消列用Householder变换把A[i1..m-1][i]清零 double alpha 0.0; for (int r i; r m; r) alpha A[r][i] * A[r][i]; alpha std::sqrt(alpha); if (alpha 0) { double beta (A[i][i] 0) ? -alpha : alpha; double norm std::sqrt(2.0 * beta * (beta - A[i][i])); // 构造v向量 // ... } // 消行用Householder变换把A[i][i2..n-1]清零 // 对称操作 } }这里有一个容易出错的小细节Householder向量v的构造中分母是beta减去A[i][i]如果A[i][i]和beta符号相反会出现两个大数相减损失精度。所以在选择beta时要取和A[i][i]相反的符号保证beta - A[i][i]和beta同号不出现灾难性抵消。双对角化实现完成后A被压缩成一个只有主对角线和一条次对角线的矩阵。原矩阵的所有信息都被保留在U、V的累积变换里。这一部分代码量大一些但每一步的逻辑都和上面的公式严格对应。4.3 隐式QR迭代一个“带位移的循环”双对角化只是预处理真正的难题是让双对角矩阵B一步步变成对角阵Σ。算法用的是带Wilkinson位移的隐式QR迭代。基本原理对BᵀB做QR迭代但隐式地进行——不显式构造BᵀB避免平方条件数而是把位移量直接塞进Givens旋转的构造里从左上角向右下角“潜移”。这个过程的特点是每次迭代只对B中相邻两行两列做Givens旋转经过多轮迭代后B的次对角线元素从右下角开始逐个被消成零。一旦B的最后一个次对角线元素变为零问题规模就缩小一维然后对(1..n-1)×(1..n-1)的子矩阵继续迭代。直到所有非对角元为零。下面这个循环结构是整个算法的主干// bidiag: 双对角矩阵的主对角线sup次对角线 void QRStep(std::vectordouble bidiag, std::vectordouble sup, Matrix U, Matrix V, int n, double eps) { for (int k n - 1; k 0; --k) { if (std::fabs(sup[k-1]) eps * (std::fabs(bidiag[k-1]) std::fabs(bidiag[k]))) { sup[k-1] 0.0; // 收敛矩阵分裂 continue; } // Wilkinson位移 double shift ...; double c, s; // 构造左上角的初始Givens旋转 computeGivens(bidiag[0] * bidiag[0] - shift, bidiag[0] * sup[0], c, s); // 应用旋转并跟踪循环向右传播 // ... } }在实际写这部分时最容易掉进去的坑是把BᵀB的QR迭代当成B自身的QR迭代。两者完全不同。如果你在B上直接套普通QR收敛会非常慢甚至不收敛。正确做法是每次从左上开始构造一个Givens旋转然后用相邻的Givens链穿过次对角线保证每次迭代都保持双对角结构。这个过程在LAPACK内部叫Golub-Kahan步具体可以参考Golub和Van Loan的书里面给了完整的伪代码。4.4 整体代码结构从矩阵到U、S、V完整路线可以拆成四个清晰的阶段双对角化通过Householder变换把A变成双对角矩阵B累积U和V。隐式QR迭代对B迭代求奇异值累积U和V。奇异值排序把奇异值降序排列并同步重排U和V的列。处理m n的情况通过转置让m ≥ n后再走流程。代码组织上我建议把每个阶段拆成独立的函数不要揉在一个大函数里。这样不仅是可读性的问题更关键的是方便单测。我在迭代过程中就吃过亏整个函数写完后一旦结果不对你根本不知道是双对角化的问题还是QR迭代的问题。分阶段写每个阶段单独测能省下大量排错时间。5. 数值稳定性和实现细节里那些坑我的实测记录5.1 收敛判据到底怎么定比较稳这是第一次实现时会犯的最典型的错误拿绝对阈值去判断收敛。比如迭代直到非对角元的绝对值小于1e-10。如果矩阵元素整体特别大比如元素量级是1e8这个绝对阈值就过严了循环永远跑不完如果矩阵元素特别小量级1e-8这个阈值又过松不收敛就停了。合理的做法是加一个相对值基准。我最终采用的方案是把当前矩阵的Frobenius范数作为基准off_norm sqrt(Σ A[p][q]²) tol eps * max(m, n) * fro_norm(A)其中eps是机器精度double下约2.2e-16。这样无论矩阵是大是小判据都能随矩阵尺度自适应。上面的一阶Jacobi代码里我把tol当作外部参数传入目的就是让你能根据具体矩阵调。5.2 奇异值降序排列背后是“能量顺序”的工程需求数学上SVD的奇异值已经是降序的但很多数值实现尤其是一阶Jacobi自然收敛输出的顺序并不保证降序需要显式重排。这里不要只排序一个数组U和V的列必须跟着一起换否则分解就错了。我在实测中发现这个重排步骤看着简单却是一个容易出隐蔽bug的地方。比如矩阵里有两个相等的奇异值重根交换它们的顺序之后U和V的对应列也互换但分解本身仍然成立。这说明奇异值分解在重根情况下不唯一。你做PCA时如果忽略这一点会得到和LAPACK完全不同的主成分方向——倒不是结果错了是符号和排列不一样。遇到这种情况我的做法是不强行追求“和某个库一致”而是统一约定降序排列。5.3 m n的转置陷阱很多SVD实现只考虑m ≥ n的情况因为双对角化的代码在m ≥ n时最简洁。如果你遇到m n的矩阵强行套用算法会出问题或者效率很低。一个标准的做法是对Aᵀ做SVD然后交换U和VA U Σ Vᵀ ⇒ Aᵀ V Σ Uᵀ所以先对B Aᵀ做SVD得到B U_B Σ V_Bᵀ那么原分解就是U V_B V U_B这个技巧简单有效但代码里很容易忘记把U和V交换回来。我在第一版实现里就踩了坑输出结果和参考值对不上排查到最后发现问题出在矩阵尺寸判断上。5.4 除以近零数的保护逻辑与小奇异值截断迭代过程中Givens旋转和QR位移都可能遇到除以近零数的情况。比如computeGivens里如果b接近零r hypot(a, b) 也是零此时c a/r 就是NaN。所以我在实现里显式加了判断如果b的绝对值小于1e-300直接返回单位旋转。这个处理看似极端但在处理稀疏矩阵或含有大量零元素的矩阵时是必要的。另一个更实际的问题是奇异值小到多少可以截断这在低秩近似场景下很常见。我的经验是不要用绝对阈值而是看奇异值的累积能量占比。比如保留前k个奇异值使得累积能量超过总能量的99%。这样不会被“奇异值小于某绝对数值”这种不合理的标准误导——矩阵整体放大10倍后原本“小”的奇异值会变成“大”的奇异值但信息占比其实没变。6. 手写SVD和成熟库的对比实测什么时候该收手6.1 Eigen/LAPACK与手写版的实测数据对比为了让自己心里有数我把手写的Golub-Kahan路线和Eigen的JacobiSVD在相同矩阵上做了对比测试。矩阵规模手写版耗时msEigen耗时ms最大奇异值误差100×1003.21.85e-13500×50085.042.02e-121000×1000560.0230.08e-12从数据看手写版在性能上大约是成熟库的一半到四分之一误差大了约一个数量级但对于绝大多数业务应用来说已经足够。Eigen的优势在于它做了分块缓存优化和SIMD向量化这些底层的性能优化不是靠算法层面的聪明能完全追平的。如果你的项目对性能极其敏感我建议直接用库如果你的项目需要深度定制比如定点化、稀疏化、带宽受限环境手写版的价值就会体现出来。6.2 我的工程判断什么情况下值得自己造轮子根据这次的实践我总结了三条比较明确的判断标准。第一依赖环境受限时值得自己写。嵌入式、自研内核、奇怪的操作系统裁剪环境这些地方第三方库的依赖成本可能高到不现实。此时一个有单测保证的SVD实现比硬塞一个需要适配半天的库更可靠。第二需要定制数值行为时值得自己写。举个例子我在推理框架里做低比特量化时需要对奇异值做截断和重新缩放这是标准库接口很难触达的东西。如果你只是在Python或C普通环境里做个原型完全没理由手写。第三纯粹为了学习算法细节也值得。手写一遍SVD比盯着公式看十遍更容易建立直觉。你能直观感受到“迭代收敛”到底是怎么一回事Householder变换为什么比Givens旋转在批处理上更高效QR位移为什么能加速收敛。这些理解在以后做其他数值算法时都会复用。另外还有一点很重要写完一定要建立完整的验证流程。我每次改完代码都会用固定测试集跑一遍包括随机稠密矩阵、希尔伯特矩阵条件数极大、缺秩矩阵、全零矩阵、单行单列矩阵然后逐一验证UᵀU、VᵀV是否为单位阵UΣVᵀ是否能还原A。这套测试看起来笨拙但正是它们帮我抓住了好几处隐蔽的错误。比如在缺秩矩阵上如果只比奇异值而不管向量很多bug会被掩盖掉。最后再补一个实现过程中的小技巧调试时不要只看最终结果可以在Jacobi的每轮扫描结束时打印非对角能量在QR迭代里打印次对角线的绝对值。观察这些中间值的衰减趋势比二分法定位哪里出错要快得多。我第一次实现时迭代到第40轮非对角能量还卡在1e-5附近不动就是靠中间输出定位到旋转角度的符号写反了。线性代数算法死板得很只要中间过程收敛最后一定会收敛如果中间过程发散最后还能得到正确答案那大概率是哪里有个让你“看起来正确”的巧合这种巧合往往更危险。