ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AdderNet:把卷积乘法换成加法,实现低功耗端侧推理

AdderNet:把卷积乘法换成加法,实现低功耗端侧推理

读论文的时候,我最常被问到的一个问题是:模型已经这么成熟了,为什么还会有人花大力气去琢磨“怎么把乘法换成加法”?做端侧部署的朋友应该都有这种感受——剪枝、量化、蒸馏轮着试,精度和速度的平衡还是很难调。AdderNet这篇论文恰恰是在一个最底层的地方动刀:它把卷积里的乘法全部拿掉,换成了绝对差值的累加,并且最终精度没有出现明显损失。如果你关心模型落地时的功耗,或者想理解除了“点积”之外,神经元还能以什么方式组织计算,这篇论文很值得读。下面我按自己的阅读习惯,把它的动机、原理、训练技巧和实验验证拆开聊,尽量说人话。

1. 先搞清楚一个反常识的问题:为什么非要把乘法去掉

1.1 一个3×3卷积窗口,到底做了多少次乘法

很多人在看到卷积的第一眼就头大,但抛开公式,它的动作其实很朴素:拿一个固定大小的模板,在输入特征图上滑动,每停一个位置,就把覆盖住的像素和模板对应位置乘起来,再全部累加。如果一个模板是3×3×64,那么每产生一个输出点,就要做576次乘法加576次加法。注意,这里每次“乘加”都是一整条流水线里的两步:先乘后加,时钟周期、硬件面积、功耗全都堆在这两步上。

更麻烦的是全连接层。一个输入长度512、输出长度512的全连接,一次前向就是26万多次乘法。虽然在现代GPU上这些运算被矩阵乘法库优化得看起来很轻快,但底层吞吐的代价并没有消失。它只是被并行度掩盖了:GPU有成千上万个乘法器在同时工作,每一刻消耗的功耗也相当可观。对于手机、摄像头、可穿戴设备这类低压供电的场景,功耗不是锦上添花的问题,而是能不能跑得动的问题。

1.2 论文真正关心的不是FLOPs,而是能耗

被提及最多的计算指标是FLOPs,但FLOPs在做一件事时很“粗心”:它一视同仁地统计乘法和加法,一个乘加操作算两次,所以不管你是乘法还是加法,在FLOPs账本上都一样重。这就导致了一个盲区:如果一个网络架构把乘法全部换成加法,FLOPs几乎不会变化,看起来好像什么都没变。

AdderNet给了一个更合理的评估视角:物理能量消耗。一颗芯片上实现乘法所消耗的能量,往往是一个加法的几倍。如果拿一张业内常用的65nm/45nm工艺下算术操作相对能耗表来看,32位浮点加法约耗0.9皮焦,32位浮点乘法约耗3.7到4.6皮焦,差了三到五倍。如果放到8位整数运算上,乘法的能耗同样显著高于加法。这意味着,宏观上FLOPs的微小差异,微观上却对应着相当可观的能效收益。我后来做硬件测算时,深刻体会到这一点:只盯着FLOPs很容易错误判断一个模型的“轻量程度”,真正的落地标准应该是“完成一次推理消耗多少能量”。AdderNet这篇论文在萌芽阶段就抓住了这个口径,方向感非常明确。

2. AdderNet干了什么:用L1距离重写整个卷积

2.1 从点积相似度到模板匹配:公式与直觉

传统卷积的输出写出来是这样的:

[ y = \sum_{i,j,c} x_{i,j,c} \times w_{i,j,c} ]

AdderNet的核心改动就是把里面的乘法换成减法,外面套上绝对值,最后取负号:

[ y = -\sum_{i,j,c} |x_{i,j,c} - w_{i,j,c}| ]

为什么要取负号?因为原始卷积的输出可能是正数也可以是负数,数值越大代表特征与模板越“相关”,越激活。而距离的累加是恒正的——距离越大反而越“不相关”,所以外面加个负号,把“距离”翻转成“相似度”,数值上越大表示距离越小、匹配程度越高。用大白话说,原来卷积是在做“对齐乘加”,AdderNet变成了“找差值的绝对值并累加”。

这个行为很像模板匹配。你可以把卷积核想象成一张标准钥匙形状的模板,原来的点积是“拿模板和特征做投影,看能不能投影出亮斑”,AdderNet则是“把模板和特征叠在一起,看哪里对不齐,越对齐输出越大”。两者语义上是一致的,但实现手段从乘法换成了减法和绝对值。

