1. 先把问题定义清楚:音箱上跑的卷积到底卡在哪
做语音前端的同行应该都有同感:模型结构早就定稿了,论文上的指标也复现出来了,真正让人睡不着觉的是把它塞进一台几百块钱的音箱之后——唤醒响应从 300ms 变成 900ms,还时不时漏唤醒。我这次接手的就是这么个活:小智音箱的本地语音前端,需要把梅尔频谱送进一个轻量卷积网络做特征提取,为后面的命令词分类提供输入。加速卷积层特征提取,就是这轮优化的全部重心。
先把边界说清楚。小智音箱的主控是一颗百兆赫兹级的 32 位 MCU,片上 SRAM 几百 KB 量级,没有 NPU,也没有能跑浮点向量运算的加速器,能用的只有一颗带 SIMD 定点指令的核和一块不算大的紧耦合内存。这种配置下,卷积层是整个前端里唯一的计算大头——全连接层可以靠参数量砍,循环层可以换结构,卷积层却是躲不掉的,因为它的计算量正比于输入分辨率乘通道数。
这篇内容适合三类人看:第一类是在做端侧语音、视觉前端,被“模型能跑但跑不快”卡住的工程师;第二类是想搞清楚卷积在资源受限设备上到底慢在哪里的学生和转岗同学,我会把算账的过程一步步写出来;第三类是做产品定义的朋友,看完能判断一个 10ms 的延迟指标到底合不合理。所有代码都是我这轮实际在用的简化版,参数按小智音箱的真实规格给,能直接对着改。
我先把结论摆出来:这轮优化把单帧特征提取的耗时从 42ms 压到 9ms 以内,峰值 SRAM 从 380KB 降到 176KB,端到端唤醒准确率掉了 0.4 个百分点,在可接受范围内。下面把这个过程拆开讲,包括我一开始走错的路。
2. 算一笔细账:算力、带宽、缓存哪个先崩
2.1 手算 MACs 与访存量,别凭感觉优化
优化最忌讳的就是“感觉这里慢就改这里”。我第一步做的事是把这个卷积前端的计算量和访存量全部算出来,用一张表摊在桌面上。小智音箱的前端输入是 40 帧 × 32 维的梅尔谱,按单通道处理,网络结构大致是:一个 3×3 步长 2 的普通卷积升到 16 通道,接三组“逐通道卷积 + 1×1 点卷积”,最后接一个全局池化和一个很小的分类头。
算 MACs 的公式很朴素:输出宽 × 输出高 × 输出通道 × 输入通道 × 卷积核宽 × 卷积核高。以第一层为例,输入 40×32×1,卷积核 3×3、步长 2、padding 1,输出是 20×16×16,那么 MACs = 20 × 16 × 16 × 1 × 3 × 3 = 46080。看起来不多。但三组深度可分离卷积里,逐通道卷积和点卷积交替出现,点卷积的 MACs = 输出像素数 × 输入通道 × 输出通道,当通道数从 16 涨到 64 时,单个点卷积就有 20×16×64×64 = 1.3M 次乘加。整网加起来约 4.1M MACs。
4.1M MACs 在 200MHz 的核上,理论下限是 20.5ms(假设一个周期一次乘加)。这里就出现了第一个关键判断:即使我的代码写得完美,单发射、无 SIMD 的情况下也做不到 10ms。所以必须用 SIMD 向量指令,把一次乘加扩成 4 路或 8 路,理论下限才能压到 3~5ms。
访存量这笔账更吓人。朴素实现里,每个输出像素都要把对应的输入块和全部权重读一遍。点卷积那层,输出 20×16×64 个像素,每个像素读 64 个输入值和 64×64 个权重——虽然权重会被缓存,但输入张量会被反复读 64 次。粗算下来访存字节数是计算量的 4 倍以上。这就引出了下一个问题。
2.2 内存墙:为什么算力只跑出了三成
我在基线上用周期计数器打点,实测 42ms,对应大约 4.1M MACs,平均每个 MAC 花了 2 个多周期。这个数字非常典型——不是算力不够,是数据没喂上。原因有三层:
第一层是缓存装不下。逐通道卷积的输出张量 20×16×32 个 int8 是 10KB,加上输入和权重,看着不大,但小智音箱的紧耦合内存只有 64KB,一旦张量跨出这块区域,每次访问都要走系统总线,延迟差一个数量级。我最开始把所有张量都放在一块普通 SRAM 里,实测比放进紧耦合内存慢了 40%。
第二层是访问模式不连续。输入是 NHWC 布局(这点后面会讲),逐通道卷积沿通道方向做累加时步长是 1,很友好;但如果代码里按 NCHW 去写,每读一个通道就要跨一整张特征图,缓存行全部浪费。
第三层是中间结果落盘太频繁。每个卷积层算完都把结果写回内存,下一层再读出来,一层一层地搬。整网十几个层,等于把中间张量在内存里搬了十几遍。这一块后来靠算子融合干掉了大半。
提示:判断一个卷积是算力瓶颈还是带宽瓶颈,最土也最有效的办法是算“算术强度”,也就是每读一字节数据能做多少次乘加。低于 10 的基本都是带宽受限,优化方向应该是提高数据复用率、减小中间张量,而不是去抠指令。
2.3 定点量化:收益最大也最容易翻车的一步
浮点直接砍成 int8 是这轮优化里收益最直接的动作,模型体积缩到四分之一,SIMD 一次能处理的元素数翻四倍,乘加从浮点单元换到定点单元,单周期吞吐也上去了。但这一步的坑最多。
小智音箱上我用的是对称量化,权重和激活都是 int8,零点为 0,累加用 int32。量化参数的计算方式是:先统计该张量的最大绝对值,scale = max_abs / 127,反量化就是real = int8_val * scale。卷积算完之后需要把 int32 的累加结果重新量化回 int8,这里要用定点乘法取高位的方式,避免浮点除法:
// requantize: acc(int32) -> out(int8) // M 是预先算好的定点乘数(Q31 格式),shift 是右移位数 static inline int8_t requantize(int32_t acc, int32_t M, int shift) { int64_t t = (int64_t)acc * M; // 64 位中间结果,防止溢出 t = t + ((int64_t)1 << (shift - 1)); // 加半个 LSB 做四舍五入 t >>= shift; if (t > 127) t = 127; // 饱和,别省这一步 if (t < -128) t = -128; return (int8_t)t; }关键点在M和shift的推导:给定输入 scales_in、权重 scales_w、输出 scales_out,真实乘数是real_mult = s_in * s_w / s_out。把这个小于 1 的小数表示成M / 2^shift,其中M落在[0.5, 1)区间再乘2^31,这样能保证精度又不溢出。我一开始偷懒用了 per-tensor 的量化参数,也就是整个张量共用一个 scale,结果第一层就掉了一个多百分点——因为梅尔谱的能量分布很不均匀,低频段能量高、高频段能量低,共用一个 scale 会把高频段全部压成 0。
改成per-channel 量化(每个输出通道一个权重 scale)之后,掉点收窄到 0.4 个百分点。代价是每个通道多读一个 scale 参数,在通道数不大(最大 64)的情况下完全可接受。
还有两个容易忽略的细节:一是padding补的值必须是输入张量的零点,对称量化下就是 0,非对称量化下是zero_point对应的整数,补错了会在边界产生系统性偏差;二是权重的 rounding 必须和训练后量化工具保持一致,我用的是 round-half-to-even,和参考实现的 round-half-away-from-zero 不一致,逐层对比时差了 1 个 LSB,虽然端到端看不出来,但排查阶段会误导你。
3. 算子实现:从朴素滑窗到分块矩阵乘
3.1 数据布局换成 NHWC,是收益最高的一次重构
先说结论:在小智音箱这种沿通道做向量化的架构上,NHWC 全面优于 NCHW。理由不复杂——SIMD 指令一次处理连续的若干个 int8,而卷积里最内层的累加恰好是沿通道方向进行的,NHWC 让通道维度在内存里连续,一条向量指令就能读 8 个或 16 个通道的值;NCHW 下相邻通道相隔一整张特征图,你得先做一次转置,白白多一遍访存。
另外 NHWC 和输入数据的天然形态一致:梅尔谱进来是“帧 × 频点”,插入通道维度后就是 NHWC。整个前端从特征提取到卷积再到池化,全程不需要任何 layout 转换,省掉了两次全张量搬运。NCHW 唯一的优势是某些权重的打包方式更规整,但在通道数不超过 64 的场景下,这点优势可以忽略。
唯一需要注意的是权重也要按 NHWC 对应的格式预打包。我的做法是离线把权重整理成[out_ch][kh][kw][in_ch],并对in_ch做 8 字节对齐补零,这样内层循环可以无条件地按向量宽度读,不用在循环体里判断边界。补零之后多算的那部分乘积是 0,不影响结果。
3.2 im2col + 分块 GEMM:把卷积变成矩阵乘
卷积的直接实现里,内层循环既有通道累加又有空间滑窗,分支多、边界判断多,向量化很难写好。我把它转成矩阵乘:把输入按卷积核的感受野展开成一个矩阵A(形状[输出像素数, kh*kw*in_ch]),权重整理成B(形状[kh*kw*in_ch, out_ch]),卷积就等价于C = A × B。这样做的好处是所有优化手段都能复用矩阵乘的成熟套路,坏处是A需要额外展开,内存占用会膨胀kh*kw倍。
在小智音箱上,3×3 卷积展开后是 9 倍膨胀,10KB 的输入变成 90KB,直接爆掉紧耦合内存。所以我没有做完整的 im2col,而是做了分块展开 + 分块计算:一次只展开输出的一小块(比如 4 行),算完立刻释放,配合双缓冲,实际峰值只比原张量多出 8KB。
内层矩阵乘的核心结构是这样的:
// 计算 C[M x N] += A[M x K] * B[K x N],均为 int8,累加用 int32 // 分块尺寸按寄存器数量定:MR=4, NR=8,内层沿 K 方向每次走 8 void gemm_int8(const int8_t *A, const int8_t *B, int32_t *C, int M, int N, int K, int ldc) { for (int i = 0; i < M; i += MR) { for (int j = 0; j < N; j += NR) { int32_t acc[MR][NR] = {{0}}; for (int k = 0; k < K; k += 8) { // 读 A 的一列 8 个元素 和 B 的 8x8 小块 // 展开成 MR*NR 次向量点积,全部在寄存器里完成 for (int ii = 0; ii < MR; ii++) { for (int jj = 0; jj < NR; jj++) { acc[ii][jj] += dot8(&A[(i+ii)*K + k], &B[k*N + j*N + jj]); // 8 元素点积 } } } // 累加结果写回(int32),后续统一 requantize for (int ii = 0; ii < MR; ii++) for (int jj = 0; jj < NR; jj++) C[(i+ii)*ldc + j+jj] += acc[ii][jj]; } } }MR=4, NR=8是我实测出来的值:再大寄存器不够用会溢出到栈上,性能反而掉;再小则循环开销占比上升。K方向每次走 8 是因为 SIMD 宽度是 128 位,正好 8 个 int8。dot8用乘法累加指令实现,一次算 8 个乘积并横向求和。
这里有个非常容易踩的坑:横向求和的开销。int8 点积的结果是 8 个 int16,需要做横向累加才能变成一个 int32。如果每个(ii,jj)都单独做一次横向求和,那这个开销就吃掉了一半的性能增益。我的做法是把NR方向的 8 个结果先凑在一起,一次性做横向归约,实测能省 25% 的时间。
3.3 深度可分离卷积要单独开小灶
深度可分离卷积里的逐通道卷积,每个通道独立卷积,输入输出通道一一对应,转成矩阵乘反而多一层拆包开销。对这种层我写了专门的实现:沿通道维度做向量化,同时处理 8 个通道,空间上正常滑窗。
// 逐通道 3x3 卷积,NHWC,一次处理 8 个通道 void dwconv3x3_s1(const int8_t *in, const int8_t *w, const int32_t *bias, int8_t *out, int H, int W, int C, int32_t M, int shift, int32_t out_zero) { for (int h = 1; h < H - 1; h++) { for (int w = 1; w < W - 1; w++) { int32_t acc[8] = {0}; for (int ky = -1; ky <= 1; ky++) { for (int kx = -1; kx <= 1; kx++) { const int8_t *p = in + ((h+ky)*W + (w+kx))*C; const int8_t *q = w + ((ky+1)*3 + (kx+1))*C; // acc[c] += p[c] * q[c],8 路并行 mac8(acc, p, q); } } int8_t *o = out + (h*W + w)*C; for (int c = 0; c < C; c += 8) requant8(o + c, acc, bias, M, shift, out_zero); } } }这个写法在通道数 32 和 64 上都很顺,因为C是 8 的倍数,循环里不需要处理尾部。如果通道数是 16 或 24 也还好,最怕的是 20、36 这种非 8 倍数的,尾部处理会拖慢 30%。我在训练侧直接约束了所有层的通道数为 8 的倍数,这个约束对精度几乎没影响,但对端侧性能影响很大。
注意:逐通道卷积的空间边界处理不要用
if判断,而是在输入张量外围预留一圈 padding 行/列,把边界值填成零点。分支预测失败的代价在这类小循环里非常明显,我实测去掉if之后这一层快了 18%。
3.4 Winograd 的适用边界:不是所有 3×3 都值得换
理论上 Winograd F(2×2, 3×3) 能把 3×3 卷积的乘法次数从 36 次(4 个输出 × 9 次乘加)降到 16 次,减少 2.25 倍。我在第一个普通卷积层上试了,确实拿到了 1.6 倍的加速。但换到后面的层就翻车了,原因有三个:
一是加法变多了。Winograd 的变换矩阵里全是 1、-1、1/2 这类系数,输入变换和输出变换都是加减法。当通道数少的时候,加减法的开销占比会超过乘法节省的部分。第一层输入通道只有 1,做完变换后是 16 个通道,勉强划算;但如果某层输入输出通道都是 8,就完全不值。
二是int8 下的动态范围问题。输入变换后,元素的取值范围会放大约 4 倍,中间结果必须用 int16 存,寄存器压力翻倍,向量化宽度减半。我一开始硬用 int8 存中间结果,出现了大量溢出,精度直接崩了。
三是代码复杂度。Winograd 的边界处理比直接卷积麻烦得多,输入块要按 4×4 切分,块与块之间有重叠,padding 的处理也更绕。我当时写了三百多行才跑通,调试花了两天。
最后的取舍是:只对第一层 3×3 步长 1 的普通卷积用 Winograd,其余全部用直接卷积 + SIMD。这个决定的依据是实测数据,不是理论推导——如果你手上的层结构不一样,建议先花半天写个微基准测试,别照搬。
4. 实操:把单帧特征提取从 42ms 压到 9ms
4.1 基线测量:先把尺子校准
优化之前一定要有可信的基线。我的做法是在每层卷积的前后各插一个周期计数器读取点,把结果存进一个静态数组,等整帧算完再统一串口输出——绝对不能在热路径里printf,串口输出一个数字的开销比你想象的大得多,我第一版打了 20 个点,直接把 42ms 测成了 90ms。
计时用的硬件周期计数器精度足够,但要记得在读取前后加内存屏障,否则编译器可能重排指令,导致你测到的时间对不上真实执行顺序。
基线数据我整理成了下面这张表,后面每一步优化的收益都对照它来评估:
| 层 | 类型 | MACs | 基线耗时 | 占比 |
|---|---|---|---|---|
| conv1 | 3×3 s2, 1→16 | 46K | 4.1ms | 9.8% |
| dw1 | 3×3 逐通道, 16 | 92K | 6.3ms | 15.0% |
| pw1 | 1×1, 16→32 | 327K | 5.2ms | 12.4% |
| dw2 | 3×3 逐通道, 32 | 184K | 7.8ms | 18.6% |
| pw2 | 1×1, 32→64 | 655K | 6.9ms | 16.4% |
| dw3 | 3×3 逐通道, 64 | 368K | 8.4ms | 20.0% |
| pw3 | 1×1, 64→64 | 655K | 3.1ms | 7.4% |
| 后端 | 池化+分类 | — | 0.2ms | 0.4% |
| 合计 | 4.1M | 42.0ms | 100% |
这张表最有价值的信息是:MACs 最小的dw2耗时占比排第二,MACs 最大的pw2反而只排第三。说明逐通道卷积的实现效率明显低于点卷积。这就是我后续优化的重心——先把dw系列的实现重写,再处理pw系列的分块参数。
4.2 算子融合:干掉中间张量和一次搬运
卷积后面跟着批归一化和激活函数,这三者在推理阶段可以完整折叠成一个卷积。原因是批归一化的本质是y = (x - mean) / std * gamma + beta,这是一个逐通道的仿射变换,可以把它折进卷积核和偏置里:
w'[oc][k] = w[oc][oc] * gamma[oc] / sqrt(var[oc] + eps) b'[oc] = (bias[oc] - mean[oc]) * gamma[oc] / sqrt(var[oc] + eps) + beta[oc]折叠在离线阶段完成,端上只跑一次卷积。这一步带来的收益不是计算量(批归一化的计算量本来就小),而是消除了一次中间张量的读写。原本每层要写一遍conv_out、读一遍conv_out、写一遍bn_out、读一遍bn_out,融合后只剩一次写。
更激进的一步是把相邻的逐通道卷积和点卷积也融合掉。这个不能完全消除中间张量,因为点卷积需要对所有通道做全连接,必须等逐通道卷积全部完成。但我做了一件事:把逐通道卷积的结果直接写在紧耦合内存的一块固定复用区,点卷积从这块区域读,读完立刻被下一层覆盖。这样中间张量永远不离开紧耦合内存,访存延迟稳定在一个数量级。
这轮融合完成后,单帧耗时从 42ms 降到 28ms,SRAM 峰值从 380KB 降到 240KB。融合本身没改一行卷积代码,纯靠消除搬运拿到了三成收益,性价比极高。
4.3 双缓冲与流水线:让搬运和计算重叠
小智音箱的输入数据存放在外部存储里,每帧梅尔谱要搬进片上内存。原本的逻辑是“搬完再算”,中间有一段纯等待时间。我改成了双缓冲的乒乓结构:
// 简化的双缓冲伪代码 static int8_t buf[2][IN_BUF_SIZE]; static volatile int dma_done[2] = {0, 0}; // DMA 完成中断里置位 void dma_irq_handler(int ch) { dma_done[ch] = 1; } int cur = 0; start_dma(buf[0], src, 0, ch0); for (int f = 1; f < FRAMES; f++) { int nxt = 1 - cur; start_dma(buf[nxt], src, f, nxt); // 先把下一帧的搬运挂上去 while (!dma_done[cur]) { /* 可以插点别的活 */ } process_frame(buf[cur]); // 计算当前帧 dma_done[cur] = 0; cur = nxt; }关键是先挂 DMA 再算,不是算完再挂。这样搬运和计算在时间上是重叠的,理论上可以把延迟压到两者中较长的那个。
这里踩了一个不大不小的坑:如果芯片带数据缓存(D-cache),DMA 写入的缓冲区在 CPU 看来可能是旧数据。必须做 cache invalidate,或者干脆把这块缓冲区配置成不可缓存。我一开始忽略了这点,跑出来的结果是间歇性的——十次里有两三次结果完全错乱,查了很久才定位到缓存一致性。同理,DMA 从 CPU 刚写过的缓冲区往外搬之前要做 cache clean。
还踩了一个坑:双缓冲的两块区域如果是相邻地址,DMA 搬运时可能落在同一个缓存组里,产生冲突。我把两块缓冲区按缓存行大小做了地址错开,抖动明显减少。
这轮优化把搬运开销从约 6ms 摊平到几乎为零,单帧耗时降到 15ms。
4.4 分块参数调优与最终收益账
最后一步是抠分块参数。矩阵乘的分块尺寸、逐通道卷积的通道并行度、循环展开的层数,这些参数没有普适最优值,只能实测。我写了个小脚本,把参数组合编成不同的宏定义,编译出多个固件版本,在板子上各跑 100 帧取中位数。这个过程看着笨,但比拍脑袋强得多。
最终这一轮把耗时压到了 9ms 附近。完整的收益记录如下:
| 阶段 | 单帧耗时 | 峰值 SRAM | 相对基线 |
|---|---|---|---|
| 基线(朴素 NHWC 实现) | 42.0ms | 380KB | 1.00× |
| int8 量化 + per-channel | 34.5ms | 112KB | 1.22× |
| 算子融合(conv+bn+act 折叠) | 28.0ms | 240KB | 1.50× |
| NHWC 重构 + im2col 分块 GEMM | 19.5ms | 198KB | 2.15× |
| 逐通道卷积专项重写 | 16.2ms | 190KB | 2.59× |
| 双缓冲 + 流水线重叠 | 15.0ms | 176KB | 2.80× |
| 首层 Winograd + 分块调优 | 9.1ms | 176KB | 4.62× |
注意中间有一行 SRAM 是上升的(从 112KB 到 240KB),那是算子融合阶段为了减少搬运引入了复用缓冲区导致的。做优化时不要只盯一个指标,延迟和内存要一起看,否则很容易出现“延迟降了但内存爆了”的返工。
回归测试我做了三层:第一层是逐层数值比对,用同一份输入在 PC 上跑浮点参考实现,把每层输出 dump 出来,和端上结果算相对误差;第二层是整网余弦相似度,阈值定在 0.995;第三层是端到端的唤醒率和误唤醒率,跑真实录音数据集。第一层能定位问题,第二层能卡住质量,第三层才是产品能接受的判据。三层都过才算完成。
5. 问题排查实录与避坑清单
5.1 精度掉点怎么定位:分层二分法
量化之后精度掉点是最常见的问题。我的排查路径固定成三步,一般半天能定位。
第一步,先确认浮点参考实现和训练侧一致。听起来废话,但我确实遇到过因为张量布局转换时把行列搞反了,导致浮点就掉点的情况。做法是拿同一份输入,PC 上的参考实现输出和端上浮点版输出做逐元素比对,误差应该在 1e-5 以内。
第二步,逐层 dump,找第一个误差超阈值的层。阈值我用的是相对误差 1%,看的是该层输出的最大绝对误差除以该层的输出范围。找到出问题的层之后,再把这层的输入单独喂进去,和参考实现对比,就能区分是“这层算错了”还是“上一层传下来的东西就不对”。我用这个方法定位到一个点卷积层的输出在特定通道上系统性偏大,最后发现是那个通道的权重 scale 算错了——量化工具输出的 scale 有个打印格式问题,被截断了。
第三步,端到端指标验证。逐层都正常但端到端掉点,通常是误差累积导致的,可以考虑把关键层改成 int16 累加或者提高输出位宽。我在第一层和最后一层做了保留 int16 累加的处理,端到端掉了 0.4 个百分点,比全 int8 好了不少。
提示:量化调试阶段一定要保留一份可切换的浮点路径,通过编译开关控制。每次改完量化逻辑,先用浮点路径确认功能没坏,再切到定点看精度,能省掉大量“到底是逻辑错还是精度错”的纠结。
5.2 延迟抖动:平均值好看不等于体感好
9ms 是平均值,但用户感知的是最差的那一帧。我一开始只看了平均值,上线后有人说“偶尔反应慢半拍”,一查 P99 到了 22ms。抖动的成因我整理了几条,按排查优先级排序:
中断抢占。音频采集、蓝牙、电源管理的中断优先级如果高于计算任务,会在计算中途插进来。我在卷积计算的整段过程里做了临时关中断(把抢占阈值调高),把中断处理推迟到帧边界。这个改动对实时性的影响要评估,不能无脑关。
Flash 取指。代码段如果太大,超出了指令缓存,每次循环都要从 Flash 取指令。我的做法是把卷积内层循环用到的函数放到紧耦合内存里,通过链接脚本的段属性控制。这个改动带来的收益比我预期的还大,光这一项就减少了 3ms。
动态内存分配。任何在热路径里的malloc都是定时炸弹,碎片化之后的分配耗时不可预测。我把所有张量的内存都在启动阶段一次性分配好,用一块静态数组手工切分。
栈溢出。矩阵乘的分块数组如果开太大,会溢出到栈上,触发硬件异常或者静默踩踏数据。我一开始acc[8][8]的 int32 就是 256 字节,加上其他局部变量突破了任务栈的限额,症状是跑了十几秒后随机死机,非常难查。
5.3 常见问题速查表
| 现象 | 首先怀疑 | 排查动作 | 典型解法 |
|---|---|---|---|
| 结果间歇性错乱 | 缓存一致性 | 比对 DMA 前后数据 | 加 clean/invalidate 或改为不可缓存 |
| 精度掉超过 2% | per-tensor 量化 | 逐层比对找首个异常层 | 改 per-channel,关键层保 int16 |
| P99 远高于 P50 | 中断抢占或取指 | 分段计时看长尾出现在哪层 | 提升计算任务优先级,热代码搬进 TCM |
| 边界像素输出异常 | padding 值填错 | 只看第一行/最后一列输出 | padding 填输入零点而非 0 |
| 通道数非 8 倍数时明显变慢 | 尾部处理分支 | 微基准对比不同通道数 | 训练侧约束通道数为 8 的倍数 |
| 跑一段时间后死机 | 栈溢出或内存踩踏 | 检查分块数组大小与栈余量 | 缩小分块或加大任务栈 |
| 加速比远低于理论值 | 横向求和开销过高 | 统计点积后归约的指令占比 | 批量归约,减少横向求和次数 |
| 权重加载耗时异常 | 权重未对齐 | 检查权重地址对齐情况 | 按 32 字节对齐并预打包补零 |
5.4 几条用返工换来的经验
先量后调,别猜。我在这轮优化里最大的时间浪费,是一开始凭直觉去优化点卷积,写了两天分块 GEMM,结果发现瓶颈在逐通道卷积。后来老老实实按表排序,按耗时占比从高到低打,效率完全不一样。
微基准测试要早写。给每个算子写一个 50 行左右的独立测试程序,固定输入、跑一万次、输出周期数。这个投入大概半天,但后面每次改实现都能立刻看到收益,而且能避免“整网变快了但不知道是哪一层变快”的困惑。
不要在热路径里做任何带分支的边界判断。能靠 padding 解决的边界,就不要用if。这一条在逐通道卷积上体现得最明显,去掉分支快了近两成。
优化是有天花板的,认清天花板在哪。这轮做到 9ms 之后我又试了几种写法,收益都在 5% 以内了。这时候继续抠下去性价比很低,不如去看特征提取的输入尺寸能不能降,或者帧移能不能加大。算法层面的一个决定,往往抵得上一周的汇编级手写优化。
保留一份可复现的对照环境。每次优化都留一个 git 分支和对应的测试数据,出问题能立刻回退对比。端侧调试最痛苦的就是环境不可复现,同一份代码在不同板子上表现不一样。
指标要说人话。给产品同事汇报的时候,别只说“延迟降低 78%”,要说“唤醒响应从接近半秒变成几乎无感,最差情况也不超过 0.1 秒”。技术指标和体感指标之间的换算关系,你得自己能算清楚,否则很容易承诺错东西。
我个人在这轮里最深的体会是,端侧卷积加速这件事,真正的难点不在写多漂亮的 SIMD 代码,而在于把账算清楚、把测量做扎实。我见过太多人一上来就抄一段手写汇编,结果用在了一个访存受限的层上,白忙活。把每一层的 MACs、访存量、耗时占比列成一张表,按占比从高到低动手,收益永远比拍脑袋强。至于那些具体参数——分块多大、展开几层、要不要上 Winograd——它们都是这张表的函数,表变了,答案就得重算。