读 ncnn 源码(Ⅷ):核心算法细讲——Activation 工厂、CPU 后端选择、im2col+GEMM 权重打包与分块
读 ncnn 源码(Ⅷ):核心算法细讲——Activation 工厂、CPU 后端选择、im2col+GEMM 权重打包与分块
承接(Ⅶ),我们这次专注在“怎么把卷积高效地变成矩阵乘”。重点是以下三个函数
create_activation_layer()激活层工厂;create_layer_cpu()按 ISA 自适应选择实现;convolution_im2col_*系列:权重预变换(A 打包) + 最佳 tile 选择 + SIMD 转置打包微内核。
TL;DR
激活工厂:create_activation_layer() 按 activation_type 构造 ReLU/LeakyReLU/Clip/Sigmoid/Mish/HardSwish,用 ParamDict 注参后立刻 create_pipeline(opt),便于后端优化复用。
CPU 后端自适应:create_layer_cpu() 运行时探测 ISA,优先选 AVX512 → FMA/AVX → SSE2(或 LASX/LSX、MSA、RVV…)对应的注册表,拿到该层的最优实现。
卷积→GEMM 映射:设 maxk=kw*kh,M=outch,K=inch*maxk,用 A(M×K) × B(K×N) = C(M×N) 形式计算。
权重预打包(A 侧):convolution_im2col_gemm_transform_kernel()
- 先 reshape 并按
elempack(=16/8/4/1)收拢通道,得到顺序更友好的A_data; - 再按 tile(M,K) 用 SIMD 转置微内核(AVX512 16×16、AVX 8×8/8×4、SSE 4×4…)生成
AT,使后续 GEMM 对 A 的装载连续且满向量。
最佳分块(tile 选择):convolution_im2col_gemm_get_optimal_tile_mnk() 基于 L2 容量 + ISA 对齐 + 线程数求 (TILE_M,TILE_K[,TILE_N]);优先不切 K,M 方向承担并行,再均衡微调对齐到 16/8/4。
何时走 sgemm:1×1 卷积几乎必走 im2col+GEMM;3×3 且 s=1、d=1、通道不小更偏向 Winograd;其余落回 packed 通用路径。
性能要点:启用 opt.use_packing_layout 且让通道数可整除向量宽度,才能命中 AVX/AVX512 打包路径;elempack=1 时收益会下降。

