读 ncnn 源码(Ⅶ):以卷积层为例——权重加载与 x86/FMA pipeline 选路

承接(Ⅵ)的“全局模型权重加载链路”,这篇聚焦卷积层:权重怎么从 .bin 读进来、什么时候在运行时做 int8 量化、以及 x86/FMA 实现如何根据核大小/步长/膨胀/通道数/缓存选择 Winograd、sgemm 或 packed 路线。

TL;DR

  • Convolution::load_model(mb)
    • dynamic_weight==1跳过读权重(权重来自第二个 bottom);
    • 否则读 weight_data_size 个权值(type=0 自动识别 f16/int8/f32/表量化);有偏置再读 num_output 个 float;
    • NCNN_INT8 && int8_scale_term → 读缩放表,且可在运行时把 float 权重量化成 int8,替换到 weight_data
  • 读法由 ModelBinFromDataReader::load(w,type)决定:
    0x01306B47=f160x000D4B38=int80x0002C056=float32 raw(带额外缩放语义)flag!=0=表驱动量化flag.f0==0=float32 raw。优先零拷贝,其次读入并对齐到 4 字节(alignSize(...,4))。
  • Convolution_x86_fma::create_pipeline(opt)
    • 先建 activation 子层;
    • int8 权重use_int8_inference → 走 create_pipeline_int8_x86
    • dilation>1 且 stride=1、不启 packing → 构造一个 dilation=1 的子卷积(把你的权重直接喂过去)来重用成熟内核;
    • 否则决策:Winograd(3×3 s1 d1,且通道较大时)→ sgemm(大通道或 1×1 时)→ packed(按 elempack/out_elempack 落在 SSE/AVX/AVX512 对应条件);
    • opt.lightmode 下,权重一旦被变换完就释放 base 权重(减内存)。


1) 卷积层读权重:Convolution::load_model(mb)

1.1 动态权重的短路

1
if (dynamic_weight) return 0;
  • 运行时权重(第二个 bottom 传进来)→ 不从 .bin 读。

1.2 权重与偏置

1
2
weight_data = mb.load(weight_data_size, 0);
if (bias_term) bias_data = mb.load(num_output, 1);
  • 权重使用 type=0(让 ModelBin 自动识别存储格式并解码);
  • 偏置type=1(强制 float32),稳定可靠。

1.3 int8 量化相关(可选)

1
2
3
4
5
6
7
if (int8_scale_term) {
weight_data_int8_scales = mb.load(num_output, 1);
bottom_blob_int8_scales = mb.load(1, 1);
}
if (int8_scale_term > 100) {
top_blob_int8_scales = mb.load(1, 1);
}
  • 根据导出策略,可能只有输入侧/权重侧的缩放,也可能还包含输出侧(>100 的分支是历史兼容/扩展位)。

1.4 运行时量化(float → int8)

1
2
3
if (weight_data.elemsize == 4u && int8_scale_term) {
// reshape -> quantize_to_int8 -> reshape 回 1D
}
  • 如果权重以 float 存储且你启了 int8 路径,这里即时量化(每输出通道一尺度),替换 weight_data 为 int8,并沿用同一个 allocator(减少额外开销)。

2) .bin 里到底存了什么?ModelBinFromDataReader::load(w,type)

自动识别(type==0)的关键分支

flag_struct.tag 语义 读法
0x01306B47 float16 w×uint16(4B 对齐);转成 float 存 Mat::from_float16
0x000D4B38 int8 w×int8(4B 对齐);Mat(w, elem_size=1)
0x0002C056 float32 raw(“with extra scaling”的 raw) 直接读 w×float
其它且 flag!=0 表驱动量化 先读 256×float 查表,再读 w×uint8 索引,解码到 float
flag==0 && f0==0 float32 raw 直接读 w×float
  • 小端平台优先 reference() 实现 零拷贝(适配内存映射/打包场景),否则才 read()
  • 所有分支都会做 4 字节对齐alignSize(sz,4)),保证访存与跨平台稳定性。
  • type==1强制 float32 路径,常用于偏置/尺度等确定为 FP32 的权重。

2.1flag_struct:一次读取、两种解读(tag vs. bytes)

