4. 计算原语与数据流:常用算子的存算一体实现方式
好,咱们直接切入正题。
矩阵乘、卷积、激活函数——这三个算子,基本覆盖了90%以上的神经网络计算量。在传统架构里,它们跑在ALU和缓存之间,数据搬来搬去,功耗一大半都耗在路上了。存算一体要做的,就是让计算发生在数据所在的位置,省掉那些无谓的搬运。
4.1 矩阵乘:存算一体的核心战场
矩阵乘是神经网络最基础的算子。全连接层、注意力机制,本质上都是矩阵乘。我个人习惯把矩阵乘的存算实现分成两类:模拟式和数字式。
4.1.1 模拟式矩阵乘:基尔霍夫定律的妙用
模拟式实现,说白了就是利用欧姆定律和基尔霍夫电流定律。你把权重存在电阻阵列里,输入电压加在字线上,位线上收集到的电流就是乘累加的结果。
公式很简单:
I_out = Σ (V_in * G_weight)
其中G_weight是电导,代表权重值。我在项目中遇到过一个问题——模拟计算的精度受限于器件的线性度和噪声。RRAM器件的电导状态往往有波动,导致计算结果偏差。嗯,这里要注意,模拟计算的精度通常只能达到6-8bit,对于推理场景够用,但训练场景就比较吃力了。
核心要点:模拟式矩阵乘的优势是能效极高,一次读操作就能完成整个向量-矩阵乘法。代价是精度受限,且需要ADC/DAC转换。
4.1.2 数字式矩阵乘:SRAM存内计算
数字式实现,我更喜欢用SRAM来做。每个存储单元除了存数据,还带一个简单的计算逻辑,比如与门或加法器。你想想看,这样每次读数据的时候,顺便就把乘法做了。
举个例子,一个典型的SRAM存内计算单元:
// 伪代码:SRAM存内计算单元
// 输入:bitline (BL), wordline (WL)
// 输出:计算结果通过sense amplifier读出
if (WL == 1) {
// 存储单元输出存储值到BL
BL_out = SRAM_cell_value;
// 同时执行与操作(用于二进制神经网络)
result = BL_out & input_bit;
}
数字式的优势是精度高,可以做到32bit浮点。但面积开销大,每个存储单元都要加计算逻辑。我建议在需要高精度的场景(比如训练)用数字式,推理场景用模拟式。
我的经验:混合精度方案往往效果最好。用模拟式做粗粒度计算,数字式做精调。我在一个语音识别芯片上试过,能效比纯数字方案提升了5倍,精度只损失了0.3%。
4.2 卷积:数据流优化的艺术
卷积算子的存算实现,核心挑战在于数据复用。你想想看,一个3x3的卷积核要在输入特征图上滑动,每个输入像素会被多个卷积核重复使用。怎么利用好这个特性,是优化的关键。
4.2.1 输入驻留数据流
输入驻留,就是把输入特征图固定在计算阵列里,然后让卷积核权重流进来。这种方式的优点是输入数据复用率高,适合输入通道数多的场景。
我记得有一次做图像分类芯片,输入是224x224x3,卷积核是3x3x64。如果用输入驻留,输入数据只需要加载一次,然后64个卷积核依次计算。数据搬移量减少了64倍。
4.2.2 权重驻留数据流
反过来,权重驻留是把卷积核权重固定在存算阵列里,输入特征图流进来。这种方式适合权重复用率高的场景,比如深度可分离卷积。
说白了,选择哪种数据流,取决于你的网络结构。我一般遵循这个原则:
| 网络层类型 | 推荐数据流 | 原因 |
|---|---|---|
| 标准卷积(输入通道多) | 输入驻留 | 减少输入数据重复加载 |
| 深度可分离卷积 | 权重驻留 | 每个通道独立计算,权重复用 |
| 1x1卷积 | 两种均可 | 计算密度高,看具体硬件 |
避坑指南:我曾经在一个项目中选了输入驻留,但没考虑到输入特征图尺寸太大,存算阵列放不下。结果不得不做分块计算,反而增加了数据搬移。所以,一定要先评估特征图尺寸和阵列大小的匹配度。
4.3 激活函数:非线性计算的存内实现
激活函数是非线性的,而存算一体天然擅长线性计算。怎么在存内实现ReLU、Sigmoid这些函数?嗯,这里有几个常用方法。
4.3.1 ReLU:最简单的存内实现
ReLU就是max(0, x)。在存算阵列的输出端加一个比较器就行。如果输出小于0,就输出0;否则保持原值。这个实现几乎不增加面积开销。
// ReLU的存内实现伪代码
// 假设存算阵列输出为 analog_out
if (analog_out < 0) {
result = 0;
} else {
result = analog_out;
}
4.3.2 Sigmoid/Tanh:查找表法
Sigmoid和Tanh比较复杂,直接用模拟电路实现很难。我建议用查找表(LUT)法。把函数曲线离散化成若干点,存在SRAM里。计算时根据输入值查表得到近似结果。
精度和面积的权衡:
- 8bit LUT:256个条目,面积约2KB,精度足够推理
- 6bit LUT:64个条目,面积0.5KB,精度略差但能效高
- 分段线性近似:用几个线性段拟合曲线,面积最小
我个人习惯用分段线性近似。在项目中试过,用4段线性拟合Sigmoid,精度损失不到1%,面积只有LUT的十分之一。
关键洞察:激活函数的存内实现,本质上是精度-面积-能效的三角权衡。没有银弹,要根据应用场景选。
4.4 数据流优化:让数据少跑路
最后聊一下数据流优化。存算一体的核心优势是减少数据搬移,但如果不注意数据流设计,这个优势很容易被浪费掉。
我总结了几条原则:
- 最大化数据复用:尽量让数据在存算阵列内多待一会儿,别算完就扔
- 流水线化:让计算和下一层的数据加载重叠进行
- 局部性优先:能近邻通信就别走全局总线
举个例子,一个典型的卷积层数据流优化:
// 优化前:每算完一个输出像素,就写回DRAM
for each output pixel:
load input patch from DRAM
compute convolution
store result to DRAM
// 优化后:在存算阵列内完成整个输出通道的计算
for each output channel:
load all input patches to SRAM
for each output pixel:
compute convolution (数据在SRAM内复用)
store all results to DRAM (一次批量写回)
你看,优化后的方案,DRAM访问次数减少了至少一个数量级。这就是存算一体的精髓——让计算靠近数据,而不是让数据靠近计算。
我的建议:设计数据流时,先画一张数据流图,标出每个数据的生命周期。然后问自己:这个数据能不能在存算阵列里多待一会儿?能不能和下一层计算共享?往往能发现优化空间。
好了,关于矩阵乘、卷积和激活函数的存算一体实现,就聊到这里。这些算子的实现方式,说白了就是怎么在存储单元里塞进计算逻辑,同时让数据流尽量高效。下一章我们会聊更具体的硬件架构设计,到时候再深入。
想要更多资料,联系可可