最近在折腾一个推理引擎的卷积算子,从最朴素的循环实现一路优化到寄存器级,中途还把剪枝稀疏化的路子走了一遍。回过头看,卷积运算的高效实现这件事,绝不是背几个优化口诀就能搞定的,它横跨算法、数据结构、硬件架构好几个层面,每一步取舍都是权衡。这篇内容就是把我自己实践下来的经验做一个系统梳理,从卷积的计算本质开始,到im2col、Winograd这些算法级优化,再到GPU/CPU上的访存与向量化落地,最后聊目前很受关注的稀疏化路线。希望能给正在做深度学习推理加速、算子优化或者底层系统开发的同行一些可直接参考的素材。
如果你刚接触这块,建议先把第1节的基础吃透,后面每一节都是在前面的复杂度分析上逐步叠加思路;如果你已经做过一些算子开发,可以直接跳到第4、5节看稀疏化的坑,那部分踩坑实录我觉得比任何论文里的加速比表格都有参考价值。
1. 先把卷积的计算本质掰清楚
1.1 卷积的两种理解方式:滑动窗口与矩阵乘法
卷积的数学定义我就不抄教科书了,直接说工程上最常用的两种理解。
第一种是滑动窗口视角,也是初学者最容易接受的方式:卷积核在输入特征图上按步长滑动,每个位置做一次逐元素相乘再求和。这种视角的好处是直观,坏处是容易让人误以为卷积就是“嵌套循环”,从而低估它的优化空间。实际上,循环只是最原始的描述方式,不是高效实现的方式。
第二种是矩阵乘法视角,把卷积操作映射成矩阵乘法。这个视角是im2col等一系列高效算法的出发点。什么意思呢?就是把输入特征图上每个滑动窗口对应的数据块取出来,拉平成一行,所有窗口的行堆叠成一个大矩阵,卷积核展开成另一个矩阵,两者做矩阵乘法,结果就是输出特征图。矩阵乘法是底层被优化到极致的运算(BLAS库、GPU的Tensor Core都针对它做了大量优化),所以把卷积转化成矩阵乘法,本质上是“蹭”矩阵乘法的优化红利。
两种视角没有谁对谁错,我个人的习惯是:分析计算量、推导复杂度时用滑动窗口视角,设计实现和优化时用矩阵乘法视角。搞清楚自己当前处于哪个阶段,就知道该用哪种视角去思考。
1.2 一次普通卷积到底有多少计算量
先说一个很多初学者容易忽略的点:FLOPs和FLOPS是两个完全不同的概念。前者是浮点运算次数(Floating Point Operations),后者是每秒浮点运算能力(Floating Point Operations Per Second)。神经网络里常说的FLOPs,其实就是一次前向推理需要做多少次乘加运算。
来算一笔具体的账。假设输入是224×224分辨率、64个通道的特征图,卷积核是3×3、输出通道也是64。那么一次卷积的计算量可以按如下方式估算:
输出特征图的空间尺寸是224×224(假设padding=1且stride=1保持尺寸不变),每个输出位置要做3×3×64 = 576次乘加运算。所以单个输出通道的FLOPs是224×224×576,约2890万。总共有64个输出通道,总FLOPs就是224×224×576×64,约18.5亿次乘加。
一次普通的3×3卷积就是18.5亿次运算,这还只是单层。实际模型动辄几十上百层,累计起来就是几百亿甚至上千亿次运算。这就是为什么卷积优化如此重要的根本原因:任何微小的单次运算效率提升,放大到整个网络规模之后,都会变成肉眼可见的性能差异。我在实际调优的时候,经常跟团队说“不要小看哪怕是5%的算力节省,放到推理侧可能就是每秒几十帧的差距”。
2. 算法层面的优化:怎么让卷积算得更快
2.1 im2col:把卷积“铺开”成矩阵乘法
im2col是“image to column”的缩写,核心操作就是把输入特征图按滑动窗口重新排列成矩阵。这个方案在深度学习框架里非常普遍,早期的Caffe基本就是靠它把卷积接入BLAS库的。
具体流程可以这样理解:假设原始输入是C×H×W的三维张量,im2col之后会变成(K×C×kh×kw)×(H_out×W_out)的二维矩阵,其中K是卷积核个数,kh和kw是卷积核尺寸,H_out和W_out是输出特征图的尺寸。每一列对应一个输出位置的感受野数据。卷积核则展开成K×(C×kh×kw)的矩阵。两个矩阵相乘,得到K×(H_out×W_out)的输出,再reshape成K×H_out×W_out就是输出特征图。
直接说这个方案的优缺点。优点是实现简单、矩阵乘法高度优化,性能稳定可预测;缺点是显存或内存占用会显著增加。还是用上面的例子,输入是64×224×224,im2col之后,矩阵的列数是输出位置的个数224×224=50176,行数是64×3×3=576,那么新矩阵的规模就是576×50176,约2880万个元素。相比原始输入的320万个元素,内存扩张了将近9倍。这还只是一层,所以im2col在硬件资源紧张的端侧场景要慎用,但在GPU和服务器端是可以接受的,因为GPU显存带宽高、容量大,BLAS库的矩阵乘法效率能抵消内存扩张带来的开销。
我在实践中的经验是:im2col适用于卷积核尺寸较大(比如5×5、7×7)或通道数很多的场景,因为这些情况下矩阵乘法的计算密度高,内存扩张的代价相对可控。对于1×1卷积,im2col退化成纯粹的矩阵乘法,不需要做任何重排,直接拿输入当矩阵乘就行。
2.2 Winograd:用加法换乘法的经典套路
如果说im2col是“空间换时间”,那Winograd就是“加法换乘法”。它的核心思想是:在卷积的计算过程中,存在大量可以复用的中间结果,通过数学变换减少乘法次数,因为乘法比加法更耗时。
这里用最简单的F(2,3)举例,也就是输出2个点、卷积核长度为3的一维卷积。原始计算需要2×3=6次乘法;Winograd变换之后,输入变换需要3次加法,输出变换需要4次加法,中间只有4次乘法。乘法次数从6降到了4,减少了1/3。
二维卷积可以把一维变换组合起来。对3×3卷积核和2×2输出块,也就是F(2×2, 3×3),乘法次数会从36次降到16次,理论加速比是2.25倍。这也是为什么3×3卷积在Winograd下收益特别明显的原因——3×3是深度学习模型中出现频率最高的卷积核尺寸。
但Winograd有几个工程上必须注意的坑。第一,数值精度会下降。因为变换矩阵里出现了1/2、1/4这样的系数,误差会被放大,对FP16或INT8精度的模型尤其敏感,严重时可能让精度掉得不可接受。第二,它适合小卷积核、小输出块,但不适合大卷积核。卷积核一大,变换矩阵本身就变得复杂,中间计算量反而上升。第三,实现复杂度高,尤其要和硬件访存模式配合时,没有经验的人写出来可能比朴素卷积还慢。我见过不止一个团队在Winograd上投入几周,最终性能没有超过优化过的im2col,就是因为Winograd的访存局部性特别难把控。
我的建议是:如果你用的是英伟达GPU,成熟的推理框架基本已经内置了Winograd实现,直接配置开启即可;如果要自己在端侧芯片上实现,建议先确认芯片的乘法器和加法器时延比例,如果这个比例不大,Winograd的理论收益本身就会打折。
2.3 FFT:一条“绕远路”的频域方案
还有一种思路是FFT,把卷积从空间域转换到频域。根据卷积定理,空间域的卷积等于频域的乘法,所以可以用FFT把输入和卷积核都变换到频域,做逐元素乘法,再逆变换回空间域。
FFT的优势在于复杂度低。空间域直接卷积复杂度是O(N²),其中N是特征图尺寸,而FFT的复杂度是O(NlogN)。当卷积核特别大、输入特别大时,比如医学影像处理中的大核卷积,FFT的优势就体现出来了。这也是传统图像处理里高斯模糊等操作常用FFT加速的原因。
但在深度学习场景中,FFT并不常用,原因是主流模型使用的卷积核几乎都是3×3,最大也不过7×7,核尺寸很小,FFT的logN开销优势根本体现不出来,反而要额外承担两次正变换和一次逆变换的开销。我在处理一个图像处理Demo时试过在小卷积核上用FFT,结果性能比朴素卷积还差,就果断放弃了。
FFT在深度学习里的归宿,我觉得更多是图像预处理和一些特殊的频域模型结构。做算子优化的人知道有这么个方案就行,别在常规卷积上浪费时间去调它。
3. 硬件层面的落地:从循环代码到处理器指令
3.1 GPU上卷积的核心矛盾:访存还是计算
GPU的浮点计算能力远超访存带宽,所以对GPU来说,多数算子的瓶颈不是计算,而是数据搬运。卷积的重心在于如何让数据在寄存器、共享内存、全局内存之间流动得更高效。
这个领域有个经典问题叫“内存墙”:计算单元空转等待数据的时钟周期,远大于真正运算的时钟周期。最直接的应对策略是提高数据的复用率。卷积天然具备两种复用:输入特征图的复用(同一个输入像素会被多个输出位置用到)和卷积核的复用(同一个卷积核权重会被整个特征图用到)。高水平的GPU卷积实现,核心就是在调度计算时最大化这两种复用。
实际工程中最常见的方法是分块(tiling)。把输出特征图分成若干个小块,让每个线程块处理一小块输出,同时把这一小块需要用到的输入数据和卷积核权重加载到共享内存,反复使用。这就是类似“手艺人把工具都放在手边,不用每次干活都起身去拿”的思路。分块尺寸的选择是个经典调参问题:分块太小,共享内存带宽利用不充分;分块太大,共享内存装不下,还得处理边界。
我在一个模拟项目X里做过GPU卷积的开发,发现一个反直觉的经验:大通道数场景下,仅依赖共享内存缓存卷积核权重,就能获得相当大的加速,因为权重复用率极高;反而缓存输入特征图的分块策略,对性能提升没那么显著,因为输入数据每次滑动窗口之间重合度不高,缓存命中率有限。
3.2 CPU上的卷积优化:向量化与缓存友好
CPU场景更复杂的点是:既有AVX这类SIMD向量指令,又有层级化的缓存结构,任何一环没用好,性能都会大打折扣。
首先是SIMD向量化。CPU一次可以对多个数据执行同一操作,比如AVX512能一次处理16个FP32数据。要让代码吃满SIMD,数据布局必须连续且对齐。这也是为什么很多高性能实现在计算时会把通道维放到最内层循环,因为同一空间位置上的多个通道,在内存中的地址是连续的,可以一次性load进向量寄存器。
其次是缓存友好。CPU的L1缓存一般只有几十KB,L2也就几百KB,一旦数据超过缓存容量,就会不断触发缓存未命中,性能断崖式下跌。卷积的实现要尽量保证数据的访问局部性——即短时间内反复访问的数据尽量在缓存里待着。实际做法就是循环重排序:把循环顺序从“遍历所有输出位置→遍历所有通道”改成“分块遍历输出位置→遍历通道”,让一小块输入数据在L1/L2缓存中反复命中。
我当时在调一个端侧CPU的卷积算子时,深刻体验过“一改循环顺序,性能翻倍”的现场。原始代码按输出位置顺序遍历,每次都需要重新从内存里拉取输入数据;改成按通道内小块遍历之后,L1缓存命中率从不到40%提升到接近90%,单算子耗时直接降了一倍多。缓存优化就是这么立竿见影。
3.3 数据布局:NCHW、NHWC与更细致的排布
数据布局看似是小事,其实对性能影响非常大。NCHW(通道在前)、NHWC(通道在后)是两种主流格式,各有利弊。
NCHW的优点是通道维连续,方便按通道处理数据,在PyTorch默认训练格式中常见;缺点是空间上同一位置的不同通道数据被分散在很远的内存地址,访问起来不连续。
NHWC把通道维放在最后,让同一位置的所有通道在内存里连续排布。这种布局对SIMD向量化非常友好,CPU可以直接load连续的一段内存做向量运算。这也是TensorFlow和很多移动端推理框架默认使用NHWC的原因。
除此之外,GPU上还常使用更复杂的排布,比如把通道分成多个组,组内仍是CHW顺序,组间交错布置,目的是让不同线程访问的数据尽量连续。实际开发中,如果你要调的卷积性能始终上不去,建议先检查数据布局是否匹配硬件特性。我踩过最大的坑就是用NCHW格式直接写SIMD代码,换来换去发现无论如何都向量化不起来,改成NHWC后代码量没增加,性能却直线上升。
3.4 算子融合:从算法到硬件的“最后一公里”
最后说一个工程性很强、效果极大的优化手段:算子融合。
通俗讲,就是把多个相邻操作合并成一个操作,省掉中间的显存读写。比如卷积之后通常接ReLU激活函数,经典做法是卷积写出一份中间结果到显存,ReLU再读出来算一次,再写回显存。合并之后,卷积算出一个值,直接流式地做一次ReLU判断,再写入最终位置,中间那份临时数据直接不存在了。
可别小看这一步。每个算子的读写都是全特征图数据量的,模型越深,中间张量越大,省下来的带宽就越可观。我做过统计,在某个图像分类模型上,仅做Conv+ReLU融合,单层推理耗时能降低10%到15%。如果再往上叠加Conv+BatchNorm+ReLU的融合,整体减少的中间读写次数非常可观。
融合的原理本质上是把“算完存起来、再读出来算”的模式,改成“在寄存器或缓存里算完直接往下传”的流水线模式。这也是典型的从算法流程倒推硬件实现的思维方式:算法上描述了数据转换的步骤,硬件实现上要让数据尽量留在离计算单元近的地方。做算子融合时,顺序不能乱,比如BatchNorm在推理时可以提前折叠进卷积的权重和偏置里,变成纯卷积+ReLU的组合,这样融合的操作更干净。
4. 稀疏化:一条看似颠覆、实则妥协的革命
4.1 稀疏化为什么有效:权重冗余与模型容量
稀疏化的思路很直接:既然模型参数那么多,其中必然有大量的接近零或者不重要的权重,把这些权重置零,模型变小、计算量降低,精度却未必明显下降。
这个想法的底气来自深度神经网络的过度参数化特征。一个训练好的模型,很多神经元的连接权重对最终输出贡献很小,可以安全地移除。稀疏化本质上是在模型容量和计算代价之间做权衡:保留容量,去掉冗余。
为什么说“看似颠覆、实则妥协”?因为稀疏化并没有改变卷积的计算本质,它只是承认了一个事实:并不是所有的乘法运算都同等重要。如果把卷积的所有乘法都一视同仁,就必须全部算完;如果接受了“很多权重是零”这一事实,就有机会跳过大量无效计算。
但稀疏化有一个根本矛盾:跳过零值的计算方法,和硬件本身“均匀计算”的设计哲学并不完全兼容。GPU和CPU本质上都是均匀的向量处理器,硬件不会天然知道哪些权重是零、哪些不是。稀疏化需要额外的元数据来描述“哪里稀疏、哪里不稀疏”,这部分开销如果设计不好,省下的计算量会被额外的访存和控制逻辑吃掉。这也是稀疏化从论文到工程落地之间最大的鸿沟。
4.2 剪枝完整流程:训练、剪枝、微调三步走
实际落地中,一个人最常用的稀疏化方法是权重剪枝,整个流程可以总结为三个阶段。
第一阶段是正常训练,得到一个收敛的稠密模型。这个阶段的目标是让模型精度足够高、权重分布足够稳定。很多人在这个阶段就犯错了:还没训练好就急着剪枝,结果后续精度怎么都补不回来。
第二阶段是剪枝。对每个权重按绝对值大小排序,把绝对值小于阈值或者排在末尾一定比例的权重直接置零。最简单的是全局阈值法——设定一个全局阈值,低于阈值的权重全部置零。常用的还有按层阈值法——每层独立设置阈值,保证每层的稀疏率一致。我在实践中更推荐按层阈值法,因为它能避免某些层的权重被全部剪光、另一些层却几乎没有稀疏的情况。
第三阶段是微调。剪枝后的模型直接在原数据集上继续训练几个epoch,让剩余权重适应被移除的路径。这一步很关键,除非你的稀疏率极低(比如低于20%),否则不微调的模型精度几乎必然掉点。微调时建议使用较小的学习率,大约是原始训练学习率的十分之一到五分之一。
整个流程中,最反直觉的经验是:一次性剪到目标稀疏率,比逐步剪更好。我试过“每次剪5%,剪到50%再微调”的渐进方案,结果精度和一次性剪到50%差不多,但花费的训练时间却翻了好几倍。所以除非你需要监控精度下降曲线来选阈值,否则直接一次剪到位再微调是更经济的选择。
4.3 结构化稀疏与非结构化稀疏:硬件友好度的分水岭
剪枝得到的稀疏模式,可分为非结构化和结构化两种,这两种的工程价值有天壤之别。
非结构化稀疏就是权重矩阵中随意散布着零值,每个位置独立决定是否置零。这种模式能获得很高的稀疏率(比如90%以上),但硬件很难加速。因为计算时无法用连续的向量指令批量跳过随机位置的零值,你需要逐个检查权重是否为非零,才能决定是否做乘法。我在CPU上做过测试,非结构化稀疏率80%时,提速比例远低于理论值,有时候甚至比稠密计算更慢,因为稀疏判断和索引开销反而更大。
结构化稀疏是约束稀疏位置的分布模式,让零值出现在规则的位置上。常见的有行稀疏、列稀疏、块稀疏。比如块稀疏,把权重矩阵划分成固定大小的块,比如2×4,每个块要么整块保留,要么整块置零。这种模式可以用一条位图元数据描述整个块的稀疏状态,硬件一旦识别块是零,可以直接跳过整块计算,不需要逐个元素判断。
从实际的工程选择角度来看:如果是服务器端GPU推理,可以接受非结构化稀疏配合专门的稀疏计算库,因为GPU并行度高,可以在一定程度上掩盖稀疏判断的开销;如果是移动端或嵌入式芯片,建议只考虑结构化的稀疏方案,否则无法利用现有的SIMD和缓存优化。
4.4 稀疏格式的底层原理:CSR与CSC
要真正落地稀疏化,绕不开稀疏矩阵存储格式,最基础的是CSR(Compressed Sparse Row)和CSC(Compressed Sparse Column)。
CSR的核心思路是只存储非零元素,再加一份行偏移数组和列索引数组。比如一个4×4的矩阵,如果只有6个非零元素,稠密存储需要16个元素,CSR只需要6个数值+6个列索引+4个行偏移(通常行偏移长度是行数+1)。稀疏率越高,节省的存储空间越大。
CSC是CSR的转置版,存储时优先按列压缩,适合按列访问的场景。普通卷积权重在内存中按输出通道或输入通道排列,选择CSR还是CSC取决于后续计算时按行还是按列访问数据。
稀疏矩阵的乘法逻辑和稠密乘法完全不同。稠密矩阵乘法是固定的三重循环,稀疏矩阵乘法需要先解析行偏移,找到某一行中所有非零元素,再逐个做乘加。这个过程是串行的,而且数据访问带有随机性,很难向量化。这也是为什么很多稀疏方案最终要配合硬件专门的稀疏单元,比如某些AI芯片上设计了稀疏感知的计算单元,能在硬件层面直接跳过零块,才真正让稀疏化发挥出性能潜力。如果你用的平台没有这类硬件支持,建议对稀疏方案保持谨慎,先做原型验证再决定是否全面铺开。
5. 稀疏化落地路上的坑与解决办法
5.1 坑一:访存不规则带来的“稀疏惩罚”
前面说过,非结构化稀疏在硬件上很难提速,最核心的问题就是访存不规则。稠密计算时,数据是连续排列的,预取器可以自动把后续数据提前搬进缓存;稀疏数据则是跳着存的,无法预取,每次访问都可能是缓存未命中。
我在一个模拟项目X中实测过:一组50%稀疏的矩阵乘法,从计算量上看应该比稠密快一倍,但实际运行时间只快了两成。剖析之后发现,时间几乎都花在索引运算和缓存未命中上,ALU反而在空等。
遇到这种问题,解决方案有两种。一种是改成块稀疏,让稀疏模式规则化,恢复一定的访存连续性;另一种是重新设计数据布局,让稀疏矩阵按“非零元素块”存储,而不是按行散存。后者实现复杂,但保留了非结构化稀疏的高压缩率优势。
5.2 坑二:负载不均衡
GPU和CPU的并行计算都依赖工作负载均匀分配。稠密卷积的每个输出位置计算量基本相同,可以把任务切成大小一致的块分给不同线程核。稀疏卷积则不然,有些输出位置对应的权重块全是零,一瞬间就完成;有些位置几乎全是非零,需要大量计算。如果这些位置被分配到不同线程,就会出现有的线程早早干完闲着,有的线程忙得没完没了。
我踩过这个坑:在某AI芯片上跑50%稀疏的卷积,加速比只有理论值的三成。查下来发现是两个线程组负载严重不均衡,其中一个线程组几乎承担了80%的计算量。
解决思路通常是重排任务分配,把稀疏率相近的输出位置聚在一起,或者在任务分配时让每个线程组拿到的稀疏率大体均衡。这里的经验是先统计稀疏率的分布,再决定如何切块,不要直接沿用稠密版本的任务切分方式。
5.3 坑三:精度与稀疏率的平衡
剪枝一定掉精度,只是程度不同。需要反复试不同的稀疏率才能找到一个既能压缩又不至于让精度崩掉的平衡点。
实操时我有一个习惯:先做小规模实验。在几百张图上跑一次完整剪枝+微调实验,画出“稀疏率-精度”曲线。一般规律是稀疏率低于60%时精度下降在1%以内;超过70%开始明显掉点;超过85%,精度可能直接跌破可用线。当然不同模型差异很大,同一个模型不同层也是不同的。
剪枝时按层选择稀疏率也很重要。浅层负责提取低阶特征,过于稀疏会影响后续所有层;深层特征相对抽象,可以更激进地剪。反过来按全局阈值剪,往往会发现第一层被剪得稀烂,后续层几乎没动,模型精度直线下降。
5.4 坑四:工具链不配合
就算算法和稀疏模式设计得再好,如果推理框架和底层库不支持稀疏计算,一切白搭。
很多训练框架导出模型时会保留mask但不会真正移除权重,导出的模型文件大小没变小,推理时也没有任何加速,等于剪了个寂寞。正确的导出流程应该是剪枝后先把权重矩阵转成稀疏格式,再重新序列化模型,才能确保推理引擎按稀疏模式加载。
另一个容易踩的坑是量化与稀疏的叠加顺序。如果你既想剪枝又想量化,建议先量化再剪枝。因为量化过程会引入噪声,如果先将权重剪没再量化,剩余权重的数值分布会被压缩得很窄,量化误差会更大。我做过一个图像分类模型的实验,先剪枝后量化精度掉了3%以上,反过来先量化后剪枝,精度只掉了不到1.5%。
6. 不同场景下的方案选型与调优参考
6.1 几张能直接用来做技术决策的参考表
我把自己平时做卷积优化时的选型经验整理成了表格,方便直接对照参考。
| 场景 | 推荐优化方向 | 注意事项 |
|---|---|---|
| GPU服务器端推理 | im2col + Tensor Core矩阵乘法 | 内存充足,适合大batch |
| GPU服务器端推理(小batch) | Winograd | 注意精度损失,建议FP32下使用 |
| 端侧CPU推理 | NHWC布局 + SIMD向量化 + 算子融合 | 重点投入缓存友好优化 |
| 端侧NPU/DSP | 结构化稀疏 + 量化 | 需硬件支持稀疏计算,否则别上稀疏 |
| 大卷积核或图像预处理 | FFT | 核尺寸小于7时收益为负 |
| 存储受限场景 | 非结构化稀疏 + CSR压缩 | 推理时追求压缩率,不追求计算加速 |
这个表格是一个基础框架,实际项目一定需要结合硬件特性调整。做技术选型的时候,我建议用“一个算子两个API”的思路来验证:先实现一个朴素但绝对正确的版本作为baseline,再实现候选优化版本,两个版本都跑性能和精度对比,数据说话,而不是拍脑袋猜。
6.2 性能剖析的通用方法
优化的前提是定位瓶颈。我自己固定用以下三个步骤定位卷积算子的性能瓶颈:
第一步,确认是访存瓶颈还是计算瓶颈。可以通过计算当前算子的实际FLOPs和有效带宽占用,对比硬件峰值来快速判断。如果有效带宽已经接近峰值,访存是瓶颈;如果FLOPs接近峰值,计算是瓶颈。这两种情况的优化方向完全不同。
第二步,看缓存命中率和指令流水线停顿。缓存命中率低,说明数据布局或分块策略有问题;流水线停顿多,说明代码依赖关系太重,并行度不够。
第三步,做“拆解计时”。把卷积拆成数据重排、矩阵乘、输出写入几个子阶段,分别计时。这个方法特别有用,可以精确定位时间到底花在哪一步——很多时候你会发现,矩阵乘法本身没多少耗时,反而是im2col的数据重排过程成了主要开销。
6.3 我的几条切身体会
做卷积优化这几年,我的体会有几条特别想说。
一是“正确的朴素实现”比“没有实现”更值钱。团队里有新人一上来就想用顶尖优化,结果写了两周代码连正确性都没验证,这种方向错了。先实现最笨、最正确的版本,把它当成功能基线,再逐步迭代优化,每一步都有对比对象,不容易迷失。
二是理论加速比永远要打折扣。im2col理论上比朴素卷积快,但内存扩张可能让带宽成为新瓶颈;Winograd理论减少乘法次数,但加法开销和数值误差都要算进去;稀疏化理论上省了算力,但访存不规则和控制开销会抵消一部分收益。我见过太多人拿着理论数字向老板汇报,落地时发现根本对不上。做优化的人一定要有“打折预期”的心态。
三是工具链和硬件能力决定了优化上限。再好的算法思路,也要匹配硬件特性和框架支持。建议动手之前先花时间搞清目标芯片的计算峰值、带宽、缓存大小、SIMD宽度,搞清楚推理框架是否支持你想要的融合或稀疏格式。这个准备越充分,后面的弯路越少。
最后分享一个小技巧:做算子优化时,记得把“精度差异”也纳入回归测试范围。很多优化会在无意中改变数值计算顺序,导致输出结果出现微小偏差。这些偏差在单层测试中看不出来,但多层累积就可能让最终精度不可用。所以除了性能跑分,一定还要写一个精度对比的自动化用例——输入相同数据,优化前后的输出差异要在可接受范围内。这个习惯帮我避免过好几次“性能很好看但根本不能用”的事故。