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

我个人习惯把形状特征画成一张图,这样一目了然:

输入 [1, 3, 224, 224] 3x3 权重 输出 [1, 64, 222, 222] 形状特征提取: • 输入维度: 4维 (NCHW) • 空间尺寸: 224x224 → 222x222 • 通道变化: 3 → 64 (通道扩展) • 计算量: 3x3x3x64x222x222 ≈ 2.8G MACs

4.3.2 精度特征

精度特征主要看数据类型和量化方式。我遇到过最坑的事情,就是FP32的算子直接改成INT8,结果精度掉得一塌糊涂。所以精度特征一定要仔细分析:

  • 数据类型:FP32、FP16、BF16、INT8、INT4
  • 量化方式:对称量化、非对称量化、per-tensor、per-channel
  • 精度敏感度:哪些层对精度敏感,哪些层可以大胆量化
我的经验:一般来说,第一层和最后一层对精度最敏感,建议保留FP16。中间层可以尝试INT8。如果发现精度下降,优先检查BatchNorm层——它经常是精度损失的罪魁祸首。

4.3.3 内存访问模式

内存访问模式,说白了就是数据怎么在内存里「走来走去」。我一般关注这几个点:

访问模式 描述 优化建议
连续访问 地址递增,比如NHWC布局 利用DMA burst传输,效率最高
跨步访问 固定间隔,比如NCHW布局 合并访问,减少DMA次数
随机访问 地址无规律,比如Gather 使用缓存,预取数据
广播访问 同一数据多次读取 利用片上SRAM缓存,避免重复搬运
避坑指南:我曾经在一个项目中,把NCHW的数据直接喂给NHWC优化的算子,结果性能直接腰斩。后来才发现,内存访问模式不匹配,导致DMA带宽利用率只有30%。所以,一定要先搞清楚数据在内存里的排布方式。

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%的算子。你拿到一个新算子,按这个流程走一遍,心里就有底了。记住,特征提取不是目的,目的是为了后续的算子映射和优化做准备。

好了,这一章的内容就到这里。算子分类和特征提取,说白了就是给算子「画像」。画得越准,后面的优化就越顺手。