从一个小例子学习方程组求解超节点(supernodal)算法

从一个小例子学习方程组求解超节点(supernodal)算法

矩阵分块分解

以 Cholesky 分解超节点方法举例说明,LULULU 和 LDLTLDL^TLDLT 类似。

考虑对称正定(SPD)线性方程组求解问题
x=b,A=AT,A≻0 x=b, \quad A=A^T, A \succ 0 x=b,A=AT,A≻0

Cholesky 分解法求解方程步骤分两段:

1.分解阶段:求下三角矩阵 L ,使得
A=LLT A=L L^T A=LLT

2.求解阶段:先解
Ly=b L y=b Ly=b

再解
LTx=y L^T x=y LTx=y

把 AAA 的对角部分成两块,左上部分和右下剩余部分,左下和右上部分形状顺应,
A=[A11A12A21A22],A12=A21T A=\left[\begin{array}{ll} A_{11} & A_{12} \\ A_{21} & A_{22} \end{array}\right], \quad A_{12}=A_{21}^T A=[A11A21A12A22],A12=A21T

假设 A11A_{11}A11 由 Cholesky 分解如下
A11=L11L11T A_{11}=L_{11} L_{11}^T A11=L11L11T

那么,我们思考,是否可以将 AAA 写成如下分解形式,
A=[L110L21I][I00S][L110L21I]T=[L11L11TL11L21TL21L11TL21L21T+S] A = \left[\begin{array}{ll}L_{11} & 0 \\ L_{21} & I\end{array}\right]\left[\begin{array}{ll} I & 0 \\ 0 & S \end{array}\right] \left[\begin{array}{ll}L_{11} & 0 \\ L_{21} & I\end{array}\right]^T= \left[\begin{array}{cc}L_{11} L_{11}^T & L_{11} L_{21}^T \\ L_{21} L_{11}^T & L_{21} L_{21}^T+S\end{array}\right] A=[L11L210I][I00S][L11L210I]T=[L11L11TL21L11TL11L21TL21L21T+S]

这里面的 L21L_{21}L21 和 SSS 是未知量。

将其与 [A11A12A21A22]\left[\begin{array}{ll}A_{11} & A_{12} \\ A_{21} & A_{22}\end{array}\right][A11A21A12A22] 比对可得,
A21=L21L11T A_{21} = L_{21} L_{11}^T A21=L21L11T
A22=L21L21T+S A_{22} = L_{21} L_{21}^T+S A22=L21L21T+S

可得
L21=A21L11−T L_{21} = A_{21} L_{11}^{-T} L21=A21L11−T
S=A22−L21L21T=A22−A21L11−TL11−1A21T=A22−A21A11−1A21T S = A_{22} - L_{21} L_{21}^T = A_{22} - A_{21} L_{11}^{-T} L_{11}^{-1}A_{21} ^T = A_{22} - A_{21} A_{11}^{-1}A_{21} ^T S=A22−L21L21T=A22−A21L11−TL11−1A21T=A22−A21A11−1A21T

这里的 SSS 大家喜欢叫 Schur 补(块高斯消元的补的部分),事实上,SSS 也是 SPD 的,对称性由表达式可看出。对于正定性,可利用定义进行证明。

任取非零向量 yyy ,令
x=[−A11−1A12yy]≠0 x=\left[\begin{array}{c} -A_{11}^{-1} A_{12} y \\ y \end{array}\right] \neq 0 x=[−A11−1A12yy]=0

那么,
xTAx=yT(A22−A21A11−1A12)y=yTSy>0x^T A x=y^T\left(A_{22}-A_{21} A_{11}^{-1} A_{12}\right) y=y^T S y >0xTAx=yT(A22−A21A11−1A12)y=yTSy>0

证毕。

进一步,假如 SSS 有 Cholesky 分解
S=L22L22TS = L_{22}L_{22}^TS=L22L22T