2.2 为什么“加法网络”还能保住精度

可能很多人第一反应是:距离匹配这么粗糙的操作,也能顶替成熟的卷积?实际真的能。原因在于卷积核本身是学习出来的,网络并不关心你用什么公式衡量“匹配”,它只关心这个衡量方式能不能提供足够区分度的梯度信号。

L1距离有一个非常好的性质:它对每个通道、每个位置上差异的响应是均匀的。无论差异大还是小,其梯度符号都指向同一个方向——朝权重去靠近输入特征的方向。这在反向传播里形成一种“持续牵引”的效应,虽然不像原卷积中乘法的梯度那样根据数值大小敏感变化,但这种稳定的牵引可以让网络在大量迭代里逐步把卷积核塑造成一类“聚类中心”。换句话说,AdderNet的卷积层更像在做逐类别的聚类,而传统卷积是在做带权投影。在数据本身具有明显类别结构时,聚类的表达能力和投影是可比的,这解释了为什么在后面实验中,精度可以维持在非常接近的水平。

3. 想训好加法网络,得先解决三个反直觉问题

3.1 输出方差太小,信号越传越弱

直接替换会出大问题。卷积的典型输出方差正比于“卷积核尺寸×输入通道数”,因为它是大量乘积累加,数值会在统计意义上被充分放大。而AdderNet的输出是若干绝对差的累加,绝对值的结果范围天然平坦得多,导致每一层输出的方差明显小于普通卷积。

别小看这个方差差异。深度网络一层接一层传递信号,如果每层都收缩一点,到后面几层激活值会拥挤在一个极小的范围内,梯度也会跟着变钝。我一开始复现时踩过这个坑:首层输出还正常,到第三四个模块之后,特征图几乎“糊成一片”。解决思路是必须给Adder算子的输出接上适当的归一化和缩放。通常做法是先接BatchNorm,再对激活乘一个缩放系数,把方差拉回和普通卷积相近的量级。这个细节看起来小,但跳过它,训练大概率崩得很惨。

3.2 L1在零点不可导,梯度怎么反传

绝对值函数在有差异时导数很简单:非负就是+1或-1,但在零点附近不可导。神经网络训练靠的是连续梯度的链式法则,这里天然不连续。可工程落地并不需要数学上的优雅,只需要一个可靠好用的次梯度。

论文的做法是:将绝对值函数的梯度近似看作符号函数。也就是说,对输入 ( x ) 的反向传播公式变成:

[ \frac{\partial y}{\partial x} \approx \mathrm{sign}(x - w) ]

这个近似的好处是梯度幅度恒定,不会因为差异太小导致梯度消失。坏处是,如果对权重更新也用同样的符号梯度,那么所有权重更新幅度一样,缺少“差异越大改得越多”的精确性,训练会变得很笨拙。所以权重的梯度不能照搬输入侧的近似,必须额外处理。

3.3 自适应学习率:给每个加法的滤波器单独配档位

如果权重梯度完全遵循符号函数的输出,更新步长就永远只有一种大小,这种“一刀切”的更新会让网络走很多弯路。论文里提出了一个非常工程化的修正:在更新权重时,给每个滤波器计算一个自适应的缩放因子,用它去调整该滤波器所有参数的实际更新幅度。

具体思路是:可以参考该层梯度绝对值的平均量级,而不是直接使用常量学习率。比如,对某一个滤波器,统计它关于所有权重位置的梯度绝对值的平均,把这个平均值作为学习率缩放的一部分。这样,梯度量级大的滤波器更新快一些,量级小的更新慢一些,既保留了符号梯度的稳定性,又恢复了“随误差大小调整步长”的能力。理解这个设计时,我建议不要盯着公式细节,而要抓住它的本质:它是在“符号梯度的鲁棒性”和“连续梯度的敏感性”之间取平衡。这个思想后来对很多自定义算子的训练都有启发。

# 概念性描述:Adder层里自适应学习率的简化写法 # grad_w 是权重位置上的梯度,final_lr 是该层最终学习率 import torch def adder_update(weight, grad_w, base_lr): # 计算该层所有滤波器梯度的平均绝对量级 # 典型实现里会按每个输出通道计算缩放,这里给出简化版本 scale = grad_w.abs().mean(dim=(1, 2, 3), keepdim=True) normalized_lr = base_lr * scale # 用符号梯度的大方向,乘以归一化后的学习率 weight -= normalized_lr * torch.sign(grad_w)