1
2
3
4
5
6
7
8
9
10
11
union
{
struct
{
unsigned char f0;
unsigned char f1;
unsigned char f2;
unsigned char f3;
};
unsigned int tag;
} flag_struct;

为什么这样设计?

  • DataReader 一次读入 4 个字节,通过 union 叠放,既能把它们当作一个 32 位整型的 tag(用于匹配明确的魔数),也能逐字节访问成 f0..f3(用于与端序无关的“粗判断”)。
  • 在大端平台会对 tag字节序交换swap_endianness_32),确保魔数匹配正确;但 f0..f3 的“逐字节求和”并不依赖端序——求和不关心顺序

tag 是干什么的?

tag 用来识别强约定的存储格式(所谓“魔数”):

  • 0x01306B47float16
  • 0x000D4B38int8
  • 0x0002C056float32 raw(带额外缩放语义)

命中这些魔数,就走对应的、明确的解码分支。

flag(四个字节求和)是干什么的?

源码里有这样一行:

1
unsigned int flag = (int)flag_struct.f0 + flag_struct.f1 + flag_struct.f2 + flag_struct.f3;

随后逻辑是:

  1. tag 命中了上述三种魔数 → 走对应分支;
  2. 否则
    • 如果 flag != 0 → 走“表驱动量化”分支:
      • 先读 256 个 float 的查表 quantization_value[256]
      • 再读 wuint8 的索引数组
      • 输出时 out[i] = quantization_value[index[i]]
    • 否则flag==0)→ 走 float32 raw 的兜底分支。

直觉上,“flag!=0” 表示这是一个旧格式/兼容格式,需要按“量化查表+索引”的方式还原;
“flag==0” 表示没有量化表,按最朴素的 float32 读取就行。

那为什么代码里写 else if (flag_struct.f0 == 0)

你会看到这样的片段:

1
2
3
4
5
6
if (flag != 0) {
// 量化表分支
}
else if (flag_struct.f0 == 0) {
// float32 raw 分支
}
  • 这里的 else if (flag_struct.f0 == 0) 实际上等价于 else(因为上面已判断 flag==0,说明四个字节都为 0,自然 f0==0)。
  • 写成 f0==0 大概是历史风格/可读性考虑:表明“第一个字节是 0,按原始 float32 读”。功能上与 else 无差别

总结一下差别

  • tag:强匹配具体魔数,用来区分 float16/int8/特殊 float32 这些“显式标记”的格式;
  • flag(四字节求和):作为“是否为量化表格式”的兜底判断(非零 → 量化表;全 0 → 原始 float32)。
  • 这种“双通道”判断既兼容了新式显式标记,也兼容了早期/其它导出工具的一些格式差异(很实用的工程折中)。

3) x86/FMA 的 pipeline 构建:怎么选路?

3.1 预设:激活与线程数

1
2
activation = create_activation_layer(activation_type, activation_params, opt);
nT = opt.num_threads;
  • 激活既可融合在 kernel 写回前,也可由 activation 子层完成(这里是后者的通用做法)。

3.2 int8 推理的专门路径

1
2
if (opt.use_int8_inference && weight_data.elemsize == 1u)
return create_pipeline_int8_x86(opt);
  • 当权重已经是 int8(上一步可能即时量化过),且显式开启 int8 推理,就走专用 pipeline。

3.3 dilation 特例:回退到 dilation=1 的子卷积

1
2
3
4
5
6
7
8
9
if (!opt.use_packing_layout && kw==kh && dilation>1 && stride==1) {
convolution_dilation1 = create_layer_cpu(LayerType::Convolution);
// 用 pd 重设 dil=1/stride=1/pad=0,塞同样权重/偏置
convolution_dilation1->load_param(pd);
convolution_dilation1->load_model(ModelBinFromMatArray(weights));
convolution_dilation1->create_pipeline(opt);
if (opt.lightmode) weight_data.release();
return 0;
}
  • 工程取舍:复杂的 dilation>1 场景交给已有的 dilation=1 内核 + 外挂采样逻辑处理,代码复用、性能可靠。
  • ModelBinFromMatArray 允许把**内存中的 Mat 直接当作“权重源”**喂给子层,绕过 .bin

Dilation 是什么?(核心概念)