那么,可以得到 AAA 的 Cholesky 分解
A=[L110L21I][I00S][L110L21I]T=[L110L21I][I00L22][I00L22T][L110L21I]T=[L110L21L22][L110L21L22]T=LLT A=\left[\begin{array}{ll} L_{11} & 0 \\ L_{21} & I \end{array}\right]\left[\begin{array}{ll} I & 0 \\ 0 & S \end{array}\right]\left[\begin{array}{ll} L_{11} & 0 \\ L_{21} & I \end{array}\right]^T =\left[\begin{array}{ll} L_{11} & 0 \\ L_{21} & I \end{array}\right] \left[\begin{array}{ll} I & 0 \\ 0 & L_{22} \end{array} \right] \left[\begin{array}{ll} I & 0 \\ 0 & L_{22}^T \end{array} \right] \left[\begin{array}{ll} L_{11} & 0 \\ L_{21} & I \end{array}\right]^T=\left[\begin{array}{ll} L_{11} & 0 \\ L_{21} & L_{22} \end{array}\right]\left[\begin{array}{ll} L_{11} & 0 \\ L_{21} & L_{22} \end{array}\right]^T=LL^T A=[L11L210I][I00S][L11L210I]T=[L11L210I][I00L22][I00L22T][L11L210I]T=[L11L210L22][L11L210L22]T=LLT

一言以蔽之,AAA Cholesky 分解的下三角矩阵 LLL 写法如下。

左上角 L11L_{11}L11 为左上角的 Cholesky 分解:
L11L_{11}L11

左下角 L21L_{21}L21 为原右下角右乘左上角的逆转置(解个下三角方程组):
L21=A21L11−T L_{21} = A_{21} L_{11}^{-T} L21=A21L11−T

右下角 L22L_{22}L22 为 Schur 补的 Cholesky 分解:
S=A22−A21A11−1A21T=L22L22TS = A_{22} - A_{21} A_{11}^{-1}A_{21} ^T = L_{22}L_{22}^TS=A22−A21A11−1A21T=L22L22T

考虑求 SSS 的 Cholesky 分解,如果对 SSS 继续分块,可以将 SSS 视为原来的 AAA,继续相似过程,以此类推。这刚好对应了 AAA 对角多分块的情形。

其实,在后续的过程中,左上角 L11L_{11}L11 和 左下角 L21L_{21}L21(蕴含了右上角),就已经固定下来了,不会再变了。A11A_{11}A11 对应的行列处理完了,我们一般就说消去了 A11A_{11}A11 对应的未知量。

事实上,上述过程表述的是给定矩阵的 Cholesky 分解的下三角的分裂式表达,这里面就蕴含着超节点(supernodal)思想核心,即"把若干列打包一起做"。真正的 supernodal 方法会把结构相似的列组成一个"超节点",然后用密集块运算(BLAS3)加速。

写一个 MATLAB 程序来验证。

复制代码
clear; clc;
n = 200;
rng(1);
R = sprandsym(n, 0.03, 0.1, 1);   % 稀疏对称矩阵
A = R'*R + n*speye(n);            % 保证SPD
A = full(A);                      % 教学代码中用full方便演示
b = randn(n,1);
blockSize = 16;  
[x_sn, L] = supernode_cholesky_solve(A, b, blockSize);
x_matlab = A \ b;
err = norm(x_sn - x_matlab)

