共计 3236 个字符,预计需要花费 9 分钟才能阅读完成。
为什么需要稀疏卷积?
在 3D 数据处理中,点云或医学影像等数据往往具有天然稀疏性——有效数据只占整个空间的一小部分。例如,一辆汽车的激光雷达点云在 3D 立方体中可能只有 0.5% 的体素包含有效信息。传统密集卷积会强制计算所有位置(包括空区域),导致:

- 计算资源浪费:95% 以上的计算量消耗在无效零值上
- 显存爆炸:假设处理 128³的 3D 网格,float32 张量直接占用 128MB 内存
数学上,标准 3D 卷积计算量为:
$$
O(C_{in} \times C_{out} \times K^3 \times D \times H \times W)
$$
其中 K 为卷积核尺寸。而稀疏卷积仅计算非零输入位置,复杂度降为:
$$
O(NNZ \times C_{in} \times C_{out} \times K^3)
$$
NNZ 表示非零元素数量。
稀疏卷积核心原理
稀疏张量存储格式
PyTorch 采用 COO(Coordinate Format)存储稀疏张量,包含三个关键组件:
indices:形状为 (NDIM, NNZ) 的坐标矩阵,记录每个非零元素的 ND 维坐标values:形状为 (NNZ, C) 的特征矩阵,存储对应位置的多通道数据size:原始张量的完整形状
例如尺寸为 (4,4,4) 的 3D 稀疏张量,其 COO 表示如下图所示:
indices = tensor([[0, 1, 3], # x 坐标
[2, 0, 3], # y 坐标
[1, 2, 0]]) # z 坐标
values = tensor([[0.1], [0.5], [0.8]]) # 特征值
两种稀疏卷积类型
- 常规稀疏卷积:输出位置只要被任一输入非零点激活就会计算
- 子流形稀疏卷积(Submanifold):仅当卷积核中心覆盖输入非零点时才计算输出
Submanifold 版本更适合保持原始稀疏模式,常用于网络浅层。其数学表达差异体现在输出位置集合 S_out:
$$
S_{out}^{sub} = {p \in \mathbb{Z}^3 | \exists q \in S_{in}, q \in \mathcal{N}(p) }
$$
$$
S_{out}^{std} = {p \in \mathbb{Z}^3 | \mathcal{N}(p) \cap S_{in} \neq \emptyset }
$$
其中 $\mathcal{N}(p)$ 表示位置 p 的卷积邻域。
PyTorch 实战实现
构建稀疏卷积层
import torch
import torch.sparse as sp
from torch.nn.parameter import Parameter
class SparseConv3d(torch.nn.Module):
def __init__(self, in_channels, out_channels, kernel_size=3):
super().__init__()
self.kernel_size = kernel_size
self.weight = Parameter(torch.randn(out_channels, in_channels, *([kernel_size]*3))
)
def forward(self, x: sp.Tensor):
# 获取输入稀疏张量的坐标和特征
indices = x.indices()
values = x.values()
# 生成输出坐标(简化版,实际需要处理边界)out_indices = indices.repeat(1, self.kernel_size**3)
# 执行稀疏矩阵乘法(实际需要更复杂的邻域聚合)out_values = torch.einsum(
'oi...,i...->o',
self.weight,
values.unsqueeze(0).expand(self.weight.size(0), -1, -1)
)
return sp.COOTensor(out_indices, out_values, x.size())
点云数据转换
def pointcloud_to_sparse(points, features, grid_size=32):
"""
将点云转换为 3D 稀疏张量
:param points: (N,3) float32 点坐标
:param features: (N,C) float32 点特征
:param grid_size: 体素化分辨率
"""
# 归一化到 [0, grid_size-1] 范围
points = (points - points.min(0)[0]) / (points.max(0)[0] - points.min(0)[0])
points = (points * (grid_size-1)).long()
# 去除重复点
unique_coords, inverse = torch.unique(points, dim=0, return_inverse=True)
unique_features = torch.zeros(len(unique_coords), features.size(1))
unique_features.scatter_add_(0, inverse.unsqueeze(-1).expand(-1,features.size(1)), features)
# 构建 COO 格式
indices = unique_coords.t().contiguous()
return sp.COOTensor(indices, unique_features, torch.Size([grid_size]*3))
性能优化技巧
显存占用对比
| 稀疏率 | 密集卷积显存(MB) | 稀疏卷积显存(MB) |
|---|---|---|
| 95% | 2048 | 102 |
| 99% | 2048 | 20 |
| 99.9% | 2048 | 2 |
测试环境:NVIDIA V100 32GB, grid_size=128, channels=32
自定义 CUDA 内核
对于高性能需求,可以扩展自定义操作符:
// 示例 CUDA 内核伪代码
__global__ void sparse_conv_forward(
const float* values,
const int* indices,
const float* weight,
float* output,
int nnz,
int kernel_volume) {
int tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid >= nnz) return;
// 从全局内存加载输入特征
float val = values[tid];
int3 coord = ((int3*)indices)[tid];
// 遍历卷积邻域
for(int k=0; k<kernel_volume; ++k) {int3 offset = decode_kernel_offset(k);
int3 out_coord = coord + offset;
// 原子更新输出
atomicAdd(&output[out_coord], val * weight[k]);
}
}
常见问题排查
- 梯度消失问题:
- 检查稀疏张量的
requires_grad属性 -
验证自定义操作的梯度公式:
torch.autograd.gradcheck(lambda x: SparseConv3d(3,16)(x).values().sum(), sparse_tensor, eps=1e-6 ) -
极端稀疏情况:
- 添加微小噪声避免全零输入:
values += 1e-6*torch.randn_like(values) - 使用双精度计算:
dtype=torch.float64
延伸探索方向
- 动态稀疏模式:如何高效处理随时间变化的稀疏结构(如运动点云)
- 混合精度训练:FP16 在稀疏场景下的稳定性挑战
- 推荐系统适配:将 3D 稀疏卷积思想迁移到推荐系统特征交互
进阶阅读
- PVCNN: Point-Voxel CNN for Efficient 3D Deep Learning
- Minkowski Engine 工业级稀疏卷积库
- TorchSparse 高效 CUDA 实现
通过本文的代码示例和原理分析,读者应该能够理解稀疏卷积的核心思想,并具备基础的实现能力。在实际项目中,建议优先使用成熟库如 MinkowskiEngine,再根据需求考虑自定义优化。
