第二章:神经网络基础回顾——卷积、池化、全连接、激活函数,理解计算模式对指令集的需求

各位同学,欢迎来到第二章。

上一章我们聊了NPU的整体架构。今天咱们回到一个更基础的问题:神经网络到底在算什么? 搞清楚了这个问题,你才能理解为什么NPU的指令集要设计成那个样子。

我个人习惯是,先看计算模式,再看硬件怎么支持。说白了,指令集就是计算模式的“翻译官”。你神经网络里跑的是卷积、池化、全连接,那指令集就得有对应的“方言”来高效表达这些操作。

2.1 卷积:NPU的“主菜”

卷积是神经网络里最核心、最耗时的操作。我做过统计,在典型的CNN模型中,卷积占了总计算量的80%~90%。所以NPU指令集设计的第一要务,就是伺候好卷积。

卷积到底在干嘛?简单说,就是用一个小的“窗口”(卷积核)在输入数据上滑动,每次做一次点积运算。

核心计算模式: 乘加累加(MAC)。一个卷积层里,可能有几百万甚至上亿次MAC操作。

举个例子,一个3x3的卷积核,在输入特征图上滑动:

// 伪代码:单次卷积计算
for (int c = 0; c < input_channels; c++) {
    for (int kh = 0; kh < kernel_height; kh++) {
        for (int kw = 0; kw < kernel_width; kw++) {
            output += input[c][h+kh][w+kw] * weight[c][kh][kw];
        }
    }
}

嗯,这里要注意:卷积的计算模式是高度规则化的。每次都是“取数据→乘→加→存结果”,循环往复。这种规律性,对指令集设计来说是个好消息——我们可以用向量化指令来批量处理。

我在项目中遇到过一个问题:早期的NPU用标量指令一条条算卷积,结果功耗高、效率低。后来改成SIMD(单指令多数据)风格的向量MAC指令,吞吐量直接翻了8倍。你想想看,这就是指令集匹配计算模式的力量。

2.1.1 卷积对指令集的需求

  • 向量MAC指令:一次指令完成多个乘加操作,比如一次算16个MAC
  • 数据复用指令:卷积核在滑动时,输入数据会被反复读取。指令集需要支持数据重用,减少内存访问
  • 步长(stride)和填充(padding)控制:指令里要能灵活指定滑动步长和边界填充方式

避坑指南: 我曾经在设计指令集时,把步长参数写死在指令编码里,结果后面发现不同层的步长不一样,不得不重新设计。建议把步长、填充作为指令的可变参数,而不是硬编码。

2.2 池化:降维的艺术

池化层的作用是降低特征图的空间尺寸,减少计算量,同时保留主要特征。常见的池化有最大池化和平均池化。

池化的计算模式比卷积简单多了:在一个窗口内,要么取最大值,要么取平均值。

// 伪代码:2x2最大池化
for (int h = 0; h < output_height; h++) {
    for (int w = 0; w < output_width; w++) {
        float max_val = -INFINITY;
        for (int ph = 0; ph < pool_size; ph++) {
            for (int pw = 0; pw < pool_size; pw++) {
                max_val = max(max_val, input[h*stride+ph][w*stride+pw]);
            }
        }
        output[h][w] = max_val;
    }
}

说白了,池化就是比较和选择。没有乘法,只有比较和赋值。这对指令集来说意味着什么?

  • 比较指令:需要高效的向量比较指令,比如一次比较16个元素,找出最大值
  • 数据移动指令:池化窗口不重叠时,数据访问模式很规整,可以用连续加载指令
  • 特殊池化指令:有些NPU会设计专门的POOL_MAXPOOL_AVG指令,一条指令搞定整个池化操作

注意: 池化虽然计算量小,但数据依赖很关键。比如最大池化需要比较所有元素后才能确定结果,这会影响指令流水线的设计。我见过一个设计,因为池化指令的延迟太长,导致后续指令空等,性能反而下降了。

2.3 全连接:矩阵乘法的“直球”

全连接层,就是传统的神经网络层。每个输入神经元都连接到每个输出神经元。计算模式就是矩阵乘法

假设输入向量是x(长度N),权重矩阵是W(尺寸M×N),输出向量是y(长度M):