function [x, L] = supernode_cholesky_solve(A, b, blockSize)
%SUPERNODE_CHOLESKY_SOLVE 用"超节点/分块Cholesky"方法求解 Ax=b
%
% 输入:
%   A         - n×n 对称正定矩阵(SPD)
%   b         - n×1 右端向量
%   blockSize - 超节点大小(块大小)
%
% 输出:
%   x - 解向量
%   L - Cholesky下三角因子,使 A = L*L'
%
% 说明:
%   这是教学版"超节点方法"实现,本质是分块(blocked)Cholesky,
%   可看作固定大小超节点的简化版本。

    [n, m] = size(A);
    
    % 工作矩阵(右看分块Cholesky)
    S = A;
    L = zeros(n, n);

    for k = 1:blockSize:n
        ke = min(k + blockSize - 1, n);
        J = k:ke;

        % 当前对角块
        Sjj = S(J, J);

        % 对角块 Cholesky 分解
        [Ljj, p] = chol(Sjj, 'lower');
        L(J, J) = Ljj;

        % 更新下面的块
        if ke < n
            I = (ke+1):n;

            % Lik * Ljj' = S(I,J)  => Lik = S(I,J) / Ljj'
            Lik = S(I, J) / Ljj';
            L(I, J) = Lik;

            % Schur补更新: S(I,I) = S(I,I) - Lik*Lik'
            S(I, I) = S(I, I) - Lik * Lik';
        end
    end

    % 前代 + 回代
    y = forward_substitution(L, b);
    x = backward_substitution(L', y);
end


function y = forward_substitution(L, b)
% 求解 Ly = b,其中 L 为下三角矩阵
    n = length(b);
    y = zeros(n,1);

    for i = 1:n
        y(i) = (b(i) - L(i,1:i-1)*y(1:i-1)) / L(i,i);
    end
end


function x = backward_substitution(U, y)
% 求解 Ux = y,其中 U 为上三角矩阵
    n = length(y);
    x = zeros(n,1);

    for i = n:-1:1
        x(i) = (y(i) - U(i,i+1:n)*x(i+1:n)) / U(i,i);
    end
end

概念引入

上述过程有几个遗留问题,比如

1、矩阵 AAA 应该如何分块,才可以使分解过程又快又准?比如令单个块的分解缓存友好。

2、矩阵 AAA 行列变换会对求解过程有什么影响?

3、原始系统可以做哪些操作,改善矩阵性质,提高分解速度。

这个过程会涉及到若干知识点

  • 消去树(elimination tree)
  • 列合并/超节点识别
  • 稀疏结构重排(如 AMD)
  • 稀疏 BLAS 优化
  • fill-in 填充
  • 分析、符号分解、数值分解、面板、左看右看、最大权匹配
  • ......

消去树(elimination tree)决定"依赖关系和传播路径",超节点(supernode)决定"把哪些列合并成一个密集块一起算"。最大权匹配(maximum weighted matching),可显著改善原始系统的对角占优性。

要讲清楚这些概念,直至可以工程实现,需要比较大的篇幅,下面只做一些抛砖引玉的概念点题,管中窥豹,可见一斑。

本质上,整个 surpernodal 方法是这样一个过程:

先换座位(重排)→ 看关系图(分析)→ 预测施工影响(符号)→ 分组施工(超节点)→ 机器高效施工(BLAS)

超节点方法

超节点法的核心:利用列之间模式相同的部分组成超节点,用 BLAS-3(矩阵-矩阵运算)提高效率。

对稀疏 SPD 矩阵 AAA ,如果你做 Cholesky:
A=LLT A=L L^T A=LLT

虽然 AAA 稀疏,但在消元过程中会产生填充(fill-in):原来是 0 的位置在 LLL 中变成非零。

如果按"逐列"处理(列 Cholesky):

  • 每列都有很多零散更新
  • 运算像 Level-1/Level-2 BLAS(小向量、小矩阵)
  • 内存访问碎片化
  • CPU 缓存和矩阵乘法性能发挥不出来

supernodal 的想法就是:

  • 找出若干"结构相似、非零模式连续"的列
  • 按结构自动识别,合成一个"超节点"
  • 把很多零散更新改成小块密集矩阵运算(BLAS3)

从而加速运算。打个比方,像做饭:

  • 一根根切菜(单列)很慢
  • 把同类菜堆一起切(超节点)效率高,还更适合用大刀(BLAS)

怎么定义超节点呢?超节点就是一段连续的列,这些列在 LLL 里满足

  • 这些列长得几乎一样(下面的非零结构一样)
  • 所以可以把它们当成一个小稠密块一起算
  • 而且这串列已经是能并的最大范围(不能再扩了)

给个简单的矩阵表视图如下,一目了然,sss 到 s′s's′ 列就是个超节点

supernodal 高效的根本原因:可以调用 dense BLAS。它可以显著提高缓存命中、更容易利用多核并行和减少调度和索引开销。在超节点方法中,消去树主要用来做 4 件事:

  • 描述依赖关系:哪一列(或哪一组列)必须等谁算完
  • 做符号分析:预测每列/每超节点会出现哪些非零(结构传播路径)
  • 识别超节点:看哪些连续列结构相似、依赖关系连在一起,适合合并
  • 调度与并行:不同子树可以并行算,父节点等孩子更新完再算

理论上"最干净"的超节点不一定最快,工程上会适当"凑块"。

  • 不要求完全严格匹配
  • 允许一些小树枝/小差异合并进来
  • 目标是增大块大小、提升 BLAS3 比例

这便是所谓的松弛超节点。

图和填充(fill-in)

不妨考虑对称矩阵比如

A={aij}=[∗∗∗000∗∗∗000∗∗∗∗∗000∗∗∗∗00∗∗∗∗000∗∗∗] A=\{a_{ij}\}= \begin{bmatrix} \ast & \ast & \ast & 0 & 0 & 0 \\ \ast & \ast & \ast & 0 & 0 & 0 \\ \ast & \ast & \ast & \ast & \ast & 0 \\ 0 & 0 & \ast & \ast & \ast & \ast \\ 0 & 0 & \ast & \ast & \ast & \ast \\ 0 & 0 & 0 & \ast & \ast & \ast \end{bmatrix} A={aij}= ∗∗∗000∗∗∗000∗∗∗∗∗000∗∗∗∗00∗∗∗∗000∗∗∗

我们先只关心"非零结构"(* 表示非零,0 表示零)。可以把每一行/列看成一个点 1~6,如果 Aij≠0A_{i j} \neq 0Aij=0(且 i≠ji \neq ji=j ),就连一条边。那么,上述矩阵的非零元位置和关系,事实上可以用一张图来表示,

复制代码
1 ----- 2
 \     /
   \ /
    3 ----- 4
      \    / \
       \  /   \
        5 ----- 6

这就建立了"非零结构"和图(顶点和边)的关系,非零元位置就表示了边关系。

在高斯消元的过程中,因为变换是整行整行去做的,所以难免会让一些原来矩阵中是 0 的位置,变成了非零,在图上体现一些节点之间原来没有边,现在有了边,这就是填充(fill-in)。

那每次消元会产生哪些填充呢?不妨以先处理第一列举例,先把第 1 个变量从后续方程里消掉。考虑第 iii 行第 jjj 列的元素,对于原来的 aij=0a_{ij} = 0aij=0,如果两个剩余变量 i,ji, ji,j 都和 1 节点相连(即 ai1≠0,aj1≠0a_{i 1} \neq 0, a_{j 1} \neq 0ai1=0,aj1=0 ),那更新 AAA 里的 (i,j)(i, j)(i,j) 项就会得到:
−ai1aj1a11 -\frac{a_{i 1} a_{j 1}}{a_{11}} −a11ai1aj1

用矩阵的语言描述,如果 ai1≠0,aj1≠0a_{i 1} \neq 0, a_{j 1} \neq 0ai1=0,aj1=0,那么高斯消元后,aija_{ij}aij 一定不等于 0。

用图的语言描述,如果节点 1 和节点 iii 和 jjj 都相连,那么消除节点 1 后,节点 iii 和 jjj 一定相连。

推广到一般的情形,在稀疏分解中,消去一个变量时,它的"邻居们"两两相连,往往会被连成团(clique),原来邻居之间可能没边,消去后需要补边(对应 fill-in)。

同一个矩阵,消元顺序会导致 fill-in 差很多,所以就有了重排,重排其实就是改变消元的顺序,使得填充尽可能少。

消去树(Elimination Tree):谁依赖谁的"家谱图"

消去树揭示了并发性信息。进一步地,它还有助于确定填充,可以帮助完成超节点的建立。它表示列与列之间在分解中的数据依赖结构(由稀疏模式决定,不看具体数值)。

消去树是一种树结构,描述非零元素依赖关系的树状结构。通常,节点 jjj 的父节点为 L 的第 jjj 列中,主对角线以下非零行索引里最小的那个行号 。它表示列 jjj 在后续计算中,最先会被需要到哪一列(更大的列)。父节点 = 第一个需要我(子节点)提供数据的"上级"。

消去树看的只是 LLL 非零元 Pattern,而不需要将 LLL 真正算出来,所以消去树是可以通过原始矩阵 AAA,及其对应的消元过程得到。

给个一目了然的示意图如下,

上图揭示了 1 的计算结果给到 2,3 的计算结果给到 4,2 和 4 的计算结果给到 5,5 的计算结果给到 6。那么 1->2 和 3->4 可以同时并行地计算。

树上的关系告诉你:

  • 哪些任务可以并行(不同子树)
  • 哪些任务必须等前置完成(祖先依赖)

消去树不是事后拍脑袋画的,它是从 LLL 的结构(而 LLL 的结构由 AAA+顺序决定)提炼出来的。

举个简单的例子:

求解稀疏线性方程组 Ax=bA x=bAx=b,其中
A=[2000102001002010002111115],b=[11112] A=\left[\begin{array}{lllll} 2 & 0 & 0 & 0 & 1 \\ 0 & 2 & 0 & 0 & 1 \\ 0 & 0 & 2 & 0 & 1 \\ 0 & 0 & 0 & 2 & 1 \\ 1 & 1 & 1 & 1 & 5 \end{array}\right], \quad b=\left[\begin{array}{l} 1 \\ 1 \\ 1 \\ 1 \\ 2 \end{array}\right] A= 2000102001002010002111115 ,b= 11112

从高斯消元的过程可以看出,因为没有 fill-in,LLL 的结构基本就是 AAA 的下三角结构(含对角),那么由消去树的定义,可以得到消去树

复制代码
    1   2 3   4
     \  | |  /
       \| |/
         5

这意味这, 1 2 3 4 的结果给到 5。换言之,本来高斯消元是强依赖前序步骤的方法,但是因为矩阵的高度稀疏,某一些计算就没有依赖关系了。1 2 3 4 列(行)的高斯消元过程可以同时完成,他们的计算不会相互影响,最后把结果汇聚到 5 列(行)即可。

对于超节点中的块运算来说,也是一样,因为矩阵稀疏性,分解计算过程中的某一些 Schur 补,不一定会受到前序某些分解步骤的影响。

消去树计算算法:

路径压缩 (Path Compression): 算法中通过 aia_iai 快速跳过已经处理过的节点,使得算法的时间复杂度接近线性,这是处理大型稀疏矩阵的关键。

重排序

对于大型稀疏矩阵,决定计算时间的关键因素之一是分解过程中产生的填充量。为降低复杂度,存在许多主要是对称的重排序技术,它们以启发式方式尝试减少填充。AMD 重排就是想办法挑一个顺序,让每次消去时邻居团尽量小,少补边。

为啥重排能减少填充?为了方便理解,我在 MATLAB 里对一个稀疏 SPD 矩阵做:

  • symamd(A) 重排序

  • chol 分解

  • spy(A)spy(L)

  • 再比较重排序前后 nnz(L)

    clear; clc; close all; rng(1)
    n = 300; d = 0.01;
    M = sprandn(n,n,d);
    A = (M'M + 1e-1speye(n));
    A = (A + A')/2;
    L0 = chol(A,'lower');
    p = symamd(A);
    Lp = chol(A(p,p),'lower');
    fprintf('nnz(L) = %d\n', nnz(L0));
    fprintf('nnz(Lp) = %d\n', nnz(Lp));
    figure
    subplot(2,2,1), spy(A), title('A')
    subplot(2,2,2), spy(L0), title('L')
    subplot(2,2,3), spy(A(p,p)), title('A(p,p)')
    subplot(2,2,4), spy(Lp), title('Lp')

直观看到:

  • fill-in 大小变化
  • 列模式相似的"块状坚条"(这就是超节点的视觉线索)
  • 重排序对于分解后的非零元填充有很大的影响。

以 LU 分解举例,所谓的重拍,就是对于矩阵 AAA,寻找一个置换矩阵 PPP,使得矩阵重排后分解的非零填充最少:
min⁡pnnz⁡(L+U) s.t. P 是置换矩阵P⊤AP=LU \begin{array}{ll} \min _p & \operatorname{nnz}(L+U) \\ \text { s.t. } & P \space 是置换矩阵 \\ & P^{\top} A P=L U \end{array} minp s.t. nnz(L+U)P 是置换矩阵P⊤AP=LU

为什么 fill-in 很重要?因为 fill-in 会导致:

  • 内存变大(要存更多非零)
  • 计算变慢(算更多乘加)
  • 稀疏问题逐渐变"稠密化"

所以整个稀疏线性代数都在想办法:"怎么安排消去顺序,才能少长 fill-in?"这就引出了 重排(AMD、ND 和 RCM 等等)。一个直觉例子:

  • 先消"度数很大"的点(连接很多邻居),容易让一大群邻居彼此补边,fill-in 爆炸
  • 先消"度数较小"的点,补边通常少一些

AMD (pproximate Minimum Degree,近似最小度)重排就是干这么一件事情。

总结重排序的主要作用:

  • 减少 fill-in(填充)
  • 改善并行性(让消去树更"胖")
  • 提高数值稳定性(LU 中尤其重要)

其他概念

分析(Analysis)

看结构(哪些位置非零,不看数值)、定策略(顺序、树、超节点、内存计划)

消去树在分析阶段能帮你做很多事:

  • 预测列模式(symbolic)
  • 确定计算顺序和依赖
  • 内存分配估计
  • 并行调度
  • 超节点识别(相邻列是否结构相似,常常和树结构有关)
符号分解(Symbolic factorization)

以 LU 分解为例,预测分解后 L/U 的非零位置。为啥要搞个符号分解?

因为在很多应用计算里,矩阵结构不变(稀疏模式一样),但数值会反复变化(比如非线性迭代、时步推进),这时你可以:分析 + 符号分解做一次,数值分解重复做很多次,这样省很多时间。

数值分解

真正计算数值(L/U/LDLᵀ 的具体的数)。

最大权匹配

pivoting 相关预处理:提高稳定性/减少零主元问题。LU 分解中常见。尽量把"大而可靠"的元素安排到对角线上(通过置换)这样之后 LU 分解更稳定、更不容易遇到零主元。

相关推荐
List<String> error_P1 小时前
经典回溯算法解析
python·算法
Fms_Sa1 小时前
设计并实现日期类Date,它至少包含下列特性:
c++·算法
linux_cfan2 小时前
打造智慧校园视听新基建:高校与在线教育平台 Web 视频播放器选型指南 (2026版)
前端·学习·音视频·教育电商
仰泳的熊猫2 小时前
题目1549:蓝桥杯算法提高VIP-盾神与积木游戏
数据结构·c++·算法·蓝桥杯
WW_千谷山4_sch2 小时前
MYOJ_11705:(洛谷P1137)旅行计划(经典拓扑排序)
c++·算法·动态规划·图论
FMRbpm2 小时前
string课后练习
c++·算法·新手入门
学编程的闹钟2 小时前
E语言赋值运算符=的深度解析
学习
Titan20242 小时前
Linux工具(入门)笔记
linux·笔记·学习
啊阿狸不会拉杆2 小时前
《计算机视觉:模型、学习和推理》第 8 章-回归模型
人工智能·python·学习·机器学习·计算机视觉·回归·回归模型