5 步优化
高斯消元通过 5 步优化(提取除法、跳过零、存储乘子、拆分循环、BLAS 表述)演变为 BLAS3 LU 分解。
CS267 · 第 16 课
从高斯消元的 5 步优化到 LU 分解的 BLAS3 形式,再到通信避免算法(CALU、TSLU、TSQR)和 GPU 实现,系统讲解稠密线性代数的并行优化方法论。
稠密线性代数算法的优化遵循三个原则:
通信避免(Communication-Avoiding)不是发明新算法,而是重新组织计算,在不改变 flop 数和答案的前提下,减少数据移动。
求解 Ax = b 的经典方法是高斯消元(Gaussian Elimination, GE)。通过 5 步优化,GE 演变为高效的 BLAS3 LU 分解。
// 原始高斯消元:对每行做除法
for (k = 0; k < n; k++)
for (i = k+1; i < n; i++) {
A[i][k] /= A[k][k]; // 除法
for (j = k+1; j < n; j++)
A[i][j] -= A[i][k] * A[k][j];
}
// 利用 A[k+1:n, k] 被覆盖为乘子(multipliers)的事实
// L = tril(A,-1) + I, U = triu(A)
// 乘子 L_ik = A[i][k] 存储在 A[i][k] 位置
// 这样 L 和 U 共用存储空间
// 将更新分为两步:
for (k = 0; k < n; k++) {
// Step 1: 计算乘子(列操作)
for (i = k+1; i < n; i++)
A[i][k] /= A[k][k];
// Step 2: 更新 trailing submatrix
for (i = k+1; i < n; i++)
for (j = k+1; j < n; j++)
A[i][j] -= A[i][k] * A[k][j];
}
// 转化为 BLAS 操作
for (k = 0; k < n; k++) {
// SCAL: 列缩放
scal(n-k-1, 1.0/A[k][k], &A[k+1][k]);
// GER: rank-1 更新(BLAS2)
ger(n-k-1, n-k-1, -1.0, &A[k+1][k], 1, &A[k][k+1], 1, &A[k+1][k+1]);
}
第 5 步的 GER 是 rank-1 更新,属于 BLAS2。这意味着数据重用仅 O(n),无法充分利用 cache。
为处理小主元,引入部分主元(partial pivoting):在第 k 列的下方找最大元素,交换行。
// GEPP
for (k = 0; k < n; k++) {
// 找主元
pivot = argmax_{i≥k} |A[i][k]|
swap rows k and pivot;
if (A[k][k] == 0) continue; // singular
// 消元
for (i = k+1; i < n; i++) {
A[i][k] /= A[k][k];
for (j = k+1; j < n; j++)
A[i][j] -= A[i][k] * A[k][j];
}
}
GEPP 产生 PA = LU 分解,其中 P 是置换矩阵。数值稳定性:增长因子通常 O(1),最坏情况 O(2^(n-1))(极罕见)。
关键技巧:延迟更新(delayed updates)。将一次 rank-1 更新累积为 rank-b 更新。
// Blocked GEPP
for (k = 0; k < n; k += b) {
// Panel factorization (BLAS2, 必须立即做,确保稳定性)
for (kk = k; kk < min(k+b, n); kk++) {
pivot = argmax_{i≥kk} |A[i][kk]|
swap rows;
for (i = kk+1; i < n; i++) A[i][kk] /= A[kk][kk];
}
// 累积乘子为三角矩阵 T (b×b)
// Trailing matrix update (BLAS3!)
A[k+b:n, k+b:n] -= L_21 * U_12; // GEMM
}
[L11 0 ] [U11 U12]
[L21 L22] = [ 0 A22']
↑panel ↑trailing update (GEMM)
Panel factorization 是 BLAS2(必须流水线化以保证稳定性),但 trailing update 是 GEMM(BLAS3),占主导地位的计算量。
| 阶段 | 计算 | BLAS 级别 | 占比 |
|---|---|---|---|
| Panel factorization | O(n²·b) | BLAS2 | O(b/n) |
| Trailing update | O(n³) | BLAS3 | ≈ 1 - O(b/n) |
| Total | 2/3 n³ + O(n²b) | - | - |
b 应足够大以使 GEMM 主导(通常 b ≥ 32),但 panel factorization 的 BLAS2 部分不能忽略。实际中 b = 64–256 是常见选择。
LU 分解(无 pivoting 或全 pivoting)的通信下界与矩阵乘法相同:
#words = Ω(n³ / √M)
#messages = Ω(n³ / M^(3/2))
Complete pivoting 需要 #messages = Ω(n³ / M),比 partial pivoting 高一个 √M 因子。这解释了为什么实际中几乎总使用 partial pivoting。
标准 LAPACK 的 DGETRF(GEPP)在三种情况下未达到通信下界:
| 情况 | 问题 | 原因 |
|---|---|---|
| 高带宽、高延迟系统 | #messages 过多 | 每次 panel 更新都触发独立通信 |
| 小 cache | #words 过多 | 分块大小受限于 M,无法充分利用 |
| 高维 torus 网络 | #words 过多 | 1D 列通信无法利用高维带宽 |
Toledo(1997)提出的递归 LU 算法天然适应任意 cache 层次:
// Recursive LU: factor A into L and U
// A is n x n, split into 2x2 block
void recursive_lu(double *A, int n) {
if (n <= BASE_CASE) {
dgetrf_(A, n); // 调用 LAPACK 基础情况
return;
}
int n1 = n/2, n2 = n - n1;
// [A11 A12] [L11 0 ] [U11 U12]
// [A21 A22] = [L21 L22] [ 0 U22]
recursive_lu(A11, n1); // Factor A11 = L11*U11
trsm("R", A11, A12); // Solve L11*U12 = A12
trsm("L", A11, A21); // Solve L21*U11 = A21
gemm(A21, A12, A22, -1, 1); // A22 -= L21*U12
recursive_lu(A22, n2); // Recurse on Schur complement
}
GE 的并行布局选择比矩阵乘法更复杂,因为 pivoting 引入了行间的顺序依赖。
Block-skewed 布局使每个处理器在每次迭代中都参与计算,消除因 pivot 行分布不均导致的负载不均。这是 ScaLAPACK 高效的关键之一。
| 布局 | 负载均衡 | 通信效率 | 实现复杂度 |
|---|---|---|---|
| 1D Row-cyclic | 差(pivot 行 idle) | 一般 | 简单 |
| 1D Column-cyclic | 一般 | 一般 | 简单 |
| 2D Block-cyclic | 好 | 好 | 中等 |
| 2D Block-Skewed | 最好 | 好 | 复杂 |
ScaLAPACK 的 PDGEF 实现了分布式 GEPP,但在大规模系统上次优:
// ScaLAPACK PDGETRF 的通信模式
for (k = 0; k < n; k += NB) {
// Panel factorization: O(log P) messages
// Broadcast pivot/column to all processors in column
// Trailing update: GEMM on local blocks
// 但每次迭代都需要 O(n/NB) 次独立通信
}
ScaLAPACK 每次 panel 更新都需要独立的通信,导致总消息数:
#messages = O(n · log P) 或 O(n²/√P · log P)
而通信下界要求 #messages = Ω(n² / P^(1/2))。当 n >> P^(1/2) 时,ScaLAPACK 的消息数远超下界。
Communication-Avoiding LU(CALU)通过锦标赛选主元(tournament pivoting)将 panel 分解的通信批量化:
// CALU 的核心思想:将多步 panel 合并
// 1. 将 k 列打包为一个 "tall skinny" panel
// 2. 在 panel 内并行做 GE(用 tournament pivoting)
// 3. 一次性通信,更新 trailing matrix
// Tournament pivoting: 每轮选出 k 个候选 pivot
// 通过 k x k 的 "tournament" 比较选出最佳
for (round = 0; round < log P; round++) {
// 每对处理器交换候选 pivot
// 本地比较,选出胜者
// 最终得到 k 个全局最优 pivot
}
对于 tall-skinny 矩阵(m >> n,如 m = 10⁶, n = 100),标准 LU 效率极低。TSLU 是为此设计的专用算法:
// TSLU for m x n matrix (m >> n)
// 1. 将矩阵分为 P 个大小为 (m/P) x n 的块
// 2. 每处理器本地做 QR(或 LU)得到局部 R_i
// 3. 将所有 R_i 堆叠,做 tournament pivoting
// 4. 一次通信选出全局 pivot,更新 trailing
// 通信量:O(n² log P) words, O(log² P) messages
// 远低于标准 LU 的 O(mn²/√M)
| 算法 | #words | #messages | 适用场景 |
|---|---|---|---|
| Standard LU | O(mn) | O(m) | 一般矩阵 |
| TSLU | O(n² log P) | O(log² P) | m >> n |
| CALU | O(mn²/√M) | O(n/P^(1/2)) | 一般矩阵,大规模 |
TSQR 是 TSLU 的 QR 版本,通过二叉树 reduction 实现高效并行:
// TSQR for m x n matrix (m >> n)
// 1. 每处理器本地 QR: A_i = Q_i * R_i
// 2. 将 R_i 配对,做 QR of stacked [R_i; R_j]
// 3. 递归直到只剩一个 R
// 树形 reduction(假设 P=4):
// Level 0: 4 local QR → 4 R_i (n×n)
// Level 1: 2 QR of [R0;R1], [R2;R3] → 2 R
// Level 2: 1 QR of [R01; R23] → final R
通信避免算法在实际系统中的加速效果:
| 算法 | 平台 | 规模 | 加速比 |
|---|---|---|---|
| CALU vs ScaLAPACK | Cray XT4, 2048 cores | 40000×40000 | 2–4× |
| TSLU vs ScaLAPACK | InfiniBand cluster | 10⁶×100 | 3–8× |
| TSQR vs Householder | Cray T3E | 10⁶×50 | 4–6× |
| CALU (GPU) | NVIDIA Fermi | 16384×16384 | 1.5–3× |
当问题规模 n 远大于 P^(1/2) 时,通信避免算法的优势最大。对于小问题或 P 很大时,标准 LAPACK/ScaLAPACK 可能已经足够。
| 问题 | 最优算法 | #words | #messages |
|---|---|---|---|
| 矩阵乘法 C=A*B | 2.5D / SUMMA | Ω(F/√M) | Ω(F/M^(3/2)) |
| LU 分解 | CALU / TSLU | Ω(n³/√M) | Ω(n²/P^(1/2)) |
| QR 分解 (tall-skinny) | TSQR | Ω(n² log P) | Ω(log P) |
| Cholesky | 分块递归 | Ω(n³/√M) | Ω(n³/M^(3/2)) |
| 特征值 | Hessenberg + QR | Ω(n³/√M) | - |
Volkov 在 SC08 的论文 "Benchmarking GPUs to tune dense linear algebra" 展示了 GPU 上的高性能 LU 实现:
// Volkov-style GPU GEMM kernel (simplified)
__global__ void volkov_gemm(float *A, float *B, float *C, int N) {
// 每个 block 处理 TILE x TILE 的 C 块
// 每个 thread 使用寄存器累加多个元素
float reg[REGS]; // 大量寄存器
int bx = blockIdx.x, by = blockIdx.y;
int tx = threadIdx.x, ty = threadIdx.y;
#pragma unroll
for (int k = 0; k < N; k += TILE) {
// 协作加载 A 和 B 的 tile 到 shared memory
__syncthreads();
// 每个线程计算多个 C 元素(寄存器密集)
#pragma unroll
for (int i = 0; i < REGS; i++) {
reg[i] += As[ty+i][k_local] * As[tx][k_local];
}
__syncthreads();
}
// 写回 C
}
在 NVIDIA GTX280 上,Volkov 的 GEMM 达到 93% 的峰值 flop/s,远超当时的 CUBLAS。对于 LU,结合 CPU-GPU 异构调度,相比纯 CPU 实现获得 4.4× 加速。
现代多核系统使用 DAG(有向无环图)调度来动态分配任务:
// Cholesky DAG (n=4, b=1)
// 每个节点是一个任务,边表示依赖
POTRF(1,1)
├── TRSM(2,1) ── POTRF(2,2)
│ ├── TRSM(3,2) ── POTRF(3,3)
│ │ └── TRSM(4,3) ── POTRF(4,4)
│ └── SYRK(3,2) ── TRSM(3,2) ...
└── SYRK(2,1) ── GEMM(3,2,1) ...
PLASMA(Parallel Linear Algebra for Scalable Multi-core Architectures)使用 QUARK 调度器:
基于本课程内容的潜在课程项目:
高斯消元通过 5 步优化(提取除法、跳过零、存储乘子、拆分循环、BLAS 表述)演变为 BLAS3 LU 分解。
Delayed updates 将 BLAS2 的 rank-1 更新累积为 BLAS3 的 rank-b 更新,大幅减少通信。
标准分布式 GEPP 的消息数 O(n log P) 远超下界 Ω(n/P^(1/2))。
通信避免算法通过批量化选主元和树形 reduction 达到通信下界,实现 2–8× 加速。
Volkov 的 register-heavy coding 和 look-ahead overlap 是 GPU LA 的典范;DAG 调度是多核时代的主流。
问题 1:分块高斯消元中,"delayed updates" 的主要目的是什么?
问题 2:ScaLAPACK PDGETRF 的通信瓶颈是什么?
问题 3:CALU 算法使用什么技术来批量化选主元?
问题 4:Volkov 的 GPU 高性能 GEMM 实现的关键策略是什么?
问题 5:TSQR 算法的核心思想是什么?