读 ncnn 源码(Ⅶ):以卷积层为例——权重加载与 x86/FMA pipeline 选路
读 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=f16、0x000D4B38=int8、0x0002C056=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 | weight_data = mb.load(weight_data_size, 0); |
- 权重使用
type=0(让ModelBin自动识别存储格式并解码); - 偏置用
type=1(强制 float32),稳定可靠。
1.3 int8 量化相关(可选)
1 | if (int8_scale_term) { |
- 根据导出策略,可能只有输入侧/权重侧的缩放,也可能还包含输出侧(
>100的分支是历史兼容/扩展位)。
1.4 运行时量化(float → int8)
1 | if (weight_data.elemsize == 4u && int8_scale_term) { |
- 如果权重以 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 | union |
为什么这样设计?
- 从
DataReader一次读入 4 个字节,通过 union 叠放,既能把它们当作一个 32 位整型的tag(用于匹配明确的魔数),也能逐字节访问成f0..f3(用于与端序无关的“粗判断”)。 - 在大端平台会对
tag做 字节序交换(swap_endianness_32),确保魔数匹配正确;但f0..f3的“逐字节求和”并不依赖端序——求和不关心顺序。
tag 是干什么的?
tag 用来识别强约定的存储格式(所谓“魔数”):
0x01306B47→ float160x000D4B38→ int80x0002C056→ float32 raw(带额外缩放语义)
命中这些魔数,就走对应的、明确的解码分支。
flag(四个字节求和)是干什么的?
源码里有这样一行:
1 | unsigned int flag = (int)flag_struct.f0 + flag_struct.f1 + flag_struct.f2 + flag_struct.f3; |
随后逻辑是:
- 若
tag命中了上述三种魔数 → 走对应分支; - 否则:
- 如果
flag != 0→ 走“表驱动量化”分支:- 先读 256 个 float 的查表
quantization_value[256] - 再读
w个uint8的索引数组 - 输出时
out[i] = quantization_value[index[i]]
- 先读 256 个 float 的查表
- 否则(
flag==0)→ 走 float32 raw 的兜底分支。
- 如果
直觉上,“flag!=0” 表示这是一个旧格式/兼容格式,需要按“量化查表+索引”的方式还原;
“flag==0” 表示没有量化表,按最朴素的 float32 读取就行。
那为什么代码里写 else if (flag_struct.f0 == 0)?
你会看到这样的片段:
1 | if (flag != 0) { |
- 这里的
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 | activation = create_activation_layer(activation_type, activation_params, opt); |
- 激活既可融合在 kernel 写回前,也可由
activation子层完成(这里是后者的通用做法)。
3.2 int8 推理的专门路径
1 | if (opt.use_int8_inference && weight_data.elemsize == 1u) |
- 当权重已经是 int8(上一步可能即时量化过),且显式开启 int8 推理,就走专用 pipeline。
3.3 dilation 特例:回退到 dilation=1 的子卷积
1 | if (!opt.use_packing_layout && kw==kh && dilation>1 && stride==1) { |
- 工程取舍:复杂的
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 | if (opt.use_packing_layout) { |
- 这一步决定后续 kernel 使用的 向量化打包宽度,对吞吐至关重要。
这两行代码的作用是为输入和输出张量(Tensor)选择最优的内存打包尺寸(Packing Size),目的是为了最大化利用 CPU 的 SIMD(单指令多数据流)并行计算能力。
为什么这么做?—— 为了 SIMD 加速
- SIMD 是什么:现代 CPU 为了加速计算,设计了 SIMD 指令集(例如 x86 平台的 SSE/AVX,ARM 平台的 NEON)。这些指令可以一次性对多个数据执行相同的操作。
- SSE/NEON 通常一次能处理 4 个 32位浮点数(128位寄存器)。
- AVX 通常一次能处理 8 个 32位浮点数(256位寄存器)。
- 数据打包 (Packing):为了让 CPU 高效地执行 SIMD 指令,内存中的数据布局必须是连续的。ncnn 这类框架会改变默认的
NCHW内存布局。它会将通道(Channel)维度进行“打包”,变成一个对硬件更友好的格式,例如NCHWc4或NCHWc8。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 | bool prefer_winograd = (use_winograd23/43/63) && (in>8 || out>8); |
- 高吞吐的小核常规。在足够大的通道/特征图下,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 | int l2 = get_cpu_level2_cache_size(); |
- 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 | if (命中若干 elempack+kernel+stride 的 SSE 专项条件) |
- 针对不同
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=3、stride=2、dilation=1、in_c=3、out_c=64。 -
Winograd 条件不满足(需要 s=1 才行)。
-
**sgemm?**在 x86/FMA 上,
in=3、out=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 后面跟 k 个 0。在两补码里:
-n= 对n按位取反再加一 → 形状是:高位全 1,低 k 位全 0。
例如:n=4 (100b),-n = ...11111100bn=8 (1000b),-n = ...11111000b
sz + n - 1:把sz先抬到下一个n的边界之前(最多抬n-1)。(... ) & -n:用-n这个掩码把低 k 位清零,就得到一个 n 的整数倍;因为我们已经做了+ n - 1的“抬高”,清零后得到的就是向上取整的结果。
小例子
alignSize(13, 4):13 + 3 = 16,16 & -4 = 16alignSize(16, 4):16 + 3 = 19,19(10011b) & ...11111100b = 10000b = 16alignSize(17, 8):17 + 7 = 24,24 & -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 Lundgren在Pixabay上发布





