Lecture 9: 并行模式七 —— 稀疏矩阵向量乘:压缩格式与负载均衡 (对应 Lab 7 / Lab 8: Sparse Matrix Multiply)

目录 · ← l8 · l10 →

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 合并访问(rowPtrrowPtr+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 读取的 valuescolIdxrowIdx 都是连续地址(因为元素按线性编号分布),因此访存是完美合并的,控制流也完全无发散(所有线程迭代次数相同)。它的代价转移到了输出端: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 个数组:datacolIdx(按排序后的顺序)、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))、多两个辅助数组(matColStartmatRowPerm),以及 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 的本质困难在数据搬运,而不在计算

推论(可用于指导所有优化决策):

  1. 减少 colIdx 的位宽(int32 → int16,当 N ≤ 65536 时)可以把每 nnz 的字节从 12 B 降到 10 B,上限提升 20%——这是”减少字节”;
  2. 行分块(block CSR)让 colIdx 只在块级别存一次,进一步降低索引开销;
  3. x 常驻共享内存或 L1(当 4N 字节放得下时),把 12 B/nnz 降到 8 B/nnz,上限提升 50%——这是最直接的收益,也是本讲”共享内存缓存 x”技巧的价值所在;
  4. 提高访存的规则性(对齐、连续、可合并)能把实际带宽从峰值的 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 有两条不同的代价路径,必须区分清楚。

  1. DRAM 路径:如果 x 太大、放不进 cache,则每次 gather 都可能是一次 DRAM 访问,8 倍放大直接吃掉带宽。此时唯一出路是把 x 做分块(tiling):把矩阵按列切成 x 能放进 L2/共享内存的块,逐块做 SpMV 累加。
  2. 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):

  1. 共享内存缓存 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 的一个片段)。
  2. float4 向量化加载:要求数据 16 B 对齐且连续。ELL 的 data[i*N + row] 在 row 连续时天然满足,一个 float4 同时喂 4 个行(每线程管 4 行),把 load 指令数降到 1/4,MLP 提升到 4 倍,实测带宽从 775 GB/s 提升到 873 GB/s。
  3. warp-per-row + shuffle 归约:把一行的归约工作分给 32 个 lane,data/colIdx 的访问变成 32 个连续元素(完美合并),控制发散只出现在”尾迭代”,实测从 448 GB/s 提升到 672 GB/s。
  4. 行重排序(JDS):把”最长行决定 warp 寿命”的极端情况变成”相邻 lane 行长接近”,利用率从 44% 提升到 85% 以上。
  5. 动态负载均衡(work stealing):warp 用一个 atomicAdd(&counter, 1) 领取下一个行号,长行与短行自然摊到不同 warp 上;代价是每行一次全局原子操作(约 200-400 cycles 的 L2 延迟)以及失去了静态的访存局部性,适合行长方差极大的图矩阵。
  6. 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
  • 【代码做什么?】
    1. 主机端准备数据:把 N 补齐到 TPB*RPT = 1024 的整数倍,这样 kernel 里完全不需要边界判断(越界的那几行填充 0,不参与验证)。生成 N×N 的随机矩阵,同时保存行主序 h_A 与列主序 h_At 两份拷贝,它们是同一份数据的两种布局。
    2. CPU 参考实现:按行主序做双重循环,用 double 累加,得到 h_ref;这份结果会被三个 kernel 的结果逐一对比(相对误差 1e-3)。
    3. 版本 1(gemv_rowmajor:每个线程负责一行,row = blockIdx.x*blockDim.x + threadIdx.x,通过 rp[j] 流式读完该行。这是最直观的写法,也是”地址可解析计算”的经典形态。
    4. 版本 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] 打破串行依赖链。
    5. 版本 3:与版本 2 相同,但在 kernel 开头把整个 x(N=4096 时是 16 KB)协作搬进共享内存,循环里读 xs[j]cacheX 开关由主机端根据 N*4 ≤ 48 KB 决定,N 太大时自动退回全局内存版本。
    6. 计时与验证:每个 kernel 先预热一次(把页表、L2 状态、指令 cache 都预热),再重复 reps 次取平均,用 cudaEventElapsedTime 得到毫秒级结果;每次运行后把 d_y 拷回主机与 h_ref 比较,打印 PASS/FAIL。
    7. 带宽口径bytes = 4*N*N + 8*N(读 A 一次、读 x 一次、写 y 一次),除以前述平均耗时得到 GB/s。这一定义在下一个程序里会换成一个更细的口径,以便公平比较稀疏版本。
  • 【并行机制与硬件映射解说】
    1. 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 数的整数倍。
    2. 合并访存分析(版本 1,坏的例子):迭代 j 时,lane t 访问 A[(row0+t)*N + j],相邻 lane 的地址相差 N*4 = 16384 B。一个 warp 的一次 load 需要 32 个不同的 32 B sector(共 1024 B),但只有 128 B 有用——8 倍放大。好消息是同一 lane 的下一个迭代 j+1j 落在同一个 32 B sector 内(32 B = 8 个 float),所以后续 7 次迭代可以在 L1 命中;坏消息是 32 个 lane 同时压着 32 条不同的 cache line,L1 的 tag/事务吞吐被 32 路打散。实测 572 GB/s,仅为峰值的 37%。
    3. 合并访存分析(版本 2/3,好的例子):迭代 j 时,lane t 访问 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%。
    4. 共享内存的 bank 分析float xs[4096] 每个元素 4 B,bank 号 = (地址/4) % 32。写入阶段,lane txs[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。
    5. 寄存器使用与占用率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,不构成新的限制。
    6. warp 发散:三个版本的循环次数对所有线程完全相同(j = 0..N-1),且 N % (TPB*RPT) == 0,所以控制发散为零,branch_efficiency 为 100%。这一点非常重要:它说明版本 1 慢的原因不是发散,而是访存模式——这是排查性能问题时的典型思路(先看 memory chart,再看 scheduler statistics)。
  • 【性能优化分析】
    1. 占用率(occupancy):版本 1 每线程约 16 个寄存器 → 100% 占用率;版本 2/3 约 75%。但版本 1 比版本 2 慢 2.0 倍、比版本 3 慢 2.3 倍,占用率更高反而更慢。结论:占用率只是”隐藏延迟的能力”,它无法弥补访存模式的缺陷。只有当 kernel 是延迟受限时,提高占用率才有效。
    2. 算术强度(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,那是错误的算法(忽略缓存复用)。

    3. 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 的并行度不足
      
    4. 瓶颈判定与优化方向:本 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. 第 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. 第 2 部分(性能测试):N = 1048576 行的稠密矩阵需要 4 TB,无法物化,所以 build_stencil9_csr 直接构造 CSR:第一遍用双重循环数出每行的非零元个数并做前缀和,第二遍按 k 递增填充 colIdx/vals。这与真实工程中的做法一致(PETSc、cuSPARSE 都是先构造 COO/结构再转换)。
    3. 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(一条指令完成乘加)。
    4. 主机端调度grid = (N + 255)/256 = 4096 个 block,每个 256 线程。数据用 cudaMalloc 分配、cudaMemcpy(d_ptr, h_ptr, bytes, cudaMemcpyHostToDevice) 上传;xy 各 4 MB,CSR 三个数组合计约 76 MB。
    5. 计时与口径bytes = 4*nnz (values) + 4*nnz (colIdx) + 4*(N+1) (rowPtr) + 4*N (x) + 4*N (y) = 88.0 MB。注意 valuescolIdx 中的每个元素都只被读一次(矩阵零复用),所以这个口径就是”理论上必须搬运的字节数”;把 gather 的 sector 放大也算进去只会让数字更难看,所以先按”有用字节”给出有效带宽(effective bandwidth)449 GB/s,再在下面的分析里讨论放大。
  • 【并行机制与硬件映射解说】(本小节逐 warp 分析两个发散,这是全讲最核心的硬件行为)
    1. 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%,存在轻微的尾部效应。
    2. 控制发散:逐 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% 的算力,在规则模板矩阵上几乎不浪费。

    3. 访存发散(未合并)与 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 上。

    4. 依赖链与 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 路并行,这是最廉价的优化。
    5. 寄存器与占用率小结:24 个寄存器 → 100% 占用率。这个结果很关键:CSR SpMV 在 A100 上通常已经是 100% 占用率,因此”提高占用率”不是它的优化方向;必须从”减少事务 / 打断依赖链 / 均衡负载”入手。
  • 【性能优化分析】(含本讲要求的”稀疏矩阵算力利用率上限”推导)
    1. 算术强度(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。这两个数字后面还要用到。

    2. 算力利用率上限(本讲的定量核心)。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%。

    3. 实测 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 却接近饱和)。

    4. 瓶颈判定:内存带宽受限(更准确地说: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 向量化加载。

示例 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
  • 【代码做什么?】
    1. 构造 CSRbuild_stencil9_csr 支持一个 hubStride/hubExtra 参数。hubStride = 0 时得到纯 9 点模板(矩阵 A);hubStride = 1024, hubExtra = 40 时,凡是行号能被 1024 整除的行都额外插入 40 个非零元(矩阵 B)。这些”枢纽行”模拟图矩阵中度数极高的节点(幂律度分布的长尾),也是 ELL 的 padding 灾难来源。
    2. CSR → ELL 转换csr_to_ell 先扫一遍 rowPtrE = max row length,把 numRows × E 的数组整体清零(这是 padding 值 0、列号 0),再把每行的 len 个元素按列主序写到 ellVal[i*numRows + r]。注意 padding 项的值是 0,所以 x[colIdx] 即使读到任意列(这里统一是 0 号列)也不会影响结果——这是 ELL 能”安全越界读”的经典技巧,但前提是 colIdx 的 padding 值必须落在合法范围内(这里是恒 0),否则会真的越界访问。
    3. kernel 1(spmv_ell:完全照讲义 page 20 的 SpMV_ELL 写法,循环次数对所有线程都是常数 E,索引 k = i*num_rows + row
    4. 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 并行执行。
    5. 验证与计时:三个 kernel 都跑一遍并与 CSR 语义的 CPU 参考结果对比;然后各自重复 reps 次取平均。
    6. 两个矩阵共用同一个函数run_case 把”构造 → 转换 → 验证 → 计时”打包,避免代码重复,也让两个矩阵的对比完全公平(同一份 kernel、同一份计时逻辑)。
  • 【并行机制与硬件映射解说】
    1. 合并访存(ELL 的核心优势):固定 i 时,warp 内 lane tdata[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%。
    2. 控制发散for (int i = 0; i < num_elem; i++) 的 trip count 是 kernel 参数 num_elem,对所有线程完全相同,warp 内 32 个 lane 永远同步,branch_efficiency = 100%。CSR 版本在同一个矩阵上的平均 lane 利用率约 100%(模板矩阵),但在矩阵 B 上会立刻恶化(长短行混在同一个 warp 里)。
    3. x[colIdx[k]] 的 gather:这是 ELL 唯一没有消除的发散。固定 i 时,lane t 的列号是 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)。
    4. 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。
    5. 共享内存与 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 粒度,容忍度高得多)。
    6. 占用率:两个 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%(有轻微尾部效应,见”性能优化分析”)。
  • 【性能优化分析】
    1. 算术强度与 Roofline:ELL 与 CSR 的”每 nnz 字节数”完全相同(values 4 B + colIdx 4 B + x 4 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。

    2. 为什么达不到 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%。
    3. 两个矩阵的对比: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 倍的数据”

    4. 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 的全部思想

    5. 瓶颈判定:矩阵 A 上判定为内存带宽受限(且已接近可持续带宽上限);矩阵 B 的 ELL 版本判定为带宽被 padding 浪费掉的带宽受限,正确处置是换格式(HYB/JDS),而不是调 kernel。

示例 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%
  • 【代码做什么?】
    1. 线程映射的改变warpId = (blockIdx.x*blockDim.x + threadIdx.x) >> 5,即每 32 个线程(1 个 warp) 协作处理一行;lane = threadIdx.x & 31 决定该 lane 负责这一行里的哪些非零元。一个 block(256 线程)处理 8 行。
    2. 循环步长 32for (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 连续的做法。
    3. warp 内树形归约for (off = 16; off > 0; off >>= 1) dot += __shfl_down_sync(0xffffffffu, dot, off);——5 步之后 lane 0 手里就是整行的和。__shfl_down_sync 是寄存器之间的数据交换(volatile 语义之外的开销极小),比”写共享内存再 __syncthreads() 再读”更快,也不需要任何共享内存。
    4. 归约后只有 lane 0 写回 y[warpId] = dot,因此 y 的写是一个 warp 一次 4 B,写流量与”每线程一行”完全相同。
    5. 正确性验证与计时:两个 kernel 都与 CPU 参考实现对比,然后各重复 reps 次取平均。
  • 【并行机制与硬件映射解说】
    1. 合并访存:从”9 倍放大”到”完全合并”。在固定的一次循环迭代 t 上,lane l 访问 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 同样合并。
    2. 控制发散:从”由最长行决定”到”只剩尾迭代”。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。
    3. 对行长分布的影响:如果矩阵的平均行长是 128 或以上(例如 3D 7 点模板在稀疏化后仍很长、或者图矩阵的高阶邻域),warp-per-row 几乎是免费的午餐;如果平均行长是 5~10(2D 模板、非常稀疏的图),更好的做法是“每个 warp 处理多行”(把 4~8 行交给一个 warp,用 __shfl 分段归约),或者干脆用 ELL/COO。
    4. __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 中非常容易出错的地方。
    5. 占用率:约 26 个寄存器 → 100% 占用率(8 个 block/SM)。grid = N/8 = 131,072 个 block,远大于 SM 容量,所以尾部效应可忽略(最后一波的填充率误差约 0.7%),比”每线程一行”版本的 5 波 tail 更平滑。
    6. 共享内存与 bank conflict:kernel 完全不使用共享内存,因此没有 bank conflict。归约走的是寄存器 shuffle 路径,每个 __shfl_down_sync 指令 1 个周期(warp 内交叉开关),5 步共 5 个周期——相对于每行 9 次 gather 的几百个周期,成本可以忽略。
  • 【性能优化分析】
    1. 算术强度与 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%)。
    2. 两种修复路线的对比
      路线                        有效带宽     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.04x
      

      warp-per-row 的收益(1.50x)小于 ELL(1.81x),因为:它修好了”访存发散”,但制造了”lane 空闲”(行长 9 时只有 9/32 个 lane 有效)。ELL 同时修好了两者(无发散、全 lane 有效),代价是多搬运 padding。没有免费的午餐:格式设计永远是”规则性”与”额外字节”之间的权衡。

    3. 瓶颈判定与优化方向:仍是内存带宽受限(准确说是”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%;
      • float4data/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%。这就是稀疏计算的本质:优化的对象是字节,不是运算。

性能优化技巧总结

  1. 先选格式,再调 kernel:格式决定了字节数与规则性,收益远大于 kernel 微调。判据是 E / 平均行长:< 1.5 用 ELL;行长方差大(> 4)用 HYB 或 JDS;极稀疏(nnz/N < 2)用 COO;大致三角/带状用 JDS;对称结构用 CSR/CSC。为什么有效:它直接改变”必须搬运多少字节”,而 SpMV 的性能上限完全由字节数决定。
  2. colIdxvalues 的访问对相邻 lane 连续:ELL 的列主序布局、JDS-T 的转置、warp-per-row 的 lane 步长都是同一个目的。为什么有效:把”每 warp 36 个 sector”降到”4 个 sector”,L1 事务数下降近 9 倍。
  3. 消除控制发散:padding(ELL)、排序(JDS)、限幅(HYB)为什么有效:SIMT 下 warp 的完成时间由最长 lane 决定,44.2% 的 lane 利用率意味着 55.8% 的发射槽被浪费(见概念 8 的 32 lane 实例)。
  4. 用 float4 向量化加载:要求 16 B 对齐且连续;ELL/COO 很容易满足。为什么有效:load 指令数降到 1/4,LSU 发射压力下降,同时把内存级并行(MLP)提升 4 倍以隐藏 L2/L1 延迟(实测 775 → 873 GB/s)。
  5. 打断累加器的依赖链#pragma unroll 4 + 4 个独立累加器,或 warp-per-row 的 shuffle 树形归约。为什么有效dot = fmaf(a, b, dot) 是串行依赖,展开后 4 条链可以并发访存,把”延迟受限”变成”带宽受限”。
  6. 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 抵消收益。
  7. colIdx 用 16 位整数(N ≤ 65536)或块压缩(BCSR):每 nnz 从 12 B 降到 10 B 或更低。为什么有效:字节数与性能上限成反比,减少 17% 的字节就提高 17% 的上限。
  8. 动态负载均衡(work stealing):warp 用 atomicAdd(&counter, 1) 领行号。为什么有效:把”最长行决定 block 寿命”的静态不均衡变成动态均衡,特别适合幂律度分布的图矩阵;代价是每行一次 L2 原子操作(约 200-400 cycles)与局部性损失。
  9. 行重排序 / 分块:按行长排序(JDS)、按结构聚类(graph partitioning)、把矩阵切成 x 能放进 L2 的列块。为什么有效:前者改善 warp 内均衡,后者让 gather 命中 L2 而不是 DRAM。
  10. 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_ptrcol_indexvaluesx 都是只读,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.sumdram__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。这也说明:格式选择必须基于矩阵的行长分布,而不是基于”哪种格式更先进”。