3. 算子分类与特性

算子分类这件事,说白了就是给NPU上的各种计算操作分门别类。我刚开始接触NPU开发时,总觉得分类是理论课的东西,跟实际调试关系不大。后来踩了几个坑才明白——不理解算子分类,你连性能瓶颈在哪都找不到。

咱们今天就把五类核心算子掰开揉碎了讲。每种算子的计算模式、访存特征、并行机会,我都会结合项目中的实际案例来说。

逐元素算子:最简单的并行

逐元素算子,就是输入张量中每个元素独立计算,输出对应位置的元素。典型的如加法、乘法、ReLU、Sigmoid。

核心特征:计算密度低,访存带宽是瓶颈。每个输出元素只依赖对应位置的输入元素,天然适合SIMD并行。

我在适配一个ResNet-50模型时,发现逐元素算子占了总计算量的15%左右,但耗时却占了30%。为什么?因为NPU的算力没喂饱,数据搬运的时间比计算还长。

逐元素算子的优化思路其实很直接:

  • 向量化加载:一次加载多个元素,减少访存指令开销
  • 计算与访存重叠:用双缓冲机制,计算当前块时预取下一块
  • 融合相邻算子:比如把ReLU和后面的卷积融合,减少中间结果写回

我的经验:逐元素算子最容易忽略的是数据类型转换。有一次我适配一个FP16模型,逐元素加法用了FP32计算,结果性能直接腰斩。后来改成FP16加法,速度翻倍。

归约算子:数据压缩的艺术

归约算子把一组数据聚合成一个或少数几个值。常见的有求和、求均值、求最大值、Softmax中的归一化。

这类算子的特点是:计算量不大,但数据依赖性强。你想想看,求和操作必须等所有部分和都算完才能得到最终结果,这天然限制了并行度。

归约类型 典型算子 并行策略 常见瓶颈
全局归约 sum, max, min 树形归约 同步开销
分段归约 segment sum 分块+局部归约 负载不均衡
窗口归约 avg pooling 滑动窗口并行 边界处理

我曾经调试过一个LayerNorm算子,里面有个全局求和的操作。一开始用最简单的串行归约,结果发现NPU的利用率不到10%。后来改成树形归约,把数据分成16块并行计算部分和,最后再合并,利用率直接拉到60%。

注意:归约算子的数值精度问题容易被忽视。浮点数求和时,不同的累加顺序可能导致不同的结果。我在做BERT模型适配时就遇到过,因为归约顺序不同,导致精度差异超过1e-5,最终影响了推理结果。

卷积算子:NPU的看家本领

卷积算子,尤其是二维卷积,是CNN模型的核心。NPU的硬件设计很大程度上就是为卷积优化的。

卷积的计算模式是:滑动窗口 + 乘累加。每个输出元素是输入窗口和卷积核的加权和。这看起来简单,但实际实现时有很多门道。

我习惯把卷积的优化分为三个层次:

  1. 算法变换:im2col + GEMM、Winograd、FFT
  2. 数据布局:NHWC vs NCHW,分块策略
  3. 硬件适配:利用NPU的脉动阵列、向量单元

关键指标:卷积算子的计算强度 = 计算量 / 访存量。计算强度越高,越容易发挥NPU的算力。一般计算强度大于10的卷积,NPU利用率能到80%以上。

我在适配MobileNet时,发现深度可分离卷积的计算强度很低(约2-3),导致NPU利用率只有20%。后来通过算子融合,把逐点卷积和深度卷积合并,计算强度提升到8,利用率也到了60%。

矩阵乘算子:大模型的基石

矩阵乘(GEMM)是Transformer、BERT、等大模型的核心算子。它的计算量巨大,访存模式也复杂。

矩阵乘的特征:

  • 计算密集:O(N^3)的计算量,O(N^2)的访存量
  • 数据复用:每个输入元素被多次使用
  • 分块友好:可以按行/列分块,适配缓存层级

矩阵乘的优化,说白了就是让数据在缓存里多待一会儿。我常用的分块策略是:

// 矩阵乘分块伪代码
for (int i = 0; i < M; i += BM) {
  for (int j = 0; j < N; j += BN) {
    for (int k = 0; k < K; k += BK) {
      // 加载A[i:i+BM][k:k+BK]到L1
      // 加载B[k:k+BK][j:j+BN]到L1
      // 计算C[i:i+BM][j:j+BN] += A * B
    }
  }
}

分块大小BM、BN、BK的选择很关键。我一般根据NPU的L1缓存大小来定,原则是让A和B的分块能同时放进缓存。比如L1是256KB,数据类型是FP16,那BM×BK + BK×BN ≤ 128K个元素。

避坑指南:我曾经在适配一个模型时,矩阵乘的M、N、K都很小(32×32),结果NPU的利用率不到5%。后来发现是小矩阵乘不适合用大核计算,改成用向量单元做,效率反而更高。小矩阵乘,用向量比用矩阵核划算。

激活函数算子:非线性之源

激活函数算子给神经网络引入非线性。常见的包括ReLU、Sigmoid、Tanh、GELU等。

这类算子的特点是:计算模式多样,硬件实现差异大。ReLU就是个比较操作,Sigmoid和Tanh需要指数运算,GELU更复杂。

激活函数 计算复杂度 硬件实现方式 精度要求
ReLU 低 比较器 低
Sigmoid 中 查找表 + 插值 中
GELU 高 多项式近似 高

激活函数的适配,我建议优先考虑查找表 + 线性插值的方案。为什么?因为NPU的算力宝贵,用多项式近似虽然精度高,但会占用大量计算单元。查找表只需要一次访存加一次乘加,效率高得多。

我在适配GELU时,用了256个点的查找表,配合线性插值,精度误差控制在1e-4以内,速度比多项式近似快了3倍。

注意:激活函数的数值范围很重要。Sigmoid在输入很大或很小时,输出会饱和。如果查找表只覆盖了[-5, 5]的范围,输入为10时就会出错。我建议查找表的范围至少覆盖[-8, 8],或者做clip处理。

五类算子的知识图谱

下面这张图总结了五类算子的核心特征和优化方向,你可以把它当作调试时的快速参考。

算子分类与特性知识图谱 NPU算子分类 逐元素算子 归约算子 卷积算子 矩阵乘算子 激活函数算子 逐元素 计算密度低 访存瓶颈 SIMD并行 归约 数据依赖强 树形归约 同步开销 卷积 滑动窗口 乘累加 算法变换 矩阵乘 计算密集 数据复用 分块策略 激活函数 非线性 查找表 多项式近似 优化方向:算子融合 → 数据布局 → 并行策略 → 硬件指令适配

这张图把五类算子的核心特征和优化方向都串起来了。你在调试时,可以先判断当前算子属于哪一类,然后对照图中的特征找瓶颈,再按优化方向去调。这样思路会清晰很多。

嗯,算子分类就讲到这里。记住一句话:不同算子的瓶颈不同,优化手段也不同。逐元素算子看访存,归约算子看同步,卷积算子看计算强度,矩阵乘算子看分块,激活函数算子看实现方式。把这些记牢了,后面调试时就能快速定位问题。