4. 实验效果怎么看:精度和能耗的双重验证

4.1 公开数据集上的准确率:与卷积几乎持平

先看分类任务。在小型的手写数字识别数据集上,AdderNet搭建的简单网络精度可以达到和普通卷积相差无几的水平。在中等规模的数据集如CIFAR系列上,配合ResNet骨架使用,多个配置下的top-1准确率与基准卷积网络差距通常控制在一个百分点以内。到了千类别级别的大规模图像分类数据集上,替换掉的ResNet网络在top-1上比原版低约一个点左右,top-5的差距更小。

只看数字可能觉得也不过如此,但如果把成本折算进来,这个精度代价很值得。通常来说,一个能省30%左右能耗且完全不影响部署流程的算子替换,是任何做边缘侧产品的人都会心动的方案。而且AdderNet不需要重新设计网络结构,也不需要复杂训练技巧,在合理调参下就能收敛。

4.2 能耗账本:乘法是算术里的电老虎

关于能耗,论文给的收益不是“快了多少倍”,而是“省了约三分之一的能量”。这个数字必须结合硬件实现方式来看。如果芯片上乘法和加法共用的是同一个ALU,那么替换后只是指令类型变了,能耗收益有限;但如果针对低功耗场景设计了专门的累加器阵列,加法能耗远低于乘法,收益就会被放大。

把45nm工艺下常用的算术单元能耗列出来会更直观:

运算类型典型相对能耗(皮焦)说明
32位浮点加法0.9基础算术
32位浮点乘法3.7是加法的4倍左右
8位整数加法0.03低精度场景
8位整数乘法0.2低精度下仍差数倍

AdderNet把所有点积变成绝对差累加,意味着理论上把乘加对中的乘号全部卸载,只保留减法器和累加器。乘法的能耗占比下来,整体推理能耗下降三分之一并不夸张。我读这篇论文时最大的感受是:它把一个常见的“规模优化”命题,变成了“算子级能效优化”命题。

4.3 一个容易被忽略的地方:收益在推理,不在训练

AdderNet的前向过程确实省了乘法,但反向传播同样需要计算梯度,梯度传播时加法运算量也不小,而且自适应学习率还会增加额外计算。所以训练阶段大概率不会比普通卷积更快,某些场景甚至更慢。我之前看一些复现帖,有人抱怨“训练时根本没什么速度提升”,这是预期错了。

它真正的舞台在推理端,尤其是模型固化到专用加速器或低功耗SoC上的场景。如果训练流程里你已经完成了蒸馏或者剪枝,最后再把推理图里的卷积替换为Adder核,配合量化工具,能效收益是非常可观的。阅读这篇论文时,一定要把“训练期开销”和“推理期收益”分开记账。

5. 在模拟项目X里复现AdderNet:操作与踩坑

5.1 把普通CNN改成AdderNet的最短实现

我建议自己写一遍不算复杂的实现,比直接看官方代码更容易理解内部逻辑。为了不陷入im2col的复杂度,下面给出的是概念版写法,代码重点在展示Adder的计算模式,真实工程里建议在卷积核层做循环或者用成熟算子库替代,不然显存占用会比较难看。

import torch import torch.nn as nn import torch.nn.functional as F class Adder2D(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0): super().__init__() # 注意:初始化尺度要比普通卷积更小一些 self.weight = nn.Parameter( torch.randn(out_channels, in_channels, kernel_size, kernel_size) * 0.05 ) self.stride = stride self.padding = padding def forward(self, x): N, C, H, W = x.shape M = self.weight.shape[0] K = self.weight.shape[2] # 提取所有滑窗patch patches = F.unfold(x, kernel_size=K, padding=self.padding, stride=self.stride) # patches shape: (N, C*K*K, L) # 把每个卷积核reshape成 (M, C*K*K, 1) w_flat = self.weight.reshape(M, -1, 1) # 对每个patch和每个filter求绝对差之和,再取负号 diff = patches.unsqueeze(1) - w_flat.unsqueeze(0) # (N, M, C*K*K, L) adder_out = -diff.abs().sum(dim=2) # (N, M, L) # 折叠回特征图 out = F.fold(adder_out, output_size=(H // self.stride, W // self.stride), kernel_size=1, padding=0) return out