1) Activation 工厂:create_activation_layer()
核心思路:用一个小工厂,把 activation_type 映射到具体层,并用 ParamDict 填入参数,然后立刻调用 create_pipeline(opt) 做预处理(例如 GPU/Vulkan 时会准备 pipeline)。
支持的类型(与 ncnn 规范一致):
1→ ReLU(无参数)2→ LeakyReLU(用 ReLU 层 +slope;pd.set(0, activation_params[0]))3→ Clip(min,max)(pd.set(0, min); pd.set(1, max))4→ Sigmoid5→ Mish6→ HardSwish(alpha, beta)`
这里的设计点:把激活当成独立子层,免去在各算子里重复实现,且可统一走
create_pipeline(),按后端自动选择最优实现(CPU/向量扩展/Vulkan)。
2) CPU 后端自适应:create_layer_cpu(index)(前面讲过)
这段就是 ncnn 的多 ISA 路由器。它根据运行时探测的指令集能力,优先选最强的一套 layer_registry_xxx:
优先级大致是:
- x86:
AVX512>FMA>AVX>SSE2 - LoongArch:
LASX>LSX - MIPS:
MSA - RISC-V:
XTHEADVECTOR>RVV - 最后回落到 通用 arch 注册表,再不行就 内建默认注册表
layer_registry。
这就保证了**同一层(index)**在不同 CPU 上自动获得最优实现,无需你手动 ifdef。且每个 registry 都是“同一个算子 API + 不同内核”的形态,方便维护。
3) im2col + GEMM 的“权重侧”核心:A 矩阵预变换(打包)
函数:convolution_im2col_gemm_transform_kernel(kernel, AT, inch, outch, kw, kh, opt)
3.1 卷积 → GEMM 维度映射(权重侧)
经典映射(NCHW):
maxk = kw * khM = outch(输出通道数)K = inch * maxk(每个输出通道对应的权重向量长度)
把卷积看成矩阵乘:
A(M×K)× B(K×N)= C(M×N)
其中 A 就是所有输出通道的权重拼成的矩阵,B 是 im2col 后的输入采样,N 是输出空间位置数(Hout×Wout)。
3.2 为什么要“预变换/打包 A”?
- 微内核(micro-kernel)通常按固定块大小(例如
TILE_M×TILE_K)取数据,最吃数据布局。 - 直接用原始权重布局访问,会导致 cache/bandwidth 与 SIMD 装载不友好。
- 预打包把 A 重排为“按微内核喜欢的顺序”,提高顺序访问率,减少跨 stride 访问与Cache miss,还能一次装满向量寄存器。
3.3 具体做了什么?
第一步:把 kernel reshape 成 A_data(近似 M×K 排列)
maxk==1(1×1 卷积):直接reshape(maxk*inch, outch),天然就是K×M的转置形(访问最简单)。- 其他卷积:先
reshape(maxk, inch, outch),然后按outch外层、inch以elempack步进、内层遍历k in [0,maxk),把 连续的elempack个输入通道对应同一 kernel 位置的标量,相邻写入到 A_data。- 这一步实现了注释里的“
maxk-inch-outch → pa-maxk-inch/pa-outch”:把输入通道分组(pa=elempack),为后续 SIMD 装载做准备。 elempack由inch对向量宽度的可整除性决定:- AVX512 → 16/8/4/1
- AVX → 8/4/1
- SSE2 → 4/1
- 这一步实现了注释里的“
第二步:把 A_data 再打成 AT(tile 化 + 转置交错)
- 目标形态:
AT的每个 channel 存一个(TILE_K × TILE_M)的子块,排列为
AT.create(TILE_K*TILE_M, ceil(K/TILE_K), ceil(M/TILE_M))。 - 实际打包在
convolution_im2col_pack_A_tile(A_data, AT_tile, i, max_ii, k, max_kk)里完成:- 一次处理一个子块:
i..i+max_ii-1(M 方向)、k..k+max_kk-1(K 方向)。 - 关键:用 SIMD 转置微内核把“按行的原数据”转成“按列交织”的布局,即:
- AVX512:
transpose16x16_ps - AVX:
transpose8x8_ps / transpose8x4_ps / transpose8x2_ps - SSE:
_MM_TRANSPOSE4_PS/unpack组合
- AVX512:
- 这样的“列主/块内交错”布局,正好匹配后续 micro-kernel“一次读 K 上的连续 8/16 标量,与 B 的 tile 做 FMA”。
- 一次处理一个子块:
小结:A 的两级变换 = 按 elempack 收拢通道 → 按 tile(M,K) 把行块做向量化转置 → 写到 AT。
之后前向时就能以高度顺序化的访存跑到峰值 FMA 带宽。
4) 最佳分块:convolution_im2col_gemm_get_optimal_tile_mnk(M,N,K, TILE_M,N,K, nT)
这段是非常实用的工程经验:根据 L2 cache 大小 与 向量宽度 与 线程数,选出“既不太小也不爆 cache”的 (TILE_M, TILE_N, TILE_K)。
4.1 先解 TILE_K(尽量不切 K)
- 近似公式:
tile_size = (L2_fp32 - 常数开销) / 向量并行因子
不同 ISA 有不同的“并行因子”与下界:- AVX512:以 16 对齐,
TILE_K = max(16, tile_size/16*16) - AVX:以 8 对齐
- SSE2:以 4 对齐
- 标量:以 2 对齐
- AVX512:以 16 对齐,
- 再按
(K + TILE_K - 1) / TILE_K调整,避免最后一个块太小(“均匀切 K”)。
为什么优先不切 K?
因为 A 的 pack 与 B 的 pack/访存都把 K 当“长向量维”,切 K 会破坏向量装载连贯性,并让 A/B 都更难复用 cache。
4.2 再解 TILE_M
- 先根据 ISA 粗估一个“把 M 分成多少块”的
nn_M,得出初始TILE_M(同样做 16/8/4/2 对齐)。 - 然后把
TILE_M乘以并行线程数(物理大核数),再进行“均匀化/对齐”与“按线程分摊”的收缩,避免每线程拿到的 M 子块过小或不均匀。
这一步是并行维度的主分块:M 方向常常作为线程外层循环的分配对象(每个线程处理一组输出通道)。
4.3 最后解 TILE_N(如果 N 已知)
N是输出空间(Hout×Wout)。权重预变换时N=0,所以这里通常不解TILE_N。- 若已知(比如某些 fused 跑法),类似方法估一个
TILE_N,并与TILE_M/TILE_K一起满足L2容量约束。
代码分为三个主要部分来计算两个关键尺寸:TILE_K 和 TILE_M。
4.4 详细过程
1. 计算 TILE_K(矩阵的公共维度 K 的分块大小)
这是为了优化缓存的使用。
-
第一步:估算理论瓦片大小 (
tile_size)1
int tile_size = (l2_cache_size_fp32 - 32) / 8; // 以 AVX 为例
这行代码在估算:如果我的 L2 缓存大小是
l2_cache_size_fp32,我先留出一小部分空间(比如 32 个 float)给其他数据,剩下的空间如果我一次处理 8 行(因为 AVX 内核通常一次处理 8 个M维度的累加),那么每行最多能放多少个K维度的元素? -
第二步:向下取整到 SIMD 宽度
1
TILE_K = std::max(8, tile_size / 8 * 8); // 以 AVX 为例
这是个经典的整数运算技巧,
tile_size / 8 * 8的作用是将tile_size向下取整到最近的8的倍数。这是为了确保每一块的宽度都能被 SIMD 指令(如 AVX 一次处理8个 float)完美整除,避免浪费计算能力。 -
第三步:负载均衡微调
1
2int nn_K = (K + TILE_K - 1) / TILE_K;
TILE_K = std::min(TILE_K, ((K + nn_K - 1) / nn_K + 7) / 8 * 8); // 以 AVX 为例这一步是为了避免最后一块(remainder)太小而导致效率低下。它会计算一个更“平均”的块大小,并再次调整,试图让所有分块的大小都比较均匀且对 SIMD 友好。
2. 计算 TILE_M(矩阵行维度 M 的分块大小)
这主要是为了适配 SIMD 和多线程并行。
- 它的计算逻辑与
TILE_K类似,也是先估算一个理论值,然后将其调整为 SIMD 宽度的整数倍,确保计算的高效性。
3. 考虑多线程并再次调整 TILE_M
-
第一步:扩展任务
1
TILE_M *= std::min(nT, get_physical_cpu_count());
代码尝试将
TILE_M放大,使其成为一个能被多个线程(nT)同时处理的“大块”。 -
第二步:再切分
1
2
3
4if (nT > 1)
{
TILE_M = std::min(TILE_M, (std::max(1, TILE_M / nT) + 7) / 8 * 8); // 以 AVX 为例
}在考虑了多线程后,它再把这个“大块”切回适合单个线程处理的大小,并再次确保这个大小是 SIMD 友好的。这是一种在线程间和线程内都寻求最优解的策略。
计算 TILE_N(矩阵列维度 N 的分块大小)
这主要是为了在确定了权重分块 A_tile 的大小后,计算出能一起放入缓存的 im2col 输入矩阵分块 B_tile 的最优宽度。
第一步:估算理论瓦片大小 (tile_size)
这一步的核心是计算在L2缓存中放下了权重块 A_tile 后,还剩下多少有效空间给输入块 B_tile。
1 | int tile_size; |
这行代码在估算:我的 L2 缓存大小是 l2_cache_size_fp32,先减去权重块 A_tile (TILE_M * TILE_K) 占用的空间。然后,根据一个基于数据复用和缓存行冲突的性能模型(分母 TILE_M + TILE_K 是其简化形式),计算出剩余空间理论上能支持的 N 维度的宽度,即 tile_size。
第二步:向下取整到 SIMD 宽度
将理论值与硬件的并行处理能力对齐。
1 | TILE_N = std::max(4, tile_size / 4 * 4); // 以 AVX/SSE 为例 |
这是个经典的整数运算技巧,tile_size / 4 * 4 的作用是将 tile_size 向下取整到最近的4的倍数。在 ncnn 的 GEMM 底层实现中,其计算微内核(micro-kernel)被设计为最高效地处理 N 维度上4的倍数的数据。这一步确保了分块宽度能被 SIMD 指令完美整除,避免计算资源浪费。
第三步:负载均衡微调
与 TILE_K 的逻辑一样,这一步是为了避免最后一块(remainder)太小而导致效率低下。
1 | int nn_N = (N + TILE_N - 1) / TILE_N; |
总结: 这几行代码通过一套复杂的、基于硬件特性(缓存大小、SIMD宽度、核心数)的启发式规则,计算出了将大矩阵拆分成小块进行计算的最佳尺寸。最终的目标只有一个:让 CPU 的计算单元永远在高速缓存中处理着排列整齐的数据,从而达到性能的极限。
5) 打包微内核:convolution_im2col_pack_A_tile(...)
这段代码的“读法”就是观察不同 ii 分支:
- AVX512 分支(一次 16 行):
连续读 16 行×16 列(_mm512_loadu_ps),做transpose16x16_ps得到“列交错”块,_mm512_store_ps顺序写出。 - AVX 分支(一次 8 行 / 4 行 / 2 行):
transpose8x8_ps / transpose8x4_ps / transpose8x2_ps,按 8 或 4 或 2 的列宽块做转置交错。 - SSE 分支(一次 4 行 / 2 行):
_MM_TRANSPOSE4_PS或unpack组合完成 4×4 / 2×4 的转置。 - 标量/收尾:
最后还有若干 fallbacks(1 行、列尾不满 8/16 的情况),保证任意形状都安全覆盖。
这就是“以 SIMD 转置实现列交错打包”:把“行主”的 A_data 重组成“微内核列主”,确保后续 GEMM 的 A 装载是“连续、满向量、少跨页”的。
1. 多层次的优化策略 (CPU 兼容性)
代码被大量的 #if __AVX512F__, #if __AVX__, #if __SSE2__ 包裹。这是一个优雅降级的策略:
- 最高级 (AVX-512):如果 CPU 支持 AVX-512,就采用最高效的
16x16微块进行处理。 - 次高级 (AVX):如果不持支,就检查是否支持 AVX,采用
8x8的微块处理。 - 基础级 (SSE):如果连 AVX 都不支持,就使用最基础的 SSE,采用
4x4的微块处理。 - 无优化:如果连 SSE 都不支持(几乎不可能在现代CPU上),就只能用最慢的普通 C++ 循环逐个处理。
这保证了代码在不同性能的 CPU 上都能以最快的方式运行。
2. 主力循环:SIMD 加速的微块转置
以 AVX 的 8x8 处理为例:
1 | for (; ii + 7 < max_ii; ii += 8) // 1. 一次处理8行 |
- 外层循环 (
ii):以 8 行为一个大步长,遍历整个输入瓦片。 - 内层循环 (
kk):以 8 列为一个大步长,遍历每一行。 - 加载 (
_mm256_loadu_ps): 使用 AVX 指令,一次性从内存加载 8 个浮点数(256位)到ymm寄存器。循环8次,就把8x8的数据块全部读入了寄存器。 - 转置 (
transpose8x8_ps): 这是魔法的核心。它通过一系列位操作和重排指令,在寄存器内部就完成了8x8矩阵的转置,速度极快。 - 存储 (
_mm256_store_ps): 将转置后的、现在已经是列优先排列的数据,连续地写回目标矩阵AT。
3. 收尾工作:处理剩余的“边角料”
1 | for (; kk < max_kk; kk++) |
如果矩阵的宽和高不是 16/8/4 的整数倍,主力循环处理完后就会剩下一些“边角料”。这些收尾循环就是用最朴素的、逐个元素赋值的方式,来处理这些剩余的数据,以保证结果的正确性。
6) 与(Ⅶ)的衔接:何时会走 im2col+GEMM?
在 Convolution_x86_fma::create_pipeline() 的选路里:
- 优先:
Winograd(3×3 s1 d1,通道不小) - 然后:
sgemm(im2col + gemm)(1×1 或 通道较大/缓存压力大) - 否则:
packed(SSE/AVX/AVX512 的通用路径)
当进入 sgemm 路线时,就会调用上面的 convolution_im2col_gemm_transform_kernel() 来预打包权重,并在 forward 时配合 B 的打包一起跑到高效的 micro-kernel。
小提示:1×1 卷积基本天然适合
sgemm,因为maxk=1,A 的预变换几乎是“白送”;而3×3 s1 d1且规模适中时,Winograd通常更优。
7) 实战感知与排错 Tips
- 性能没到位?
- 确认是否命中了 AVX/AVX512 的打包路径(
opt.use_packing_layout=true且inch/outch能被 8/16 整除); - 查看
TILE_K/M是否过小(L2 估算是否合理); - 1×1 是否走了 sgemm(预期是的)。
- 确认是否命中了 AVX/AVX512 的打包路径(
- 边界/小通道退化:
elempack=1时打包收益会下降,SSE/AVX 收尾分支会多一些拷贝/转置开销。
- 多线程伸缩:
TILE_M *= min(nT, physical_cpu_count)这句能让 M 维度并行更饱和,但也要避免把 M 切得过细(代码里有后续“均匀化”约束)。
- 缓存观感:
- A/B 都按
TILE_K尽量连贯访问,切 K通常是最后的选择; TILE_N不确定时直接略过(权重预处理阶段确实没必要考虑 N)。
- A/B 都按
8) 小结
- Activation 工厂:把激活统一为子层 +
ParamDict注参 +create_pipeline,便于后端优化与复用。 - CPU 后端选择:运行时探测 ISA,自动选用 AVX512/AVX/FMA/SSE 或其它架构的最佳实现。
- im2col + GEMM(权重侧):
- 通道分组(elempack) + 按 kernel 位置聚合得到 A_data;
- 按 tile(M,K) 做 SIMD 转置,得到微内核友好的
AT; - 分块策略由 L2 容量 + ISA 对齐 + 线程数共同决定,优先不切 K,M 方向承担并行。
- 这三者合到一起,让 ncnn 的卷积在通用 CPU 上也能跑出高而稳的吞吐。





