读 ncnn 源码(Ⅺ):Packed Kernel Transform 的“通性”

优先级位于 Winograd 和 im2col+GEMM 之后,它是兜底策略,也是基础策略。

本篇把 convolution_transform_kernel_packed(...) 讲成平台无关的一套方法论:
① 为啥要重排?② 重排成啥形状?③ 怎么重排最顺手?


TL;DR

  • 目的:把原始权重 W[out, in, k]k∈[0..kw*kh))重排成微核友好的小片(tile),以便一次处理 pb 个输出通道 × pa 个输入通道 × 全部 k 的累加。

  • 形状:重排后张量可视为
    kernel_tm.shape = [ w = pb*pa*maxk , h = Σ(inpacks) , c = Σ(outpacks) ]
    其中 Σ(inpacks)/Σ(outpacks) 是把 inch/outch 用 {16,8,4,2,1} 逐级分块后块数的和。

  • 填充顺序(核心一行):
    对每个 tile(由某个 out 的 pb × 某个 in 的 pa × 全部 k 构成),按

    1
    tile[((k * pa) + i) * pb + j] = W[q + j][p + i][k]

    写入,保持 同一 k、同一 i 下的 j(pb 个 out)连续,便于矢量 FMA。

  • 没有 AVX/NEON 也没关系:用朴素 3 重 for 写入即可;有向量扩展时,用 transpose{8,16}gather 批量化搬运,语义不变


1) 为什么要重排?

卷积前向(直接卷积 / im2col+GEMM / Winograd)最终都落到一个微核上干同一件事:

pb 个输出通道,用pa 个输入通道一串核位点 k做乘加并累加。

如果沿用原始布局 W[out, in, k],微核会频繁跨 stride 跳读。将权重预排成 (k,i,j) 紧邻的小片后,计算时能顺序读取整段数据,缓存友好带宽友好寄存器利用率高


2) 重排成什么样?

2.1 选择打包宽度(pack)

  • 输出 pack pb:一次并行出多少个输出通道(取自平台向量宽度/微核设计)。
  • 输入 pack pa:一次并行累加多少个输入通道。
  • 两者都用集合 {16,8,4,2,1} 分解通道:能整除先用大块,余数用更小块补。

2.2 目标张量的形状(平台无关)

  • maxk = kw*kh
  • w 维:一个 tile 的线性长度 pb * pa * maxk
  • h 维:输入通道分块后的块数和 Σ(inpacks)
  • c 维:输出通道分块后的块数和 Σ(outpacks)

因此你会在实现里看到类似:

1
2
3
kernel_tm.create(/* w= */ pb*pa*maxk,
/* h= */ inch/16 + (inch%16)/8 + (inch%8)/4 + (inch%4)/2 + inch%2,
/* c= */ outch/16 + (outch%16)/8 + (outch%8)/4 + (outch%4)/2 + outch%2);

channel(...) 下标的那串 q/16 + (q%16)/8 + ... 正是把输出通道块线性映射到 c 维的计算方式。


3) 怎么重排?(跨平台伪代码)

设原始权重 W[out, in, k]。对每个输出块 q..q+pb-1,每个输入块 p..p+pa-1,生成一个连续的 tile 并写入 kernel_tm

1
2
3
4
5
6
7
8
9
10
11
12
13
const int maxk = kw * kh;
for (int q = 0; q < outch; q += pb) // 输出按 pb 分块
for (int p = 0; p < inch; p += pa) // 输入按 pa 分块
{
float* tile = locate_kernel_tm(q, p, pb, pa); // 等同 Intel 实现里的 g00

for (int k = 0; k < maxk; ++k)
for (int i = 0; i < pa; ++i) // 输入 pack 内索引
for (int j = 0; j < pb; ++j) // 输出 pack 内索引
{
tile[((k * pa) + i) * pb + j] = W[q + j][p + i][k];
}
}
  • 写入顺序固定为 (k → i → j),于是同一 k、同一 i 的 pb 个 out 连续。这正是微核一次 FMA 要“吃”的布局。
  • 尾块:当 inch/outch 不能整除时,沿 {16→8→4→2→1} 依次下沉,最后总能覆盖全通道。

4) Intel 代码平台

x86 版本做了两件“加速不改义”的事:

  1. 大块转置
    transpose16x16_ps / transpose16x8_ps / transpose8x8_ps / transpose8x4_ps / _MM_TRANSPOSE4_PS
    把 “N 个 out × M 个 in 在同一 k 下的一坨标量” 一次性转置成 (i,j) 紧邻。等价于把上面伪代码中 for i, for j 的两层循环向量化。
  2. 按索引采样(gather)
    _mm_i32gather_ps / _mm256_i32gather_ps / _mm512_i32gather_ps
    把“跨 maxk stride 的元素”批量抓出来;没有 AVX2/512 时,就落回标量读 + 朴素 for(你代码里已有 fallback)。

其余细节:

  • kernel_tm.create(...) 那组 case:正是在为不同 (pb,pa) 组合选定 w 和分块后的 h/c
  • g00 += stride 的步进与 tile[((k*pa)+i)*pb + j] 的线性索引完全一致:
    • 写完一个 j → 前进 1;
    • 写完一个 i(pb 个)→ 前进 pb
    • 写完一个 k(pa×pb 个)→ 前进 pb*pa

5) 3×3 例子

kw=kh=3 → maxk=9inch=3outch=5,选 pb=4pa=2

  • w = 4*2*9 = 72
  • h = 3 分解为 2+1 → 2
  • c = 5 分解为 4+1 → 2

第一块 (q=0..3, p=0..1) 的 tile 前 8 个数依次是:

1
2
k=0, i=0: j=0..3 → W[0..3,0,0]
k=0, i=1: j=0..3 → W[0..3,1,0]

接着是 k=1 的 8 个……直到 k=8。任何 ISA 的实现,只要 tile 的线性布局是这个顺序,微核就能顺序“吞”下它。


6) 实战提示(跨平台通用)

  • 别死磕固定 pack:让 pb/pa 随 ISA 能力与微核设计变化,逻辑只关心“把 tile 写成 (k,i,j) 紧邻”。
  • 一次重排,多次使用:权重只 transform 一次;opt.lightmode 下可释放原权重以省内存。
  • 缓存友好w = pb*pa*maxk 让每个 tile 是一段连续小块,L1/L2 命中高。
  • 无向量也能跑:没有 AVX2/512/NEON 就用朴素 for,正确性先行;需要时再替换为 transpose + gather 的批量路径。
  • 与 Winograd / im2col 的关系
    • im2col+GEMM:对 A/B 面都讲究 pack,权重这边就是把 K 维(in×maxk)与 M 维(out)按微核偏好重排;
    • Winograd:先做 G * g * G^T 得到 Winograd 域权重,再对变换后的片做同样的 (k,i,j) 打包(此时的“k”是 Winograd 域的 B 维)。

7) 结语

Packed Kernel Transform 的“通性”

先决定 pb/pa,再把 W[q+j, p+i, k]k→i→j 顺序写成连续内存,
让微核一次把一块 pb×pa×maxk 吃干抹净。平台不同只是“怎么更快地搬”,算法本身一模一样

该封面图片由Karl EggerPixabay上发布