实际使用中,Adder层后面通常要接BatchNorm并在激活前乘一个固定缩放系数,不能像普通Conv那样直接扔给ReLU。缩放的目的是把L1距离的响应幅度拉回普通卷积的范围。按照我自己的经验,缩放系数可以先设为1,然后用第一个batch的激活统计量做一个自适应乘数,比盲目拍脑袋设一个固定值更稳。

5.2 从train到test的常见翻车现场

复现过程中最常见的几个问题都是围绕训练稳定性的。第一个是训练初期loss直接变NaN,这通常是因为学习率没有调低。普通卷积积攒了足够经验值,很多3e-4左右还能跑,但Adder的符号梯度让更新方向更“硬”,学习率沿用原来的数值会非常容易震荡。建议初始学习率先调小一半以上,再观察loss曲线。

第二个是深层特征图接近全零。这个前面提到过,根源是方差收缩。如果你发现中间特征图统计值太小,不要只加BN,还要检查是否需要额外的激活缩放,以及在更深的层放大缩放倍数。第三个是测试时想当然地沿用训练时的BatchNorm统计量,导致精度异常下降。Adder对分布比卷积更敏感,BN的移动均值在测试阶段经常需要多跑几个batch重新校准。

还有一点非常隐蔽:有些框架会对输入和权重做融合优化,自动把L1距离又拆成若干乘法。如果你最终想拿到真实的加法收益,必须检查底层算子是否真的生成了减法/绝对值指令,而不是靠软硬件模拟。我在模拟项目X里就遇到过类似的坑,最终发现是推理引擎把自定义Op降级成了通用卷积去执行,收益直接归零。

6. 边界和启发:它不是什么万灵药,但值得学思路

6.1 AdderNet能干什么,不能干什么

AdderNet在图像分类这一类任务上表现得非常成熟,尤其是配合ResNet这种结构规整、卷积核尺寸统一的骨架。但在目标检测、语义分割这类对局部细节和位置信息要求更高的任务上,直接替换能否保持同样精度,论文给出的证据还不够充分。原因也好理解,L1距离匹配强调“整体形状对齐”,而卷积里的乘法天然会放大强特征响应,后者在定位任务上往往更有利。

另外AdderNet提出的适配思路是针对密集卷积的,对全连接层和注意力矩阵也可以迁移,但收益会下降。因为注意力矩阵通常需要保持在确定的数值范围内,替换为L1之后归一化和温度参数的调整需要额外设计,这会稀释它的工程优势。把它的适用范围概括一下就是:核尺寸规整、通道大、以分类或特征提取为主的网络,收益最明显;核尺寸大、任务复杂、有连续回归输出的场景,要谨慎验证。

6.2 从替代乘法开始,看模型轻量化的另一条路线

多数轻量化方法瞄准的是“减少参数”或“减少FLOPs”,AdderNet瞄准的是“更换基础算子”。这条路线更底层,也更彻底。它背后的通用启发是:如果某个运算很贵,那么第一步不是砍掉它,而是找一个数学上等价、物理上更便宜的替代品。二值化网络也是这条思路上的典型代表,它把乘法变成位运算;一些移位网络则把乘法近似成移位加加法。AdderNet把乘法变成L1距离,本质上是在“精度保持”和“算子简化”之间寻找一个甜蜜点。

读到这,我还回想起一个容易被忽视的细节:AdderNet真正推动的不是某一个网络结构的进步,而是很多加速硬件开始把“距离计算单元”当作一个可配置的基本单元来设计。当软件层面定义清楚“加法算子”的语义后,硬件可以在电路级别为它优化——比如并行的绝对差求和器,这比仅仅让编译器去优化三次乘法有意义得多。

我个人在实际操作中的体会是:读这类论文不要把公式推完就结束,最好亲手把替换后的网络跑一遍。我在模拟项目X里把这套思路用在一个轻量骨干网络上时,精度只掉了不到一个点,但用硬件估算了节约的能耗之后,心里会特别踏实。如果看完这篇内容你也想动手复现,建议从一个小模型起步,先把Adder层和BatchNorm的缩放关系调通,再逐步放大网络规模。那个把“乘法视角”切换成“能量视角”的思维惯性,才是这篇论文最值得带走的东西。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进