// 伪代码:全连接层计算
for (int i = 0; i < M; i++) {
    float sum = 0;
    for (int j = 0; j < N; j++) {
        sum += x[j] * W[i][j];
    }
    y[i] = sum + bias[i];
}

全连接的计算模式和卷积很像,也是乘加累加。但区别在于:全连接没有数据复用。每个输入只被用一次,每个权重也只被用一次。所以内存带宽往往是瓶颈。

我记得有一次优化一个全连接层,发现计算单元只用了30%,剩下的时间都在等数据从内存里搬过来。后来我改用了矩阵分块指令,把大矩阵切成小块,让数据在片上缓存里多待一会儿,利用率才提上去。

2.3.1 全连接对指令集的需求

  • 矩阵乘法指令:支持不同尺寸的矩阵乘法,最好能自动处理分块
  • 向量加偏置指令:全连接层通常有偏置项,需要一条指令完成“乘加+加偏置”
  • 数据搬运指令:因为内存带宽是瓶颈,指令集需要支持DMA(直接内存访问),让数据搬运和计算并行

关键点: 全连接层的计算密度(计算量/数据量)比卷积低很多。所以指令集设计时,要更关注数据流优化,而不是纯计算能力。

2.4 激活函数:非线性变换

激活函数给神经网络引入非线性。没有它,再多层也只是线性变换的堆叠。常见的激活函数有ReLU、Sigmoid、Tanh等。

激活函数的计算模式很简单:对每个元素做一次数学变换。

// 伪代码:ReLU激活函数
for (int i = 0; i < N; i++) {
    output[i] = max(0, input[i]);
}

// 伪代码:Sigmoid激活函数
for (int i = 0; i < N; i++) {
    output[i] = 1.0 / (1.0 + exp(-input[i]));
}

你想想看,激活函数是逐元素操作,没有数据依赖,非常适合并行处理。但问题在于:不同的激活函数计算复杂度差异很大

  • ReLU:就是一个比较,硬件实现极其简单
  • Sigmoid/Tanh:需要指数运算、除法,硬件实现复杂得多

我在项目中遇到过:一开始只支持ReLU,后来客户要求支持Sigmoid。结果发现用软件模拟Sigmoid太慢,只好在指令集里加了一条ACT_SIGMOID指令,用查找表+线性插值来实现。

2.4.1 激活函数对指令集的需求

  • 逐元素操作指令:支持对向量中的每个元素做同样的操作
  • 特殊函数指令:对于复杂的激活函数(如Sigmoid),最好有硬件加速指令,而不是用多条指令拼凑
  • 融合指令:很多NPU会把“卷积+激活”或“全连接+激活”合并成一条指令,减少中间结果的存储

技巧: 设计指令集时,可以预留一些激活函数编码位。比如用3位编码支持8种激活函数,这样未来扩展新激活函数时,不用改指令格式。

2.5 计算模式总结:指令集设计的“北极星”

好了,我们把四种基本操作都过了一遍。现在来总结一下,这些计算模式对指令集设计到底提出了什么需求。

操作类型 计算模式 核心指令需求 性能瓶颈
卷积 乘加累加(规则循环) 向量MAC、数据复用、步长控制 计算单元利用率
池化 比较/求和(窗口操作) 向量比较、连续加载、专用池化指令 数据依赖延迟
全连接 矩阵乘法(无数据复用) 矩阵乘法、DMA、分块处理 内存带宽
激活函数 逐元素变换 逐元素操作、特殊函数、融合指令 函数复杂度

说白了,指令集设计就是在计算模式和硬件资源之间找平衡。你不能什么都用硬件实现,那芯片面积会爆炸;也不能什么都用软件模拟,那性能会惨不忍睹。

我个人习惯是:把最常用、最耗时的操作做成硬件指令(比如卷积的MAC),把不常用的操作留给软件组合(比如某些小众激活函数)。这样既保证了性能,又控制了成本。

核心思想: 指令集是计算模式的“镜像”。你理解了神经网络的计算模式,就理解了NPU指令集为什么长这样。后面的章节,我们会深入每条指令的设计细节。

好,这一章就到这里。下一章我们开始正式进入指令集设计,聊聊数据格式和精度——这可是NPU的“命根子”。