首页 / 资讯中心 / 文章详情

小智音箱端侧卷积加速:从42ms到9ms的MCU优化实战

小智音箱端侧卷积加速:从42ms到9ms的MCU优化实战 ★ FEATURED ARTICLE
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 // 分块尺寸按寄存器数量定MR4, NR8内层沿 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[(iii)*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[(iii)*ldc jjj] acc[ii][jj]; } } }MR4, NR8是我实测出来的值再大寄存器不够用会溢出到栈上性能反而掉再小则循环开销占比上升。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 ((hky)*W (wkx))*C; const int8_t *q w ((ky1)*3 (kx1))*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 压到 9ms4.1 基线测量先把尺子校准优化之前一定要有可信的基线。我的做法是在每层卷积的前后各插一个周期计数器读取点把结果存进一个静态数组等整帧算完再统一串口输出——绝对不能在热路径里printf串口输出一个数字的开销比你想象的大得多我第一版打了 20 个点直接把 42ms 测成了 90ms。计时用的硬件周期计数器精度足够但要记得在读取前后加内存屏障否则编译器可能重排指令导致你测到的时间对不上真实执行顺序。基线数据我整理成了下面这张表后面每一步优化的收益都对照它来评估层类型MACs基线耗时占比conv13×3 s2, 1→1646K4.1ms9.8%dw13×3 逐通道, 1692K6.3ms15.0%pw11×1, 16→32327K5.2ms12.4%dw23×3 逐通道, 32184K7.8ms18.6%pw21×1, 32→64655K6.9ms16.4%dw33×3 逐通道, 64368K8.4ms20.0%pw31×1, 64→64655K3.1ms7.4%后端池化分类—0.2ms0.4%合计4.1M42.0ms100%这张表最有价值的信息是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 降到 28msSRAM 峰值从 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-cacheDMA 写入的缓冲区在 CPU 看来可能是旧数据。必须做 cache invalidate或者干脆把这块缓冲区配置成不可缓存。我一开始忽略了这点跑出来的结果是间歇性的——十次里有两三次结果完全错乱查了很久才定位到缓存一致性。同理DMA 从 CPU 刚写过的缓冲区往外搬之前要做 cache clean。还踩了一个坑双缓冲的两块区域如果是相邻地址DMA 搬运时可能落在同一个缓存组里产生冲突。我把两块缓冲区按缓存行大小做了地址错开抖动明显减少。这轮优化把搬运开销从约 6ms 摊平到几乎为零单帧耗时降到 15ms。4.4 分块参数调优与最终收益账最后一步是抠分块参数。矩阵乘的分块尺寸、逐通道卷积的通道并行度、循环展开的层数这些参数没有普适最优值只能实测。我写了个小脚本把参数组合编成不同的宏定义编译出多个固件版本在板子上各跑 100 帧取中位数。这个过程看着笨但比拍脑袋强得多。最终这一轮把耗时压到了 9ms 附近。完整的收益记录如下阶段单帧耗时峰值 SRAM相对基线基线朴素 NHWC 实现42.0ms380KB1.00×int8 量化 per-channel34.5ms112KB1.22×算子融合convbnact 折叠28.0ms240KB1.50×NHWC 重构 im2col 分块 GEMM19.5ms198KB2.15×逐通道卷积专项重写16.2ms190KB2.59×双缓冲 流水线重叠15.0ms176KB2.80×首层 Winograd 分块调优9.1ms176KB4.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关键层保 int16P99 远高于 P50中断抢占或取指分段计时看长尾出现在哪层提升计算任务优先级热代码搬进 TCM边界像素输出异常padding 值填错只看第一行/最后一列输出padding 填输入零点而非 0通道数非 8 倍数时明显变慢尾部处理分支微基准对比不同通道数训练侧约束通道数为 8 的倍数跑一段时间后死机栈溢出或内存踩踏检查分块数组大小与栈余量缩小分块或加大任务栈加速比远低于理论值横向求和开销过高统计点积后归约的指令占比批量归约减少横向求和次数权重加载耗时异常权重未对齐检查权重地址对齐情况按 32 字节对齐并预打包补零5.4 几条用返工换来的经验先量后调别猜。我在这轮优化里最大的时间浪费是一开始凭直觉去优化点卷积写了两天分块 GEMM结果发现瓶颈在逐通道卷积。后来老老实实按表排序按耗时占比从高到低打效率完全不一样。微基准测试要早写。给每个算子写一个 50 行左右的独立测试程序固定输入、跑一万次、输出周期数。这个投入大概半天但后面每次改实现都能立刻看到收益而且能避免“整网变快了但不知道是哪一层变快”的困惑。不要在热路径里做任何带分支的边界判断。能靠 padding 解决的边界就不要用if。这一条在逐通道卷积上体现得最明显去掉分支快了近两成。优化是有天花板的认清天花板在哪。这轮做到 9ms 之后我又试了几种写法收益都在 5% 以内了。这时候继续抠下去性价比很低不如去看特征提取的输入尺寸能不能降或者帧移能不能加大。算法层面的一个决定往往抵得上一周的汇编级手写优化。保留一份可复现的对照环境。每次优化都留一个 git 分支和对应的测试数据出问题能立刻回退对比。端侧调试最痛苦的就是环境不可复现同一份代码在不同板子上表现不一样。指标要说人话。给产品同事汇报的时候别只说“延迟降低 78%”要说“唤醒响应从接近半秒变成几乎无感最差情况也不超过 0.1 秒”。技术指标和体感指标之间的换算关系你得自己能算清楚否则很容易承诺错东西。我个人在这轮里最深的体会是端侧卷积加速这件事真正的难点不在写多漂亮的 SIMD 代码而在于把账算清楚、把测量做扎实。我见过太多人一上来就抄一段手写汇编结果用在了一个访存受限的层上白忙活。把每一层的 MACs、访存量、耗时占比列成一张表按占比从高到低动手收益永远比拍脑袋强。至于那些具体参数——分块多大、展开几层、要不要上 Winograd——它们都是这张表的函数表变了答案就得重算。
阅读完成 · 觉得有帮助?
咨询建站