做端侧语音的同行大概都遇到过同一个尴尬:算法同事在服务器上训好的卷积模型,指标漂亮得能贴墙上,一放到小智音箱这种 128KB 内存、百来兆主频的板子上,跑一次要小半天,实时性直接崩盘。我这次要聊的就是小智音箱加速卷积层特征提取这件事——具体点说,是把一段 16kHz 的麦克风音频,在端侧先变成 64 维的梅尔对数谱,再送进一个小型卷积网络做特征提取和分类,整个过程从原来的 100 毫秒压到 8.7 毫秒,让这颗核从"几乎被吃满"回到"只占一成"。这里的关键词是定点化、卷积层、特征提取、SIMD 打包、访存优化。这篇内容适合三类人:手上正被端侧推理性能卡住固件工程师、想把模型塞进低端硬件的算法同学,以及刚接触嵌入式神经网络、想知道"书上那些优化手段在真实板子上到底值几个毫秒"的新手。我会把坑一个一个摊开讲,包括我算错参数重训一次的教训。
1. 先把问题摆清楚:音箱端跑卷积,到底卡在哪
1.1 "小智"这个项目的时间预算有多紧
小智音箱是我们内部给一个语音交互原型起的代号,主控是一颗主频 120MHz 的 32 位音频 SoC,带一块 128KB 的片上 SRAM,没有独立 NPU,也没有硬件浮点单元,音频前端有一路 16kHz/16bit 的 I2S 麦克风输入。功能上要做的是"本地关键词与声音事件识别":主人喊"小智小智"要能唤醒,厨房里玻璃碎裂、水流、油烟机这类声音也要能分辨出来,一共 12 个类别。
推理节奏是每 100 毫秒出一次结果,也就是 10Hz。这个节奏不是随便定的——唤醒词要跟手,超过 200 毫秒用户就会觉得"这音箱有点傻",而 100 毫秒的窗口配上 640 毫秒的滑动上下文,误唤醒率能压得住。问题就出在这:基线版本跑一帧要100.4 毫秒,推理周期也是 100 毫秒,等于这颗核 100% 被卷积吃掉。一旦播放器线程要解码 MP3,或者蓝牙链路要收包,立刻就丢帧,表现为"喊三声才应一声"。这就是我要加速卷积层特征提取最原始的动机,不是什么技术洁癖,是产品真的不能用。
1.2 算力、内存、带宽这三本账
做端侧优化,我习惯动手之前先把三本账算清楚,否则容易在错误的方向上使劲。
算力账:120MHz 意味着每毫秒只有 12 万次时钟周期。如果按标量指令一次做一次乘加算,一帧模型的 174 万次乘加需要 14.5 毫秒,还没算循环开销和访存;如果走软件浮点,一次浮点乘法就要十来个周期,直接爆到 100 毫秒以上——我们实测的基线就是这么来的。
内存账:128KB SRAM 里,音频 DMA 双缓冲要 6.4KB,FFT 工作区要 4KB,log-mel 特征环形缓冲要 4KB,剩下的才轮到卷积用。最大的中间特征图是 64×32×16 的 int8,正好 32KB,两块乒乓就是 64KB,加上 15KB 常驻权重,勉强塞得下,但余量只有不到 20KB。这个数字决定了后面所有的优化手段选择——任何会把特征图放大的方案,比如标准 im2col,直接出局。
带宽账:这颗核没有 cache,所有数据都在 SRAM 里跑,一次 32 位读要 1 个周期,一次 32 位写要 1 个周期。也就是说理论上一个"读权重、读激活、乘加、写累加"的最优循环是 2 读 1 算,瓶颈在取指和访存而不是 ALU。这解释了为什么后来我把循环顺序和寄存器分块调了一遍,收益比单纯换 int8 还大。
1.3 为什么选深度可分离卷积而不是直接搬骨干网
选型阶段我们试过两套结构。一套是直接把移动端常用的瓶颈残差结构裁剪到 32 通道,参数量 21 万,实测乘加 6.8M;另一套是纯深度可分离的堆叠结构,参数量 1.5 万,乘加 1.74M。前者准确率大概高 1.2 个百分点,但耗时是后者的近四倍,在 100 毫秒预算里根本放不下。
我最终选后者,还有一个容易被忽略的理由:深度可分离结构的层数多、但每层的数据量小,天然适合分块塞进小 SRAM,也方便做逐层打点。如果是一整块大卷积,你只能整体优化,出了问题很难定位是哪一段慢。分成九层之后,我能拿着计时器一层一层看,哪个慢改哪个,这个可观测性在资源紧张的板子上比理论效率更值钱。
提醒一句:深度可分离卷积在训练侧有个副作用,3×3 深度卷积的权重初始化对最终精度影响比普通卷积大得多。我们第一版直接用默认初始化,收敛后掉了 2.8 个点,后来改成先训 pointwise 再解冻深度卷积的两段式训练才补回来。
2. 整体设计:把卷积前端拆成一条能跑满的流水线
2.1 四段式切分与定点化的边界
整条链路我切成四段:采集与预加重 → 分帧加窗与 FFT → 梅尔滤波与对数 → 卷积特征提取与分类。切分的原则不是按算法模块,而是按"数据形态"——前两段是流式实时数据,每 10 毫秒必到一次,必须做成中断驱动;后两段是块式数据,攒够 64 帧才触发一次,可以放在主循环里跑。
定点化的边界就落在第三段和第四段之间。前面全用 Q15 定点(16 位,1 位符号,15 位小数),后面的卷积全用 int8。为什么不统一?因为 FFT 的衰减特性需要比较高的动态范围,Q15 下信噪比损耗不到 0.3dB,可以接受;而卷积层的权重动态范围小、对量化更宽容,用 int8 能把存储和带宽都砍到四分之一。这条边界我试过两种划法,另一条是在 log-mel 输出后再转 int8,结果因为 log 值域跨度太大(0 到 8.9 左右),简单线性量化掉点明显,最后还是选了现在这样:log-mel 出来后先做一次均值方差归一化压到约 [-4, 4],再乘 16 转 int8,正好用满 -128 到 127 的范围。
2.2 量化方案:对称与非对称的取舍
权重用对称量化,激活用非对称量化——这是端侧最常规的搭配,但它背后有具体原因。权重分布基本以 0 为中心、近似对称,对称量化不需要零点(zero point),卷积累加时省掉一次零点补偿,内核实现简单不少。激活不一样,经过 ReLU 之后分布明显偏向正值,如果硬用对称量化,整个正半轴只能用掉 128 个量化级里的一半,白白损失一位精度。
真正麻烦的是**重量化(requantization)**这一步。int32 的累加结果要变回 int8,中间要做一次acc × scale的乘法,而 scale 是个小于 1 的浮点数。板子上跑浮点太贵,标准做法是把它转成定点乘加移位:
/* mult 为 Q31 定点乘数,shift 为右移位数,zp 为输出零点 */ static inline int8_t requant(int32_t acc, int32_t mult, int shift, int32_t zp) { int64_t t = (int64_t)acc * mult; /* 64 位中间量防溢出 */ int32_t r = (int32_t)((t + (1LL << (shift - 1))) >> shift); /* 加半再移,四舍五入 */ r += zp; if (r < -128) r = -128; if (r > 127) r = 127; return (int8_t)r; }这里的mult和shift是怎么定的?给定输入 scales_in、权重 scales_w、输出 scales_out,真实比例是s_in * s_w / s_out,把它写成M / 2^shift的形式,让 M 落在[0.5, 1)区间再乘2^31取整。计算时先在主机侧用 Python 算好,跟权重一起烧进 flash,板上不做任何浮点运算。有一点必须注意:shift最大可能到 31,如果直接写1 << shift在 32 位机上会溢出,所以上面用了1LL。我就在这上面浪费过半天——精度莫名其妙掉了 0.4 个点,最后发现是舍入项算错了。
2.3 内存规划:128KB 里怎么塞下三层特征图
内存这张表是反复推倒重来的,最后定下来是这样:
| 缓冲区 | 大小 | 放置位置 | 备注 |
|---|---|---|---|
| 音频 DMA 乒乓 | 2 × 3.2KB | SRAM | 160 点 × 2 字节 × 10 帧 |
| 窗函数表 + 旋转因子 | 3.2KB | Flash 常驻 | 只读,走常量总线 |
| FFT 工作区 | 4KB | SRAM | 256 点复数,Q15 |
| log-mel 环形缓冲 | 64 × 64 = 4KB | SRAM | 64 帧 × 64 维,int8 |
| 特征图 A(最大) | 64 × 32 × 16 = 32KB | SRAM | Conv1 输出 |
| 特征图 B | 32 × 16 × 32 = 16KB | SRAM | PW3 输出 |
| 权重与偏置 | 15KB | SRAM 常驻 | 从 flash 拷一次,之后不再读 |
| 栈与外设 | 约 12KB | SRAM | 留足中断嵌套余量 |
| 合计 | 约 91KB | 余量 37KB |
表里有两个决定值得说说。第一,权重一定要搬到 SRAM。我一开始图省事,直接让内核从 flash 里读权重,结果同样的代码慢了 34%。原因是 flash 控制器和 SRAM 共享同一条总线,CPU 取指、取权重、取激活三者抢带宽,流水线几乎每三个周期就停一次。把 15KB 权重在初始化时整体拷进 SRAM,这 15KB 换来的性能提升是整轮优化里性价比最高的单项。
第二,特征图只做两级乒乓,不做全缓存。深度可分离网络是严格串行的,第 N 层的输出就是第 N+1 层的输入,所以只需要"当前输出"和"上一级输出"两块,层与层之间通过指针交换来复用内存。我最初想给每层分配独立缓冲,算下来要 180KB,直接超了。用内存池 + 指针交换的思路,峰值直接压到 48KB。
3. 卷积层加速的四个着力点
3.1 先看 MAC 分布:71% 的算力花在 1×1 卷积上
动手之前我做了一件事:把每一层的乘加次数打印出来,按降序排。
| 层 | 类型 | 输入尺寸 | 输出通道 | 乘加次数 | 占比 |
|---|---|---|---|---|---|
| PW7 | 1×1 | 16×8×64 | 64 | 524,288 | 30.1% |
| PW3 | 1×1 | 32×16×16 | 32 | 262,144 | 15.0% |
| PW5 | 1×1 | 16×8×32 | 64 | 262,144 | 15.0% |
| Conv1 | 3×3 | 64×32×1 | 16 | 294,912 | 16.9% |
| PW9 | 1×1 | 8×4×64 | 96 | 196,608 | 11.3% |
| DW2/DW6 | 3×3 深度 | — | — | 各 73,728 | 各 4.2% |
| DW4 | 3×3 深度 | — | — | 36,864 | 2.1% |
| DW8 | 3×3 深度 | — | — | 18,432 | 1.1% |
四个 1×1 卷积加起来占了71.4%。这个数字非常关键,它直接推翻了我原本的优化计划——我本来打算花两周做 Winograd,因为教科书上都说 3×3 卷积用 Winograd 能省 2.25 倍乘法。可这张表告诉我,3×3 深度卷积总共才占 11.6%,就算把它优化到零,整帧也只快 1 毫秒;而 1×1 卷积占了七成,那里才是金矿。这一点我希望你记住:优化之前先做剖面,别信经验,信数据。
3.2 通道方向的 SIMD 打包与内核写法
1×1 卷积在数学上就是每一个像素位置做一次"通道向量内积",天然适合在通道方向打包。这颗核有一条打包乘加指令,一次能把 4 个 int8 和另外 4 个 int8 做点积再累加到 int32,相当于一个周期吃 4 次乘加。前提是数据布局必须是通道在最低维,也就是常说的 NHWC。
我们训练侧的模型默认是 NCHW,导出时必须转。转换的时候顺手把输出通道补齐到 4 的倍数,把输入通道也补齐,补的位置填 0(填 0 对卷积没有影响,因为权重对应的那一栏也是 0)。补零这件事看起来简单,但它是后面所有内核能无条件按 4 对齐跑的前提,省掉了内核里一大堆边界判断分支。我们的层通道数是 16、32、64、96,全是 4 的倍数,只有 Conv1 的输入是单通道,单独走一条标量路径。
内核的写法才是真正的重头戏。最朴素的写法是"对每个输出像素、每个输出通道,遍历输入通道做点积",这样每个输入值要被读out_c次,访存开销爆炸。真正跑得动的是寄存器分块:一次算 4 个输出通道 × 4 个像素,把 4 个像素的输入值(4 个 32 位寄存器)和 4 个输出通道的权重(4 个 32 位寄存器)全压在寄存器里,中间做 16 次打包乘加,每个 32 位输入值被复用了 4 次。这一招让访存次数从O(in_c × out_c)降到O(in_c + out_c)。
/* 1x1 卷积:4 输出通道 x 4 像素寄存器分块 * in : [pix][in_c] int8,in_c 已补齐到 4 的倍数 * w : [out_c][in_c] int8,out_c 已补齐到 4 的倍数 * acc : [pix][out_c] int32 */ static void pw1x1_s8(const int8_t *in, const int8_t *w, int32_t *acc, int in_c, int out_c, int pix) { for (int p = 0; p < pix; p += 4) { for (int oc = 0; oc < out_c; oc += 4) { int32_t s00=0,s01=0,s02=0,s03=0; /* 像素 p+0 的 4 个通道 */ int32_t s10=0,s11=0,s12=0,s13=0; /* 像素 p+1 */ int32_t s20=0,s21=0,s22=0,s23=0; /* 像素 p+2 */ int32_t s30=0,s31=0,s32=0,s33=0; /* 像素 p+3 */ for (int ic = 0; ic < in_c; ic += 4) { int32_t v0 = load4(in + (p+0)*in_c + ic); int32_t v1 = load4(in + (p+1)*in_c + ic); int32_t v2 = load4(in + (p+2)*in_c + ic); int32_t v3 = load4(in + (p+3)*in_c + ic); int32_t k0 = load4(w + (oc+0)*in_c + ic); int32_t k1 = load4(w + (oc+1)*in_c + ic); int32_t k2 = load4(w + (oc+2)*in_c + ic); int32_t k3 = load4(w + (oc+3)*in_c + ic); s00 = mac4(v0, k0, s00); s01 = mac4(v0, k1, s01); s02 = mac4(v0, k2, s02); s03 = mac4(v0, k3, s03); s10 = mac4(v1, k0, s10); s11 = mac4(v1, k1, s11); s12 = mac4(v1, k2, s12); s13 = mac4(v1, k3, s13); s20 = mac4(v2, k0, s20); s21 = mac4(v2, k1, s21); s22 = mac4(v2, k2, s22); s23 = mac4(v2, k3, s23); s30 = mac4(v3, k0, s30); s31 = mac4(v3, k1, s31); s32 = mac4(v3, k2, s32); s33 = mac4(v3, k3, s33); } /* 每个输出通道的重量化参数不同,必须分开做 */ store4(acc + (p+0)*out_c + oc, requant4(s00,s01,s02,s03, oc)); store4(acc + (p+1)*out_c + oc, requant4(s10,s11,s12,s13, oc)); store4(acc + (p+2)*out_c + oc, requant4(s20,s21,s22,s23, oc)); store4(acc + (p+3)*out_c + oc, requant4(s30,s31,s32,s33, oc)); } } }注意:
requant4里每个输出通道用不同的乘数和移位数,这意味着每层需要准备一组mult[out_c]和shift[out_c]。我见过有人图省事用同一个参数量化整层,结果分类准确率掉了 3 个点以上,尤其是最后那层只有 96 个通道、通道间动态范围差异大的地方。
3.3 访存优化:为什么坚决不上 im2col
im2col 是把卷积展开成大矩阵乘的经典做法,在 PC 上很好用,因为内存不要钱。但在 128KB SRAM 上它是灾难:把 64×32×1 的输入按 3×3 展开成 9 倍,光这一层就要 18KB,而且每一层都要重做一次数据搬运。我实测过一版 im2col 实现,卷积计算本身只要 1.1 毫秒,但搬运花了 2.6 毫秒,加速比为负。
最后我把 3×3 卷积全部改成直接卷积 + 输入复用。核心思路是:3×3 卷积里相邻输出像素共享 6 个输入,横向滑窗的时候把已经读进来的 3 列数据留着,只新读 1 列,这样输入读取量从每像素 9 次降到约 3 次。再叠加 4 输出通道的寄存器分块,实测 3×3 卷积的有效乘加效率从 0.9 MAC/周期 提到 2.2 MAC/周期。
深度卷积(depthwise)是另一套逻辑。它每个通道独立卷积,通道之间没有复用,SIMD 打包在这里没用武之地。我用了另一种办法:把 2 个通道的 int8 拼成 1 个 int16,用 16 位乘法一次算两个通道。