Dilation(膨胀),在深度学习的卷积操作中,指的是 卷积核(Kernel)元素之间的间隔。它也被称为 空洞卷积 (Atrous Convolution)

  • 标准卷积 (dilation = 1): 卷积核的每个元素都紧密相连,作用于输入特征图上一个连续的邻域。一个 3x3 的标准卷积核,它的感受野(Receptive Field)就是 3x3。
  • 膨胀卷积 (dilation > 1): 卷积核的元素之间被插入了 dilation - 1 个“空洞”。这意味着卷积核会跳过一些像素,去采样一个更广阔区域的输入。卷积核本身的参数数量(权重)不变,但它的感受野变大了

形象化理解:

想象一下用一个 3x3 的印章去盖章:

  • dilation = 1:就是正常的盖章,印在 3x3 的区域上。
  • dilation = 2:你把印章的 9 个触点拉开,中间都隔开 1 个像素的空隙。虽然印章还是 9 个点(参数不变),但它覆盖的区域变成了一个 5x5 的范围。

3.4 elempack / out_elempack 决策(SSE/AVX/AVX512)

1
2
3
4
5
if (opt.use_packing_layout) {
// AVX512: 16/8/4;AVX: 8/4;SSE2: 4
elempack = (num_input % vec == 0) ? vec : ... ;
out_elempack = (num_output % vec == 0) ? vec : ... ;
}
  • 这一步决定后续 kernel 使用的 向量化打包宽度,对吞吐至关重要。

这两行代码的作用是为输入和输出张量(Tensor)选择最优的内存打包尺寸(Packing Size),目的是为了最大化利用 CPU 的 SIMD(单指令多数据流)并行计算能力。

为什么这么做?—— 为了 SIMD 加速

  1. SIMD 是什么:现代 CPU 为了加速计算,设计了 SIMD 指令集(例如 x86 平台的 SSE/AVX,ARM 平台的 NEON)。这些指令可以一次性对多个数据执行相同的操作
    • SSE/NEON 通常一次能处理 4 个 32位浮点数(128位寄存器)。
    • AVX 通常一次能处理 8 个 32位浮点数(256位寄存器)。
  2. 数据打包 (Packing):为了让 CPU 高效地执行 SIMD 指令,内存中的数据布局必须是连续的。ncnn 这类框架会改变默认的 NCHW 内存布局。它会将通道(Channel)维度进行“打包”,变成一个对硬件更友好的格式,例如 NCHWc4NCHWc8
    • elempack = 8:意味着框架会把每 8 个通道的数据打包在一起。这样,当进行卷积计算时,CPU 可以用一条 AVX 指令同时加载这 8 个通道的数据,并同时完成 8 次乘加运算,效率极高。
    • elempack = 4:意味着框架会把每 4 个通道的数据打包在一起,以适配 SSE 或 NEON 指令。
    • elempack = 1:意味着通道数不规整(例如 3, 7, 13),无法高效打包。此时框架会退回到最朴素的逐个计算方式(或者采用需要额外处理的 padding 策略),性能相对较低。

3.5 先看 Winograd:3×3 s1 d1 的王道选项

1
2
3
4
5
6
7
8
bool prefer_winograd = (use_winograd23/43/63) && (in>8 || out>8);
if (use_winograd_convolution && prefer_winograd && kw==kh==3 && dil==1 && str==1) {
if (动态形状) 直接选 23/43/63 的默认;
else 根据 (w,h,in,out) 测试优选 23/43/63,并考虑用户禁用位的回退;
变换权重到 weight_winogradXX_data;
if (lightmode) release base weight_data;
return 0;
}
  • 高吞吐的小核常规。在足够大的通道/特征图下,Winograd 通常胜于直接 sgemm。

Winograd 算法是什么?

你可以把 Winograd 算法看作是专门用于小型卷积核(特别是 3x3)的一种“快速傅里叶变换”(FFT)。它通过将输入数据和卷积核转换到一个特殊的数学空间,将原来复杂的卷积运算变成简单的逐元素相乘,从而大幅减少计算所需的乘法次数,带来显著的性能提升(通常超过2倍)。

这个算法效果拔群,但主要适用于步长为1的3x3卷积,而这正是这段代码的目标优化场景。

代码逻辑解析

这段代码是模型加载阶段的决策核心。

