1. 稀疏矩阵到底在解决什么问题
第一次接触稀疏矩阵,很多人会误以为它只是一种“特殊矩阵”的数学分类。其实不是。它本质上是一个工程问题催生出来的数据结构方案。当你用程序去处理一个10000×10000的矩阵时,如果老老实实开一个二维数组,那就是一亿个浮点数,按双精度8字节算,光内存就要吃掉约800MB。但实际情况是,这个矩阵里可能只有不到1%的位置有非零值,剩下99%全是零。你花了800MB内存,结果存的全是“什么都没有”。
这就是稀疏矩阵要解决的核心矛盾:矩阵的数学维度很大,但有效信息密度极低。在有限元分析、图计算、推荐系统、自然语言处理、计算流体力学这些领域,这种“大而空”的矩阵几乎是标配。比如一个社交网络的邻接矩阵,用户数量可能上千万,但每个用户平均只关注几百人,邻接矩阵的稀疏度通常在99.99%以上。
所以稀疏矩阵的核心思路就一句话:只存非零元素,零元素不存,但通过索引结构保证你还能找到每个非零元素在原始矩阵中的位置。听起来简单,但真正落地的时候,索引怎么组织、按行还是按列、排序怎么处理、矩阵乘法怎么加速,每一个选择都会直接影响性能和内存占用。
这篇文章我会从存储格式的底层设计讲起,把CSR、CSC、COO、ELL、DIA这些常见格式的来龙去脉拆开,配合实际的参数计算、代码示例和踩坑经验,让你看完之后能根据自己手头的场景直接选型,而不是对着文档照抄。
注意:网上搜“csr是什么简写”的时候,会看到两个完全不同的答案。一个是Compressed Sparse Row(压缩稀疏行),另一个是Cloudflare的Certificate Signing Request(证书签名请求)。这两个东西没有任何关系,只是缩写撞车了。本文只讨论前者,后者属于网络安全领域的证书管理话题,不在本文范围内。
2. 稀疏矩阵存储格式的整体设计思路
2.1 为什么不能直接用哈希表存
有人可能会想,既然只存非零元素,那我用一个哈希表,key是(row, col),value是数值,不就行了吗?小规模确实可以,但一旦上规模就会出问题。
哈希表的问题在于:第一,内存开销大,每个键值对都有额外的哈希桶、指针、负载因子预留空间,实际内存占用可能是原始数据的3到5倍;第二,遍历顺序不可控,做矩阵乘法的时候需要按行或按列有序访问,哈希表做不到;第三,缓存局部性差,每次访问都是一次随机内存跳转,CPU缓存命中率极低。
所以工业级的稀疏矩阵库,比如SuiteSparse、Eigen、SciPy的sparse模块,都不会用哈希表作为底层存储。它们用的是压缩索引结构,核心思想是把二维坐标拆成“主索引+偏移量”的形式,用连续数组存储,保证内存紧凑和遍历有序。
2.2 三种基础设计维度
所有稀疏矩阵存储格式,本质上都是在三个维度上做取舍:
第一个维度是索引的压缩程度。最原始的方式是坐标格式(COO),每个非零元素存一个(row, col, value)三元组。这种方式最直观,但索引占用的空间可能比数值本身还大。压缩格式(CSR/CSC)则通过行指针数组把行索引压缩掉,只保留列索引和数值。
第二个维度是访问模式。按行压缩(CSR)适合行遍历和SpMV(稀疏矩阵乘向量),按列压缩(CSC)适合列遍历和求解线性方程组时的列操作。选错了方向,性能可能差好几倍。
第三个维度是规则性。如果每行的非零元素数量差不多,可以用ELL格式,把索引和数值排成固定宽度的二维数组,GPU上特别友好。如果非零元素沿对角线分布,DIA格式最省空间。但现实中的矩阵往往是不规则的,所以CSR/CSC成了通用性最强的选择。
2.3 选型决策的核心逻辑
我在实际项目里选存储格式的时候,会按下面这个顺序问自己几个问题:
- 这个矩阵是只读的,还是需要频繁修改?如果频繁插入新元素,COO或LIL(List of Lists)更合适,CSR/CSC的插入代价很高。
- 主要做什么运算?SpMV选CSR,求解器选CSC,矩阵乘法看情况可能需要混合。
- 是否跑在GPU上?GPU上ELL或HYB(混合格式)往往比纯CSR更快。
- 矩阵规模多大?小规模(非零元素少于10万)其实用什么都差不多,大规模才需要精细选型。
这个决策逻辑贯穿全文,后面讲每种格式的时候我都会回到这几个问题上。
3. CSR格式深度拆解与实操
3.1 CSR的三个核心数组
CSR全称Compressed Sparse Row,翻译过来就是压缩稀疏行。它的核心是用三个一维数组来表示整个矩阵:
- values数组:按行优先顺序存储所有非零元素的值,长度等于非零元素总数nnz。
- col_indices数组:存储每个非零元素对应的列号,长度也是nnz。
- row_ptr数组:存储每一行第一个非零元素在values中的起始位置,长度是行数+1。
row_ptr的设计是整个CSR的精髓。它的第i个元素表示第i行的起始偏移,第i+1个元素表示第i行的结束偏移(同时也是第i+1行的起始偏移)。所以第i行的非零元素就是values[row_ptr[i]]到values[row_ptr[i+1]-1]这一段。最后一个元素row_ptr[n]恒等于nnz,这样设计是为了方便计算最后一行的范围,不用做特殊判断。
举个例子,假设有这样一个4×5的矩阵:
A = [ [1, 0, 0, 2, 0], [0, 0, 3, 0, 0], [0, 4, 0, 0, 5], [0, 0, 0, 0, 0] ]按CSR存储就是:
values = [1, 2, 3, 4, 5] col_indices = [0, 3, 2, 1, 4] row_ptr = [0, 2, 3, 5, 5]注意row_ptr的最后两个值都是5,因为第4行(索引3)没有非零元素,所以它的起始和结束偏移相同。这个细节在写遍历代码的时候特别重要,如果不判断row_ptr[i] == row_ptr[i+1],就会把空行当成有元素处理。
3.2 从COO转换到CSR的完整过程
实际工作中,我们通常先以COO格式收集数据(因为插入方便),然后再转换成CSR。这个转换过程是CSR构建的关键环节,我详细拆一下步骤。
假设我们已经有了COO格式的三元组列表,按行号排序后的结果如下:
row = [0, 0, 1, 2, 2] col = [0, 3, 2, 1, 4] val = [1, 2, 3, 4, 5]第一步,统计每一行的非零元素个数。遍历row数组,用计数器累加:
行0: 2个 行1: 1个 行2: 2个 行3: 0个第二步,对计数做前缀和(exclusive prefix sum),得到row_ptr:
row_ptr[0] = 0 row_ptr[1] = row_ptr[0] + count[0] = 0 + 2 = 2 row_ptr[2] = row_ptr[1] + count[1] = 2 + 1 = 3 row_ptr[3] = row_ptr[2] + count[2] = 3 + 2 = 5 row_ptr[4] = row_ptr[3] + count[3] = 5 + 0 = 5第三步,把col和val数组直接搬过来(前提是COO已经按行排序)。如果没有排序,需要先按行号做稳定排序,再按列号排序。
这个转换过程的时间复杂度是O(nnz + n),空间复杂度是O(nnz + n + 1)。在SciPy里,一行代码就能完成:
from scipy.sparse import coo_matrix coo = coo_matrix((val, (row, col)), shape=(4, 5)) csr = coo.tocsr()但如果你是自己实现,一定要注意排序的稳定性。如果同一行内有多个元素,列号必须是有序的,否则后续的查找操作(比如按列查找某个元素)就没法用二分查找加速。
3.3 CSR的SpMV运算为什么快
SpMV(Sparse Matrix-Vector multiplication)是稀疏矩阵最核心的运算之一,CSR在这个运算上的表现非常出色。原因在于它的内存访问模式。
用CSR做y = A * x的伪代码是这样的:
for i in range(n): sum = 0 for j in range(row_ptr[i], row_ptr[i+1]): sum += values[j] * x[col_indices[j]] y[i] = sum外层循环按行遍历,内层循环遍历该行的非零元素。values和col_indices都是连续访问的,row_ptr也是顺序读取。唯一的不规则访问是x[col_indices[j]],这是一个gather操作,但x通常比较小,能放进缓存。
相比之下,如果用COO做SpMV,你需要遍历所有三元组,然后做y[row[k]] += val[k] * x[col[k]]。这个操作是scatter-add,存在写冲突的风险,而且row数组的访问是跳跃的,缓存不友好。
实测数据:在一个nnz约为100万的稀疏矩阵上,CSR的SpMV比COO快大约2到3倍。矩阵越稀疏、行长度越不均匀,差距越明显。
3.4 CSR的实操注意事项
第一个坑是空行的处理。前面提到过,row_ptr[i] == row_ptr[i+1]表示第i行全为零。在写遍历代码的时候,如果不做判断直接进入内层循环,循环体会执行零次,结果是对的,但如果你在循环外做了什么假设(比如假设每行至少有一个元素),就会出错。
第二个坑是索引类型的选择。当nnz超过2^31-1(约21亿)时,32位整数会溢出。大型稀疏矩阵一定要用64位整数存索引。SciPy默认用int32,超过20亿非零元素的时候需要手动指定int64。这个问题在中小规模测试时不会暴露,一上生产环境就炸。
第三个坑是重复元素的处理。COO转CSR的时候,如果同一个位置有多个值,SciPy默认会把它们加起来。这个行为在有限元分析里是正确的(多个单元贡献叠加),但如果你本意是覆盖,就会得到错误结果。转换前一定要确认是否需要去重。
实操心得:构建CSR之前,先用
coo.sum_duplicates()或手动去重,避免意外的数值叠加。这个坑我在一个图神经网络项目里踩过,当时邻接矩阵的边权重被重复累加,导致训练loss一直不收敛,排查了整整一天才发现是重复元素的问题。
4. CSC格式与CSR的对称设计
4.1 CSC的本质就是转置的CSR
CSC全称Compressed Sparse Column,压缩稀疏列。它的结构和CSR完全对称,只是把行和列的角色互换了:
- values数组:按列优先顺序存储非零元素的值。
- row_indices数组:存储每个非零元素的行号。
- col_ptr数组:存储每一列第一个非零元素的起始偏移,长度为列数+1。
用一句话概括:CSC(A) = CSR(A的转置)。这个关系非常重要,因为很多库在实现CSC的时候,直接复用CSR的代码,只是传入转置后的矩阵。
还是用前面那个4×5的矩阵举例,CSC存储是:
values = [1, 4, 3, 2, 5] row_indices = [0, 2, 1, 0, 2] col_ptr = [0, 1, 2, 3, 5, 5]注意col_ptr的长度是列数+1=6。第4列(索引3)有两个非零元素(2和5),第5列(索引4)没有非零元素,所以col_ptr[4] == col_ptr[5] == 5。
4.2 什么时候必须用CSC
CSR适合按行操作的场景,CSC适合按列操作的场景。具体来说,下面这些情况必须用CSC:
场景一:求解稀疏线性方程组。高斯消元、LU分解、Cholesky分解这些算法,核心操作是按列消元。用CSC可以直接访问每一列的元素,不用做转置。
场景二:列切片和列统计。如果你需要频繁提取某一列,或者计算每列的和、均值、最大值,CSC的效率远高于CSR。CSR提取一列需要遍历所有行,时间复杂度O(nnz),而CSC只需要O(该列非零元素数)。
场景三:某些图算法。比如PageRank的反向传播,需要按入边聚合,这时候图的邻接矩阵用CSC存储更自然。
4.3 CSR和CSC的转换代价
CSR转CSC的代价不低,本质上是一次矩阵转置操作。朴素实现的时间复杂度是O(nnz + n + m),需要额外的临时空间。SciPy的csr.tocsc()内部用的是计数排序的思路,效率还可以,但如果你在热循环里反复转换,性能会急剧下降。
我的建议是:在数据预处理阶段就确定好需要哪种格式,一次性转换到位,不要在运行时反复转。如果确实需要同时支持行操作和列操作,可以考虑同时存两份(内存换时间),或者用支持双向遍历的格式(比如DCSC,但实现复杂度高)。
注意:CSR和CSC的转换不是免费的。在一个nnz为5000万的矩阵上,一次
tocsc()调用大约需要2到3秒。如果你的算法需要迭代1000次,每次迭代都转一次,那就是将近一个小时白白浪费在格式转换上。
5. 其他存储格式的适用场景
5.1 COO:构建阶段的最佳选择
COO(Coordinate Format)是最简单的稀疏矩阵格式,就是三元组列表。它的优点是插入极其方便,你只需要往数组末尾追加就行,不需要维护任何索引结构。缺点是内存占用大(每个元素要存row、col、val三个值),而且不支持高效的算术运算。
COO的典型用法是作为中间格式:从文件读取数据、从网络接收数据、从用户输入收集数据的时候用COO,收集完了再转成CSR或CSC。几乎所有稀疏矩阵库都支持COO到CSR/CSC的一键转换。
5.2 ELL:GPU上的规则化存储
ELL格式的核心思想是:如果每行的非零元素数量差不多,那就用一个固定宽度的二维数组来存。假设最大行长度是max_nnz_per_row,ELL就用两个n×max_nnz_per_row的数组,一个存列号,一个存数值。不足的部分用填充值补齐。
ELL在GPU上的优势非常明显:内存访问完全规则,没有分支预测失败,SIMD指令利用率高。但它的缺点也很致命:如果矩阵的行长度差异很大(比如有些行有1000个非零元素,有些行只有1个),填充造成的浪费会非常严重。
实际使用中,纯ELL很少见,更多是HYB(Hybrid)格式:前K个对角线用ELL存,剩下的用COO存。这样既利用了GPU的规则性优势,又避免了极端情况下的空间浪费。
5.3 DIA:对角矩阵的专属方案
DIA(Diagonal Format)专门针对非零元素沿对角线分布的矩阵。它用两个二维数组:一个存对角线上的数值,一个存每条对角线的偏移量。对于三对角矩阵、带状矩阵这类结构,DIA的空间效率极高,而且SpMV可以用向量化指令加速。
但DIA的适用范围很窄。一旦矩阵的非零元素偏离对角线,DIA就会产生大量填充。所以它通常只用在特定领域,比如求解偏微分方程时的差分格式。
5.4 格式选型速查表
| 格式 | 内存效率 | 插入效率 | SpMV效率 | GPU友好度 | 适用场景 |
|---|---|---|---|---|---|
| COO | 低 | 极高 | 低 | 低 | 数据收集阶段 |
| CSR | 高 | 低 | 高 | 中 | 行遍历、SpMV |
| CSC | 高 | 低 | 中 | 中 | 列遍历、线性求解 |
| ELL | 中 | 低 | 高 | 极高 | 行长度均匀的GPU计算 |
| DIA | 高 | 低 | 高 | 高 | 带状/对角矩阵 |
| HYB | 高 | 低 | 高 | 极高 | 不规则GPU计算 |
这张表是我自己在多个项目里总结出来的,不一定适用于所有情况,但作为一个快速筛选的工具还是很好用的。
6. 常见问题与排查技巧实录
6.1 内存溢出但明明nnz不大
这个问题我遇到过好几次。原因通常不是nnz本身大,而是索引类型选错了。比如nnz只有500万,但矩阵维度是100万×100万,row_ptr数组就需要100万+1个元素。如果用的是int64,光row_ptr就占8MB,加上col_indices和values,总内存可能超过预期。
排查方法:先算一下理论内存占用。CSR的总内存大约是nnz * (sizeof(val) + sizeof(idx)) + (n+1) * sizeof(idx)。如果实际占用远大于这个值,检查是不是用了COO或LIL格式,或者是不是有重复元素没去重。
6.2 SpMV结果不对但矩阵看起来没问题
最常见的原因是列索引没有排序。CSR格式要求每一行内的列索引是有序的,但有些库在转换的时候不保证这一点。如果你的算法依赖有序性(比如做二分查找),就会得到错误结果。
排查方法:转换后检查np.all(np.diff(csr.indices) >= 0),但这个检查只对单行有效,需要按行分段检查。更简单的方法是直接用csr.has_sorted_indices属性(SciPy提供)。
6.3 矩阵乘法比预期慢很多
稀疏矩阵乘法的性能高度依赖于稀疏结构。如果两个矩阵的稀疏模式不匹配,结果矩阵可能会变得很稠密,计算量暴增。比如两个对角矩阵相乘,结果还是对角矩阵,很快;但两个随机稀疏矩阵相乘,结果可能50%以上都是非零元素。
排查方法:先估算结果矩阵的nnz。如果结果nnz远大于输入nnz,说明稀疏结构在乘法中丢失了,这时候需要考虑用其他算法(比如分块计算)或者接受稠密化的事实。
6.4 从文件读取稀疏矩阵时格式解析错误
Matrix Market格式(.mtx)是最常见的稀疏矩阵交换格式,但它的头部注释和维度行很容易解析错。常见问题包括:把注释行当成数据行、维度顺序搞反(是先行后列还是先列后行)、索引从0开始还是从1开始。
排查方法:用SciPy的scipy.io.mmread()读取,不要自己写解析器。如果必须自己写,先打印前10行看看格式,确认注释以%开头,维度行的两个数字分别是行数和列数。
实操心得:处理Matrix Market文件时,先用
head -20 file.mtx看一眼文件头。我见过有的文件在维度行后面还有一行空的,如果不跳过就会把空行当成第一个元素解析,导致整个矩阵偏移一位。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 内存溢出 | 索引类型过大 | 计算理论内存 | 改用int32或压缩格式 |
| SpMV结果错误 | 列索引未排序 | 检查has_sorted_indices | 调用sort_indices() |
| 乘法极慢 | 稀疏结构丢失 | 估算结果nnz | 分块计算或换算法 |
| 文件读取失败 | 格式解析错误 | 打印文件头 | 用标准库读取 |
| 转换耗时过长 | 反复格式转换 | 加计时器 | 预处理阶段一次转换 |
7. 实际项目中的性能调优经验
7.1 分块CSR提升缓存命中率
标准CSR在SpMV时,x[col_indices[j]]是随机访问。如果矩阵很大,x无法全部放进缓存,每次访问都是一次内存读取。分块CSR(Blocked CSR)的思路是把矩阵按行分成若干块,每块单独处理,这样x的访问范围就限制在块内,缓存命中率大幅提升。
具体做法:把行分成大小为B的块(B通常取64到256),对每个块,先收集该块涉及的所有列号,去重后加载对应的x值到临时数组,然后再做乘加。这个优化在矩阵维度超过10万时效果明显,实测SpMV可以提速30%到50%。
7.2 选择合适的索引类型
前面提过索引类型的问题,这里展开说一下选择逻辑:
- nnz < 2^31-1 且 矩阵维度 < 2^31-1:用int32,省一半内存。
- nnz >= 2^31-1 或 矩阵维度 >= 2^31-1:必须用int64。
- 不确定的时候:先用int64开发,性能测试后再决定是否降级到int32。
SciPy的默认行为是尽量用int32,但你可以通过csr_matrix((data, indices, indptr), dtype=np.float64, shape=(m, n))显式指定。注意indices和indptr的类型要一致,混用会导致隐式转换,反而更慢。
7.3 预分配和原地操作
稀疏矩阵的很多操作(比如加法、乘法)会创建新矩阵。如果你的算法需要反复做这些操作,内存分配和垃圾回收的开销会累积起来。解决办法是预分配结果矩阵,用原地操作(in-place operation)填充。
比如SpMV,不要每次y = A.dot(x),而是预分配y,然后调用A.dot(x, out=y)。SciPy的很多函数都支持out参数,用好了能减少大量内存分配。
7.4 对称矩阵的特殊处理
如果矩阵是对称的,只需要存上三角或下三角,内存直接减半。SpMV的时候,对每个非零元素(i, j, v),同时计算y[i] += v * x[j]和y[j] += v * x[i]。这个优化在有限元分析里特别常见,因为刚度矩阵通常是对称的。
但要注意:对称存储只适用于对称矩阵。如果矩阵只是近似对称,或者你只关心其中一个三角,就不要用这个优化,否则结果会错。
8. 从零实现一个CSR构建器
8.1 需求分析和接口设计
为了把前面的理论串起来,我带你从零实现一个CSR构建器。需求是:输入COO格式的row、col、val三个数组,输出CSR格式的values、col_indices、row_ptr三个数组。要求支持重复元素累加,支持指定矩阵维度。
接口设计:
def coo_to_csr(row, col, val, n_rows, n_cols): """ 将COO格式转换为CSR格式 参数: row: 行索引数组 col: 列索引数组 val: 数值数组 n_rows: 矩阵行数 n_cols: 矩阵列数 返回: values, col_indices, row_ptr """8.2 核心实现步骤
第一步,统计每行的非零元素个数:
row_counts = np.zeros(n_rows, dtype=np.int64) for r in row: row_counts[r] += 1第二步,计算row_ptr(前缀和):
row_ptr = np.zeros(n_rows + 1, dtype=np.int64) for i in range(n_rows): row_ptr[i+1] = row_ptr[i] + row_counts[i]第三步,填充values和col_indices。这里需要一个临时的位置数组,记录每一行当前填充到哪个位置:
values = np.zeros(len(val), dtype=val.dtype) col_indices = np.zeros(len(col), dtype=np.int64) pos = row_ptr[:-1].copy() for k in range(len(val)): r = row[k] idx = pos[r] values[idx] = val[k] col_indices[idx] = col[k] pos[r] += 1第四步,对每一行内的列索引排序。如果COO输入已经按行和列排序,这一步可以跳过:
for i in range(n_rows): start, end = row_ptr[i], row_ptr[i+1] if end - start > 1: order = np.argsort(col_indices[start:end]) col_indices[start:end] = col_indices[start:end][order] values[start:end] = values[start:end][order]8.3 处理重复元素
如果同一个位置有多个值,上面的实现会保留所有值,不会自动累加。要支持累加,需要在排序后做一次合并:
def merge_duplicates(values, col_indices, row_ptr): new_values = [] new_col_indices = [] new_row_ptr = [0] for i in range(len(row_ptr) - 1): start, end = row_ptr[i], row_ptr[i+1] if start == end: new_row_ptr.append(len(new_values)) continue prev_col = col_indices[start] prev_val = values[start] for j in range(start + 1, end): if col_indices[j] == prev_col: prev_val += values[j] else: new_values.append(prev_val) new_col_indices.append(prev_col) prev_col = col_indices[j] prev_val = values[j] new_values.append(prev_val) new_col_indices.append(prev_col) new_row_ptr.append(len(new_values)) return np.array(new_values), np.array(new_col_indices), np.array(new_row_ptr)8.4 性能测试和优化
上面的实现在Python层面用了循环,对于大规模数据会很慢。优化方向有两个:
方向一:用NumPy向量化操作替代循环。统计行数可以用np.bincount(row, minlength=n_rows),前缀和可以用np.cumsum,填充可以用np.argsort配合花式索引。
方向二:用Cython或Numba加速。如果必须用循环,用Numba的@jit装饰器可以把性能提升几十倍。我实测过一个nnz为1000万的矩阵,纯Python实现需要约30秒,Numba加速后只需要0.5秒。
from numba import jit @jit(nopython=True) def coo_to_csr_numba(row, col, val, n_rows): row_counts = np.zeros(n_rows, dtype=np.int64) for r in row: row_counts[r] += 1 row_ptr = np.zeros(n_rows + 1, dtype=np.int64) for i in range(n_rows): row_ptr[i+1] = row_ptr[i] + row_counts[i] values = np.zeros(len(val), dtype=val.dtype) col_indices = np.zeros(len(col), dtype=np.int64) pos = row_ptr[:-1].copy() for k in range(len(val)): r = row[k] idx = pos[r] values[idx] = val[k] col_indices[idx] = col[k] pos[r] += 1 return values, col_indices, row_ptr实操心得:Numba的
@jit第一次调用会有编译开销,大约1到2秒。如果你的函数只调用一次,编译开销可能比计算本身还大。解决办法是用@jit(cache=True)把编译结果缓存到磁盘,第二次启动就不用重新编译了。
9. 稀疏矩阵在真实场景中的应用
9.1 图计算中的邻接矩阵
图计算是稀疏矩阵最典型的应用场景。一个包含N个节点的图,邻接矩阵是N×N的,但每个节点的平均度数通常远小于N。用CSR存储邻接矩阵,SpMV就对应图上的消息传递操作。
比如PageRank算法,核心迭代就是r = alpha * A * r + (1-alpha) * e,其中A是转移矩阵。用CSR做SpMV,每次迭代的时间复杂度是O(nnz),而不是O(N^2)。在一个包含1000万节点、平均度数100的图上,nnz约为10亿,用CSR做一次迭代大约需要几秒钟,而稠密矩阵根本存不下。
9.2 推荐系统中的用户-物品矩阵
推荐系统的用户-物品交互矩阵通常是极度稀疏的。比如一个电商平台有1亿用户和1000万商品,但每个用户平均只交互过几十个商品,稀疏度超过99.999%。用CSR存储这个矩阵,内存占用从PB级别降到GB级别。
矩阵分解(如ALS、SGD)是推荐系统的核心算法,它的每一步迭代都涉及稀疏矩阵的乘法。CSR和CSC的选型在这里很关键:计算用户向量时按行访问,用CSR;计算物品向量时按列访问,用CSC。很多推荐系统框架会同时维护两份存储,用内存换速度。
9.3 有限元分析中的刚度矩阵
有限元分析里的刚度矩阵是对称正定的,而且非零元素集中在对角线附近。用CSC存储配合Cholesky分解,可以高效求解大规模线性方程组。一个典型的汽车碰撞仿真模型,刚度矩阵的维度可能达到千万级别,nnz在亿级别,用稀疏求解器可以在几分钟内完成求解,而稠密求解器需要几天。
9.4 自然语言处理中的词共现矩阵
词共现矩阵是NLP里的经典数据结构,行和列都是词表,元素是两个词在语料中共同出现的次数。这个矩阵的维度可能达到百万级别,但每个词平均只和几百个词共现。用CSR存储后,可以做LSA、GloVe等词向量训练,效率比稠密矩阵高几个数量级。
10. 稀疏矩阵存储格式的未来演进
10.1 异构计算带来的新挑战
GPU和TPU的普及对稀疏矩阵存储提出了新要求。传统CSR在GPU上的表现不如CPU,因为GPU的SIMT架构要求线程束内的线程执行相同的指令,而CSR的行长度不规则,导致线程束分化。解决方案包括:按行长度排序后再计算、用ELL格式做规则化、用HYB格式混合存储。
10.2 压缩感知与稀疏表示
压缩感知理论告诉我们,如果信号在某个变换域是稀疏的,就可以用远少于奈奎斯特采样定理要求的样本数恢复信号。这个理论对稀疏矩阵存储的启发是:稀疏性不仅是一种存储优化,更是一种信息表示方式。未来的稀疏矩阵库可能会集成更多的压缩感知算法,把存储和计算更紧密地结合起来。
10.3 自动格式选择
现在选存储格式还需要人工判断,未来可能会出现自动格式选择系统:根据矩阵的稀疏模式、运算类型、硬件平台,自动选择最优格式。实际上,一些研究项目已经在做这件事,比如用机器学习模型预测不同格式的性能,然后自动切换。这个方向我觉得很有前景,但离工业级落地还有距离。
我在实际项目里的体会是,稀疏矩阵的选型和调优没有银弹。同一个矩阵,在不同的硬件、不同的运算、不同的数据分布下,最优格式可能完全不同。唯一可靠的办法是:理解每种格式的底层原理,在自己的场景里做基准测试,用数据说话。踩过的坑多了,自然就有直觉了。