读 ncnn 源码(Ⅷ):核心算法细讲——Activation 工厂、CPU 后端选择、im2col+GEMM 权重打包与分块

承接(Ⅶ),我们这次专注在“怎么把卷积高效地变成矩阵乘”。重点是以下三个函数

  1. create_activation_layer() 激活层工厂;
  2. create_layer_cpu() 按 ISA 自适应选择实现;
  3. 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*khM=outchK=inch*maxk,用 A(M×K) × B(K×N) = C(M×N) 形式计算。

权重预打包(A 侧)convolution_im2col_gemm_transform_kernel()

  1. 先 reshape 并按 elempack(=16/8/4/1) 收拢通道,得到顺序更友好的 A_data
  2. 再按 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 规范一致):

  • 1ReLU(无参数)
  • 2LeakyReLU(用 ReLU 层 + slopepd.set(0, activation_params[0])
  • 3Clip(min,max)pd.set(0, min); pd.set(1, max)
  • 4Sigmoid
  • 5Mish
  • 6HardSwish(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 * kh
  • M = outch(输出通道数)
  • K = inch * maxk(每个输出通道对应的权重向量长度)

把卷积看成矩阵乘:
AM×K)× BK×N)= CM×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 外层、inchelempack 步进、内层遍历 k in [0,maxk),把 连续的 elempack 个输入通道对应同一 kernel 位置的标量,相邻写入到 A_data。
    • 这一步实现了注释里的“maxk-inch-outch → pa-maxk-inch/pa-outch”:把输入通道分组(pa=elempack),为后续 SIMD 装载做准备。
    • elempackinch 对向量宽度的可整除性决定:
      • 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 组合
    • 这样的“列主/块内交错”布局,正好匹配后续 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 对齐
  • 再按 (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_KTILE_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
    2
    int 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
    4
    if (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
2
3
4
5
6
7
8
9
10
11
int tile_size;
if (TILE_K >= K)
{
// 特殊情况:K维度很小,不需要分块
tile_size = (l2_cache_size_fp32 - TILE_M * TILE_K) / TILE_K;
}
else
{
// 通用情况:K维度也需要分块
tile_size = (l2_cache_size_fp32 - TILE_M * TILE_K) / (TILE_M + TILE_K);
}

这行代码在估算:我的 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
2
int nn_N = (N + TILE_N - 1) / TILE_N;
TILE_N = std::min(TILE_N, ((N + nn_N - 1) / nn_N + 3) / 4 * 4); // 以 AVX/SSE 为例

总结: 这几行代码通过一套复杂的、基于硬件特性(缓存大小、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_PSunpack 组合完成 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (; ii + 7 < max_ii; ii += 8) // 1. 一次处理8行
{
// ...
int kk = 0;
for (; kk + 7 < max_kk; kk += 8) // 2. 一次处理8列
{
// 3. 加载8行数据到8个AVX寄存器
__m256 _r0 = _mm256_loadu_ps(p0);
...
__m256 _r7 = _mm256_loadu_ps(p7);

// 4. 关键:对这8个寄存器进行 8x8 矩阵转置
transpose8x8_ps(_r0, _r1, ...);

// 5. 将转置后的结果写回新内存区域
_mm256_store_ps(pp, _r0);
...
}
// ...
}
  • 外层循环 (ii):以 8 行为一个大步长,遍历整个输入瓦片。
  • 内层循环 (kk):以 8 列为一个大步长,遍历每一行。
  • 加载 (_mm256_loadu_ps): 使用 AVX 指令,一次性从内存加载 8 个浮点数(256位)到 ymm 寄存器。循环8次,就把 8x8 的数据块全部读入了寄存器。
  • 转置 (transpose8x8_ps): 这是魔法的核心。它通过一系列位操作和重排指令,在寄存器内部就完成了 8x8 矩阵的转置,速度极快。
  • 存储 (_mm256_store_ps): 将转置后的、现在已经是列优先排列的数据,连续地写回目标矩阵 AT

3. 收尾工作:处理剩余的“边角料”

1
2
3
4
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=trueinch/outch 能被 8/16 整除);
    • 查看 TILE_K/M 是否过小(L2 估算是否合理);
    • 1×1 是否走了 sgemm(预期是的)。
  • 边界/小通道退化
    • elempack=1 时打包收益会下降,SSE/AVX 收尾分支会多一些拷贝/转置开销。
  • 多线程伸缩
    • TILE_M *= min(nT, physical_cpu_count) 这句能让 M 维度并行更饱和,但也要避免把 M 切得过细(代码里有后续“均匀化”约束)。
  • 缓存观感
    • A/B 都按 TILE_K 尽量连贯访问,切 K通常是最后的选择;
    • TILE_N 不确定时直接略过(权重预处理阶段确实没必要考虑 N)。

8) 小结

  • Activation 工厂:把激活统一为子层 + ParamDict 注参 + create_pipeline,便于后端优化与复用。
  • CPU 后端选择:运行时探测 ISA,自动选用 AVX512/AVX/FMA/SSE 或其它架构的最佳实现。
  • im2col + GEMM(权重侧)
    1. 通道分组(elempack) + 按 kernel 位置聚合得到 A_data;
    2. 按 tile(M,K) 做 SIMD 转置,得到微内核友好的 AT
    3. 分块策略由 L2 容量 + ISA 对齐 + 线程数共同决定,优先不切 K,M 方向承担并行。
  • 这三者合到一起,让 ncnn 的卷积在通用 CPU 上也能跑出高而稳的吞吐。

该封面图片由AlexaPixabay上发布