1. 稀疏计算基础:稀疏矩阵定义、存储格式与稀疏度计算
大家好,欢迎来到《稀疏计算加速卷积网络实战》的第一章。
说实话,做深度学习加速这些年,我踩过最大的坑之一,就是「明明模型里大部分权重都是零,却还在用稠密矩阵做乘法」。你想想看,一个卷积核里 70% 的参数都是 0,那这 70% 的乘加运算不就是白费电吗?
稀疏计算,说白了就是「别做无用功」。咱们这一章先把地基打牢——搞清楚什么是稀疏矩阵、怎么存它、怎么算它的稀疏度。后面再聊怎么用它加速卷积。
1.1 什么是稀疏矩阵?
先给个直觉。一个矩阵里,零元素占绝大多数,就叫稀疏矩阵。反过来,零很少的叫稠密矩阵。
但「绝大多数」到底是多少?业内有个不成文的规矩:稀疏度超过 50%,就可以考虑用稀疏格式来存储和计算了。当然,实际项目中我一般等到稀疏度超过 70% 才动手,因为格式转换本身也有开销。
稀疏矩阵的数学定义:设矩阵 A 的维度为 m×n,非零元素个数为 nnz,则稀疏度 sparsity = 1 - nnz / (m × n)。
当 sparsity → 1 时,矩阵越稀疏。
举个例子,一个 1000×1000 的矩阵,只有 100 个非零元素,那稀疏度就是 1 - 100/1,000,000 = 0.9999,也就是 99.99%。这种矩阵用稠密格式存,纯属浪费内存。
我的经验:在剪枝后的卷积网络中,权重矩阵的稀疏度经常能达到 90% 以上。但要注意,稀疏度越高,加速收益不一定线性增长——因为存储格式的开销和计算模式的适配度也会影响最终性能。
1.2 三种主流稀疏存储格式
存稀疏矩阵,核心思路就一个:只存非零元素,不存零。但怎么存、存哪些辅助信息,不同格式各有千秋。
1.2.1 COO 格式(Coordinate Format)
COO 是最直观的格式。它用三个数组:
- row:非零元素的行索引
- col:非零元素的列索引
- data:非零元素的值
三个数组长度都是 nnz。每个非零元素对应一组 (row[i], col[i], data[i])。
# COO 格式示例
import numpy as np
# 稠密矩阵
dense = np.array([
[0, 0, 3, 0],
[4, 0, 0, 0],
[0, 0, 0, 5],
[0, 6, 0, 0]
])
# 提取非零元素
rows, cols = np.where(dense != 0)
data = dense[rows, cols]
print("row:", rows) # [0, 1, 2, 3]
print("col:", cols) # [2, 0, 3, 1]
print("data:", data) # [3, 4, 5, 6]
COO 的优点:构建简单,支持重复索引(可以累加)。缺点:做矩阵乘法时,按行或按列访问都不连续,缓存不友好。
我曾经踩过的坑:在 PyTorch 里用 torch.sparse_coo_tensor 时,忘了指定 size 参数,结果自动推断的 shape 比实际小,导致后续计算越界。记住:COO 必须显式指定矩阵尺寸。
1.2.2 CSR 格式(Compressed Sparse Row)
CSR 是工业界最常用的格式。它把 COO 的 row 数组做了压缩:
- row_ptr:长度为 m+1,记录每行第一个非零元素在 col 和 data 中的起始位置
- col:非零元素的列索引,长度 nnz
- data:非零元素的值,长度 nnz
# CSR 格式示例
row_ptr = [0, 1, 2, 3, 4] # 每行有 1 个非零元素
col = [2, 0, 3, 1]
data = [3, 4, 5, 6]
# 解读:第 0 行非零元素在 col[0] 和 data[0]
# 第 1 行非零元素在 col[1] 和 data[1]
# 以此类推
CSR 的优势:按行访问非常快,适合行优先的矩阵乘法。Scipy、TensorFlow、PyTorch 的稀疏张量底层都大量使用 CSR。
我个人的习惯:做卷积网络剪枝后的权重存储,首选 CSR。因为卷积的 im2col 展开后,权重矩阵的行对应输出通道,按行访问正好匹配推理时的计算顺序。
1.2.3 CSC 格式(Compressed Sparse Column)
CSC 是 CSR 的「转置版」。它压缩的是列索引:
- col_ptr:长度为 n+1,记录每列第一个非零元素的位置
- row:非零元素的行索引
- data:非零元素的值
CSC 适合按列访问的场景,比如稀疏矩阵乘以稠密向量时,如果向量是列向量,CSC 的缓存命中率更高。
1.2.4 三种格式对比
| 特性 | COO | CSR | CSC |
|---|---|---|---|
| 存储空间 | 3 × nnz | 2 × nnz + m + 1 | 2 × nnz + n + 1 |
| 构建难度 | 最简单 | 中等 | 中等 |
| 行访问效率 | 低 | 高 | 低 |
| 列访问效率 | 低 | 低 | 高 |
| 矩阵乘法 | 慢 | 快(行优先) | 快(列优先) |
| 适用场景 | 构建阶段、增量添加 | 推理、行优先计算 | 列优先计算、稀疏分解 |
1.3 稀疏度计算与可视化
稀疏度计算本身很简单,但实际项目中我经常用它来做「剪枝效果评估」。
def compute_sparsity(matrix):
"""
计算矩阵的稀疏度
matrix: numpy array 或 torch.Tensor
"""
total = matrix.size
zeros = (matrix == 0).sum()
sparsity = zeros / total
return sparsity.item()
# 示例
import torch
weight = torch.randn(64, 256)
weight[weight.abs() < 0.5] = 0 # 简单剪枝
sparsity = compute_sparsity(weight)
print(f"稀疏度: {sparsity:.2%}") # 输出类似 58.73%
光看数字不够直观。我习惯把稀疏模式画出来,一眼就能看出哪些通道被剪掉了。
import matplotlib.pyplot as plt
def plot_sparsity_pattern(matrix, title="Sparsity Pattern"):
plt.figure(figsize=(6, 6))
plt.spy(matrix, markersize=0.5)
plt.title(title)
plt.xlabel("Columns")
plt.ylabel("Rows")
plt.show()
# 可视化一个剪枝后的卷积权重
plot_sparsity_pattern(weight.numpy(), "Conv Layer 1 Weight")
你会看到,剪枝后的权重往往呈现「结构化稀疏」——整行或整列都是零。这种模式对硬件加速特别友好,因为可以跳过整个通道的计算。
1.4 本章知识体系总览
下面这张图把本章的核心逻辑串起来了。你可以把它当作一个「稀疏计算入门地图」。
1.5 小结与避坑指南
这一章我们覆盖了三个核心点:
- 稀疏矩阵的定义:零元素占主导,稀疏度 = 1 - nnz / total
- 三种存储格式:COO 简单直观,CSR 行优先高效,CSC 列优先灵活
- 稀疏度计算与可视化:用代码算、用图看,快速评估剪枝效果
避坑指南(我亲身经历):
- 别在 GPU 上用 COO 做矩阵乘法——CUDA 的原子操作会把你卡死。CSR 或 CSC 才是 GPU 上的正确选择。
- 稀疏度不是越高越好。超过 99% 时,非零元素分布极度不均匀,会导致负载不均衡,加速效果反而下降。
- 转换格式时注意数据类型。int32 的索引在超大矩阵(nnz > 2^31)时会溢出,记得用 int64。