第4章:算子分类与特征提取
各位同学,今天我们来聊聊算子分类这件事。说实话,我刚入行那会儿,面对几十上百个算子,头都是大的。后来慢慢摸索出一些门道——其实算子就像人一样,可以按不同维度去分类。你想想看,我们认识一个人,可以从性别、年龄、职业去分,算子也一样。
4.1 按计算类型分类
我个人习惯把算子分成三大类:计算密集型、访存密集型、控制密集型。这个分类方法,我在多个项目中验证过,挺实用的。
| 类型 | 典型算子 | 计算特点 | 优化方向 |
|---|---|---|---|
| 计算密集型 | Conv2D、MatMul、GEMM | 算力瓶颈,计算单元满载 | 提升MAC利用率,减少流水线气泡 |
| 访存密集型 | Reshape、Transpose、Gather | 带宽瓶颈,计算单元闲置 | 优化数据布局,减少DMA次数 |
| 控制密集型 | NonMaxSuppression、TopK | 分支多,流水线频繁冲刷 | 减少条件分支,向量化处理 |
嗯,这里要注意一点:同一个算子在不同输入下,分类可能变化。比如Conv2D,如果卷积核是1x1且通道数很少,它可能变成访存密集型。我在做MobileNet部署时就遇到过这种情况——明明是个卷积,结果瓶颈在数据搬运上。
4.2 按数据流分类
按数据流分类,说白了就是看数据在算子内部怎么流动。我把它分成三类:
- 逐元素算子:每个输出元素只依赖一个输入元素。比如ReLU、Sigmoid、加法。这类算子最容易并行,我一般直接上向量指令。
- 规约算子:多个输入元素产生一个输出。比如Sum、Max、Mean。这类算子需要做树形归约,我建议用分块策略。
- 窗口算子:输出依赖输入的一个邻域。比如Conv2D、Pooling、Dilation。这类算子最麻烦,因为存在数据复用问题。
为什么会这样分?因为不同的数据流模式,决定了我们怎么设计数据搬运策略。我曾经在一个项目中,把窗口算子误当成逐元素算子去优化,结果性能惨不忍睹——后来才发现,数据复用没做好,DMA带宽全浪费了。
4.3 算子特征提取
特征提取,说白了就是给算子做「体检」。我一般从三个维度去分析:
4.3.1 形状特征
形状特征包括:输入输出的维度数、每个维度的大小、是否有广播、是否有动态形状。举个例子:
# 一个典型的Conv2D形状特征
输入: [N, C, H, W] = [1, 3, 224, 224]
权重: [K, C, R, S] = [64, 3, 3, 3]
输出: [N, K, H_out, W_out] = [1, 64, 222, 222]
# 关键特征提取
- 输入维度: 4维 (NCHW)
- 空间尺寸: 224x224
- 通道数: 3 -> 64
- 卷积核: 3x3, stride=1, padding=0
我个人习惯把形状特征画成一张图,这样一目了然:
4.3.2 精度特征
精度特征主要看数据类型和量化方式。我遇到过最坑的事情,就是FP32的算子直接改成INT8,结果精度掉得一塌糊涂。所以精度特征一定要仔细分析:
- 数据类型:FP32、FP16、BF16、INT8、INT4
- 量化方式:对称量化、非对称量化、per-tensor、per-channel
- 精度敏感度:哪些层对精度敏感,哪些层可以大胆量化
4.3.3 内存访问模式
内存访问模式,说白了就是数据怎么在内存里「走来走去」。我一般关注这几个点:
| 访问模式 | 描述 | 优化建议 |
|---|---|---|
| 连续访问 | 地址递增,比如NHWC布局 | 利用DMA burst传输,效率最高 |
| 跨步访问 | 固定间隔,比如NCHW布局 | 合并访问,减少DMA次数 |
| 随机访问 | 地址无规律,比如Gather | 使用缓存,预取数据 |
| 广播访问 | 同一数据多次读取 | 利用片上SRAM缓存,避免重复搬运 |
4.4 实战:算子特征提取模板
最后,我分享一个我自己常用的算子特征提取模板。每次拿到一个新算子,我都会按这个流程走一遍:
# 算子特征提取模板
def extract_operator_features(op_name, inputs, outputs):
features = {}
# 1. 计算类型
if is_compute_intensive(op_name):
features['compute_type'] = 'compute_intensive'
elif is_memory_intensive(op_name):
features['compute_type'] = 'memory_intensive'
else:
features['compute_type'] = 'control_intensive'
# 2. 数据流模式
features['dataflow'] = analyze_dataflow(inputs, outputs)
# 3. 形状特征
features['shape'] = {
'input_dims': [inp.shape for inp in inputs],
'output_dims': [out.shape for out in outputs],
'broadcast': check_broadcast(inputs),
'dynamic': check_dynamic_shape(inputs)
}
# 4. 精度特征
features['precision'] = {
'input_dtype': inputs[0].dtype,
'output_dtype': outputs[0].dtype,
'quantized': is_quantized(inputs)
}
# 5. 内存访问模式
features['memory'] = {
'access_pattern': analyze_memory_access(inputs),
'stride': compute_stride(inputs),
'reuse_factor': compute_data_reuse(inputs)
}
return features
嗯,这个模板我用了好几年,基本覆盖了90%的算子。你拿到一个新算子,按这个流程走一遍,心里就有底了。记住,特征提取不是目的,目的是为了后续的算子映射和优化做准备。
好了,这一章的内容就到这里。算子分类和特征提取,说白了就是给算子「画像」。画得越准,后面的优化就越顺手。