读 ncnn 源码(Ⅺ):**Packed Kernel Transform 的“通性”**
读 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 | kernel_tm.create(/* w= */ pb*pa*maxk, |
channel(...) 下标的那串 q/16 + (q%16)/8 + ... 正是把输出通道块线性映射到 c 维的计算方式。
3) 怎么重排?(跨平台伪代码)
设原始权重 W[out, in, k]。对每个输出块 q..q+pb-1,每个输入块 p..p+pa-1,生成一个连续的 tile 并写入 kernel_tm:
1 | const int maxk = kw * kh; |
- 写入顺序固定为
(k → i → j),于是同一 k、同一 i 的 pb 个 out 连续。这正是微核一次 FMA 要“吃”的布局。 - 尾块:当
inch/outch不能整除时,沿 {16→8→4→2→1} 依次下沉,最后总能覆盖全通道。
4) Intel 代码平台
x86 版本做了两件“加速不改义”的事:
- 大块转置:
transpose16x16_ps / transpose16x8_ps / transpose8x8_ps / transpose8x4_ps / _MM_TRANSPOSE4_PS
把 “N 个 out × M 个 in 在同一 k 下的一坨标量” 一次性转置成(i,j)紧邻。等价于把上面伪代码中for i, for j的两层循环向量化。 - 按索引采样(gather):
_mm_i32gather_ps / _mm256_i32gather_ps / _mm512_i32gather_ps
把“跨maxkstride 的元素”批量抓出来;没有 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=9,inch=3,outch=5,选 pb=4、pa=2:
w = 4*2*9 = 72h = 3 分解为 2+1 → 2c = 5 分解为 4+1 → 2
第一块 (q=0..3, p=0..1) 的 tile 前 8 个数依次是:
1 | k=0, i=0: j=0..3 → W[0..3,0,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 Egger在Pixabay上发布