初步筛选

代码开头的 if 语句像一个过滤器,用于判断当前卷积层是否满足使用 Winograd 的基本条件:

  • 全局开关:用户是否在选项中启用了 Winograd。
  • 适用场景:当前层是否为步长为1的3x3卷积
  • 性能启发:一个简单的性能判断 (prefer_winograd),比如对于通道数过少(例如小于8)的“薄”层,Winograd 的转换开销可能会得不偿失,不如使用传统卷积。

动态与静态尺寸决策

代码接着根据输入张量的尺寸是否已知,分为两种决策流程:

  • 动态尺寸 (Dynamic Shape):如果模型加载时未指定输入尺寸,代码会基于启发式规则(如输入输出通道数)来做一个“最佳猜测”,选择一个通用性能较好的 Winograd 版本(如 F(4,3)F(6,3))。
  • 静态尺寸 (Static Shape):这是更常见且更优的场景。代码会调用性能测试函数 (test_prefer_winograd...),它会根据具体的张量宽度、高度和通道数进行精确计算,从而选择在该硬件和尺寸下表现最快的 Winograd 版本(F(2,3), F(4,3), 或 F(6,3))。

策略回退与权重转换

代码设计了一个非常稳健的回退机制 (Fallback)。例如,如果性能测试表明 F(6,3) 是最优选择,但用户在选项中禁用了它,代码会自动回退到次优且可用的选项(如 F(4,3)),确保了灵活性和性能。

决策完成后,代码会调用相应的 ..._transform_kernel 函数。这个函数的任务是对原始的卷积权重进行一次性预处理,将它们转换到 Winograd 空间。这个转换是在模型加载时完成的,之后在推理过程中就可以直接使用这些转换好的权重,从而实现加速。

内存优化

最后,如果启用了 lightmode(轻量模式),代码会在转换完成后释放掉原始权重的内存,这对于在手机等内存受限的设备上部署模型至关重要。

3.6 再看 sgemm:大通道或 1×1 的强势

1
2
3
4
5
6
7
int l2 = get_cpu_level2_cache_size();
bool prefer_sgemm = in*out*kw*kh*...*2*sizeof(float) > l2 || (in>16 || out>16);
if ((use_sgemm_convolution && prefer_sgemm) || (kw==1 && kh==1)) {
convolution_im2col_gemm_transform_kernel(..., weight_sgemm_data, ...);
if (lightmode) weight_data.release();
return 0;
}
  • 1×1 卷积几乎总是 sgemm 的地盘;大通道/大核积累的算量也会推向 sgemm。

这部分代码是一个性能决策引擎。它通过一系列启发式规则(Heuristics)来判断,对于当前的卷积层,是使用传统的“直接卷积”算法快,还是使用im2col + GEMM的策略更快。

1. 定义性能启发式规则 (prefer_sgemm)

这一行复杂的布尔表达式是在“猜测”使用 GEMM 是否划算:

1
bool prefer_sgemm = num_input * ... * 2 > l2_cache_size || (num_input > 16 || num_output > 16);

它包含两个条件,满足其一即可:

  • 缓存大小判断... > l2_cache_size
    • 这个长长的乘法是在估算卷积操作所需的临时数据量
    • l2_cache_size 是 CPU 的二级缓存大小,这是访问速度非常快的一小块内存。
    • 逻辑是:如果计算所需的数据量远大于 L2 缓存,那么数据反正都要频繁地从主内存中读写,此时 im2col 造成的内存开销影响较小,而 GEMM 本身极高的计算效率将占据主导优势。反之,如果数据能装进 L2 缓存,那么传统的直接卷积可能因为更好的数据局部性而更快。
  • 通道数判断num_input > 16 || num_output > 16
    • 这是一个更简单的规则。如果卷积层的输入或输出通道数很多(这里是大于16),通常意味着这是一个计算密集型的“重”卷积。对于这类卷积,GEMM 的计算优势几乎总是能胜出。

2. 最终决策 if(...)

