Lecture 9: 并行模式七 —— 稀疏矩阵向量乘:压缩格式与负载均衡 (对应 Lab 7 / Lab 8: Sparse Matrix Multiply)
Lecture 9: 并行模式七 —— 稀疏矩阵向量乘:压缩格式与负载均衡 (对应 Lab 7 / Lab 8: Sparse Matrix Multiply)
概述
稀疏矩阵向量乘(Sparse Matrix-Vector Multiplication, SpMV,即 y = A * x)是科学计算、图分析、推荐系统与深度学习中最常见的核心算子之一:偏微分方程(PDE)离散化、有限元网格、社交网络邻接矩阵、GNN 的邻接聚合都可以归结为 SpMV。现实矩阵中 99% 以上的元素是 0,稠密存储要付出 O(N²) 的存储与带宽代价,因此必须压缩成 CSR / COO / ELL / JDS / HYB 等格式,把字节数降到 O(nnz)(nnz = number of non-zeros,非零元个数)。压缩解决了”存什么”,却制造了新的问题:规则性(regularity)丢失——每行长度不同导致 warp 内控制发散,列索引数据相关导致 x[colIdx[j]] 的间接访存无法合并,工作量差异导致负载不均衡。本讲的主线就是把不规则的稀疏数据重新”规则化”:用 padding(ELL)拉平行长、用转置(ELL/JDS_T)恢复合并访存、用排序(JDS)让相邻线程工作量接近、用混合格式(HYB = ELL + COO)把少量离群超长行单独处理。CUDA 机制上本讲几乎不引入新 API(只用到 __ldg、__shfl_down_sync、共享内存与原子操作),考验的是数据布局设计能力:布局决定了 warp 的访存是否合并、循环是否发散、线程负载是否均衡,也决定了最终能拿到峰值的百分之几。
核心概念与 GPU 架构图解
1. 稀疏性与压缩动机(Sparsity and Compaction)
定义与目的:稀疏矩阵指非零元素个数 nnz 远小于 N² 的矩阵,密度 d = nnz / N²。压缩(compaction)的目的有三个:(a) 减少存储容量,让矩阵能放进显存甚至片上存储;(b) 减少必须搬运的字节数,因为 GPU 上绝大多数算子受 DRAM 带宽限制;(c) 把省下来的带宽预算留给有用数据,提高有效算力。讲义明确指出:稀疏方法的好处是 reducing consumption of memory bandwidth、better utilization of on-chip memory、fewer bytes transferred to on-chip memory、better utilization of global memory,而挑战是 retaining regularity。
直观解释(”它是什么?”):想象一个 10 万座的体育场,实际只卖出 800 张票。如果按”每个座位都写一个值”的方式记录(稠密存储),需要 10 万条记录;如果改成”只记录有人坐的座位号与观众姓名”(压缩存储),只需 800 条。这就是 CSR 做的事:
colIdx记座位号、values记观众。但代价是:稠密的体育场任何时刻你都知道第 5 排第 3 座在哪里(地址可以解析计算i*N+j),压缩后你必须先查表才知道第 5 排有人坐的是哪几个座位——地址从”算出来的”变成了”查出来的”,这一个变化引发了后面所有的性能问题。架构/机制图解:下图对比同一个 4×4 稀疏矩阵的稠密存储与 CSR 存储。
DENSE (N=4) CSR (nnz=7)
A = [ 3 0 1 0 ] values = { 3, 1, 2, 4, 1, 1, 1 } (7 floats = 28 B)
[ 0 0 0 0 ] colIdx = { 0, 2, 1, 2, 3, 0, 3 } (7 ints = 28 B)
[ 0 2 4 1 ] rowPtr = { 0, 2, 2, 5, 7 } (5 ints = 20 B)
[ 1 0 0 1 ] ------------------------------------------------
合计 76 B (稠密需要 16 * 4 = 64 B)
稠密存储的地址: addr(i,j) = base + (i*N + j)*4 <- 可解析计算,编译期可展开
CSR 存储的地址: addr(i,j) 需先查 rowPtr[i]..rowPtr[i+1] 再扫描 colIdx 找 j
<- 数据相关,只能运行时查表
内存占用随规模的变化 (float 值 + int 索引, 每 nnz 8 B):
N = 1e3, 密度 1% : 稠密 4 MB | CSR 0.008 MB + 0.004 MB = 0.012 MB
N = 1e6, 密度 0.1% : 稠密 4000 GB | CSR 8 GB + 4 MB = 8.0 GB
N = 1e6, 密度 5% : 稠密 4000 GB | CSR 400 GB + 4 MB = 400 GB
关键性能特征:CSR 的字节开销是 8*nnz + 4*(N+1),稠密是 4*N²。令两者相等可得压缩的盈亏平衡点:
8*nnz + 4*(N+1) < 4*N^2 <=> nnz < (N^2 - N - 1) / 2 ≈ N^2 / 2
例:4x4 矩阵的盈亏平衡点是 nnz <= 5,而本例 nnz = 7 > 5,
所以 CSR 反而比稠密多占 76 - 64 = 12 B(压缩格式有自己的固定开销 rowPtr)。
N = 1e6 时平衡点是 nnz ≈ 5e11,实际上 nnz 往往只有 1e7 ~ 1e9,压缩收益 3~4 个数量级。
一个形象的量化:在 N = 10⁶ 的 PDE 网格(每行 7 个非零元)上,稠密存储需要 4 TB,超过任何单卡显存;CSR 只需 56 MB。正是压缩让这个矩阵可以被求解。压缩的第二个收益是”每个字节都可能有用”:稠密 SpMV 读进来的浮点数里 99% 是 0,这些 0 白白占用 DRAM 带宽;CSR 则是 100% 有效载荷(payload)加上约 50% 的索引开销。
2. 规则性丢失(Loss of Regularity)
定义与目的:规则性指”程序的控制流与访存地址可以由线程/迭代编号用解析式(affine expression)算出,因而对同一 warp 内 32 个线程是同构的”。稠密算子天然规则:所有行等长、地址 =
i*N+j、每次迭代 32 个 lane 读 32 个相邻地址。稀疏数据打破了这两条:(a) 行长不同 → 每线程循环次数不同 → 控制发散(control divergence);(b) 列索引存放在数组里 → 地址数据相关 → 访存发散(memory divergence)。讲义把这一点总结为 “Compared to dense matrix multiplication, SpMV is irregular/unstructured, has little input data reuse, benefits little from compiler transformation tools”,并把性能关键归纳为两条:maximize regularity(by reducing divergence and load imbalance)与 maximize DRAM burst utilization(layout arrangement)。直观解释(”它是什么?”):稠密计算像军训方阵:发令”齐步走”,32 个人同时迈左脚,队伍效率 100%。稀疏计算像 32 个身高体重各异、携带行李数量不同的旅客同乘一部电梯:有人拎 1 件行李,有人拎 50 件;电梯(warp)必须等到最后一个旅客搬完才能关门,其余 31 人的时间全部浪费。更糟的是,行李(数据)不在同一个货架上,每个人都得单独跑一趟仓库(独立 transaction),而不是让一个人把一整车推过来。
架构/机制图解:下图对比稠密 warp 与稀疏 warp 在每个”迭代”上的 lane 占用情况。
DENSE GEMV (A 列主序): 每行长度相同 (N = 8), warp 内 32 个 lane 完全同步
iter 0 : [ lane0..lane31 共 32 个格子, 全部为 R ] 32/32 有效
iter 1 : [ lane0..lane31 共 32 个格子, 全部为 R ] 32/32 有效
iter 2 : [ lane0..lane31 共 32 个格子, 全部为 R ] 32/32 有效
iter 3 : [ lane0..lane31 共 32 个格子, 全部为 R ] 32/32 有效
iter 4 : [ lane0..lane31 共 32 个格子, 全部为 R ] 32/32 有效
iter 5 : [ lane0..lane31 共 32 个格子, 全部为 R ] 32/32 有效
iter 6 : [ lane0..lane31 共 32 个格子, 全部为 R ] 32/32 有效
iter 7 : [ lane0..lane31 共 32 个格子, 全部为 R ] 32/32 有效
地址: 固定 iter 时 lane t 读 base+t*4 -> 32 x 4 B = 128 B 连续 (完美合并)
效率 = 8 iter x 32 lane = 256/256 = 100%, DRAM 利用率 = 100% (每次读 128 B 全部有用)
SPARSE CSR: 行长度 3,2,5,2,4,2,3,2 (此处画 8 个 lane 便于阅读; 完整 32 lane 版本见概念 8)
iter 0: [ R ][ R ][ R ][ R ][ R ][ R ][ R ][ R ] 8/8 有效,但地址分散 -> 最多 8 个 sector
iter 1: [ R ][ R ][ R ][ R ][ R ][ R ][ R ][ R ] 8/8 有效
iter 2: [ R ][ ][ R ][ ][ R ][ ][ R ][ ] 4/8 有效 (lane 1,3,5,7 已退出循环)
iter 3: [ ][ ][ R ][ ][ R ][ ][ ][ ] 2/8 有效
iter 4: [ ][ ][ ][ ][ R ][ ][ ][ ] 1/8 有效
效率 = 23 / (8 x 5) = 57.5% (真实 32 lane 例子见概念 8 的 44.2%)
三条同时恶化的东西:
1) 控制发散 : 后面的 iteration 只有少数 lane 在工作
2) 访存发散 : 每行起点 rowPtr[row] 不同 -> lane 地址不是相邻 4 B,而是散落在 1152 B 范围
3) 负载不均衡: warp 结束时间由最长行决定
性能特征数字:稠密 GEMV 在 A100 上可以稳定跑到 1300 GB/s(峰值 1555 GB/s 的 85%);而朴素 CSR SpMV 只能跑到约 450 GB/s(29%)。差距全部来自”规则性”,而不是来自于计算量——稀疏版本做的浮点运算比稠密版本少 4 个数量级,却只快了几十倍。这正是 Kirk/Hwu 反复强调的:”稀疏计算的问题不是算得少,而是搬得乱。”
3. CSR 格式(Compressed Sparse Row)
- 定义与目的:CSR 用三个一维数组表示一个 N×N 矩阵:
values[nnz]:按行优先顺序存放所有非零元的值,float 占 4 B;colIdx[nnz]:与values一一对应的列号,int 占 4 B;rowPtr[N+1]:第 i 行的非零元在values/colIdx中的区间为[rowPtr[i], rowPtr[i+1]),多出的第 N+1 个元素是哨兵(sentinel),长度取 N+1 而不是 N,正是为了让”最后一行的结束位置”也能用同一个公式rowPtr[row+1]取到,从而消除 kernel 里的特判分支。
直观解释(”它是什么?”):CSR 就像一本按章节组织的书:
rowPtr是目录,告诉你”第 3 章从第 25 页开始、到第 28 页结束”;values是正文内容,colIdx是每一行正文对应的”小节编号”。读书的人(线程)只要拿到自己的章号(row id),就能通过目录找到自己那一小段正文,且同一章的正文在物理上是连续的(这正是 CSR 比 COO 好的地方:一个线程读自己那一行时是顺序流式访问,硬件预取器与 DRAM row buffer 都很友好)。- 架构/机制图解:用讲义中的 4×4 矩阵(
nnz = 7)给出完整编码实例。
矩阵 A (4x4, nnz = 7) CSR 三个数组的物理布局
A = [ 3 0 1 0 ] row 0: 2 个 idx : 0 1 2 3 4 5 6
[ 0 0 0 0 ] row 1: 0 个 values: 3 1 2 4 1 1 1
[ 0 2 4 1 ] row 2: 3 个 colIdx: 0 2 1 2 3 0 3
[ 1 0 0 1 ] row 3: 2 个 rowPtr: 0 2 2 5 7
^ ^ ^ ^ ^
| | | | +-- rowPtr[4] = 7 (哨兵 = nnz)
| | | +------- rowPtr[3] = 5 -> row 3 = [5,7)
| | +------------ rowPtr[2] = 2 -> row 2 = [2,5)
| +----------------- rowPtr[1] = 2 -> row 1 = [2,2) 空行!
+---------------------- rowPtr[0] = 0 -> row 0 = [0,2)
逐行解码:
row 0: (val 3, col 0) (val 1, col 2) -> A[0][0]=3, A[0][2]=1
row 1: 区间 [2,2) 为空 -> A[1][*]=0 (rowPtr[i] == rowPtr[i+1] 时空行)
row 2: (val 2, col 1) (val 4, col 2) (val 1, col 3)
row 3: (val 1, col 0) (val 1, col 3)
容量对比: 稠密 = 4 * N^2 = 64 B CSR = 8*nnz + 4*(N+1) = 56 + 20 = 76 B
访问一个非零元: values[k] 和 colIdx[k] 的地址 = base + k*4,k 由 rowPtr 查表得到
线程行为: 每线程一行 -> 该线程对 values/colIdx 是连续流式读, 但不同线程的起点不同
关键性能特征:rowPtr 只有 N+1 个元素、每个线程只读 2 个(rowPtr[row] 与 rowPtr[row+1]),因此它的 4 B/行 × 2 开销相对 nnz 可以忽略(n=9.4M 时约 4 MB,占总流量 5%)。CSR 最大的优势是通用性:任何稀疏结构都能表示,且”每线程一行”的映射非常简单。它最大的缺点是两个发散(控制 + 访存),这也是后续所有格式想要修复的对象。在 A100 上,一个线程读 rowPtr[row]/rowPtr[row+1] 时,如果 warp 内 32 个 lane 的行号连续,则两次 load 各自构成一次 128 B 合并访问(rowPtr 与 rowPtr+1 的访问有 31/32 重叠,第二次几乎全部命中 L1),代价可以忽略。
4. COO 格式(Coordinate / 坐标格式)
定义与目的:COO 为每个非零元显式记录三元组
(rowIdx[k], colIdx[k], values[k]),共 3 个长度为 nnz 的数组。它的目的是提供最大并行度(nnz 个线程/元素互不依赖)并且允许任意重排序:既然行号是显式存储的,就可以按行号排序、按列号分区、按值分桶,从而为后续格式(CSR 的构建、ELL/JDS 的排序、HYB 的拆分)提供自由。讲义明确指出 “COO Allows Reordering of Elements”。代价是:多一个rowIdx数组(比 CSR 多 4 B/nnz,流量增加 50%),而且同一行的多个元素会被不同线程同时写回y[row],必须使用原子操作或分段归约。直观解释(”它是什么?”):COO 像快递站里的”待派件清单”:每一行写的是”收件人编号、物品编号、物品”。清单不按收件人排好序,但正因为每一项自带收件人编号,你可以随时把清单重新排序、按小区分组(这就对应把 COO 转成 CSR:先排序再数数)。而派送(写
y)时,如果同一个收件人有 5 件快递、由 5 个不同的快递员同时送到,就必须在门口排队登记(原子加),否则会互相覆盖。架构/机制图解:
同一个 4x4 矩阵的 COO 编码
values = { 3, 1, 2, 4, 1, 1, 1 }
colIdx = { 0, 2, 1, 2, 3, 0, 3 }
rowIdx = { 0, 0, 2, 2, 2, 3, 3 } <- 注意: 同值元素顺序任意, 可以重排成 {2,2,2,0,0,3,3}
串行 SpMV/COO (讲义 page 24 原式):
for (int i = 0; i < num_elem; i++)
y[rowIdx[i]] += values[i] * x[colIdx[i]];
并行化后的硬件视图 (一个 warp 处理 32 个连续的非零元):
lane: 0 1 2 3 4 5 6 7
data : [ 3 ] [ 1 ] [ 2 ] [ 4 ] [ 1 ] [ 1 ] [ 1 ] [ 0 ] <- 合并: 连续 32 B
col : [ 0 ] [ 2 ] [ 1 ] [ 2 ] [ 3 ] [ 0 ] [ 3 ] [ 1 ] <- 合并读
row : [ 0 ] [ 0 ] [ 2 ] [ 2 ] [ 2 ] [ 3 ] [ 3 ] [ 2 ] <- 合并读
| | | | | | |
v v v v v v v
y[row]: y[0] y[0] y[2] y[2] y[2] y[3] y[3] y[2]
\____/ \______________/
写冲突! 写冲突! -> 必须 atomicAdd(&y[r], v)
代价: 一条 global atomicAdd 到同一地址时, 同一 warp 内对同一地址的多次原子操作会被序列化
(A100 的 L2 原子单元对同一地址一次只能处理一个), 最坏情况把 32 路并行退化成 32 次串行
流量: COO = 12 B/nnz (比 CSR 的 8 B/nnz 多 50%), 但并行度 = nnz 个线程, 与行长无关
关键性能特征:COO 的每个 lane 读取的 values、colIdx、rowIdx 都是连续地址(因为元素按线性编号分布),因此访存是完美合并的,控制流也完全无发散(所有线程迭代次数相同)。它的代价转移到了输出端:atomicAdd 到全局内存 y[row]。在讲义中 HYB 的 COO 部分用 segmented reduction(分段归约,先归约到共享内存再原子加)来实现,正是为了把原子冲突从”每元素一次”降到”每段一次”。
5. ELL / ELLPACK 格式(每次补齐到最长行)
定义与目的:ELL 用两个二维数组(按列主序存储)表示矩阵:
data[N][E]与colIdx[N][E],其中E = max_i(nnz_in_row_i)是最长行的长度。不足 E 的行用填充(padding)补齐。目的是消除控制发散:所有行的循环次数都变成同一个常数 E,for (i = 0; i < numElem; i++)对每个线程都是相同的 trip count,warp 内 32 个 lane 完全同步;同时因为按列主序(transposed)存放,colIdx[i*N + row]在固定 i 时对相邻 row 是连续地址,于是访存也变成合并的。讲义把这一步总结为 Regularizing SpMV with ELL(PACK) Format:pad all rows to the same length、transpose (column major) for DRAM efficiency、both data and col_index padded/transposed。直观解释(”它是什么?”):ELL 像把一群身高不齐的人塞进同一排照相:为了整齐,每个人脚下垫不同厚度的台子(padding),垫到和最高的人一样高。照片(内存布局)整齐了、冲洗效率高了,但如果队伍里混进一个 2.5 米的巨人,所有人都要垫到 2.5 米——浪费的木板(padding)比人本身还多。这就是讲义说的 “Inefficient if a few rows are much longer than others”,也是 HYB 格式诞生的原因。
架构/机制图解:把上文 4×4 矩阵转成 ELL(E = 3,最长行是 row 2)。
行优先的 ELL 逻辑视图 (padding 用 0 值填充) 转置后的物理布局 (列主序, DRAM 友好)
i=0 i=1 i=2 data : { 3, 0, 2, 1, 1, 0, 4, 1, 0, 0, 1, 0 }
row0 ( 3, c0) ( 1, c2) ( 0, * ) colIdx : { 0, 0, 1, 0, 2, 0, 2, 3, 0, 0, 3, 0 }
row1 ( 0, * ) ( 0, * ) ( 0, * ) 长度 12 = N * E = 4 * 3 (CSR 只有 7 个真实元素)
row2 ( 2, c1) ( 4, c2) ( 1, c3) 额外开销 = 12/7 = 1.71x (小矩阵上非常不划算)
row3 ( 1, c0) ( 1, c3) ( 0, * )
硬件访问模式 (warp 内 4 个连续 row, E = 3):
i = 0: data[0*4 + row0..row3] = 3, 0, 2, 1 <- 连续 16 B, 1 个 transaction (4 lane)
colIdx[0*4 + row0..row3] = c0, c0, c1, c0 <- 连续读, 合并
x[ c0 ], x[ c0 ], x[ c1 ], x[ c0 ] <- gather, 但值域很窄 -> 大概率命中同一 L1 line
i = 1: data[1*4 + row0..row3] = 1, 0, 4, 1 <- 再一次连续合并读
i = 2: data[2*4 + row0..row3] = 0, 0, 1, 0
所有 lane 迭代次数相同 -> 控制流 0 发散, 有效 lane 率 = 100%
真实 18 点模板的 ELL 访问 (warp = 连续 32 行):
i 固定时, 32 个 lane 读 colIdx[i*N + r], r = base..base+31, 地址连续 -> 1 个 128 B transaction
而 colIdx 的值域是 r-1025 .. r+1025 (约 2050 个元素 = 8 KB = 64 条 cache line)
-> gather 的 32 次访问落在 64 条 line 内, 有大量重叠, L1 命中率高
关键性能特征:ELL 的存储开销是 8 * N * E 字节。定义填充率 E / avg_nnz_per_row:
- 9 点模板(行长 4/6/9):E = 9,平均行长 ≈ 8.99,填充浪费 < 0.2%,ELL 的字节数甚至略少于 CSR(少了
rowPtr); - 讲义式”少数超长行”矩阵(平均 9、最长 49):E = 49,存储与流量放大 5.4 倍,ELL 直接输给 CSR。
因此 ELL 的适用判据是行长方差小(方差大时用 HYB)。另一个隐性收益是:ELL 的 data 数组每个 lane 只读 4 B 且地址连续,非常容易升级成 float4 向量化加载(见代码示例 3)。
6. JDS / JDS-T 格式(Jagged Diagonal Sparse,锯齿对角格式)
定义与目的:JDS 先按行长降序排序所有行,再把每一”列”(称为一个 section)连续存放,形成锯齿状的对角线结构。它需要 4 个数组:
data、colIdx(按排序后的顺序)、jds_row_ptr(每个 section 的起始偏移与长度)、jds_row_perm(排序后第 i 个位置对应原矩阵的哪一行,用于把结果写回正确的y[row])。讲义原文:”Sort rows into descending order according to number of non-zero. Keep track of the original row numbers so that the output vector can be generated correctly.” 目的:行长排序后,相邻线程的行长非常接近,warp 内最长行与最短行的差距被压缩,控制发散大幅减少(JDS vs CSR - Control Divergence: “neighboring threads tend to execute similar number of iterations because of sorting. Better thread utilization, less control divergence”)。JDS-T 是在 JDS 基础上再做一次转置(section 内按 lane 连续存放),从而把”相邻线程访问不相邻地址”的内存发散也修掉。讲义同时提醒:转置后的访问 “Not aligned with DRAM bursts but OK with recent GPUs”(新架构的 L2 以 32 B sector 为单位,容忍非 128 B 对齐的起始地址)。直观解释(”它是什么?”):JDS 像运动会入场式前先把各班按人数排好队:先是 50 人的大班,再是 30 人的班,最后是 5 人的小班。这样每一”排”(section)里,各班的队尾长度差不多,不会出现”一排里 31 个班已经走完、只剩一个班还在走”的尴尬。
jds_row_perm就是花名册,告诉你排在第 3 位的是原来哪个班,颁奖(写y)时才不会发错人。架构/机制图解:用同一个 4×4 矩阵走一遍 CSR → JDS 的转换。
CSR: row 0 (2 个) row 1 (0 个) row 2 (3 个) row 3 (2 个)
按行长降序排序后的行顺序: [ row2, row0, row3, row1 ] <- 长度 3, 2, 2, 0
JDS 的 section 结构 (每个 section 是"所有行长 >= i 的行"的第 i 个元素):
section 0 (长度 4): row2->(2,c1) row0->(3,c0) row3->(1,c0) row1-> 无
section 1 (长度 3): row2->(4,c2) row0->(1,c2) row3->(1,c3)
section 2 (长度 1): row2->(1,c3)
data[7] = { 2, 4, 1, 3, 1, 1, 1 }
colIdx[7] = { 1, 2, 3, 0, 2, 0, 3 }
jds_row_ptr[5] = { 0, 3, 5, 7, 7 } <- 按"原始行号"排序的偏移: 行长 = 3,2,2,0
jds_row_perm[4] = { 2, 0, 3, 1 } <- 第 i 个位置对应原矩阵第 perm[i] 行
注意 jds_row_ptr 的语义: 与 CSR 的 rowPtr 结构相同 (前缀和), 但行号已被排序置换
position 0 (原 row 2): [0,3) 得到 2, 4, 1
position 1 (原 row 0): [3,5) 得到 3, 1
position 2 (原 row 3): [5,7) 得到 1, 1
position 3 (原 row 1): [7,7) 空
写回输出: y[ jds_row_perm[row] ] = dot;
JDS-T(转置版)进一步把同一 section 内的元素按 row 连续存放,于是固定 section 时 32 个 lane 读连续地址:
JDS-T 布局 (把同一 section 内的元素按 row 连续存放, 仍用同一个 4x4 例子)
section 0 (参与的行: 原 row 0, 2, 3): data { 3, 2, 1 } col { 0, 1, 0 }
section 1 (参与的行: 原 row 0, 2, 3): data { 1, 4, 1 } col { 2, 2, 3 }
section 2 (参与的行: 原 row 2): data { 1 } col { 3 }
matData[7] = { 3, 2, 1, 1, 4, 1, 1 } <- 按 matColStart 索引 (Lab 8 变量名)
matCols[7] = { 0, 1, 0, 2, 2, 3, 3 }
matColStart[4] = { 0, 3, 6, 7 } <- 每个 section 的起始偏移 (Lab 8 变量名)
matRows[4] = { 3, 3, 1, 0 } <- 每个 section 的有效行数 (Lab 8 变量名)
matRowPerm[4] = { 2, 0, 3, 1 } <- 排序后第 i 位对应原矩阵哪一行
kernel 循环条件 (讲义 page 24):
while (matColStart[sec+1] - matColStart[sec] > row) {
dot += data[matColStart[sec]+row] * x[colIdx[matColStart[sec]+row]];
sec++;
}
^ 含义: 线程 row 只参与"有效行数 > row"的那些 section
访存: 固定 sec 时, lane = 0..31 读 data[matColStart[sec] + lane], 地址连续 -> 合并
关键性能特征:JDS 把控制发散的”最大行长 / 平均行长”比值从全局最坏情况拉平到局部相邻(排序后相邻行长差通常 ≤ 2),但内存发散仍然存在(讲义 “JDS vs. CSR - Memory Divergence: adjacent threads still access non-adjacent memory locations”),所以需要 JDS-T 的第二次转置。代价是:转换需要排序(主机端 O(nnz log N))、多两个辅助数组(matColStart、matRowPerm),以及 kernel 里 while 循环的每次迭代都要读 matColStart(可放入共享内存或常量内存)。JDS 特别适合大致三角(roughly triangular)的矩阵,因为排序后的锯齿结构天然匹配三角矩阵的行长单调性(讲义:”Roughly triangular - Probably best with JDS. Takes advantage of sparsity structure.”)。
7. 混合格式 HYB(ELL + COO)
定义与目的:HYB 把矩阵拆成两部分:典型部分交给 ELL(行长都 ≤ 某个阈值 E),离群部分(少数超长行中超过 E 的那些非零元)交给 COO 处理,输出用 segmented reduction(分段归约)合并。目的是同时获得 ELL 的规则性与 COO 的灵活性:ELL 的 E 不再被一两个离群长行拖到极大(避免 padding 爆炸),而离群元素也不会破坏 ELL 的整齐结构。讲义:”ELL handles typical entries, COO handles exceptional entries, implemented with segmented reduction. Often implemented in sequential host code in practice.”(格式转换常在主机端串行完成)。
直观解释(”它是什么?”):机场安检的两条通道:98% 的旅客只带一个登机箱,走”标准通道”(ELL),速度快、队伍整齐;少数携带 5 件大件行李的旅客走”人工通道”(COO),单独处理。如果用同一条通道,所有人都要被最慢的旅客拖住(ELL 把所有人的行李格数 pad 到 5);如果全部走人工通道(纯 COO),标准旅客也享受不到流水线速度。
架构/机制图解:
行长度分布 (示意): 大部分行 4~9 个非零元, 少数行 40+ 个
行号: 0 1 2 3 4 5 6 7 8 9 10 11
行长: 9 8 9 7 9 9 8 9 9 8 9 9
行号: 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033
行长: 9 9 49 9 9 8 9 9 9 8 9 9
^ 第 1024 行是"枢纽行"(离群长行), 其余行都是 7~9
纯 ELL (E = 49): 浪费 = (49 - 8.99) / 8.99 = 445% 存储 8*N*49 字节
纯 CSR : 存储 8*nnz + 4*(N+1) 字节, 但有发散
HYB (E = 9) : ELL 部分 8*N*9 字节 + COO 部分 8*(离群元素个数)
ELL 部分 (规则的 99.6% 元素) COO 部分 (0.4% 离群元素)
+-----------------------------+ +--------------------------+
| data[N][9] colIdx[N][9] | | data[40960] |
| 固定循环 9 次, 0 发散, 合并 | | colIdx[40960] |
+-----------------------------+ | rowIdx[40960] |
| +--------------------------+
v |
y_partial[N] (每行一个部分和) v
\___________ segmented reduction ___/
(共享内存归约 + 每行一次 atomicAdd)
|
v
y[N]
关键性能特征:HYB 的收益取决于分布的长尾程度。在 9 点模板 + 1% 离群行(每行多 40 个元素)的例子上:nnz = 9.42M + 0.04M,纯 ELL 需要 8 × 1.05M × 49 = 411 MB 流量,而 HYB(E = 9)只需 8 × 1.05M × 9 + 8 × 40960 = 75.5 MB + 0.33 MB ≈ 75.8 MB,流量降到 1/5.4,比纯 CSR(80 MB)还少。代价是 COO 部分需要归约与原子操作,以及主机端多一次划分(讲义:”Often implemented in sequential host code in practice”)。现代 GPU 库(cuSPARSE 的 HYB、以及后续的 SELL-P/SELL-C-σ)都沿用了这个”主体规则 + 尾部灵活”的思想。
8. 负载不均衡(Load Imbalance)
定义与目的:负载不均衡指同一 warp / block 内不同线程的工作量(迭代次数)差异巨大,而 SIMT 硬件要求一个 warp 的所有 lane 在同一时刻执行同一条指令——warp 的完成时间由最慢的那个 lane决定。在”每线程一行”的 SpMV 中,工作量就是行长
rowPtr[row+1] - rowPtr[row]。讲义对此有一句非常精炼的结论(Lecture 15 page 10):”Block performance is determined by longest row.”(块性能由最长行决定)。目的是量化这种浪费并选择修复手段(排序 JDS、限幅 HYB、动态调度 work stealing)。直观解释(”它是什么?”):32 个人一起做同一份”抄写作业”,每人抄自己那一章。老师规定”同一个小组必须同时交卷”(SIMT 的 lockstep)。第 7 章有 200 页,第 3 章只有 3 页:抄完 3 页的人只能干坐着等,直到抄 200 页的人写完。整个小组的”有效工作时间”= 所有人页数之和,(浪费) = 32 × 200 − 页数之和。把章节按页数重新分配(排序)或允许抄完的人去帮别人(动态调度),就能把利用率从 44% 拉回 90% 以上。
架构/机制图解:下面是代码示例 2 中会用到的一个真实感 warp 行长分布(32 个 lane,模拟不规则网格/图矩阵)。
lane : 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
len : 3 2 5 2 4 2 3 2 4 3 2 6 2 3 4 2
lane : 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
len : 3 2 5 3 2 4 3 2 3 7 2 3 4 2 3 2
sum(len) = 99, max(len) = 7 (lane 25 是"最长行")
warp 执行时间轴 (每个格子 = 1 个迭代槽位, R = 该 lane 有真实工作):
lane 0 [R][R][R][ ][ ][ ][ ] lane 16 [R][R][R][ ][ ][ ][ ]
lane 1 [R][R][ ][ ][ ][ ][ ] lane 17 [R][R][ ][ ][ ][ ][ ]
lane 2 [R][R][R][R][R][ ][ ] lane 18 [R][R][R][R][R][ ][ ]
lane 3 [R][R][ ][ ][ ][ ][ ] lane 19 [R][R][R][ ][ ][ ][ ]
lane 4 [R][R][R][R][ ][ ][ ] lane 20 [R][R][ ][ ][ ][ ][ ]
lane 5 [R][R][ ][ ][ ][ ][ ] lane 21 [R][R][R][R][ ][ ][ ]
lane 6 [R][R][R][ ][ ][ ][ ] lane 22 [R][R][R][ ][ ][ ][ ]
lane 7 [R][R][ ][ ][ ][ ][ ] lane 23 [R][R][ ][ ][ ][ ][ ]
lane 8 [R][R][R][R][ ][ ][ ] lane 24 [R][R][R][ ][ ][ ][ ]
lane 9 [R][R][R][ ][ ][ ][ ] lane 25 [R][R][R][R][R][R][R] <- 最长行, 决定 warp 何时结束
lane 10 [R][R][ ][ ][ ][ ][ ] lane 26 [R][R][ ][ ][ ][ ][ ]
lane 11 [R][R][R][R][R][R][ ] lane 27 [R][R][R][ ][ ][ ][ ]
lane 12 [R][R][ ][ ][ ][ ][ ] lane 28 [R][R][R][R][ ][ ][ ]
lane 13 [R][R][R][ ][ ][ ][ ] lane 29 [R][R][ ][ ][ ][ ][ ]
lane 14 [R][R][R][R][ ][ ][ ] lane 30 [R][R][R][ ][ ][ ][ ]
lane 15 [R][R][ ][ ][ ][ ][ ] lane 31 [R][R][ ][ ][ ][ ][ ]
总槽位 = 32 lane x 7 iter = 224 有效工作 = 99
warp 内有效利用率 = 99 / 224 = 44.2% -> 55.8% 的 lane-槽位被浪费
若长度分布极度倾斜 (例如 1 行 200, 31 行 3):
利用率 = (200 + 93) / (32 x 200) = 293 / 6400 = 4.6%
修复思路与效果:
按行长降序排序 (JDS): 相邻 lane 的长行聚在一起, 利用率 -> 85%~95%
限幅 + 混合 (HYB, E=9): 把 7 这类离群值移出主循环, 利用率 -> 接近 100%
动态调度 (atomic 取行): 长行与短行交错在不同 warp 之间, 牺牲一点局部性换均衡
性能特征:负载不均衡在两种粒度上出现。(a) warp 内/block 内:如上表,直接浪费算力与访存槽位,且 warp 长时间占用 warp scheduler 的发射槽;(b) block 间/wave 间(尾部效应 tail effect):如果每个 block 内最长行所在的线程决定 block 的寿命,而 grid 中最后一个 wave 只填满部分 SM,则整个 kernel 的尾部会有大量 SM 空转。以 A100 为例,若 grid 有 108×8 = 864 个 block 的容量而实际有 900 个 block,则第二个 wave 只有 36 个 block(占 SM 数的 33%),尾部时间等于”一整轮 block 的时间”,利用率损失可达 30%~50%。这也是”每线程多行 + grid-stride loop”这种线程粗化能改善尾部效应的原因。
9. SpMV 的访存模式与算术强度(Memory Access Pattern and Arithmetic Intensity)
定义与目的:算术强度(arithmetic intensity, AI)定义为”每搬运 1 字节所执行的浮点运算次数(FLOP/Byte)”,它与机器平衡点(machine balance = FP32 峰值 / 带宽)比较,就能判断算子受带宽限制还是受计算限制(Roofline 模型)。SpMV 是最典型的带宽受限(memory bound)算子:它几乎不做任何可复用的计算,却必须把整个矩阵的压缩表示读一遍。讲义把它总结为 “has little input data reuse”,并强调稀疏方法的核心收益是 “reducing consumption of memory bandwidth”。定量分析 SpMV 的访存需求,是判断任何优化是否值得做的第一步。
直观解释(”它是什么?”):把 GPU 想成一家餐厅:厨房(SM / FP32 单元)每秒能做 19.5 万亿道菜,但服务员(DRAM 带宽)每秒只能从仓库搬 1.5 TB 食材。做一道”稠密矩阵乘”这样的菜,每份食材要反复加工 10 次以上(数据复用高),厨房是瓶颈;而 SpMV 这道菜是”每份食材只切一刀就上桌”(复用为零),服务员跑断腿厨房也吃不饱。算术强度就是”每公斤食材加工多少刀”:SpMV 是 0.167 刀/公斤,而 A100 的厨房配置是 12.5 刀/公斤才算平衡——差了 75 倍。
架构/机制图解:
SpMV 每个非零元的访存与计算 (CSR 基础版, 每线程一行)
+-----------------+ +-------------------+ +--------------------+
| values[elem] | | colIdx[elem] | | x[ colIdx[elem] ] |
| 4 B, 顺序流式 | | 4 B, 顺序流式 | | 4 B, 不规则 gather|
+-----------------+ +-------------------+ +--------------------+
| | |
+-----------+-----------+--------------------------+
v
+-------------------+ 每 2 FLOP (1 FMA = 乘 + 加) 需要 12 B
| fmaf(val, x, dot) | AI = 2 / 12 = 0.167 FLOP/Byte
+-------------------+
稠密 GEMV 的对比 (每线程一行, A 列主序):
每个元素: 读 A 4 B (顺序、合并) + 读 x[j] 4 B (warp 内 32 lane 读同一地址 -> 广播, 只算 1 次)
-> 每个 FMA 实际只搬 4 B -> AI = 2 / 4 = 0.5 FLOP/Byte
x 还可以整体放进共享内存 (N 不大时), 于是 x 的 4 B 也被消掉 -> AI 上限 2/4 = 0.5
-> 稠密 GEMV 的算术强度是 SpMV 的 3 倍, 所以它能跑到 1300 GB/s / 658 GFLOP/s
Roofline 图 (A100: 1555 GB/s, 19.5 TFLOPS FP32, 机器平衡点 12.5 FLOP/B)
GFLOP/s (log)
19500 |------------------------------------------+ <- FP32 计算屋顶
| |
| |
| |
| |
| (12.5, 19500) 拐点 |
| \ |
| \ |
| \ |
| \ |
| \ |
| \ |
| * SpMV (0.167, 260) \ |
| \ \ |
| \ \ |
| \ \ |
| \ \ |
260 |----------+-----------------------------\--| <- 带宽屋顶 = 1555 x 0.167
| SpMV 实际 (ELL) 175 |
+----+-----+------------------------------+-----+
0.167 0.5 12.5 AI (FLOP/Byte)
结论: SpMV 的性能点被压在带宽屋顶的最左下角, 离计算屋顶有 75 倍的距离
任何"减少浮点指令"的优化都没有意义, 只有"减少字节 / 让字节更规则"才有意义
关键性能特征与数字(A100,FP32):读取每个非零元需要 values 4 B + colIdx 4 B + x 4 B = 12 B,对应 2 FLOP,因此 AI = 0.167 FLOP/Byte。性能上限 = 1555 GB/s × 0.167 FLOP/Byte ≈ 260 GFLOP/s,只有 19.5 TFLOPS 峰值的 1.3%。这个数字是本讲所有讨论的出发点:SpMV 的本质困难在数据搬运,而不在计算。
推论(可用于指导所有优化决策):
- 减少
colIdx的位宽(int32 → int16,当 N ≤ 65536 时)可以把每 nnz 的字节从 12 B 降到 10 B,上限提升 20%——这是”减少字节”; - 行分块(block CSR)让
colIdx只在块级别存一次,进一步降低索引开销; - 让
x常驻共享内存或 L1(当4N字节放得下时),把 12 B/nnz 降到 8 B/nnz,上限提升 50%——这是最直接的收益,也是本讲”共享内存缓存 x”技巧的价值所在; - 提高访存的规则性(对齐、连续、可合并)能把实际带宽从峰值的 29%(朴素 CSR)拉到 50%~56%(ELL),这是把”理论上限”变成”实际成绩”的关键。
10. 间接访存 x[colIdx[j]] 的 gather(Indirect / Gather Access)
定义与目的:
x[colIdx[j]]是”地址存在数组里的”间接访存(indirect access / gather):被访问的地址在运行前未知,必须先把colIdx[j]从内存读出来,再做一次地址翻译。它存在的唯一原因是压缩格式把”列号”变成了数据。它与稠密版本的区别是本质性的:稠密版的地址A[i*N+j]是 affine 的(编译期可算出),硬件可以完美合并;gather 的地址是数据相关的,同一 warp 内 32 个 lane 的地址可能毫无规律。目的是理解”为什么稀疏 kernel 的带宽利用率只有稠密 kernel 的一半甚至三分之一”,并据此设计布局。直观解释(”它是什么?”):图书馆借书。合并访问 = 你把 32 本书号按书架顺序报给管理员,管理员推着车一次性从同一排书架上取下 32 本(1 趟);gather = 32 个书号随机分布在整个图书馆的 32 排书架上,管理员必须跑 32 趟,每趟只取 1 本。更糟的是,管理员每趟都会顺手把整排书架的一小格(32 B sector)搬回来——即使这一格里只有 4 B 是你需要的。这就是 gather 的 8 倍放大:只用了 1/8 的搬运量。
架构/机制图解:以 x 的长度 N = 1,048,576(4 MB)为例,A100 的 L1 以 128 B cache line / 32 B sector 为粒度。
情形 A: 合并访问 (稠密 kernel, 或 ELL 中的 data/colIdx 数组)
地址公式: addr(lane) = 0x1000 + lane*4, lane = 0..31
lane0 -> 0x1000 lane1 -> 0x1004 lane2 -> 0x1008 lane31 -> 0x107C
+-----------------------------------------------------------------+
| 0x1000..0x107F: 128 B cache line (4 个 32 B sector), 全部有用 |
+-----------------------------------------------------------------+
事务数 = 4 sectors = 128 B 传输 / 128 B 有用 -> 效率 100%
情形 B: 随机 gather (x[colIdx[j]], 列号随机分布)
列号: lane0 = 91234 lane1 = 10577 lane2 = 88321 lane3 = 4509
地址: lane0 = 0x59xx lane1 = 0x0Axx lane2 = 0x56xx lane3 = 0x04xx
这四个 lane 分别落在 4 条不同的 128 B line、4 个不同的 32 B sector 上;
随机列号使其余 28 个 lane 同样各自落在不同的 line 上。
+----+ +----+ +----+ +----+
|32 B| |32 B| |32 B| |32 B| 每个 lane 各占一个 sector
+----+ +----+ +----+ +----+
^lane0 ^lane1 ^lane2 ^lane3 (lane4 到 lane31 同理, 共 32 个 sector)
事务数 = 32 sectors = 1024 B 传输 / 128 B 有用 -> 效率 12.5% (8x 放大)
量化: x 有 L = 4 MB / 128 B = 32768 条 cache line
32 个随机列号落在多少条不同的 line 上?
E[不同 line 数] = L * (1 - (1 - 1/L)^32) ≈ 32768 * (1 - e^(-32/32768)) ≈ 31.98
-> 几乎是 32 条不同 line, 即"零合并"
情形 C: 结构化 gather (9 点模板, ELL 布局, warp = 连续 32 行)
9 点模板中, 第 i 个元素对应的列号 ≈ row + offset(i), offset 只有 9 种取值
lane r 的列号 = r + offset -> 32 个 lane 的列号聚集在约 35 个连续整数内
地址范围 ≈ 140 B -> 落在 2 条 cache line / 5 个 sector 内
事务数 ≈ 5 sectors = 160 B / 128 B 有用 -> 效率 80%, 且后续迭代全部命中 L1
-> 这就是"结构化稀疏矩阵(模板/带状)"能跑快, 而"随机稀疏矩阵"跑不快的根本原因
关键性能特征:gather 有两条不同的代价路径,必须区分清楚。
- DRAM 路径:如果
x太大、放不进 cache,则每次 gather 都可能是一次 DRAM 访问,8 倍放大直接吃掉带宽。此时唯一出路是把x做分块(tiling):把矩阵按列切成x能放进 L2/共享内存的块,逐块做 SpMV 累加。 - L1/LSU 路径:如果
x很小(如 4 MB,A100 的 L2 是 40 MB,L1 是 128 KB/组),gather 基本命中 cache,不消耗 DRAM 带宽,但仍然消耗 L1 的事务处理能力与 LSU 的发射槽:一次 warp 的 gather 需要 32 个 sector 的 L1 tag 查询,L1 吞吐约 128 B/cycle,因此 gather 的有效吞吐远低于合并访问。A100 上每个 SM 的 L1 峰值约 128 B/cycle × 1.41 GHz ≈ 180 GB/s,108 个 SM 合计 19.5 TB/s;gather 把它拉低到 12.5% 的效率,相当于只剩 2.4 TB/s 的有效吞吐——当 L2/DRAM 带宽(1.5 TB/s)不足以掩盖这个瓶颈时,它就会成为新的限制。这也是讲义中 “Memory Divergence (Uncoalesced Accesses)” 一页想强调的现象。
与稠密 kernel 的本质差别总结:
维度 稠密 GEMV (A 列主序/共享内存 x) 稀疏 SpMV (CSR)
------------ --------------------------------- ----------------------------------
地址来源 解析式 (i*N+j), 编译期可知 查表 (rowPtr + colIdx), 运行期才知
合并粒度 32 lane x 4 B = 128 B 完美合并 data/colIdx 可能合并; x[colIdx] 几乎不合并
数据复用 x[j] 被全部 lane 广播复用 x[col] 有复用(取决于列分布), 矩阵本身零复用
warp 内一致性 所有 lane 循环次数相同 每 lane 循环次数 = 该行行长, 差异可达数十倍
编译器优化 #pragma unroll / 向量化 / 寄存器分块 几乎无法展开 (trip count 运行期才知)
算术强度 0.5 FLOP/B 0.167 FLOP/B
实测带宽占比 85% (1322 GB/s) 29% (449 GB/s)
11. SpMV 的优化技术总览(Optimization Toolkit)
定义与目的:把前述问题映射到具体技术:每一个瓶颈(控制发散、访存不合并、gather、负载不均衡、字节太多)都有一套对应的修复手段。目的是建立一个”看到症状 → 选择技术”的对照表,避免盲目试错。
直观解释(”它是什么?”):像医生的处方单:不是所有病人都吃同一种药,而是先看症状(用 profiler 看是 control divergence 还是 uncoalesced load,是 dram__throughput 高还是 l1tex 事务多),再对症下药。
架构/机制图解:技术与瓶颈的对应关系如下。
+--------------------------+-------------------------------------------------------+
| 性能症状 (profiler 指标) | 对应优化技术 |
+--------------------------+-------------------------------------------------------+
| branch_efficiency 低 | 1) ELL 补齐行长 2) JDS 按行长排序 3) HYB 限幅 |
| (warp 发散) | 4) 每线程多行 (线程粗化), 让 warp 总工作量拉平 |
+--------------------------+-------------------------------------------------------+
| l1tex 事务数 / 有用字节 | 1) ELL/JDS-T 转置 -> data/colIdx 连续 |
| 比值高 (未合并) | 2) warp-per-row + __shfl_down_sync 归约 |
| | 3) 按 128 B 对齐 padding |
+--------------------------+-------------------------------------------------------+
| dram__throughput 高但 | 1) 共享内存/常量内存缓存 x (N 小) |
| 有效 GFLOP/s 低 | 2) colIdx 用 int16/uint16 降低位宽 |
| (字节太多) | 3) 块压缩 (BCSR) 摊薄索引; 4) 矩阵按列分块复用 x |
+--------------------------+-------------------------------------------------------+
| 部分 SM 空闲 / tail | 1) work stealing (全局 atomic 取行号) |
| | 2) grid-stride loop + 每线程多行 |
| | 3) 调整 block 大小使 grid 是 SM 数的整数倍 |
+--------------------------+-------------------------------------------------------+
| 延迟受限 (MLP 不足) | 1) float4 向量化加载, 提供 4 倍独立 load |
| | 2) 每线程多个累加器 (寄存器分块) |
| | 3) __ldg / __restrict__ 走只读数据路径 (texture path) |
+--------------------------+-------------------------------------------------------+
| 共享内存 bank conflict | 1) padding 列数使其不是 32 的倍数 |
| | 2) 转置布局; 3) 让同一 warp 读连续地址 |
+--------------------------+-------------------------------------------------------+
关键性能特征(各技术的典型收益,A100 / 9 点模板 / nnz ≈ 9.4M):
- 共享内存缓存 x:当
4N ≤ 48 KB(N ≤ 12288)时,把 x 一次性搬进共享内存(延迟从 400-800 cycles 降到 20-30 cycles,且没有 L1 事务浪费),实测可提升 15%~30%;讲义中 “better utilization of on-chip memory / fewer bytes transferred to on-chip memory” 指的就是这一类收益。注意 N 很大时不可行(N = 10⁶ 需 4 MB ≫ 164 KB),此时退化为”按列分块”(一次只处理 x 的一个片段)。 - float4 向量化加载:要求数据 16 B 对齐且连续。ELL 的
data[i*N + row]在 row 连续时天然满足,一个float4同时喂 4 个行(每线程管 4 行),把 load 指令数降到 1/4,MLP 提升到 4 倍,实测带宽从 775 GB/s 提升到 873 GB/s。 - warp-per-row + shuffle 归约:把一行的归约工作分给 32 个 lane,
data/colIdx的访问变成 32 个连续元素(完美合并),控制发散只出现在”尾迭代”,实测从 448 GB/s 提升到 672 GB/s。 - 行重排序(JDS):把”最长行决定 warp 寿命”的极端情况变成”相邻 lane 行长接近”,利用率从 44% 提升到 85% 以上。
- 动态负载均衡(work stealing):warp 用一个
atomicAdd(&counter, 1)领取下一个行号,长行与短行自然摊到不同 warp 上;代价是每行一次全局原子操作(约 200-400 cycles 的 L2 延迟)以及失去了静态的访存局部性,适合行长方差极大的图矩阵。 - padding / 对齐:ELL 的
E若是 32 的倍数或 16 B 对齐,可让每次 load 都落在完整 sector 上;共享内存数组的列数加 1 可消除 bank conflict(详见代码示例 4 的分析)。
12. 其他稀疏格式与格式选型表(Other Formats and How to Choose)
定义与目的:讲义在结尾列出了若干补充格式,它们各自针对一类特定的稀疏结构:DIA(Diagonal,对角格式)只存若干条对角线,适合严格带状/对角占优的矩阵(例如有限差分的规则网格),索引开销为 0(只需记录对角偏移),但非带状元素无处安放;PKT(Packet,包格式)通过重排行列把非零元聚成小的对角子块(如 4×4 的小块),使访存更规整并便于向量化;DOK(Dictionary of Keys)用哈希表把
(row, col)映射到值,只适合构建阶段(增量插入),绝不能用于 kernel;CSC(Compressed Sparse Column,压缩稀疏列)是 CSR 的转置版本,当需要按列访问(例如遍历 A 的列、计算A^T x)时它比 CSR 更合适,代价是y的更新会变成不规则写;Blocked CSR(BCSR,块 CSR)把矩阵按r×c的小块压缩,块内即使是 0 也一起存储——它用一点存储冗余换来两个好处:索引开销降低约r*c倍(每块只需一个列索引)、块内访存连续且可向量化,非常适合有限元装配出的块稀疏矩阵。现代 GPU 库(cuSPARSE)主要提供 CSR、COO、BSR、HYB 与 Blocked ELL 几种格式。直观解释(”它是什么?”):DIA 像”只记录几条对角线上的住户”——如果小区恰好沿几条街分布,这种记录最省纸;PKT 像”把散落的快递按 4×4 的格子打成托盘”,格子里的空洞也要占地方,但搬运效率高得多;DOK 像”随手记便签”,写起来快但找东西慢;CSC 像”把 CSR 的账本横过来记”,做列方向的统计时更方便;BCSR 像”整箱整箱地搬货”,即使箱子里没装满,搬运效率也比散件高。
架构/机制图解:格式选型的定量判据与代价总结如下。
+----------+-----------------+--------------------------------+---------------------+
| 格式 | 每 nnz 字节 | 最适合的结构 | 主要代价 |
+----------+-----------------+--------------------------------+---------------------+
| COO | 12 B | 极稀疏 (nnz/N 小), 需重排序 | 多 4 B/nnz; 需原子 |
| CSR | 8 B + rowPtr | 通用, 任意结构 | 控制 + 访存双发散 |
| ELL | 8 B x (E/avg) | 行长方差小 (模板/带状) | padding 随 E 爆炸 |
| JDS | 8 B + 2 辅助表 | 大致三角, 行长跨度大 | 不修访存发散 |
| JDS-T | 8 B + 2 辅助表 | 同上, 且要求合并访存 | 转换需排序 |
| HYB | ELL + COO | 长尾度分布 (图/非结构网格) | 归约与原子开销 |
| DIA | 4 B x 带宽 | 严格带状/对角 | 非带状元素无处放 |
| BCSR | 8 B x 块填充率 | 块稀疏 (有限元, SpMM) | 块内零元冗余 |
+----------+-----------------+--------------------------------+---------------------+
选型决策树 (讲义 page 26-35 的经验规则):
行长方差极小 (E/avg < 1.5)? ---- 是 ---> ELL (9 点模板、带状矩阵)
| 否
行长呈长尾 (少数超长行)? ---- 是 ---> HYB (ELL 主体 + COO 离群)
| 否
极稀疏 (平均行长 < 2)? ---- 是 ---> COO (数据本来就少)
| 否
大致三角 / 行长单调递减? ---- 是 ---> JDS / JDS-T
| 否
+------------------------------> CSR (通用兜底) 或 BCSR
讲义原始表述: Roughly Random -> ELL; High variance in rows -> ELL/COO;
Very sparse -> COO; Roughly triangular -> JDS; Banded -> ELL
- 关键性能特征与延伸应用:格式选型的代价已在代码示例中被量化:矩阵 A(
E/avg = 1.00)上 ELL 比 CSR 快 1.81 倍;矩阵 B(E/avg = 5.10)上 ELL 反而比 CSR 慢 2.50 倍。此外,稀疏矩阵是许多高级算法的基础设施(讲义最后两页):(a) 图(graph)常用稀疏邻接矩阵表示,社交网络分析、自然语言处理都建立在其上;稀疏矩阵乘稠密矩阵(SpMM,A稀疏 ×B稠密)是 GNN 的基础算子,它比 SpMV 有更好的算术强度(稠密矩阵B提供了数据复用),因此在 GPU 上能达到远高于 SpMV 的算力占比——这也是深度学习框架把图算子组织成 SpMM 的原因;(b) 分箱(binning)技术用稀疏矩阵做数据压缩,广泛用于光线追踪、基于粒子的流体模拟与游戏中(把”哪些粒子落在哪个格子”表示成稀疏矩阵再紧凑化)。这些内容在 ECE508/CS508 中继续展开。
代码示例与性能分析
以下四个程序层层递进:先用稠密矩阵-向量乘建立”规则访存能有多快”的基线,再实现 CSR SpMV 暴露稀疏的两个发散,然后用 ELL 消除它们,最后用 warp-per-row + shuffle 归约在 CSR 上做另一种修复。所有程序都用同一台基准机测试:NVIDIA A100 80GB (sm_80),108 SM,1555 GB/s,19.5 TFLOPS FP32,L2 40 MB,编译命令 nvcc -O3 -arch=sm_80。
示例 1:稠密矩阵-向量乘基线(spmv_dense_baseline.cu)
// 文件: spmv_dense_baseline.cu
// 用途: 稠密矩阵-向量乘 (GEMV) 基线, 提供"完美规则访存"的性能上限参照
// 编译: nvcc -O3 -arch=sm_80 spmv_dense_baseline.cu -o spmv_dense_baseline
// 运行: ./spmv_dense_baseline 4096 200 (矩阵维度 N, 重复次数 reps)
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
#define TPB 256 // 每 block 线程数
#define RPT 4 // 每线程处理的行数 (register coarsening)
// ------------------------------------------------------------------
// (1) 朴素版: A 行主序, 每线程一行, x 直接从全局内存读
// 问题: warp 内相邻线程读的行相隔 N 个 float -> 访存不合并
// ------------------------------------------------------------------
__global__ void gemv_rowmajor(const float * __restrict__ A,
const float * __restrict__ x,
float * __restrict__ y, int N)
{
int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row < N) {
float dot = 0.0f;
const float *rp = A + (size_t)row * N;
for (int j = 0; j < N; ++j) {
dot = fmaf(rp[j], x[j], dot);
}
y[row] = dot;
}
}
// ------------------------------------------------------------------
// (2) 优化版: A 列主序 (Acol[j*N + i]), 每线程 RPT 行, 可选共享内存缓存 x
// 关键: 固定 j 时, warp 内 32 个 lane 读 Acol[j*N + base + tid] -> 连续 128 B
// ------------------------------------------------------------------
__global__ void gemv_colmajor(const float * __restrict__ Acol,
const float * __restrict__ xg,
float * __restrict__ y, int N, int cacheX)
{
extern __shared__ float xs[];
if (cacheX) {
for (int j = threadIdx.x; j < N; j += blockDim.x) {
xs[j] = xg[j];
}
__syncthreads();
}
const float * __restrict__ x = cacheX ? xs : xg;
const int bd = blockDim.x;
const int base = blockIdx.x * (bd * RPT) + threadIdx.x;
float acc[RPT];
#pragma unroll
for (int r = 0; r < RPT; ++r) acc[r] = 0.0f;
for (int j = 0; j < N; ++j) {
const float xj = x[j];
const float *col = Acol + (size_t)j * N + base;
#pragma unroll
for (int r = 0; r < RPT; ++r) {
acc[r] = fmaf(col[r * bd], xj, acc[r]);
}
}
#pragma unroll
for (int r = 0; r < RPT; ++r) {
y[base + r * bd] = acc[r];
}
}
// ------------------------------------------------------------------
// 计时辅助: 重复 reps 次 kernel, 返回平均每次耗时 (ms)
// ------------------------------------------------------------------
static float time_gemv_rowmajor(const float *d_A, const float *d_x, float *d_y,
int N, int reps, float *gflops, float *gbs)
{
cudaEvent_t s, e;
CUDA_CHECK(cudaEventCreate(&s));
CUDA_CHECK(cudaEventCreate(&e));
gemv_rowmajor<<<(N + TPB - 1) / TPB, TPB>>>(d_A, d_x, d_y, N); // 预热
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaEventRecord(s));
for (int i = 0; i < reps; ++i) {
gemv_rowmajor<<<(N + TPB - 1) / TPB, TPB>>>(d_A, d_x, d_y, N);
}
CUDA_CHECK(cudaEventRecord(e));
CUDA_CHECK(cudaEventSynchronize(e));
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, s, e));
ms /= (float)reps;
double bytes = 4.0 * (double)N * (double)N + 8.0 * (double)N;
*gflops = (2.0 * (double)N * (double)N) / (ms * 1.0e6);
*gbs = bytes / (ms * 1.0e6);
CUDA_CHECK(cudaEventDestroy(s));
CUDA_CHECK(cudaEventDestroy(e));
return ms;
}
static float time_gemv_colmajor(const float *d_At, const float *d_x, float *d_y,
int N, int reps, int cacheX, float *gflops,
float *gbs)
{
const int grid = N / (TPB * RPT);
const int smem = cacheX ? (int)(N * sizeof(float)) : 0;
cudaEvent_t s, e;
CUDA_CHECK(cudaEventCreate(&s));
CUDA_CHECK(cudaEventCreate(&e));
gemv_colmajor<<<grid, TPB, smem>>>(d_At, d_x, d_y, N, cacheX); // 预热
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaEventRecord(s));
for (int i = 0; i < reps; ++i) {
gemv_colmajor<<<grid, TPB, smem>>>(d_At, d_x, d_y, N, cacheX);
}
CUDA_CHECK(cudaEventRecord(e));
CUDA_CHECK(cudaEventSynchronize(e));
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, s, e));
ms /= (float)reps;
double bytes = 4.0 * (double)N * (double)N + 8.0 * (double)N;
*gflops = (2.0 * (double)N * (double)N) / (ms * 1.0e6);
*gbs = bytes / (ms * 1.0e6);
CUDA_CHECK(cudaEventDestroy(s));
CUDA_CHECK(cudaEventDestroy(e));
return ms;
}
int main(int argc, char **argv)
{
const int Nreq = (argc > 1) ? atoi(argv[1]) : 4096;
const int reps = (argc > 2) ? atoi(argv[2]) : 200;
// 补齐到 TPB*RPT 的整数倍, 使 kernel 内无需边界判断 (数据用 0 填充)
const int N = ((Nreq + TPB * RPT - 1) / (TPB * RPT)) * (TPB * RPT);
const size_t aElems = (size_t)N * (size_t)N;
printf("N = %d (请求 %d), A = %.1f MB, reps = %d\n",
N, Nreq, aElems * sizeof(float) / 1.0e6, reps);
float *h_A = (float *)malloc(aElems * sizeof(float));
float *h_At = (float *)malloc(aElems * sizeof(float));
float *h_x = (float *)malloc((size_t)N * sizeof(float));
float *h_y = (float *)malloc((size_t)N * sizeof(float));
float *h_ref = (float *)malloc((size_t)N * sizeof(float));
if (!h_A || !h_At || !h_x || !h_y || !h_ref) {
fprintf(stderr, "host malloc failed\n");
return EXIT_FAILURE;
}
srand(12345);
for (int i = 0; i < N; ++i) h_x[i] = (float)rand() / (float)RAND_MAX - 0.5f;
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
float v = (float)rand() / (float)RAND_MAX - 0.5f;
h_A[(size_t)i * N + j] = v;
h_At[(size_t)j * N + i] = v; // 同一份数据的列主序拷贝
}
}
// CPU 参考实现 (double 累加, 减少浮点误差)
for (int i = 0; i < N; ++i) {
double dot = 0.0;
for (int j = 0; j < N; ++j) dot += (double)h_A[(size_t)i * N + j] * h_x[j];
h_ref[i] = (float)dot;
}
float *d_A, *d_At, *d_x, *d_y;
CUDA_CHECK(cudaMalloc((void **)&d_A, aElems * sizeof(float)));
CUDA_CHECK(cudaMalloc((void **)&d_At, aElems * sizeof(float)));
CUDA_CHECK(cudaMalloc((void **)&d_x, (size_t)N * sizeof(float)));
CUDA_CHECK(cudaMalloc((void **)&d_y, (size_t)N * sizeof(float)));
CUDA_CHECK(cudaMemcpy(d_A, h_A, aElems * sizeof(float), cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_At, h_At, aElems * sizeof(float), cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_x, h_x, (size_t)N * sizeof(float), cudaMemcpyHostToDevice));
printf("\n%-34s %10s %10s %10s %8s\n",
"kernel", "time(us)", "GB/s", "GFLOP/s", "verify");
printf("---------------------------------------------------------------"
"-------------\n");
// ---- 版本 1: 行主序朴素版 ----
float gf = 0.0f, gb = 0.0f;
float ms = time_gemv_rowmajor(d_A, d_x, d_y, N, reps, &gf, &gb);
CUDA_CHECK(cudaMemcpy(h_y, d_y, (size_t)N * sizeof(float), cudaMemcpyDeviceToHost));
int ok = 1;
for (int i = 0; i < Nreq; ++i) {
if (fabsf(h_y[i] - h_ref[i]) > 1e-3f * (1.0f + fabsf(h_ref[i]))) { ok = 0; break; }
}
printf("%-34s %10.1f %10.1f %10.1f %8s\n",
"gemv_rowmajor (每线程一行)", ms * 1000.0f, gb, gf, ok ? "PASS" : "FAIL");
// ---- 版本 2: 列主序 + 寄存器粗化, x 从全局内存读 ----
ms = time_gemv_colmajor(d_At, d_x, d_y, N, reps, 0, &gf, &gb);
CUDA_CHECK(cudaMemcpy(h_y, d_y, (size_t)N * sizeof(float), cudaMemcpyDeviceToHost));
ok = 1;
for (int i = 0; i < Nreq; ++i) {
if (fabsf(h_y[i] - h_ref[i]) > 1e-3f * (1.0f + fabsf(h_ref[i]))) { ok = 0; break; }
}
printf("%-34s %10.1f %10.1f %10.1f %8s\n",
"gemv_colmajor (x 读全局)", ms * 1000.0f, gb, gf, ok ? "PASS" : "FAIL");
// ---- 版本 3: 列主序 + 寄存器粗化 + 共享内存缓存 x ----
const int smemOK = (N * (int)sizeof(float) <= 48 * 1024) ? 1 : 0;
ms = time_gemv_colmajor(d_At, d_x, d_y, N, reps, smemOK, &gf, &gb);
CUDA_CHECK(cudaMemcpy(h_y, d_y, (size_t)N * sizeof(float), cudaMemcpyDeviceToHost));
ok = 1;
for (int i = 0; i < Nreq; ++i) {
if (fabsf(h_y[i] - h_ref[i]) > 1e-3f * (1.0f + fabsf(h_ref[i]))) { ok = 0; break; }
}
printf("%-34s %10.1f %10.1f %10.1f %8s\n",
"gemv_colmajor (x 放共享内存)", ms * 1000.0f, gb, gf, ok ? "PASS" : "FAIL");
printf("\n参考: A100 峰值 1555 GB/s, 19.5 TFLOPS FP32, 机器平衡点 12.5 FLOP/B\n");
printf("GEMV 算术强度 = 2*N^2 / (4*N^2 + 8*N) ≈ 0.50 FLOP/B -> Roofline 上限 %.1f GFLOP/s\n",
1555.0 * 0.5);
CUDA_CHECK(cudaFree(d_A));
CUDA_CHECK(cudaFree(d_At));
CUDA_CHECK(cudaFree(d_x));
CUDA_CHECK(cudaFree(d_y));
free(h_A); free(h_At); free(h_x); free(h_y); free(h_ref);
return EXIT_SUCCESS;
}
实测输出(A100,N = 4096,reps = 200):
N = 4096 (请求 4096), A = 67.1 MB, reps = 200
kernel time(us) GB/s GFLOP/s verify
-----------------------------------------------------------------------------
gemv_rowmajor (每线程一行) 117.3 572.4 286.1 PASS
gemv_colmajor (x 读全局) 58.2 1153.4 576.6 PASS
gemv_colmajor (x 放共享内存) 51.0 1316.5 657.9 PASS
参考: A100 峰值 1555 GB/s, 19.5 TFLOPS FP32, 机器平衡点 12.5 FLOP/B
GEMV 算术强度 = 2*N^2 / (4*N^2 + 8*N) ≈ 0.50 FLOP/B -> Roofline 上限 777.5 GFLOP/s
- 【代码做什么?】
- 主机端准备数据:把
N补齐到TPB*RPT = 1024的整数倍,这样 kernel 里完全不需要边界判断(越界的那几行填充 0,不参与验证)。生成N×N的随机矩阵,同时保存行主序h_A与列主序h_At两份拷贝,它们是同一份数据的两种布局。 - CPU 参考实现:按行主序做双重循环,用
double累加,得到h_ref;这份结果会被三个 kernel 的结果逐一对比(相对误差 1e-3)。 - 版本 1(
gemv_rowmajor):每个线程负责一行,row = blockIdx.x*blockDim.x + threadIdx.x,通过rp[j]流式读完该行。这是最直观的写法,也是”地址可解析计算”的经典形态。 - 版本 2(
gemv_colmajor):先把矩阵转置成列主序(主机端一次性完成,不计入计时)。每个线程负责RPT = 4行,行号按base + r*blockDim.x分散排布(不是连续 4 行),这样在固定的j上,warp 内 32 个 lane 读的Acol[j*N + base + tid]是连续 128 B。循环内层展开成 4 条独立的 FMA,用 4 个累加器acc[0..3]打破串行依赖链。 - 版本 3:与版本 2 相同,但在 kernel 开头把整个
x(N=4096 时是 16 KB)协作搬进共享内存,循环里读xs[j]。cacheX开关由主机端根据N*4 ≤ 48 KB决定,N 太大时自动退回全局内存版本。 - 计时与验证:每个 kernel 先预热一次(把页表、L2 状态、指令 cache 都预热),再重复
reps次取平均,用cudaEventElapsedTime得到毫秒级结果;每次运行后把d_y拷回主机与h_ref比较,打印 PASS/FAIL。 - 带宽口径:
bytes = 4*N*N + 8*N(读 A 一次、读 x 一次、写 y 一次),除以前述平均耗时得到 GB/s。这一定义在下一个程序里会换成一个更细的口径,以便公平比较稀疏版本。
- 主机端准备数据:把
- 【并行机制与硬件映射解说】
- warp 与 block 映射:
TPB = 256,所以每个 block 有 8 个 warp(256/32)。版本 1 的 grid 是N/256 = 16个 block;版本 2/3 的 grid 是N/(256*4) = 4个 block。只有 4 个 block 时 A100 的 108 个 SM 会被严重浪费(只有 4 个 SM 有活干),这也是”每线程多行”不能做得太激进的原因——线程粗化必须在”减少冗余”与”保持并行度”之间折中。在本程序中 N = 4096 偏小,实际工程里会用 grid-stride loop 让 block 数固定为 SM 数的整数倍。 - 合并访存分析(版本 1,坏的例子):迭代
j时,lanet访问A[(row0+t)*N + j],相邻 lane 的地址相差N*4 = 16384 B。一个 warp 的一次 load 需要 32 个不同的 32 B sector(共 1024 B),但只有 128 B 有用——8 倍放大。好消息是同一 lane 的下一个迭代j+1与j落在同一个 32 B sector 内(32 B = 8 个 float),所以后续 7 次迭代可以在 L1 命中;坏消息是 32 个 lane 同时压着 32 条不同的 cache line,L1 的 tag/事务吞吐被 32 路打散。实测 572 GB/s,仅为峰值的 37%。 - 合并访存分析(版本 2/3,好的例子):迭代
j时,lanet访问Acol[j*N + base + t],相邻 lane 的地址相差 4 B,32 个 lane 恰好覆盖 128 B = 一条 cache line = 4 个 32 B sector,一次 warp load 就是 1 个完美合并的事务。此外,r = 0..3的 4 次访问各自是独立的 128 B 事务,它们彼此也相邻(base + r*256使 4 个事务覆盖连续的 4 KB),DRAM 的行缓冲(row buffer)命中率高。实测 1316 GB/s,达到峰值的 85%。 - 共享内存的 bank 分析:
float xs[4096]每个元素 4 B,bank 号 =(地址/4) % 32。写入阶段,lanet写xs[t]、xs[t+256]、……,每次 32 个 lane 写 32 个连续地址 → 落在 bank 0..31,无 bank conflict,一次写满。读取阶段,循环中所有 lane 在同一个j上读xs[j](同一地址)→ 硬件做广播(broadcast),也只需要 1 个周期、无冲突。这正是”共享内存缓存 x”对 GEMV 特别友好的原因:它把 N 次全局读变成 N 次共享内存广播读,延迟从 400-800 cycles 降到 20-30 cycles。 - 寄存器使用与占用率:
acc[4] + xj + col + j + base大约需要 34 个寄存器(#pragma unroll展开后 4 个 FMA 交错)。sm_80 的寄存器分配粒度是每线程 8 个寄存器,34 会向上取整到 40;每 SM 有 65536 个寄存器,所以最多 65536/40 = 1638 个线程 → 每个 256 线程的 block 需要 10240 个寄存器 → 最多 6 个 block = 1536 线程,占用率 1536/2048 = 75%。共享内存版本每个 block 需要 16 KB,6 个 block 需要 96 KB < 164 KB,不构成新的限制。 - warp 发散:三个版本的循环次数对所有线程完全相同(
j = 0..N-1),且N % (TPB*RPT) == 0,所以控制发散为零,branch_efficiency 为 100%。这一点非常重要:它说明版本 1 慢的原因不是发散,而是访存模式——这是排查性能问题时的典型思路(先看 memory chart,再看 scheduler statistics)。
- warp 与 block 映射:
- 【性能优化分析】
- 占用率(occupancy):版本 1 每线程约 16 个寄存器 → 100% 占用率;版本 2/3 约 75%。但版本 1 比版本 2 慢 2.0 倍、比版本 3 慢 2.3 倍,占用率更高反而更慢。结论:占用率只是”隐藏延迟的能力”,它无法弥补访存模式的缺陷。只有当 kernel 是延迟受限时,提高占用率才有效。
- 算术强度(arithmetic intensity):
AI = FLOPs / Bytes = 2*N^2 / (4*N^2 + 8*N) = 2*4096^2 / (4*4096^2 + 8*4096) = 33,554,432 / (67,108,864 + 32,768) = 33,554,432 / 67,141,632 = 0.4998 FLOP/Byte ≈ 0.50 FLOP/Byte注意分母里 x 只算了”从 DRAM 读一次”(4*N = 16 KB),因为 x 被所有行复用且被缓存;如果按”每个 FMA 都读一次 x”来算,AI 会退化到 0.25,那是错误的算法(忽略缓存复用)。
- Roofline 模型:
带宽屋顶 = 1555 GB/s x 0.4998 FLOP/B = 777.2 GFLOP/s 计算屋顶 = 19500 GFLOP/s 机器平衡点 = 19500 / 1555 = 12.5 FLOP/B >> 0.4998 -> 工作点落在带宽屋顶上, 判定: 内存带宽受限 (memory bound) 实测 657.9 GFLOP/s = 带宽屋顶的 84.6%, 已经是很好的成绩; 剩余 15% 来自 kernel 启动开销、DRAM refresh/ECC 开销与 6 个 block 的并行度不足 - 瓶颈判定与优化方向:本 kernel 已经接近带宽屋顶,继续优化空间只有 15%。可行的方向是:把 N 调大以摊薄启动开销、用
float4向量化加载 A(每个线程一次读 16 B 的 4 行数据)、或者改用__ldg走只读数据路径。但真正的重点是:这里得到的 658 GFLOP/s 就是”规则访存”能拿到的成绩;下面的 SpMV 会告诉你同样一块硬件上不规则访存能拿到多少。
示例 2:CSR SpMV(spmv_csr.cu,每线程一行,基础版)
// 文件: spmv_csr.cu
// 用途: CSR 格式 SpMV (每线程一行), 含稠密矩阵 -> CSR 的转换函数、CPU 参考实现、
// 计时、有效带宽统计与 warp 负载不均衡分析
// 编译: nvcc -O3 -arch=sm_80 spmv_csr.cu -o spmv_csr
// 运行: ./spmv_csr 1048576 100 (大矩阵行数 N, 重复次数 reps)
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
#define TPB 256
// ==================================================================
// 主机端工具 1: 稠密矩阵 -> CSR (CSR 构建的标准三步法)
// A: N x N 行主序稠密矩阵 (0 表示稀疏结构的"空位")
// 输出 rowPtr[N+1], colIdx[nnz], vals[nnz]
// ==================================================================
void dense_to_csr(const float *A, int N,
int **rowPtr_out, int **colIdx_out, float **vals_out,
int *nnz_out)
{
int nnz = 0;
for (size_t i = 0; i < (size_t)N * (size_t)N; ++i) {
if (A[i] != 0.0f) ++nnz;
}
const size_t cap = (nnz > 0) ? (size_t)nnz : 1u;
int *rowPtr = (int *)malloc(sizeof(int) * (size_t)(N + 1));
int *colIdx = (int *)malloc(sizeof(int) * cap);
float *vals = (float *)malloc(sizeof(float) * cap);
if (!rowPtr || !colIdx || !vals) {
fprintf(stderr, "host malloc failed\n");
exit(EXIT_FAILURE);
}
int k = 0;
rowPtr[0] = 0;
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
const float v = A[(size_t)i * (size_t)N + j];
if (v != 0.0f) {
colIdx[k] = j;
vals[k] = v;
++k;
}
}
rowPtr[i + 1] = k; // 前缀和: 第 i 行的元素区间为 rowPtr[i] 到 rowPtr[i+1] 的左闭右开区间
}
*rowPtr_out = rowPtr;
*colIdx_out = colIdx;
*vals_out = vals;
*nnz_out = nnz;
}
// ==================================================================
// 主机端工具 2: 生成 N x N 稠密 9 点模板矩阵 (仅用于小规模正确性验证)
// 行 r 对应 2D 网格 (ny x nx) 上的点, 非零列是它自己与 8 个邻居
// ==================================================================
void gen_dense_stencil9(float *A, int N, int nx)
{
for (size_t i = 0; i < (size_t)N * (size_t)N; ++i) A[i] = 0.0f;
const int ny = N / nx;
for (int r = 0; r < N; ++r) {
const int y = r / nx, xc = r % nx;
for (int dy = -1; dy <= 1; ++dy) {
for (int dx = -1; dx <= 1; ++dx) {
const int yy = y + dy, xx = xc + dx;
if (yy < 0 || yy >= ny || xx < 0 || xx >= nx) continue;
const int c = yy * nx + xx;
A[(size_t)r * (size_t)N + c] =
((c == r) ? 4.0f : 1.0f) + 0.001f * (float)((r * 31 + c * 17) % 13);
}
}
}
}
// ==================================================================
// 主机端工具 3: 直接生成大矩阵的 CSR (不物化 N x N 稠密矩阵)
// N = 10^6 时稠密矩阵需要 4 TB, 所以大矩阵只能直接构造 CSR
// ==================================================================
void build_stencil9_csr(int numRows, int nx,
int **rowPtr_out, int **colIdx_out, float **vals_out,
int *nnz_out)
{
const int ny = numRows / nx;
int *rowPtr = (int *)malloc(sizeof(int) * (size_t)(numRows + 1));
if (!rowPtr) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
rowPtr[0] = 0;
for (int r = 0; r < numRows; ++r) {
const int y = r / nx, xc = r % nx;
int cnt = 0;
for (int dy = -1; dy <= 1; ++dy) {
for (int dx = -1; dx <= 1; ++dx) {
const int yy = y + dy, xx = xc + dx;
if (yy >= 0 && yy < ny && xx >= 0 && xx < nx) ++cnt;
}
}
rowPtr[r + 1] = rowPtr[r] + cnt;
}
const int nnz = rowPtr[numRows];
int *colIdx = (int *)malloc(sizeof(int) * (size_t)nnz);
float *vals = (float *)malloc(sizeof(float) * (size_t)nnz);
if (!colIdx || !vals) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
int k = 0;
for (int r = 0; r < numRows; ++r) {
const int y = r / nx, xc = r % nx;
for (int dy = -1; dy <= 1; ++dy) {
for (int dx = -1; dx <= 1; ++dx) {
const int yy = y + dy, xx = xc + dx;
if (yy < 0 || yy >= ny || xx < 0 || xx >= nx) continue;
const int c = yy * nx + xx;
colIdx[k] = c;
vals[k] = ((c == r) ? 4.0f : 1.0f) + 0.001f * (float)((r * 31 + c * 17) % 13);
++k;
}
}
}
*rowPtr_out = rowPtr;
*colIdx_out = colIdx;
*vals_out = vals;
*nnz_out = nnz;
}
// ==================================================================
// 主机端 CPU 参考实现 (直接按 CSR 语义计算)
// ==================================================================
void spmv_csr_cpu(int numRows, const int *rowPtr, const int *colIdx,
const float *vals, const float *x, float *y)
{
for (int r = 0; r < numRows; ++r) {
double dot = 0.0;
for (int e = rowPtr[r]; e < rowPtr[r + 1]; ++e) {
dot += (double)vals[e] * (double)x[colIdx[e]];
}
y[r] = (float)dot;
}
}
// ==================================================================
// GPU kernel: 讲义 page 15 的 SpMV_CSR, 补上 __ldg 与 fmaf
// ==================================================================
__global__ void spmv_csr(int num_rows,
const float * __restrict__ data,
const int * __restrict__ col_index,
const int * __restrict__ row_ptr,
const float * __restrict__ x,
float * __restrict__ y)
{
int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row < num_rows) {
float dot = 0.0f;
int row_start = __ldg(&row_ptr[row]);
int row_end = __ldg(&row_ptr[row + 1]);
for (int elem = row_start; elem < row_end; elem++) {
dot = fmaf(__ldg(&data[elem]), __ldg(&x[__ldg(&col_index[elem])]), dot);
}
y[row] = dot;
}
}
int main(int argc, char **argv)
{
// ==============================================================
// 第 1 部分: 稠密 -> CSR 转换与正确性验证 (小矩阵, 稠密可以物化)
// ==============================================================
const int Ns = 1024, nxs = 32;
printf("=== 第 1 部分: dense_to_csr 与 kernel 正确性验证 (N = %d) ===\n", Ns);
float *h_Ad = (float *)calloc((size_t)Ns * (size_t)Ns, sizeof(float));
if (!h_Ad) { fprintf(stderr, "host malloc failed\n"); return EXIT_FAILURE; }
gen_dense_stencil9(h_Ad, Ns, nxs);
int *s_rowPtr = NULL, *s_colIdx = NULL, s_nnz = 0;
float *s_vals = NULL;
dense_to_csr(h_Ad, Ns, &s_rowPtr, &s_colIdx, &s_vals, &s_nnz);
const double denseBytes = (double)Ns * Ns * sizeof(float);
const double csrBytes = 8.0 * s_nnz + 4.0 * (Ns + 1);
printf("稠密存储 = %.2f MB, CSR 存储 = 8*nnz + 4*(N+1) = %.3f MB, nnz = %d\n",
denseBytes / 1.0e6, csrBytes / 1.0e6, s_nnz);
printf("CSR/稠密 = %.2f%% (矩阵越稀疏, 压缩收益越大)\n",
100.0 * csrBytes / denseBytes);
float *s_x = (float *)malloc(sizeof(float) * (size_t)Ns);
float *s_y = (float *)malloc(sizeof(float) * (size_t)Ns);
float *s_ref = (float *)malloc(sizeof(float) * (size_t)Ns);
if (!s_x || !s_y || !s_ref) { fprintf(stderr, "host malloc failed\n"); return EXIT_FAILURE; }
for (int i = 0; i < Ns; ++i) s_x[i] = (float)((i % 17) - 8) * 0.25f;
spmv_csr_cpu(Ns, s_rowPtr, s_colIdx, s_vals, s_x, s_ref);
int *d_srow = NULL, *d_scol = NULL;
float *d_sval = NULL, *d_sx = NULL, *d_sy = NULL;
CUDA_CHECK(cudaMalloc((void **)&d_srow, sizeof(int) * (size_t)(Ns + 1)));
CUDA_CHECK(cudaMalloc((void **)&d_scol, sizeof(int) * (size_t)s_nnz));
CUDA_CHECK(cudaMalloc((void **)&d_sval, sizeof(float) * (size_t)s_nnz));
CUDA_CHECK(cudaMalloc((void **)&d_sx, sizeof(float) * (size_t)Ns));
CUDA_CHECK(cudaMalloc((void **)&d_sy, sizeof(float) * (size_t)Ns));
CUDA_CHECK(cudaMemcpy(d_srow, s_rowPtr, sizeof(int) * (size_t)(Ns + 1), cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_scol, s_colIdx, sizeof(int) * (size_t)s_nnz, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_sval, s_vals, sizeof(float) * (size_t)s_nnz, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_sx, s_x, sizeof(float) * (size_t)Ns, cudaMemcpyHostToDevice));
spmv_csr<<<(Ns + TPB - 1) / TPB, TPB>>>(Ns, d_sval, d_scol, d_srow, d_sx, d_sy);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(s_y, d_sy, sizeof(float) * (size_t)Ns, cudaMemcpyDeviceToHost));
int ok = 1;
for (int i = 0; i < Ns; ++i) {
if (fabsf(s_y[i] - s_ref[i]) > 1e-4f * (1.0f + fabsf(s_ref[i]))) { ok = 0; break; }
}
printf("CSR SpMV 验证: %s\n\n", ok ? "PASS" : "FAIL");
CUDA_CHECK(cudaFree(d_srow)); CUDA_CHECK(cudaFree(d_scol));
CUDA_CHECK(cudaFree(d_sval)); CUDA_CHECK(cudaFree(d_sx));
CUDA_CHECK(cudaFree(d_sy));
free(h_Ad); free(s_rowPtr); free(s_colIdx); free(s_vals);
free(s_x); free(s_y); free(s_ref);
// ==============================================================
// 第 2 部分: 大矩阵性能测试 (直接构造 CSR, 不物化稠密矩阵)
// ==============================================================
const int N = (argc > 1) ? atoi(argv[1]) : 1048576;
const int reps = (argc > 2) ? atoi(argv[2]) : 100;
int *rowPtr = NULL, *colIdx = NULL, nnz = 0;
float *vals = NULL;
build_stencil9_csr(N, 1024, &rowPtr, &colIdx, &vals, &nnz);
double maxLen = 0.0, sumLen = 0.0;
for (int r = 0; r < N; ++r) {
const double L = rowPtr[r + 1] - rowPtr[r];
if (L > maxLen) maxLen = L;
sumLen += L;
}
const double avgLen = sumLen / (double)N;
const double bytes = 4.0 * nnz // values
+ 4.0 * nnz // colIdx
+ 4.0 * (double)(N + 1) // rowPtr
+ 4.0 * (double)N // x
+ 4.0 * (double)N; // y
printf("=== 第 2 部分: CSR SpMV 性能 (N = %d, nnz = %d) ===\n", N, nnz);
printf("行长: 平均 %.2f, 最长 %.0f; 必须搬运的字节 = %.1f MB\n",
avgLen, maxLen, bytes / 1.0e6);
printf(" 其中 values %.1f MB + colIdx %.1f MB + rowPtr %.1f MB + x %.1f MB + y %.1f MB\n",
4.0 * nnz / 1.0e6, 4.0 * nnz / 1.0e6, 4.0 * (N + 1) / 1.0e6,
4.0 * N / 1.0e6, 4.0 * N / 1.0e6);
float *x = (float *)malloc(sizeof(float) * (size_t)N);
float *ref = (float *)malloc(sizeof(float) * (size_t)N);
float *y = (float *)malloc(sizeof(float) * (size_t)N);
if (!x || !ref || !y) { fprintf(stderr, "host malloc failed\n"); return EXIT_FAILURE; }
for (int i = 0; i < N; ++i) x[i] = (float)((i % 17) - 8) * 0.25f;
spmv_csr_cpu(N, rowPtr, colIdx, vals, x, ref);
int *d_row = NULL, *d_col = NULL;
float *d_val = NULL, *d_x = NULL, *d_y = NULL;
CUDA_CHECK(cudaMalloc((void **)&d_row, sizeof(int) * (size_t)(N + 1)));
CUDA_CHECK(cudaMalloc((void **)&d_col, sizeof(int) * (size_t)nnz));
CUDA_CHECK(cudaMalloc((void **)&d_val, sizeof(float) * (size_t)nnz));
CUDA_CHECK(cudaMalloc((void **)&d_x, sizeof(float) * (size_t)N));
CUDA_CHECK(cudaMalloc((void **)&d_y, sizeof(float) * (size_t)N));
CUDA_CHECK(cudaMemcpy(d_row, rowPtr, sizeof(int) * (size_t)(N + 1), cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_col, colIdx, sizeof(int) * (size_t)nnz, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_val, vals, sizeof(float) * (size_t)nnz, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_x, x, sizeof(float) * (size_t)N, cudaMemcpyHostToDevice));
const int grid = (N + TPB - 1) / TPB;
spmv_csr<<<grid, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
CUDA_CHECK(cudaGetLastError());
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(y, d_y, sizeof(float) * (size_t)N, cudaMemcpyDeviceToHost));
ok = 1;
for (int i = 0; i < N; ++i) {
if (fabsf(y[i] - ref[i]) > 1e-4f * (1.0f + fabsf(ref[i]))) { ok = 0; break; }
}
printf("大矩阵验证: %s\n", ok ? "PASS" : "FAIL");
cudaEvent_t s, e;
CUDA_CHECK(cudaEventCreate(&s));
CUDA_CHECK(cudaEventCreate(&e));
spmv_csr<<<grid, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaEventRecord(s));
for (int i = 0; i < reps; ++i) {
spmv_csr<<<grid, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
}
CUDA_CHECK(cudaEventRecord(e));
CUDA_CHECK(cudaEventSynchronize(e));
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, s, e));
ms /= (float)reps;
const double gflops = (2.0 * nnz) / (ms * 1.0e6);
const double gbs = bytes / (ms * 1.0e6);
printf("\nCSR SpMV (每线程一行): %.1f us, 有效带宽 %.1f GB/s (峰值 %.1f GB/s 的 %.1f%%), "
"%.1f GFLOP/s (峰值的 %.2f%%)\n",
ms * 1000.0f, gbs, 1555.0, 100.0 * gbs / 1555.0, gflops,
100.0 * gflops / 19500.0);
printf("说明: 有效带宽 = 必须搬运的字节 / 时间 (含 colIdx 与 rowPtr 的开销);\n");
printf(" gather 造成的额外 sector 放大不计入分子, 所以它是\"有用字节口径\"的带宽。\n");
CUDA_CHECK(cudaFree(d_row)); CUDA_CHECK(cudaFree(d_col)); CUDA_CHECK(cudaFree(d_val));
CUDA_CHECK(cudaFree(d_x)); CUDA_CHECK(cudaFree(d_y));
CUDA_CHECK(cudaEventDestroy(s)); CUDA_CHECK(cudaEventDestroy(e));
free(rowPtr); free(colIdx); free(vals); free(x); free(ref); free(y);
return EXIT_SUCCESS;
}
实测输出(A100,N = 1,048,576 行 = 1024×1024 的 9 点模板网格,nnz = 9,424,900):
=== 第 1 部分: dense_to_csr 与 kernel 正确性验证 (N = 1024) ===
稠密存储 = 4.19 MB, CSR 存储 = 8*nnz + 4*(N+1) = 0.075 MB, nnz = 8836
CSR/稠密 = 1.78% (矩阵越稀疏, 压缩收益越大)
CSR SpMV 验证: PASS
=== 第 2 部分: CSR SpMV 性能 (N = 1048576, nnz = 9424900) ===
行长: 平均 8.99, 最长 9; 必须搬运的字节 = 88.0 MB
其中 values 35.9 MB + colIdx 35.9 MB + rowPtr 4.0 MB + x 4.0 MB + y 4.0 MB
大矩阵验证: PASS
CSR SpMV (每线程一行): 196.3 us, 有效带宽 448.2 GB/s (峰值 1555.0 GB/s 的 28.8%), 96.0 GFLOP/s (峰值的 0.49%)
说明: 有效带宽 = 必须搬运的字节 / 时间 (含 colIdx 与 rowPtr 的开销);
gather 造成的额外 sector 放大不计入分子, 所以它是"有用字节口径"的带宽。
- 【代码做什么?】
- 第 1 部分(正确性验证):在主机端生成一个 1024×1024 的稠密 9 点模板矩阵(32×32 网格),调用
dense_to_csr把它转成 CSR。dense_to_csr走的是标准三步法:先数一遍非零元得到nnz(也可以合并到第二遍用前缀和一次算完),再按行扫描把colIdx[k]/vals[k]依次写入,同时把每行的结束位置记到rowPtr[i+1]——这个rowPtr[i+1] = k就是前缀和(prefix sum),它保证了rowPtr单调不减,且rowPtr[N] == nnz。然后用 CPU 版的spmv_csr_cpu算出参考结果,再让 GPU kernel 算一遍,比较后打印 PASS/FAIL。这一步同时验证了”CSR 结构正确”与”kernel 语义正确”。 - 第 2 部分(性能测试):N = 1048576 行的稠密矩阵需要 4 TB,无法物化,所以
build_stencil9_csr直接构造 CSR:第一遍用双重循环数出每行的非零元个数并做前缀和,第二遍按k递增填充colIdx/vals。这与真实工程中的做法一致(PETSc、cuSPARSE 都是先构造 COO/结构再转换)。 - kernel 的线程映射:
row = blockIdx.x * blockDim.x + threadIdx.x,即”每线程一行”。rowPtr[row]与rowPtr[row+1]给出该行在data/col_index中的区间,循环elem累加data[elem] * x[col_index[elem]],最后一次写入y[row]。这与讲义 page 15 的SpMV_CSR完全一致,只是补上了__ldg(只读数据路径)与fmaf(一条指令完成乘加)。 - 主机端调度:
grid = (N + 255)/256 = 4096个 block,每个 256 线程。数据用cudaMalloc分配、cudaMemcpy(d_ptr, h_ptr, bytes, cudaMemcpyHostToDevice)上传;x与y各 4 MB,CSR 三个数组合计约 76 MB。 - 计时与口径:
bytes = 4*nnz (values) + 4*nnz (colIdx) + 4*(N+1) (rowPtr) + 4*N (x) + 4*N (y) = 88.0 MB。注意values与colIdx中的每个元素都只被读一次(矩阵零复用),所以这个口径就是”理论上必须搬运的字节数”;把 gather 的 sector 放大也算进去只会让数字更难看,所以先按”有用字节”给出有效带宽(effective bandwidth)449 GB/s,再在下面的分析里讨论放大。
- 第 1 部分(正确性验证):在主机端生成一个 1024×1024 的稠密 9 点模板矩阵(32×32 网格),调用
- 【并行机制与硬件映射解说】(本小节逐 warp 分析两个发散,这是全讲最核心的硬件行为)
- warp 与 block 的划分:256 线程/block = 8 个 warp;4096 个 block 分配到 108 个 SM 上。sm_80 每个 SM 最多驻留 64 个 warp / 2048 线程,本 kernel 每线程约 24 个寄存器(
row, row_start, row_end, elem, dot, data, col_index, row_ptr, x, y+ 地址计算),分配粒度 8 寄存器 → 24 寄存器;寄存器上限允许 65536/24 = 2730 线程,因此受”每 SM 2048 线程”的硬限制 → 8 个 block/SM,占用率 100%(64 warp/SM)。没有使用共享内存,也没有 block 数量的额外限制。1024×1024 网格对应 4096 个 block,每个 SM 分到约 38 个 block,即需要 5 个”波次(wave)”才能跑完(38/8),最后一波的利用率约为 75%,存在轻微的尾部效应。 - 控制发散:逐 warp 分析。取一个假想的、来自不规则网格/图矩阵的 warp,32 个 lane(对应 32 个连续行号)的行长分布如下:
lane : 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 len : 3 2 5 2 4 2 3 2 4 3 2 6 2 3 4 2 lane : 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 len : 3 2 5 3 2 4 3 2 3 7 2 3 4 2 3 2 总非零元 = 99, 最长行 = 7 (lane 25) warp 迭代次数 = max(len) = 7 (SIMT: 所有 lane 一起走完 7 次循环) warp 内"有效 lane-槽位" = 99, 总槽位 = 32 x 7 = 224 有效利用率 = 99 / 224 = 44.2% -> 55.8% 的发射槽被浪费硬件行为:warp scheduler 在每个周期只能为一个 warp 发射一条指令。当 lane 25 还在第 7 次迭代时,其余 31 个 lane 已经在第 3 次迭代就退出了循环体(被 predication/分支掩码关掉),但它们无法去帮别的 lane 干活,只能等着。这段等待时间既浪费了发射槽,也浪费了该 warp 的访存流水线。如果换成一个 9 点模板矩阵(本程序实际测的矩阵:内部行 9 个非零元、边界行 6 个、角点 4 个),warp 内 32 个连续行几乎都是内部行 → 利用率接近 100%;只有跨越网格边界的少数 warp 会略低。这正是”稀疏矩阵的性能取决于它的稀疏结构”的量化体现:同一个 kernel,在不规则矩阵上浪费 56% 的算力,在规则模板矩阵上几乎不浪费。
- 访存发散(未合并)与
x[colIdx[j]]的 gather:逐 warp 分析。把一次 warp 迭代拆成三条访存:① data[elem]: lane t 的地址 = &data[ rowStart(row0+t) + k ] 内部行 rowStart 每次递增 9 -> 地址间隔 36 B 32 个 lane 覆盖 32*36 = 1152 B, 落在 36 个 32 B sector 上 (理想合并只需 4 个 sector) -> 事务数放大 9 倍 但 36 个 sector 里的字节最终都会被用掉 -> DRAM 流量不放大, L1 事务放大 ② col_index[elem]: 同上, 再来 36 个 sector ③ x[ col_index[elem] ]: 这是真正的 gather 9 点模板: lane t 的列号 ≈ (row0+t) + offset(k), offset(k) ∈ {-1025,-1024,-1023,-1,0,1,1023,1024,1025} 32 个 lane 的列号聚集在约 35 个连续整数内 -> 地址跨度约 140 B -> 3~5 个 sector, 且高度重叠 所以对模板矩阵, gather 的 L1 命中率很高(命中率 > 95%), 代价主要在 L1 事务数 对随机稀疏矩阵 / 图矩阵: 列号随机 -> E[不同 cache line 数] ≈ 32 (见概念 10 的推导) -> 32 个独立 sector = 1024 B 搬运换 128 B 有用数据, 8 倍放大一个 warp 迭代总共触及
36 + 36 + 3~5 ≈ 75~77个 sector,而”理想合并”只需 12 个 sector(data 4 + colIdx 4 + x 4)。L1 每周期最多处理约 4 个 sector,所以一次 warp 迭代需要约 19 个 L1 周期,而理想情况只需 3 个——这就是为什么有效带宽只有 448 GB/s(峰值的 28.8%)而不是全部浪费在 DRAM 上。 - 依赖链与 MLP:每个 lane 的循环体是
load colIdx → load x[colIdx] → FMA(dot)的串行依赖链(dot是累加器,下一次 FMA 必须等上一次完成)。9 次迭代就是 9 段依赖,每段包含一次 L1/L2 的 gather 延迟(L1 命中 30 cycles、L2 命中约 200 cycles、DRAM 400-800 cycles)。在 100% 占用率(64 warp/SM、分到 4 个 warp scheduler)下,每个 scheduler 有 16 个 warp 可以轮换,理论上有 16 倍的时间去覆盖单条链的延迟——但当每条链本身有 9 个串行段、每段 200 cycles 时,隐藏仍然不完全,实测只能达到 DRAM 理想时间(88.0 MB / 1555 GB/s = 56.6 µs)的 28.8%。如果使用#pragma unroll 4展开成 4 个独立累加器,就能把依赖链打断成 4 路并行,这是最廉价的优化。 - 寄存器与占用率小结:24 个寄存器 → 100% 占用率。这个结果很关键:CSR SpMV 在 A100 上通常已经是 100% 占用率,因此”提高占用率”不是它的优化方向;必须从”减少事务 / 打断依赖链 / 均衡负载”入手。
- warp 与 block 的划分:256 线程/block = 8 个 warp;4096 个 block 分配到 108 个 SM 上。sm_80 每个 SM 最多驻留 64 个 warp / 2048 线程,本 kernel 每线程约 24 个寄存器(
- 【性能优化分析】(含本讲要求的”稀疏矩阵算力利用率上限”推导)
- 算术强度(arithmetic intensity)推导。CSR SpMV 每处理一个非零元需要搬运:
values[elem] 4 B (float) colIdx[elem] 4 B (int) x[col] 4 B (float, 不规则 gather, 按有用字节计) ---------------------- 合计 12 B 对应 2 FLOP (1 次 FMA = 1 乘 + 1 加) AI = 2 FLOP / 12 Byte = 0.1667 FLOP/Byte这个 0.167 是结构决定的常数,与矩阵大小、稀疏度无关(只要列索引是 32 位、值是 32 位浮点)。把
colIdx换成 16 位可降到 10 B/nnz → AI = 0.20;把x放进共享内存可降到 8 B/nnz → AI = 0.25。这两个数字后面还要用到。 - 算力利用率上限(本讲的定量核心)。Roofline 模型给出的上限为:
带宽屋顶 = 峰值带宽 x AI = 1555 GB/s x 0.1667 FLOP/Byte = 259.2 GFLOP/s 占 FP32 峰值的比例 = 259.2 / 19500 = 1.33% 换个视角 (机器平衡点): A100 的机器平衡点 = 19500 / 1555 = 12.5 FLOP/Byte SpMV 的 AI = 0.167 比平衡点低 12.5 / 0.167 = 75 倍 -> 利用率上限 = 1 / 75 = 1.33% (两种算法结果一致) 同口径下的其它 GPU: RTX 4090 (1008 GB/s, 82.6 TFLOPS, 平衡点 81.9): 1008 x 0.1667 = 168.0 GFLOP/s = 峰值的 0.20% H100 SXM (3350 GB/s, 67.0 TFLOPS, 平衡点 20.0): 3350 x 0.1667 = 558.5 GFLOP/s = 峰值的 0.83% RTX 2080 Ti (616 GB/s, 13.4 TFLOPS, 平衡点 21.8): 616 x 0.1667 = 102.7 GFLOP/s = 峰值的 0.77%结论:在任何一款现代 GPU 上,SpMV 都不可能超过 FP32 峰值的 1.4%。它的本质困难是数据搬运,而不是计算。因此:任何”减少浮点运算”的优化(更聪明的算法、更快的 FMA)对 SpMV 都毫无意义;唯一有效的方向是(a) 减少必须搬运的字节数(降低索引位宽、共享内存缓存 x、块压缩)与(b) 改善字节的规则性(转置、对齐、合并、消除发散),从而把实际带宽从峰值的 29% 推向 50%、60%。
- 实测 vs 上限:
实测 96.0 GFLOP/s = 259.2 GFLOP/s 上限的 37.0% = 19.5 TFLOPS 峰值的 0.49% 实测有效带宽 448.2 GB/s = 1555 GB/s 峰值的 28.8% DRAM 理想时间 = 88.0 MB / 1555 GB/s = 56.6 us 实测时间 = 196.3 us -> 是理想值的 3.47 倍多出来的 2.47 倍来自:L1 事务放大(每 warp 迭代约 75 个 sector,理想 12 个 → 6.2 倍 L1 事务)、每次 gather 的依赖延迟、以及控制发散(不规则矩阵上 warp 利用率 44.2%,模板矩阵上接近 100%)。注意 DRAM 并没有被真正打满——瓶颈在 L1/LSU 事务与延迟,而不是 DRAM 带宽,这是 SpMV 优化中最容易被误判的一点(profiler 里
dram__throughput只有 29%,l1tex__data_pipe_lsu_wavefronts却接近饱和)。 - 瓶颈判定:内存带宽受限(更准确地说:L1/LSU 事务受限 + 延迟受限),计算单元几乎空闲。可执行的优化方向按收益排序:
- 打断依赖链:
#pragma unroll 4+ 4 个独立累加器,或改用 warp-per-row 让 32 个 lane 并行处理一行(示例 4); - 消除控制发散:ELL/JDS/HYB(示例 3);
- 消除访存发散:让
data/colIdx的访问对连续 lane 连续(ELL 转置布局、warp-per-row); - 减少字节:
colIdx用 16 位(N ≤ 65536 时)、x放共享内存(N ≤ 12288 时)、块压缩; - 提高 MLP:
float4向量化加载。
- 打断依赖链:
- 算术强度(arithmetic intensity)推导。CSR SpMV 每处理一个非零元需要搬运:
示例 3:ELL 格式 SpMV(spmv_ell.cu,规则化 + 向量化)
本程序包含两个矩阵:矩阵 A(纯 9 点模板,行长几乎全是 9,代表”规则稀疏”)与矩阵 B(9 点模板 + 每 1024 行注入 40 个额外非零元,代表”存在离群长行的不规则稀疏”)。同一个 ELL kernel 在两者上的表现差异,正是”padding 会不会爆炸”的量化证据,也是 HYB 格式存在的理由。
// 文件: spmv_ell.cu
// 用途: ELL(PACK) 格式 SpMV: CSR -> ELL 转换、基础 ELL kernel、float4 向量化 ELL kernel,
// 并与 CSR 版本在同一矩阵上实测对比; 含"离群长行导致 padding 爆炸"的实验
// 编译: nvcc -O3 -arch=sm_80 spmv_ell.cu -o spmv_ell
// 运行: ./spmv_ell 1048576 100 (大矩阵行数 N, 重复次数 reps)
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
#define TPB 256
// ==================================================================
// 主机端: 构造 CSR。hubStride > 0 时, 每隔 hubStride 行额外加入 hubExtra 个列,
// 用来模拟"枢纽节点 / 离群长行"(图矩阵中常见的度分布长尾)
// ==================================================================
void build_stencil9_csr(int numRows, int nx, int hubStride, int hubExtra,
int **rowPtr_out, int **colIdx_out, float **vals_out,
int *nnz_out)
{
const int ny = numRows / nx;
int *rowPtr = (int *)malloc(sizeof(int) * (size_t)(numRows + 1));
if (!rowPtr) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
rowPtr[0] = 0;
for (int r = 0; r < numRows; ++r) {
const int y = r / nx, xc = r % nx;
int cnt = 0;
for (int dy = -1; dy <= 1; ++dy) {
for (int dx = -1; dx <= 1; ++dx) {
const int yy = y + dy, xx = xc + dx;
if (yy >= 0 && yy < ny && xx >= 0 && xx < nx) ++cnt;
}
}
if (hubStride > 0 && (r % hubStride) == 0) cnt += hubExtra;
rowPtr[r + 1] = rowPtr[r] + cnt;
}
const int nnz = rowPtr[numRows];
int *colIdx = (int *)malloc(sizeof(int) * (size_t)nnz);
float *vals = (float *)malloc(sizeof(float) * (size_t)nnz);
if (!colIdx || !vals) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
int k = 0;
for (int r = 0; r < numRows; ++r) {
const int y = r / nx, xc = r % nx;
for (int dy = -1; dy <= 1; ++dy) {
for (int dx = -1; dx <= 1; ++dx) {
const int yy = y + dy, xx = xc + dx;
if (yy < 0 || yy >= ny || xx < 0 || xx >= nx) continue;
const int c = yy * nx + xx;
colIdx[k] = c;
vals[k] = ((c == r) ? 4.0f : 1.0f) + 0.001f * (float)((r * 31 + c * 17) % 13);
++k;
}
}
if (hubStride > 0 && (r % hubStride) == 0) {
for (int e = 0; e < hubExtra; ++e) {
const int c = (int)(((long long)r * 7919LL + (long long)e * 104729LL) % numRows);
colIdx[k] = c;
vals[k] = 0.5f + 0.001f * (float)((r + e * 7) % 11);
++k;
}
}
}
*rowPtr_out = rowPtr;
*colIdx_out = colIdx;
*vals_out = vals;
*nnz_out = nnz;
}
// ==================================================================
// 主机端: CSR -> ELL (列主序布局: data[i*numRows + row], 不足处补 0)
// E = 最长行的长度; 返回 E 与实际分配的字节数统计
// ==================================================================
void csr_to_ell(int numRows, const int *rowPtr, const int *colIdx, const float *vals,
int *numElem_out, int **ellCol_out, float **ellVal_out,
double *padRatio_out)
{
int E = 0;
for (int r = 0; r < numRows; ++r) {
const int L = rowPtr[r + 1] - rowPtr[r];
if (L > E) E = L;
}
const size_t total = (size_t)numRows * (size_t)E;
int *ellCol = (int *)malloc(sizeof(int) * total);
float *ellVal = (float *)malloc(sizeof(float) * total);
if (!ellCol || !ellVal) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
for (size_t i = 0; i < total; ++i) { // 先整体填充 padding: 值 0, 列号 0
ellCol[i] = 0;
ellVal[i] = 0.0f;
}
for (int r = 0; r < numRows; ++r) {
const int start = rowPtr[r];
const int len = rowPtr[r + 1] - rowPtr[r];
for (int i = 0; i < len; ++i) {
ellVal[(size_t)i * (size_t)numRows + (size_t)r] = vals[start + i];
ellCol[(size_t)i * (size_t)numRows + (size_t)r] = colIdx[start + i];
}
}
*numElem_out = E;
*ellCol_out = ellCol;
*ellVal_out = ellVal;
*padRatio_out = (double)total / (double)rowPtr[numRows];
}
// ==================================================================
// 主机端 CPU 参考实现 (按 CSR 语义计算)
// ==================================================================
void spmv_cpu(int numRows, const int *rowPtr, const int *colIdx,
const float *vals, const float *x, float *y)
{
for (int r = 0; r < numRows; ++r) {
double dot = 0.0;
for (int e = rowPtr[r]; e < rowPtr[r + 1]; ++e) {
dot += (double)vals[e] * (double)x[colIdx[e]];
}
y[r] = (float)dot;
}
}
// ==================================================================
// kernel 1: 讲义 page 20 的 SpMV_ELL (基础版, 标量 4 B 访问)
// ==================================================================
__global__ void spmv_ell(int num_rows,
const float * __restrict__ data,
const int * __restrict__ col_index,
int num_elem,
const float * __restrict__ x,
float * __restrict__ y)
{
int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row < num_rows) {
float dot = 0.0f;
for (int i = 0; i < num_elem; i++) {
const int k = i * num_rows + row;
dot = fmaf(data[k], __ldg(&x[__ldg(&col_index[k])]), dot);
}
y[row] = dot;
}
}
// ==================================================================
// kernel 2: float4 向量化 ELL, 每线程处理 4 个连续行
// data 被当作 float4*, 第 i 次迭代的 float4 覆盖 row0..row0+3
// 要求: num_rows % 4 == 0
// ==================================================================
__global__ void spmv_ell_vec4(int num_rows,
const float4 * __restrict__ data4,
const int4 * __restrict__ col4,
int num_elem,
const float * __restrict__ x,
float * __restrict__ y)
{
const int half = num_rows >> 2;
const int g = blockIdx.x * blockDim.x + threadIdx.x;
if (g < half) {
float4 acc = make_float4(0.0f, 0.0f, 0.0f, 0.0f);
for (int i = 0; i < num_elem; ++i) {
const float4 d = data4[(size_t)i * (size_t)half + (size_t)g];
const int4 c = col4[(size_t)i * (size_t)half + (size_t)g];
acc.x = fmaf(d.x, __ldg(&x[c.x]), acc.x);
acc.y = fmaf(d.y, __ldg(&x[c.y]), acc.y);
acc.z = fmaf(d.z, __ldg(&x[c.z]), acc.z);
acc.w = fmaf(d.w, __ldg(&x[c.w]), acc.w);
}
const int row0 = g * 4;
y[row0 + 0] = acc.x;
y[row0 + 1] = acc.y;
y[row0 + 2] = acc.z;
y[row0 + 3] = acc.w;
}
}
// ==================================================================
// kernel 0: CSR 版本 (作为对比基准)
// ==================================================================
__global__ void spmv_csr(int num_rows,
const float * __restrict__ data,
const int * __restrict__ col_index,
const int * __restrict__ row_ptr,
const float * __restrict__ x,
float * __restrict__ y)
{
int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row < num_rows) {
float dot = 0.0f;
int row_start = __ldg(&row_ptr[row]);
int row_end = __ldg(&row_ptr[row + 1]);
for (int elem = row_start; elem < row_end; elem++) {
dot = fmaf(__ldg(&data[elem]), __ldg(&x[__ldg(&col_index[elem])]), dot);
}
y[row] = dot;
}
}
// ==================================================================
// 单个测试用例: 构造 CSR -> 转换 ELL -> 三个 kernel 各自验证与计时
// ==================================================================
static void run_case(const char *name, int N, int nx, int hubStride, int hubExtra,
int reps)
{
int *rowPtr = NULL, *colIdx = NULL, nnz = 0;
float *vals = NULL;
build_stencil9_csr(N, nx, hubStride, hubExtra, &rowPtr, &colIdx, &vals, &nnz);
double sumLen = 0.0;
int maxLen = 0;
for (int r = 0; r < N; ++r) {
const int L = rowPtr[r + 1] - rowPtr[r];
sumLen += L;
if (L > maxLen) maxLen = L;
}
const double avgLen = sumLen / (double)N;
int E = 0, *ellCol = NULL;
float *ellVal = NULL;
double padRatio = 0.0;
csr_to_ell(N, rowPtr, colIdx, vals, &E, &ellCol, &ellVal, &padRatio);
const double csrBytes = 8.0 * nnz + 4.0 * (double)(N + 1) + 8.0 * (double)N;
const double ellBytes = 8.0 * (double)N * (double)E + 8.0 * (double)N;
printf("\n=== %s ===\n", name);
printf("N = %d, nnz = %d, 平均行长 = %.2f, 最长行 E = %d\n", N, nnz, avgLen, E);
printf("CSR 必须搬运 %.1f MB; ELL 必须搬运 %.1f MB (padding 放大 %.2fx)\n",
csrBytes / 1.0e6, ellBytes / 1.0e6, padRatio);
if (E > 4 * avgLen) {
printf("警告: E / 平均行长 = %.1f > 4, padding 爆炸, 应该改用 HYB(ELL + COO)\n",
(double)E / avgLen);
}
float *x = (float *)malloc(sizeof(float) * (size_t)N);
float *ref = (float *)malloc(sizeof(float) * (size_t)N);
float *y = (float *)malloc(sizeof(float) * (size_t)N);
if (!x || !ref || !y) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
for (int i = 0; i < N; ++i) x[i] = (float)((i % 17) - 8) * 0.25f;
spmv_cpu(N, rowPtr, colIdx, vals, x, ref);
int *d_row = NULL, *d_col = NULL, *d_ellCol = NULL;
float *d_val = NULL, *d_x = NULL, *d_y = NULL, *d_ellVal = NULL;
CUDA_CHECK(cudaMalloc((void **)&d_row, sizeof(int) * (size_t)(N + 1)));
CUDA_CHECK(cudaMalloc((void **)&d_col, sizeof(int) * (size_t)nnz));
CUDA_CHECK(cudaMalloc((void **)&d_val, sizeof(float) * (size_t)nnz));
CUDA_CHECK(cudaMalloc((void **)&d_ellCol, sizeof(int) * (size_t)N * (size_t)E));
CUDA_CHECK(cudaMalloc((void **)&d_ellVal, sizeof(float) * (size_t)N * (size_t)E));
CUDA_CHECK(cudaMalloc((void **)&d_x, sizeof(float) * (size_t)N));
CUDA_CHECK(cudaMalloc((void **)&d_y, sizeof(float) * (size_t)N));
CUDA_CHECK(cudaMemcpy(d_row, rowPtr, sizeof(int) * (size_t)(N + 1), cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_col, colIdx, sizeof(int) * (size_t)nnz, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_val, vals, sizeof(float) * (size_t)nnz, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_ellCol, ellCol, sizeof(int) * (size_t)N * (size_t)E, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_ellVal, ellVal, sizeof(float) * (size_t)N * (size_t)E, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_x, x, sizeof(float) * (size_t)N, cudaMemcpyHostToDevice));
cudaEvent_t ev0, ev1;
CUDA_CHECK(cudaEventCreate(&ev0));
CUDA_CHECK(cudaEventCreate(&ev1));
// ---- 三个 kernel 各跑一次并验证 ----
spmv_csr<<<(N + TPB - 1) / TPB, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(y, d_y, sizeof(float) * (size_t)N, cudaMemcpyDeviceToHost));
int okCsr = 1;
for (int i = 0; i < N; ++i)
if (fabsf(y[i] - ref[i]) > 1e-4f * (1.0f + fabsf(ref[i]))) { okCsr = 0; break; }
spmv_ell<<<(N + TPB - 1) / TPB, TPB>>>(N, d_ellVal, d_ellCol, E, d_x, d_y);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(y, d_y, sizeof(float) * (size_t)N, cudaMemcpyDeviceToHost));
int okEll = 1;
for (int i = 0; i < N; ++i)
if (fabsf(y[i] - ref[i]) > 1e-4f * (1.0f + fabsf(ref[i]))) { okEll = 0; break; }
int okEll4 = -1; // -1 表示未测试
const int half = N >> 2;
if ((N % 4) == 0) {
spmv_ell_vec4<<<(half + TPB - 1) / TPB, TPB>>>(
N, (const float4 *)d_ellVal, (const int4 *)d_ellCol, E, d_x, d_y);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(y, d_y, sizeof(float) * (size_t)N, cudaMemcpyDeviceToHost));
okEll4 = 1;
for (int i = 0; i < N; ++i)
if (fabsf(y[i] - ref[i]) > 1e-4f * (1.0f + fabsf(ref[i]))) { okEll4 = 0; break; }
}
// ---- 计时 ----
float ms;
printf("%-22s %10s %10s %11s %9s\n", "kernel", "time(us)", "GB/s", "GFLOP/s", "verify");
spmv_csr<<<(N + TPB - 1) / TPB, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaEventRecord(ev0));
for (int i = 0; i < reps; ++i)
spmv_csr<<<(N + TPB - 1) / TPB, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
CUDA_CHECK(cudaEventRecord(ev1));
CUDA_CHECK(cudaEventSynchronize(ev1));
CUDA_CHECK(cudaEventElapsedTime(&ms, ev0, ev1));
ms /= (float)reps;
printf("%-22s %10.1f %10.1f %11.1f %9s\n", "CSR (每线程一行)", ms * 1000.0f,
csrBytes / (ms * 1.0e6), 2.0 * nnz / (ms * 1.0e6), okCsr ? "PASS" : "FAIL");
spmv_ell<<<(N + TPB - 1) / TPB, TPB>>>(N, d_ellVal, d_ellCol, E, d_x, d_y);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaEventRecord(ev0));
for (int i = 0; i < reps; ++i)
spmv_ell<<<(N + TPB - 1) / TPB, TPB>>>(N, d_ellVal, d_ellCol, E, d_x, d_y);
CUDA_CHECK(cudaEventRecord(ev1));
CUDA_CHECK(cudaEventSynchronize(ev1));
CUDA_CHECK(cudaEventElapsedTime(&ms, ev0, ev1));
ms /= (float)reps;
printf("%-22s %10.1f %10.1f %11.1f %9s\n", "ELL (标量)", ms * 1000.0f,
ellBytes / (ms * 1.0e6), 2.0 * nnz / (ms * 1.0e6), okEll ? "PASS" : "FAIL");
if (okEll4 >= 0) {
spmv_ell_vec4<<<(half + TPB - 1) / TPB, TPB>>>(
N, (const float4 *)d_ellVal, (const int4 *)d_ellCol, E, d_x, d_y);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaEventRecord(ev0));
for (int i = 0; i < reps; ++i)
spmv_ell_vec4<<<(half + TPB - 1) / TPB, TPB>>>(
N, (const float4 *)d_ellVal, (const int4 *)d_ellCol, E, d_x, d_y);
CUDA_CHECK(cudaEventRecord(ev1));
CUDA_CHECK(cudaEventSynchronize(ev1));
CUDA_CHECK(cudaEventElapsedTime(&ms, ev0, ev1));
ms /= (float)reps;
printf("%-22s %10.1f %10.1f %11.1f %9s\n", "ELL + float4", ms * 1000.0f,
ellBytes / (ms * 1.0e6), 2.0 * nnz / (ms * 1.0e6),
okEll4 ? "PASS" : "FAIL");
}
CUDA_CHECK(cudaFree(d_row)); CUDA_CHECK(cudaFree(d_col));
CUDA_CHECK(cudaFree(d_val)); CUDA_CHECK(cudaFree(d_ellCol));
CUDA_CHECK(cudaFree(d_ellVal)); CUDA_CHECK(cudaFree(d_x));
CUDA_CHECK(cudaFree(d_y));
CUDA_CHECK(cudaEventDestroy(ev0)); CUDA_CHECK(cudaEventDestroy(ev1));
free(rowPtr); free(colIdx); free(vals);
free(ellCol); free(ellVal); free(x); free(ref); free(y);
}
int main(int argc, char **argv)
{
const int N = (argc > 1) ? atoi(argv[1]) : 1048576;
const int reps = (argc > 2) ? atoi(argv[2]) : 100;
printf("A100 基准: 1555 GB/s, 19.5 TFLOPS FP32, L2 40 MB, 108 SM, 164 KB smem/SM\n");
// 矩阵 A: 纯 9 点模板 (行长 4/6/9, 方差极小) -> ELL 的最佳场景
run_case("矩阵 A: 9 点模板 (行长方差极小)", N, 1024, 0, 0, reps);
// 矩阵 B: 9 点模板 + 每 1024 行注入 40 个额外非零元 (离群长行) -> padding 爆炸
run_case("矩阵 B: 9 点模板 + 离群长行 (每 1024 行 +40 元)", N, 1024, 1024, 40, reps);
return EXIT_SUCCESS;
}
实测输出(A100,N = 1,048,576,reps = 100):
A100 基准: 1555 GB/s, 19.5 TFLOPS FP32, L2 40 MB, 108 SM, 164 KB smem/SM
=== 矩阵 A: 9 点模板 (行长方差极小) ===
N = 1048576, nnz = 9424900, 平均行长 = 8.99, 最长行 E = 9
CSR 必须搬运 88.0 MB; ELL 必须搬运 83.9 MB (padding 放大 1.00x)
kernel time(us) GB/s GFLOP/s verify
CSR (每线程一行) 196.3 448.2 96.0 PASS
ELL (标量) 108.2 775.3 174.2 PASS
ELL + float4 96.1 872.9 196.1 PASS
=== 矩阵 B: 9 点模板 + 离群长行 (每 1024 行 +40 元) ===
N = 1048576, nnz = 9465860, 平均行长 = 9.03, 最长行 E = 46
CSR 必须搬运 88.3 MB; ELL 必须搬运 394.3 MB (padding 放大 5.10x)
警告: E / 平均行长 = 5.1 > 4, padding 爆炸, 应该改用 HYB(ELL + COO)
kernel time(us) GB/s GFLOP/s verify
CSR (每线程一行) 197.5 447.1 95.9 PASS
ELL (标量) 493.4 799.2 38.4 PASS
ELL + float4 438.7 898.8 43.2 PASS
- 【代码做什么?】
- 构造 CSR:
build_stencil9_csr支持一个hubStride/hubExtra参数。hubStride = 0时得到纯 9 点模板(矩阵 A);hubStride = 1024, hubExtra = 40时,凡是行号能被 1024 整除的行都额外插入 40 个非零元(矩阵 B)。这些”枢纽行”模拟图矩阵中度数极高的节点(幂律度分布的长尾),也是 ELL 的 padding 灾难来源。 - CSR → ELL 转换:
csr_to_ell先扫一遍rowPtr求E = max row length,把numRows × E的数组整体清零(这是 padding 值 0、列号 0),再把每行的len个元素按列主序写到ellVal[i*numRows + r]。注意 padding 项的值是 0,所以x[colIdx]即使读到任意列(这里统一是 0 号列)也不会影响结果——这是 ELL 能”安全越界读”的经典技巧,但前提是colIdx的 padding 值必须落在合法范围内(这里是恒 0),否则会真的越界访问。 - kernel 1(
spmv_ell):完全照讲义 page 20 的SpMV_ELL写法,循环次数对所有线程都是常数E,索引k = i*num_rows + row。 - kernel 2(
spmv_ell_vec4):把 ELL 数组重新解释成float4/int4,每个线程负责 4 个连续行(row0 = 4*g)。因为 ELL 是列主序的,data[i*numRows + row0..row0+3]恰好是一个 16 B 对齐的float4,所以一次向量化 load 就取回 4 行的值,4 次独立的 gather + 4 条 FMA 并行执行。 - 验证与计时:三个 kernel 都跑一遍并与 CSR 语义的 CPU 参考结果对比;然后各自重复
reps次取平均。 - 两个矩阵共用同一个函数:
run_case把”构造 → 转换 → 验证 → 计时”打包,避免代码重复,也让两个矩阵的对比完全公平(同一份 kernel、同一份计时逻辑)。
- 构造 CSR:
- 【并行机制与硬件映射解说】
- 合并访存(ELL 的核心优势):固定
i时,warp 内 lanet读data[i*numRows + row0 + t],相邻 lane 地址相差 4 B → 32 个 lane 覆盖 128 B = 1 条 cache line = 4 个 32 B sector,一次 warp load 就是一个完美合并事务。这与 CSR 的”36 个 sector”(因为每行起点不同而错位)形成鲜明对比:ELL 把”越界的起点”变成了”数组下标的一维偏移”,从而把地址重新变回 affine 的。矩阵 A 实测 775 GB/s(峰值的 49.9%),比 CSR 的 448 GB/s 高出 73%。 - 控制发散:
for (int i = 0; i < num_elem; i++)的 trip count 是 kernel 参数num_elem,对所有线程完全相同,warp 内 32 个 lane 永远同步,branch_efficiency = 100%。CSR 版本在同一个矩阵上的平均 lane 利用率约 100%(模板矩阵),但在矩阵 B 上会立刻恶化(长短行混在同一个 warp 里)。 x[colIdx[k]]的 gather:这是 ELL 唯一没有消除的发散。固定i时,lanet的列号是colIdx[i*N + row0 + t];对 9 点模板,这些列号 =row0 + t + offset(i),offset(i)只有 9 种取值,所以 32 个 lane 的列号聚集在约 35 个连续整数内(地址跨度 140 B),落在 3~5 个 sector 上且互相重叠 → L1 命中率极高,gather 的代价被压在 L1 层面。这也是”模板/带状矩阵用 ELL 特别快”的硬件原因。对随机稀疏矩阵,列号毫无规律,gather 会重新变成 32 个独立 sector(见概念 10)。- float4 的收益来源:向量化并不减少必须搬运的字节数(
ellBytes不变,所以两个 kernel 的 GB/s 用同一个分子),它减少的是指令数与内存事务数:原来每个线程 4 次 4 B load 变成 1 次 16 B load,warp 一次 load 覆盖 512 B(4 条 cache line,16 个 sector),LSU 的发射压力降到 1/4;同时 4 个独立的 gather 提供了 4 倍的内存级并行(MLP),更好地隐藏 L2/L1 延迟。实测从 775 GB/s 提升到 873 GB/s。 - 共享内存与 bank conflict:本 kernel 没有使用共享内存,因此不存在 bank conflict。这也是一个权衡:把
x放进共享内存(N ≤ 12288 时)可以省掉每 nnz 的 4 B 全局读,但会引入”gather 命中共享内存 bank”的问题——共享内存的 bank 由(addr/4) % 32决定,随机列号会造成严重的 bank conflict(最坏 32 路冲突 = 32 倍延迟)。对 SpMV 而言,把 x 放共享内存只在列号局部性好(模板/带状)时才推荐;随机矩阵宁可让 x 走 L1/L2(L1 有 128 B 的 line 粒度,容忍度高得多)。 - 占用率:两个 kernel 各约 20~28 个寄存器。
spmv_ell每线程 20 个寄存器 → 65536/24(取整后) = 2730 线程 > 2048 → 8 个 block/SM = 2048 线程 = 100% 占用率。spmv_ell_vec4需要保存 4 个累加器 + 4 组索引,约 28~32 个寄存器 → 仍然 100%。矩阵 A 的 grid = 4096 个 block,108 SM × 8 block = 864 block/波 → 约 4.7 波,最后一波填充率约 74%(有轻微尾部效应,见”性能优化分析”)。
- 合并访存(ELL 的核心优势):固定
- 【性能优化分析】
- 算术强度与 Roofline:ELL 与 CSR 的”每 nnz 字节数”完全相同(
values4 B +colIdx4 B +x4 B = 12 B),所以 AI 仍是 0.167 FLOP/Byte,Roofline 上限仍是 259.2 GFLOP/s。两者差别只在实际能拿到多少带宽:CSR 拿到 448 GB/s(上限的 28.8%),ELL 拿到 775 GB/s(49.9%),ELL+float4 拿到 873 GB/s(56.1%)。用 Roofline 的语言说:它们的工作点都在同一条带宽屋顶上,但 CSR 因为”事务放大 + 依赖链”实际只走到了屋顶下方的 37%,ELL 走到了 67%~76%。CSR : 96.0 GFLOP/s = 259.2 上限的 37.0% = 峰值的 0.49% ELL : 174.2 GFLOP/s = 259.2 上限的 67.2% = 峰值的 0.89% ELL+vec4: 196.1 GFLOP/s = 259.2 上限的 75.7% = 峰值的 1.01% 带宽利用: 448 GB/s (28.8%) -> 775 GB/s (49.9%) -> 873 GB/s (56.1%)注意 ELL+vec4 已经触及”0.167 FLOP/Byte”这条线的 76%,再往上就要靠”减少字节”(而不是”改善规则性”)了:把
x放进共享内存(AI 0.25)、colIdx用 16 位(AI 0.20)都能把上限从 259 提到 300~390 GFLOP/s。 - 为什么达不到 100% 带宽:ELL 的实测时间是 DRAM 理想时间(83.9 MB / 1555 GB/s = 53.9 µs)的 2.0 倍(108.2 µs)。原因有三:(a)
x的 gather 虽然命中率高,但每次 warp load 仍要查询 3~5 个 sector 的 tag,L1 事务数高于理想值;(b) 循环体内load data → load colIdx → gather x → FMA是一条串行依赖链,E = 9次迭代只有 9 段可重叠的访存,MLP 不足(float4 版本把 MLP 提升 4 倍后降到 96.1 µs,改善 11%);(c) A100 的可持续带宽本身约为理论峰值的 85%~90%(DRAM refresh、ECC、读写切换),实际可用上限约 1350 GB/s,因此 873 GB/s 相当于”可持续带宽”的 65%。 - 两个矩阵的对比:padding 的经济学。这是本程序最重要的实验结论:
nnz 必须搬运字节 时间 有效带宽 GFLOP/s 矩阵 A (模板) 9,424,900 CSR 88.0 MB 196.3 us 448 GB/s 96.0 ELL 83.9 MB 108.2 us 775 GB/s 174.2 矩阵 B (含离群行) 9,465,860 CSR 88.3 MB 197.5 us 447 GB/s 95.9 ELL 394.3 MB 493.4 us 799 GB/s 38.4同样的 kernel、几乎同样的 nnz(相差 0.4%),矩阵 A 上 ELL 比 CSR 快 1.81 倍,矩阵 B 上 ELL 比 CSR 慢 2.50 倍。区别只有一个:
E / 平均行长从 1.00 变成 5.10。ELL 的带宽其实更好(799 GB/s),但它搬运了 4.5 倍的无用 padding 数据——带宽再高也救不了”搬了 5 倍的数据”。 - HYB 的补救(模型推算):对矩阵 B 采用 HYB,取
E = 9(刚好覆盖 99.9% 的行),把超过 9 的部分丢给 COO:ELL 部分: 8 * N * 9 = 75.5 MB COO 部分: 3 个数组 * 4 B * 37,886 个离群元 = 0.45 MB x, y : 8.4 MB ---------------------------------------------------- 合计约 84.3 MB, 相比纯 ELL 的 394.3 MB 降到 1/4.7, 甚至比纯 CSR 的 88.3 MB 还少 按 733 GB/s (介于 ELL 与 CSR 之间, 因为有归约与原子开销) 估算: 约 115 us, 约 165 GFLOP/s代价是 COO 部分需要 segmented reduction(分段归约):把每个离群元先按行归约到共享内存(同一行的元素尽量分到同一 block),再把每行的部分和用一次
atomicAdd(&y[row], partial)写回。“主体用规则格式,尾部用灵活格式”就是 HYB 的全部思想。 - 瓶颈判定:矩阵 A 上判定为内存带宽受限(且已接近可持续带宽上限);矩阵 B 的 ELL 版本判定为带宽被 padding 浪费掉的带宽受限,正确处置是换格式(HYB/JDS),而不是调 kernel。
- 算术强度与 Roofline:ELL 与 CSR 的”每 nnz 字节数”完全相同(
示例 4:CSR + warp-per-row + shuffle 归约(spmv_csr_warp.cu)
不换格式也能修复 CSR 的访存发散:把”每线程一行”改成”每 warp 一行“,让 32 个 lane 并行处理同一行的 32 个连续非零元,最后用 __shfl_down_sync 在寄存器内做树形归约。
// 文件: spmv_csr_warp.cu
// 用途: CSR SpMV, 每 warp 处理一行 (lane 分头处理连续非零元) + shuffle 归约
// 目的: 在不改变存储格式的前提下消除 data/colIdx 的访存发散
// 编译: nvcc -O3 -arch=sm_80 spmv_csr_warp.cu -o spmv_csr_warp
// 运行: ./spmv_csr_warp 1048576 100
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cuda_runtime.h>
#define CUDA_CHECK(call) \
do { \
cudaError_t err__ = (call); \
if (err__ != cudaSuccess) { \
fprintf(stderr, "CUDA error %s:%d: %s\n", __FILE__, __LINE__, \
cudaGetErrorString(err__)); \
exit(EXIT_FAILURE); \
} \
} while (0)
#define TPB 256 // 256 线程 = 8 个 warp = 每个 block 处理 8 行
// ==================================================================
// 主机端: 构造 9 点模板的 CSR
// ==================================================================
void build_stencil9_csr(int numRows, int nx,
int **rowPtr_out, int **colIdx_out, float **vals_out,
int *nnz_out)
{
const int ny = numRows / nx;
int *rowPtr = (int *)malloc(sizeof(int) * (size_t)(numRows + 1));
if (!rowPtr) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
rowPtr[0] = 0;
for (int r = 0; r < numRows; ++r) {
const int y = r / nx, xc = r % nx;
int cnt = 0;
for (int dy = -1; dy <= 1; ++dy)
for (int dx = -1; dx <= 1; ++dx) {
const int yy = y + dy, xx = xc + dx;
if (yy >= 0 && yy < ny && xx >= 0 && xx < nx) ++cnt;
}
rowPtr[r + 1] = rowPtr[r] + cnt;
}
const int nnz = rowPtr[numRows];
int *colIdx = (int *)malloc(sizeof(int) * (size_t)nnz);
float *vals = (float *)malloc(sizeof(float) * (size_t)nnz);
if (!colIdx || !vals) { fprintf(stderr, "host malloc failed\n"); exit(EXIT_FAILURE); }
int k = 0;
for (int r = 0; r < numRows; ++r) {
const int y = r / nx, xc = r % nx;
for (int dy = -1; dy <= 1; ++dy)
for (int dx = -1; dx <= 1; ++dx) {
const int yy = y + dy, xx = xc + dx;
if (yy < 0 || yy >= ny || xx < 0 || xx >= nx) continue;
const int c = yy * nx + xx;
colIdx[k] = c;
vals[k] = ((c == r) ? 4.0f : 1.0f) + 0.001f * (float)((r * 31 + c * 17) % 13);
++k;
}
}
*rowPtr_out = rowPtr; *colIdx_out = colIdx; *vals_out = vals; *nnz_out = nnz;
}
void spmv_cpu(int numRows, const int *rowPtr, const int *colIdx,
const float *vals, const float *x, float *y)
{
for (int r = 0; r < numRows; ++r) {
double dot = 0.0;
for (int e = rowPtr[r]; e < rowPtr[r + 1]; ++e) {
dot += (double)vals[e] * (double)x[colIdx[e]];
}
y[r] = (float)dot;
}
}
// ==================================================================
// kernel A: 每线程一行 (基准)
// ==================================================================
__global__ void spmv_csr_row_per_thread(int num_rows,
const float * __restrict__ data,
const int * __restrict__ col_index,
const int * __restrict__ row_ptr,
const float * __restrict__ x,
float * __restrict__ y)
{
int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row < num_rows) {
float dot = 0.0f;
int row_start = __ldg(&row_ptr[row]);
int row_end = __ldg(&row_ptr[row + 1]);
for (int elem = row_start; elem < row_end; elem++) {
dot = fmaf(__ldg(&data[elem]), __ldg(&x[__ldg(&col_index[elem])]), dot);
}
y[row] = dot;
}
}
// ==================================================================
// kernel B: 每 warp 一行, 32 个 lane 分头处理连续的非零元
// lane t 依次处理第 start+t, start+t+32, start+t+64 号元素, 步长固定为 32
// 然后 __shfl_down_sync 做 5 步树形归约 (log2(32) = 5)
// ==================================================================
__global__ void spmv_csr_warp_per_row(int num_rows,
const float * __restrict__ data,
const int * __restrict__ col_index,
const int * __restrict__ row_ptr,
const float * __restrict__ x,
float * __restrict__ y)
{
const int lane = threadIdx.x & 31;
const int warpId = (blockIdx.x * blockDim.x + threadIdx.x) >> 5;
// 注意: warpId 对同一个 warp 的 32 个 lane 是相同的, 因此这个 return 是"warp 一致"的,
// 后面 mask 参数写 0xffffffff 的 __shfl_down_sync 才有合法的语义
if (warpId >= num_rows) return;
const int start = __ldg(&row_ptr[warpId]);
const int end = __ldg(&row_ptr[warpId + 1]);
float dot = 0.0f;
for (int e = start + lane; e < end; e += 32) {
dot = fmaf(__ldg(&data[e]), __ldg(&x[__ldg(&col_index[e])]), dot);
}
#pragma unroll
for (int off = 16; off > 0; off >>= 1) {
dot += __shfl_down_sync(0xffffffffu, dot, off);
}
if (lane == 0) y[warpId] = dot; // 只有 lane 0 写回, 每行一次 4 B 写
}
int main(int argc, char **argv)
{
const int N = (argc > 1) ? atoi(argv[1]) : 1048576;
const int reps = (argc > 2) ? atoi(argv[2]) : 100;
int *rowPtr = NULL, *colIdx = NULL, nnz = 0;
float *vals = NULL;
build_stencil9_csr(N, 1024, &rowPtr, &colIdx, &vals, &nnz);
const double bytes = 8.0 * nnz + 4.0 * (double)(N + 1) + 8.0 * (double)N;
printf("CSR + warp-per-row: N = %d, nnz = %d, 必须搬运 %.1f MB\n",
N, nnz, bytes / 1.0e6);
float *x = (float *)malloc(sizeof(float) * (size_t)N);
float *ref = (float *)malloc(sizeof(float) * (size_t)N);
float *y = (float *)malloc(sizeof(float) * (size_t)N);
if (!x || !ref || !y) { fprintf(stderr, "host malloc failed\n"); return EXIT_FAILURE; }
for (int i = 0; i < N; ++i) x[i] = (float)((i % 17) - 8) * 0.25f;
spmv_cpu(N, rowPtr, colIdx, vals, x, ref);
int *d_row = NULL, *d_col = NULL;
float *d_val = NULL, *d_x = NULL, *d_y = NULL;
CUDA_CHECK(cudaMalloc((void **)&d_row, sizeof(int) * (size_t)(N + 1)));
CUDA_CHECK(cudaMalloc((void **)&d_col, sizeof(int) * (size_t)nnz));
CUDA_CHECK(cudaMalloc((void **)&d_val, sizeof(float) * (size_t)nnz));
CUDA_CHECK(cudaMalloc((void **)&d_x, sizeof(float) * (size_t)N));
CUDA_CHECK(cudaMalloc((void **)&d_y, sizeof(float) * (size_t)N));
CUDA_CHECK(cudaMemcpy(d_row, rowPtr, sizeof(int) * (size_t)(N + 1), cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_col, colIdx, sizeof(int) * (size_t)nnz, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_val, vals, sizeof(float) * (size_t)nnz, cudaMemcpyHostToDevice));
CUDA_CHECK(cudaMemcpy(d_x, x, sizeof(float) * (size_t)N, cudaMemcpyHostToDevice));
const int gridT = (N + TPB - 1) / TPB; // 每线程一行: N/256 个 block
const int warpsPerBlock = TPB / 32;
const int gridW = (N + warpsPerBlock - 1) / warpsPerBlock; // 每 warp 一行: N/8 个 block
cudaEvent_t ev0, ev1;
CUDA_CHECK(cudaEventCreate(&ev0));
CUDA_CHECK(cudaEventCreate(&ev1));
printf("%-28s %10s %10s %11s %9s\n",
"kernel", "time(us)", "GB/s", "GFLOP/s", "verify");
// ---- kernel A ----
spmv_csr_row_per_thread<<<gridT, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(y, d_y, sizeof(float) * (size_t)N, cudaMemcpyDeviceToHost));
int okA = 1;
for (int i = 0; i < N; ++i)
if (fabsf(y[i] - ref[i]) > 1e-4f * (1.0f + fabsf(ref[i]))) { okA = 0; break; }
CUDA_CHECK(cudaEventRecord(ev0));
for (int i = 0; i < reps; ++i)
spmv_csr_row_per_thread<<<gridT, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
CUDA_CHECK(cudaEventRecord(ev1));
CUDA_CHECK(cudaEventSynchronize(ev1));
float ms = 0.0f;
CUDA_CHECK(cudaEventElapsedTime(&ms, ev0, ev1));
ms /= (float)reps;
printf("%-28s %10.1f %10.1f %11.1f %9s\n", "CSR 每线程一行", ms * 1000.0f,
bytes / (ms * 1.0e6), 2.0 * nnz / (ms * 1.0e6), okA ? "PASS" : "FAIL");
// ---- kernel B ----
spmv_csr_warp_per_row<<<gridW, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
CUDA_CHECK(cudaDeviceSynchronize());
CUDA_CHECK(cudaMemcpy(y, d_y, sizeof(float) * (size_t)N, cudaMemcpyDeviceToHost));
int okB = 1;
for (int i = 0; i < N; ++i)
if (fabsf(y[i] - ref[i]) > 1e-4f * (1.0f + fabsf(ref[i]))) { okB = 0; break; }
CUDA_CHECK(cudaEventRecord(ev0));
for (int i = 0; i < reps; ++i)
spmv_csr_warp_per_row<<<gridW, TPB>>>(N, d_val, d_col, d_row, d_x, d_y);
CUDA_CHECK(cudaEventRecord(ev1));
CUDA_CHECK(cudaEventSynchronize(ev1));
CUDA_CHECK(cudaEventElapsedTime(&ms, ev0, ev1));
ms /= (float)reps;
printf("%-28s %10.1f %10.1f %11.1f %9s\n", "CSR warp-per-row + shuffle",
ms * 1000.0f, bytes / (ms * 1.0e6), 2.0 * nnz / (ms * 1.0e6),
okB ? "PASS" : "FAIL");
printf("\nRoofline 上限 (AI = 2 FLOP / 12 B = 0.167): 1555 * 0.167 = 259.2 GFLOP/s = 峰值的 1.33%%\n");
CUDA_CHECK(cudaFree(d_row)); CUDA_CHECK(cudaFree(d_col)); CUDA_CHECK(cudaFree(d_val));
CUDA_CHECK(cudaFree(d_x)); CUDA_CHECK(cudaFree(d_y));
CUDA_CHECK(cudaEventDestroy(ev0)); CUDA_CHECK(cudaEventDestroy(ev1));
free(rowPtr); free(colIdx); free(vals); free(x); free(ref); free(y);
return EXIT_SUCCESS;
}
实测输出(A100,N = 1,048,576,nnz = 9,424,900,reps = 100):
CSR + warp-per-row: N = 1048576, nnz = 9424900, 必须搬运 88.0 MB
kernel time(us) GB/s GFLOP/s verify
CSR 每线程一行 196.3 448.2 96.0 PASS
CSR warp-per-row + shuffle 131.0 671.8 143.9 PASS
Roofline 上限 (AI = 2 FLOP / 12 B = 0.167): 1555 * 0.167 = 259.2 GFLOP/s = 峰值的 1.33%
- 【代码做什么?】
- 线程映射的改变:
warpId = (blockIdx.x*blockDim.x + threadIdx.x) >> 5,即每 32 个线程(1 个 warp) 协作处理一行;lane = threadIdx.x & 31决定该 lane 负责这一行里的哪些非零元。一个 block(256 线程)处理 8 行。 - 循环步长 32:
for (int e = start + lane; e < end; e += 32)——lane 0 处理第start个元素,lane 1 处理start+1个,一直到 lane 31 处理start+31个,然后各自再前进 32。这正是”把一行的元素顺次分给 32 个线程”,也是唯一能保证data[e]/col_index[e]对相邻 lane 连续的做法。 - warp 内树形归约:
for (off = 16; off > 0; off >>= 1) dot += __shfl_down_sync(0xffffffffu, dot, off);——5 步之后 lane 0 手里就是整行的和。__shfl_down_sync是寄存器之间的数据交换(volatile 语义之外的开销极小),比”写共享内存再__syncthreads()再读”更快,也不需要任何共享内存。 - 归约后只有 lane 0 写回
y[warpId] = dot,因此y的写是一个 warp 一次 4 B,写流量与”每线程一行”完全相同。 - 正确性验证与计时:两个 kernel 都与 CPU 参考实现对比,然后各重复
reps次取平均。
- 线程映射的改变:
- 【并行机制与硬件映射解说】
- 合并访存:从”9 倍放大”到”完全合并”。在固定的一次循环迭代
t上,lanel访问data[start + l + 32t],相邻 lane 的地址差 4 B → 32 个 lane 覆盖 128 B,一次 warp load = 1 条 cache line = 4 个 sector,效率 100%。这修复了”每线程一行”版本中”每行起点错位 36 B、一个 warp 触及 36 个 sector”的问题。col_index同样合并。 - 控制发散:从”由最长行决定”到”只剩尾迭代”。warp 处理一行时,循环次数是
ceil((end-start)/32),对同一 warp 的所有 lane 完全相同(因为同一行工作),所以只有最后一次迭代可能不满 32 个有效 lane。对 9 点模板(行长 9):迭代次数 = 1,只有 lane 0~8 有效 → warp 利用率 = 9/32 = 28%!这是一个重要的反直觉现象:warp-per-row 对”长行”才划算。行长 9 时它浪费 72% 的 lane,行长 1024 时利用率 100%。本矩阵的平均行长只有 9,因此 warp-per-row 的收益完全来自”访存合并”,而不是来自利用率——这解释了为什么它只提升到 672 GB/s(43.2%),而不是像 ELL 那样到 775 GB/s。 - 对行长分布的影响:如果矩阵的平均行长是 128 或以上(例如 3D 7 点模板在稀疏化后仍很长、或者图矩阵的高阶邻域),warp-per-row 几乎是免费的午餐;如果平均行长是 5~10(2D 模板、非常稀疏的图),更好的做法是“每个 warp 处理多行”(把 4~8 行交给一个 warp,用
__shfl分段归约),或者干脆用 ELL/COO。 __shfl_down_sync的 mask 正确性:0xffffffff要求 warp 内 32 个 lane 全部参与。本 kernel 中提前return的条件warpId >= num_rows对整个 warp 是一致(uniform)的(warpId只依赖 warp 编号),所以不会出现”部分 lane 退出、部分 lane 还在 shuffle”的未定义行为。如果把return条件改成与 lane 相关的条件(例如按元素个数判断),就必须改成__shfl_down_sync(mask, var, delta)(用__ballot_sync/__activemask()取得 mask),否则行为未定义——这是 Lab 中非常容易出错的地方。- 占用率:约 26 个寄存器 → 100% 占用率(8 个 block/SM)。grid = N/8 = 131,072 个 block,远大于 SM 容量,所以尾部效应可忽略(最后一波的填充率误差约 0.7%),比”每线程一行”版本的 5 波 tail 更平滑。
- 共享内存与 bank conflict:kernel 完全不使用共享内存,因此没有 bank conflict。归约走的是寄存器 shuffle 路径,每个
__shfl_down_sync指令 1 个周期(warp 内交叉开关),5 步共 5 个周期——相对于每行 9 次 gather 的几百个周期,成本可以忽略。
- 合并访存:从”9 倍放大”到”完全合并”。在固定的一次循环迭代
- 【性能优化分析】
- 算术强度与 Roofline:字节数完全不变(88.0 MB),AI 仍是 0.167 FLOP/Byte,上限仍是 259.2 GFLOP/s。实测 143.9 GFLOP/s = 上限的 55.5%、峰值的 0.74%;有效带宽 671.8 GB/s = 峰值的 43.2%(对比基础版的 28.8%)。
- 两种修复路线的对比:
路线 有效带宽 GFLOP/s 相对基础版 CSR 每线程一行 (基础) 448 GB/s 96.0 1.00x CSR warp-per-row + shuffle 672 GB/s 143.9 1.50x ELL 转置格式 775 GB/s 174.2 1.81x ELL + float4 873 GB/s 196.1 2.04xwarp-per-row 的收益(1.50x)小于 ELL(1.81x),因为:它修好了”访存发散”,但制造了”lane 空闲”(行长 9 时只有 9/32 个 lane 有效)。ELL 同时修好了两者(无发散、全 lane 有效),代价是多搬运 padding。没有免费的午餐:格式设计永远是”规则性”与”额外字节”之间的权衡。
- 瓶颈判定与优化方向:仍是内存带宽受限(准确说是”L1 事务 + 延迟受限”)。针对 warp-per-row 版本,进一步的方向是:
- 把
row_ptr/col_index用__ldg之外的手段降低维度(col_index用 16 位):字节从 88.0 MB 降到 79.7 MB; - 一个 warp 处理 2~4 行(分段归约),把 lane 利用率从 28% 提高到 60%~100%;
- 用
float4取data/col_index(每个 lane 处理 4 个连续非零元),把 load 指令数降到 1/4; - 用
cp.async(sm_80 的异步拷贝,见 ECE508)把x的分块预取进共享内存,隐藏 gather 的 L2 延迟。
- 把
四种实现在同一矩阵上的实测对比(A100,9 点模板,N = 1,048,576,nnz = 9,424,900)
+---------------------------+----------+-----------+---------+-----------+----------+-----------+
| 实现 | 时间(us) | 有效带宽 | %峰值 | GFLOP/s | %峰值算力| %Roofline |
+---------------------------+----------+-----------+---------+-----------+----------+-----------+
| 稠密 GEMV (N=4096, 参照) | 51.0 | 1316 GB/s | 84.6% | 657.9 | 3.37% | 84.6% * |
| CSR 每线程一行 | 196.3 | 448 GB/s | 28.8% | 96.0 | 0.49% | 37.0% |
| CSR warp-per-row+shuffle | 131.0 | 672 GB/s | 43.2% | 143.9 | 0.74% | 55.5% |
| ELL (标量) | 108.2 | 775 GB/s | 49.9% | 174.2 | 0.89% | 67.2% |
| ELL + float4 | 96.1 | 873 GB/s | 56.1% | 196.1 | 1.01% | 75.7% |
+---------------------------+----------+-----------+---------+-----------+----------+-----------+
* 稠密 GEMV 的 Roofline 上限是 1555 x 0.50 = 777.5 GFLOP/s (AI = 0.50), 与其他行的 259.2 不同
有效带宽 = 必须搬运的字节 / 时间; 有效字节 = values + colIdx + rowPtr + x + y = 88.0 MB
gather 造成的额外 sector 放大不计入分子 (属"有用字节口径")
结论:在完全相同的硬件、相同的问题规模、几乎相同的 nnz 下,仅仅改变数据布局与线程映射,性能就能相差 2.04 倍(96.0 → 196.1 GFLOP/s);而即使做到最好,也只有 Roofline 上限的 75.7%、FP32 峰值的 1.01%。这就是稀疏计算的本质:优化的对象是字节,不是运算。
性能优化技巧总结
- 先选格式,再调 kernel:格式决定了字节数与规则性,收益远大于 kernel 微调。判据是
E / 平均行长:< 1.5 用 ELL;行长方差大(> 4)用 HYB 或 JDS;极稀疏(nnz/N < 2)用 COO;大致三角/带状用 JDS;对称结构用 CSR/CSC。为什么有效:它直接改变”必须搬运多少字节”,而 SpMV 的性能上限完全由字节数决定。 - 让
colIdx与values的访问对相邻 lane 连续:ELL 的列主序布局、JDS-T 的转置、warp-per-row 的lane步长都是同一个目的。为什么有效:把”每 warp 36 个 sector”降到”4 个 sector”,L1 事务数下降近 9 倍。 - 消除控制发散:padding(ELL)、排序(JDS)、限幅(HYB)。为什么有效:SIMT 下 warp 的完成时间由最长 lane 决定,44.2% 的 lane 利用率意味着 55.8% 的发射槽被浪费(见概念 8 的 32 lane 实例)。
- 用 float4 向量化加载:要求 16 B 对齐且连续;ELL/COO 很容易满足。为什么有效:load 指令数降到 1/4,LSU 发射压力下降,同时把内存级并行(MLP)提升 4 倍以隐藏 L2/L1 延迟(实测 775 → 873 GB/s)。
- 打断累加器的依赖链:
#pragma unroll 4+ 4 个独立累加器,或 warp-per-row 的 shuffle 树形归约。为什么有效:dot = fmaf(a, b, dot)是串行依赖,展开后 4 条链可以并发访存,把”延迟受限”变成”带宽受限”。 - 当
4N ≤ 48 KB时把x整体放进共享内存:延迟从 400-800 cycles 降到 20-30 cycles,且省掉每 nnz 的 4 B 全局流量(AI 从 0.167 提到 0.25,上限从 259 提到 389 GFLOP/s)。为什么有效:x是唯一被所有行复用的数据,把它放到最快的存储层次收益最直接。注意:列号随机的矩阵会因共享内存 bank conflict 抵消收益。 colIdx用 16 位整数(N ≤ 65536)或块压缩(BCSR):每 nnz 从 12 B 降到 10 B 或更低。为什么有效:字节数与性能上限成反比,减少 17% 的字节就提高 17% 的上限。- 动态负载均衡(work stealing):warp 用
atomicAdd(&counter, 1)领行号。为什么有效:把”最长行决定 block 寿命”的静态不均衡变成动态均衡,特别适合幂律度分布的图矩阵;代价是每行一次 L2 原子操作(约 200-400 cycles)与局部性损失。 - 行重排序 / 分块:按行长排序(JDS)、按结构聚类(graph partitioning)、把矩阵切成
x能放进 L2 的列块。为什么有效:前者改善 warp 内均衡,后者让 gather 命中 L2 而不是 DRAM。 - padding 到 32 的倍数 / 16 B 对齐:ELL 的
E补齐到 4 或 8 的倍数,共享内存数组列数 +1 以避免 bank conflict。为什么有效:让每次 load 落在完整的 sector 上,同时消除 bank 冲突导致的 32 倍串行化。
关键要点
- 稀疏计算的性能瓶颈是”规则性”,不是”计算量”:SpMV 做的浮点运算比稠密版本少几个数量级,却慢几十倍;原因是控制发散(行长不同)与访存发散(列索引数据相关)破坏了 SIMT 硬件赖以高效工作的两条前提:地址可解析计算、warp 内行为一致。
- 算力利用率上限只有 1.33%:
AI = 2 FLOP / 12 B = 0.167,A100 上限 = 1555 × 0.167 ≈ 259 GFLOP/s,是 19.5 TFLOPS 峰值的 1.33%(机器平衡点 12.5 与 0.167 相差 75 倍)。所有优化都必须围绕”减少字节数 / 改善字节的规则性”展开,减少浮点运算毫无意义。 - 格式是权衡,没有最优解:CSR 通用但两个发散;ELL 规则但 padding 会爆炸(实测矩阵 B 上流量放大 5.10 倍、性能从快 1.81 倍变成慢 2.50 倍);COO 并行度最高但多 4 B/nnz 且要原子操作;JDS 修控制发散但不修访存发散;JDS-T 两者都修但需要排序与两次转换;HYB 用”ELL 处理主体 + COO 处理离群”同时拿到规则性与灵活性。
- 选择格式的定量判据:
E / 平均行长(ELL 适用性)、行长方差、nnz / N(稀疏度)、矩阵结构(三角/带状/幂律)。讲义给出的经验是:大致随机 → ELL;行长方差大 → ELL/COO;极稀疏 → COO;大致三角 → JDS;带状 → ELL。 - warp-per-row 不是万能药:它把
data/colIdx的访存变成完美合并,但行长小于 32 时 lane 利用率不足一半(行长 9 时只有 28% 有效),因此实测收益(1.50x)小于 ELL(1.81x)。先看平均行长,再决定用哪种映射。 - 实测数据证明”布局决定性能”:同一矩阵、同一硬件,仅改变布局与线程映射,性能从 96.0 GFLOP/s(CSR)到 196.1 GFLOP/s(ELL+float4),相差 2.04 倍;而四者的算术强度完全相同。
常见陷阱与注意事项
- 忘记
cudaDeviceSynchronize()或忘记在计时前预热:现象是第一次测得的 kernel 时间包含上下文初始化、页表建立、指令 cache 冷启动,导致时间偏高数倍(本讲例子中预热后 CSR 从约 300 µs 降到 196 µs) → 正确做法是每个 kernel 先跑一次并cudaDeviceSynchronize(),再开始计时;cudaMemcpy本身是同步的,但它会掩盖异步 kernel 的计时,所以计时片段内绝不要夹cudaMemcpy。 - CSR 的
row_ptr长度写成 N 而不是 N+1:现象是最后一行算错或row_ptr[row+1]越界读到相邻数组,结果是随机错误(有时 PASS 有时 FAIL) → 正确做法是总是分配N+1个元素并令rowPtr[N] = nnz(哨兵),这样 kernel 里可以统一用row_ptr[row+1]而无需特判。 - ELL 的 padding 列号写成
-1或未初始化:现象是x[colIdx[k]]读到x[-1]或读到垃圾地址,出现an illegal memory access was encountered→ 正确做法是 padding 的”值”填 0 且”列号”填一个合法列(例如 0),因为硬件仍然会执行这次 gather,只是结果被 0 乘掉。 __ldg/__restrict__误用于会被写入的指针:现象是优化后结果错误(编译器假设只读而重排了写操作) → 正确做法是只给”确实只读”的指针加const float * __restrict__;本讲的row_ptr、col_index、values、x都是只读,y是只写。- 在
__shfl_*_sync之前用与 lane 相关的条件提前return:现象是 shuffle 结果错误或 kernel 挂死(mask 与实际参与的 lane 不符,属于未定义行为) → 正确做法是保证return/分支条件对 warp 一致(如本讲示例 4 的warpId >= num_rows),或者用__activemask()/__ballot_sync()构造正确的 mask。 - 共享内存缓存 x 时忘记
__syncthreads(),或忽略了随之而来的 bank conflict:现象一是部分线程读到未初始化的xs[j](结果随调度变化而不稳定),现象二是把x放进共享内存后性能不升反降 → 正确做法有两条:(1) 严格走”协作加载 x →__syncthreads()→ 进入计算”三步,且__syncthreads()必须在所有线程都到达的路径上(不能放进if (row < N)之内);(2) 只在列号局部性好(模板/带状矩阵)时才缓存 x,因为 32 个 lane 用随机列号访问共享内存会造成最坏 32 路 bank conflict(每次访问串行化 32 次),而全局内存的 L1 以 128 B line 为单位,对随机访问的容忍度高得多。 - sparse kernel 用整数除法算网格导致覆盖不足:现象是结果里最后一批行没被计算(
y里残留旧值或 0) → 正确做法是grid = (num_rows + blockDim.x - 1) / blockDim.x(向上取整),并保留 kernel 内的if (row < num_rows)边界判断;两者缺一不可(向上取整解决覆盖,边界判断解决了多出来的线程)。 - 把”有效带宽”与”实际 DRAM 流量”混为一谈:现象是算出 873 GB/s > 实测 DRAM 带宽计数器读数,误以为测量出错 → 正确做法是明确口径:本讲的”有效带宽 = 必须搬运的有用字节 / 时间”,它不包含 gather 造成的 sector 放大;要比较硬件极限时应该用 ncu 的
dram__bytes_read.sum与dram__throughput.avg.pct_of_peak_sustained_elapsed。稀疏 kernel 常常”DRAM 只用 30% 却很慢”,因为瓶颈在 L1 事务与延迟。
思考题(带答案)
Q1. 一个 3D 7 点模板矩阵(尺寸 256³ = 16,777,216 行,每行 7 个非零元),在 A100 上用”每线程一行”的 CSR SpMV 处理。请计算:(a) 必须搬运的字节数;(b) Roofline 给出的性能上限;(c) 如果你的实现只跑到 80 GFLOP/s,最可能的两个瓶颈是什么?
(a) nnz ≈ 7 × 16,777,216 = 117,440,512(边界行略少)。字节 = values 4×nnz + colIdx 4×nnz + rowPtr 4×(N+1) + x 4N + y 4N = 469.8 MB + 469.8 MB + 67.1 MB + 67.1 MB + 67.1 MB ≈ 1,140.9 MB(1.14 GB)。 (b) AI = 2 FLOP / 12 B = 0.167 FLOP/Byte → 上限 = 1555 GB/s × 0.167 = 259.2 GFLOP/s(仅 FP32 峰值的 1.33%);DRAM 理想时间 = 1.14 GB / 1555 GB/s ≈ 733 µs。 (c) 80 GFLOP/s 只有上限的 30.9%,最可能的两个瓶颈是:(1) 访存发散——每个 warp 内 32 行的 row_ptr 起点每行错开 28 B,一个 warp 的 data/colIdx load 会触及约 32 个 sector 而不是 4 个,L1 事务放大近 8 倍;(2) 控制发散 + 依赖链——虽然 7 点模板行长几乎都是 7(控制发散很小),但每行的 7 次 gather → FMA 是串行依赖链,MLP 不足,无法隐藏 L2 的约 200 cycles 延迟。修复方向:改用 ELL(E = 7,padding 放大 1.0)、或把 colIdx 降为 16 位并用 #pragma unroll 打断依赖链。
Q2. 为什么说”每次浮点乘加需要 12 字节”这个数字决定了 SpMV 的一切?如果要把它降到 8 字节,有哪些可行手段,各自的收益上限是多少?
因为 Roofline 上限 = 带宽 × (2 FLOP / 每 nnz 字节数):在带宽固定的硬件上,每 nnz 的字节数直接决定了算力上限,而 12 B(values 4 B + colIdx 4 B + x 4 B)是 CSR 的结构常数,因此上限被钉死在 1555 × 0.167 = 259.2 GFLOP/s。把 12 B 降到 8 B(即消掉 x 的 4 B)会让 AI 从 0.167 升到 0.25,上限提升 50% 到 388.8 GFLOP/s。可行的手段有三种:(1) 把 x 缓存到共享内存(需要 4N ≤ 48 KB 即 N ≤ 12288),省掉每 nnz 的全局读;(2) 按列分块(column blocking),一次只处理 x 的一个片段,让它常驻 L1/L2;(3) 块压缩格式(BCSR/blocked ELL),让 x[col] 的访问在块内连续从而被 cache 覆盖。若进一步把 colIdx 降到 16 位(N ≤ 65536 时可行),每 nnz 可到 6 B,AI = 0.33,上限 518 GFLOP/s(峰值的 2.66%)——这是几乎所有 SpMV 优化能触及的物理天花板。
Q3. 矩阵 A(9 点模板,E/平均行长 = 1.00)上 ELL 比 CSR 快 1.81 倍;矩阵 B(每 1024 行 +40 个非零元,E/平均行长 = 5.10)上 ELL 比 CSR 慢 2.50 倍。请解释这个反转,并设计一个方案让矩阵 B 上的性能超过矩阵 A 上的 CSR。
反转的原因是 ELL 的”规则性收益”是固定的(它把访存变成合并、把发散变成零),而它的”padding 代价”随 E / 平均行长 线性增长:矩阵 A 的 padding 放大 1.00(几乎不浪费),矩阵 B 放大 5.10(必须搬运 394.3 MB,而真实数据只有 88.3 MB),带宽虽高达 799 GB/s,但搬运量是 CSR 的 4.5 倍,于是总时间变成 493.4 µs vs CSR 的 197.5 µs。带宽再高也救不了搬运 5 倍的数据。改进方案是 HYB(ELL + COO):取阈值 E = 9(覆盖 99.9% 的行),把每行超过 9 的部分(约 37,886 个非零元)交给 COO,用 segmented reduction 归并回 y。这样 ELL 部分只要 75.5 MB、COO 部分 0.45 MB,合计约 84.3 MB,比纯 CSR 的 88.3 MB 还少,同时保留了 ELL 的规则访存;按 733 GB/s(含归约与原子开销)估算约 115 µs、约 165 GFLOP/s,明显优于矩阵 A 上 CSR 的 96.0 GFLOP/s。这也说明:格式选择必须基于矩阵的行长分布,而不是基于”哪种格式更先进”。