最终的 if 语句结合了上面的启发式规则和一个特殊情况:

  • (opt.use_sgemm_convolution && prefer_sgemm):如果用户在全局选项中开启了 SGEMM 优化,并且上面的启发式规则也建议使用,那么就采纳。
  • || (kernel_w == 1 && kernel_h == 1)这是一个特殊捷径。对于 1x1 的卷积,它在数学上天然等价于一次矩阵乘法,根本不需要 im2col 这个复杂的步骤。因此,使用 GEMM 来实现 1x1 卷积是最直接、最高效的方式,无需任何犹豫。

3. 执行权重转换

如果决策结果是使用 GEMM,代码就会调用 convolution_im2col_gemm_transform_kernel。这个函数会在模型加载时执行一次,将原始的卷积权重预先转换并重排成 GEMM 所需的矩阵形态,为后续的快速计算做好准备。

3.7 否则:packed 路径(通用 fallback)

1
2
3
4
5
6
7
if (命中若干 elempack+kernel+stride 的 SSE 专项条件)
convolution_transform_kernel_packed_sse(..., weight_data_tm, ...);
else
convolution_transform_kernel_packed(..., weight_data_tm, ...);

if (lightmode) weight_data.release();
return 0;
  • 针对不同 elempack/out_elempack 与常见 3×3/2×2 组合,有一组 SSE/AVX 专项变换;
  • 这一步把权重重排到“计算友好”的布局(weight_data_tm),为 forward() 做准备。

4) 回到 SqueezeNet:conv1 实际会走哪条?

你的 .param

1
Convolution conv1 ... 0=64 1=3 3=2 5=1 6=1728 9=1
  • kw=kh=3stride=2dilation=1in_c=3out_c=64

  • Winograd 条件不满足(需要 s=1 才行)。

  • **sgemm?**在 x86/FMA 上,in=3out=64,通道不算很大,且 stride=2;通常会落到 packed 路径

  • elempack/out_elempack

    • in=3 → 不能 pack4/8 → elempack=1
    • out=64 → 常能 pack8(AVX)或 pack16(AVX512),所以 out_elempack=8/16
  • create_pipeline() 里有专门的条件:

    1
    (elempack==1 && out_elempack==8 && kw==3 && kh==3 && dil==1 && stride==2) → packed_sse

    因此 conv1 会执行 convolution_transform_kernel_packed_sse,把权重重排到 weight_data_tm,随后 forward() 走 packed 实现。


5) alignSize(sz, n) 的小注脚

1
static inline size_t alignSize(size_t sz, int n) { return (sz + n - 1) & -n; }

它在做什么?

sz 向上取整到最小的、能被 n 整除的数(且 n 必须是 2 的幂,这是源码注释里的约束)。

原理分解(两补码 + 掩码)

假设 n = 2^k,二进制恰好是 1 后面跟 k0。在两补码里:

  • -n = 对 n 按位取反再加一 → 形状是:高位全 1,低 k 位全 0
    例如:
    • n=4 (100b)-n = ...11111100b
    • n=8 (1000b)-n = ...11111000b
  • sz + n - 1:把 sz 先抬到下一个 n 的边界之前(最多抬 n-1)。
  • (... ) & -n:用 -n 这个掩码把低 k 位清零,就得到一个 n 的整数倍;因为我们已经做了 + n - 1 的“抬高”,清零后得到的就是向上取整的结果。

小例子

  • alignSize(13, 4)13 + 3 = 1616 & -4 = 16
  • alignSize(16, 4)16 + 3 = 1919(10011b) & ...11111100b = 10000b = 16
  • alignSize(17, 8)17 + 7 = 2424 & -8 = 24

注意点

  • n 必须是 2 的幂,否则 -n 的二进制是“高位全 1 + 低位全 0”的掩码,公式失效。
    若需通用写法,用整除法:((sz + n - 1) / n) * n(但要注意潜在溢出;ncnn 这里选择了更快的位运算方案)。
  • 这个对齐在 ModelBin 读取里很常见(比如 half/int8 读取时对齐到 4 字节)——让底层缓冲对齐,有利于零拷贝和 SIMD 访存。

6) 小结

  • 卷积层的 load_model:把权重按需解码(f16/int8/f32/表量化),必要时在运行时量化成 int8;
  • x86/FMA 的 create_pipeline:按 dilation/stride/kernel通道规模缓存打包能力,在 Winograd → sgemm → packed 之间选路;

该封面图片由Tracy LundgrenPixabay上发布