读 ncnn 源码(Ⅸ):im2col+GEMM 原理与 `Mat::reshape(w,h,c)` 的对齐与 cstep
读 ncnn 源码(Ⅸ):im2col+GEMM 原理与 Mat::reshape(w,h,c) 的对齐与 cstep
TL;DR
- 卷积可重写为矩阵乘:令
maxk=kw*kh、M=outch、K=inch*maxk、N=Hout*Wout,则有 A(M×K) × B(K×N) = C(M×N)。A 由权重重排得到,B 由输入经 im2col 展开得到。 - 为跑出 SIMD 峰值,ncnn 先把 A/B 预打包成微内核喜欢的布局(按 tile 和 elempack),再做 GEMM。
Mat::reshape(w,h,c)不改变元素个数;关键是按 16 字节对齐计算cstep(每个通道的步长),必要时新建缓冲并逐通道 memcpy;否则尽量 header-only 视图重构(零拷贝)。- 触发拷贝的典型情况:
w*h*elemsize不是 16 字节对齐、或改变了c导致需要“先拉平再按新通道对齐”。

一、im2col + GEMM:把卷积“摊平”成矩阵乘
以 NCHW 为例,单张图片、步幅 s、无 dilation 的标准 2D 卷积:
- A(权重矩阵):
- 每个输出通道是一根长度
K = inch * (kw*kh)的向量; - 堆成
M = outch行,得到 A(M×K)。
- 每个输出通道是一根长度
- B(im2col 特征矩阵):
- 对输入特征图的每个输出位置
(y,x),把其卷积感受野内的inch * kw * kh个值按固定顺序展开,形成一列; - 所有输出位置拼起来,列数
N = Hout * Wout,得到 B(K×N)。
- 对输入特征图的每个输出位置
- C(输出矩阵):
- 结果 C = A × B,形状 (M×N);再把它 reshape 回
(outch, Hout, Wout)。
- 结果 C = A × B,形状 (M×N);再把它 reshape 回
这就是 ncnn 在 sgemm 路线里做的事:
- 预先把 A 按 SIMD 友好的方式重排(pack);
- 前向时把输入特征做 im2col 并 pack 成 B 的 tile;
- 跑 GEMM 微内核(FMA/AVX/AVX512 等),最后再加偏置/激活。
口诀:卷积=GEMM,靠的不是“数学魔法”,而是形状重排 + 内存布局优化 + 分块并行。
二、读 Mat::reshape(w,h,c):为什么要对齐?cstep 是什么?
首先,我们再次明确这个 reshape 函数的核心原理:
它不仅仅是改变逻辑维度(修改 w, h, c 的值),它的“灵魂”在于处理并保证物理内存布局。
- 快速路径 (零拷贝):如果改变维度后,数据依然能满足“每通道起始地址16字节对齐”的要求,它就只修改头信息(维度、步长),共享同一块内存。速度极快。
- 慢速路径 (内存拷贝):如果改变维度会破坏对齐,它就会不惜代价——重新开辟一块严格对齐的新内存,并把旧数据拷贝过去——来强制维持内存对齐这一黄金法则。
reshape 在 im2col+GEMM 流程中的两大作用
im2col+GEMM 本质上是将卷积(一种四维张量运算)转换为矩阵乘法(二维运算)。这个“转换”过程的头和尾,都离不开 reshape。
作用一:准备权重矩阵 (模型加载时)
- 背景:一个卷积层的原始权重(kernel)通常是一个四维张量,维度为
[输出通道, 输入通道, 高, 宽]。 GEMM的要求:为了进行矩阵乘法,这个四维权重必须被转换成一个二维矩阵,维度为[输出通道, (输入通道 * 高 * 宽)],也就是我们之前讨论的[M, K]矩阵。reshape的角色:reshape正是执行这个“四维压成二维”操作的完美工具。在模型加载、进行权重转换时(例如在convolution_im2col_gemm_transform_kernel函数里),第一步往往就是调用reshape将权重拍平,为后续更精细的tiling和packing做准备。
作用二:还原输出结果 (模型推理时)
- 背景:
im2col+GEMM计算完成后,得到的结果是一个二维矩阵,维度为[输出通道, (输出高 * 输出宽)],也就是[M, N]矩阵。 - 下一层的要求:这个二维的结果对于下一层网络来说是无意义的,它需要的是一个三维的特征图(Feature Map),维度为
[输出通道, 输出高, 输出宽]。 reshape的角色:在推理流程的最后,需要调用reshape将这个平铺的二维结果,还原成下一层网络可以理解的三维特征图。这一步至关重要,并且性能要求极高。因为GEMM刚完成了数百万甚至上千万次的计算,如果最后这一步还需要耗时地拷贝数据,那么之前的优化效果就会大打折扣。因此,这一步几乎总是希望能命中reshape的快速路径(零拷贝),仅通过修改元数据就完成转换。
1 | Mat Mat::reshape(int _w, int _h, int _c, Allocator* _allocator) const |
核心设计哲学:逻辑维度 vs. 物理内存
要理解这个函数,首先要掌握一个核心概念:一个 Mat 对象包含两个层面的信息:
- 逻辑维度 (Logical Dimensions):这是我们作为用户所看到的维度,即
width(宽)、height(高)、channels(通道数)。 - 物理内存 (Physical Memory):这是数据在内存条上实际的、一维的、连续或非连续的存储方式。
reshape 的根本任务是在不改变数据总元素的前提下,修改逻辑维度。而这个 ncnn 版本 reshape 的灵魂在于,它在执行任务的同时,必须不惜一切代价捍卫一个性能黄金法则:内存对齐。具体来说,就是保证每个通道(channel)的数据块起始地址都是 16 字节对齐的,这样才能让 CPU 的 SIMD 指令(如 SSE/AVX)发挥出最大威力。
这个函数的全部复杂性,都源于在“改变逻辑维度”和“捍卫内存对齐”这两个目标之间寻找最佳路径。
详细过程讲解
第1步:合法性检查 (The Golden Rule)
1 | if (w * h * d * c != _w * _h * _c) |
- 过程:函数首先进行一个基本健全性检查。它计算原始
Mat的总元素数 (w*h*d*c) 和目标形状的总元素数 (_w*_h*_c)。 - 讲解:这是
reshape操作不可违背的铁律。
第2步:决策分叉口:是否需要“大动干戈”?
接下来,函数进入核心的决策逻辑,判断是否需要为了保持内存对齐而进行高成本的操作。
路径 A:“慢速路径” - 强制重新对齐(内存分配+数据拷贝)
1 | if ((size_t)_w * _h != alignSize((size_t)_w * _h * elemsize, 16) / elemsize) |
- 触发条件解析:
(size_t)_w * _h:这是新形状下,一个通道逻辑上应该包含的元素数量。alignSize(...) / elemsize:这是新形状下,为了保证16字节对齐,一个通道在物理内存中必须占据的元素空间。- 何时触发:当“逻辑所需”和“物理必须”不相等时。
- 例子:假设
float(4字节) 数据,目标形状是w=7, h=1。逻辑上需要7个元素。但为了让下一个通道的起始地址是16的倍数,当前通道必须占据alignSize(7 * 4, 16) = 32字节的空间,这相当于32 / 4 = 8个元素的空间。此时,逻辑所需(7) != 物理必须(8),条件成立,进入慢速路径。
- 例子:假设
- 处理过程:
- 创建新画布 (
m.create):函数发现无法在原有内存上“优雅地”实现对齐,于是只能放弃,调用create方法申请一块全新的、尺寸和对齐都完美的内存。 - 搬运数据 (
memcpy):因为内存是新的,必须将原始数据从旧内存中逐通道地拷贝到新内存的正确位置。
- 创建新画布 (
- 讲解:这是成本最高的一条路,因为它涉及昂贵的内存分配和数据拷贝。但这是必要的牺牲,它保证了函数返回的
Mat对象在性能上是“纯净”的,为后续的高性能计算扫清了障碍。
路径 B:“递归路径” - 化繁为简
1 | else if (c != _c) |
- 触发条件:当处理一个已经是多维的
Mat并且需要改变通道数c时,直接计算内存布局比较复杂。 - 处理过程:
- 拍平 (
reshapeto 1D):函数先递归调用自己,把整个Mat拍平成一个简单的一维长条向量。 - 重塑 (
reshapeto 3D):然后,再对这个内存布局极其简单的一维向量,调用reshape把它塑造成最终的目标三维形状。这一步通常能命中下面的“快速路径”。
- 拍平 (
- 讲解:这是一种非常聪明的“化繁为简”策略,通过一个中间状态(一维向量)来回避复杂的内存计算。
路径 C:“快速路径” - 零拷贝,只改元数据
1 | Mat m = *this; |
- 触发条件:如果代码能走到这里,说明是理想情况:新的逻辑维度可以直接在原有的物理内存上完美表达,无需移动任何数据。
- 处理过程:
Mat m = \*this;: 这是关键! 这里执行的是浅拷贝 (Shallow Copy)。新对象m和原始对象this共享同一块数据内存。这个操作几乎是瞬时的。- 修改元数据: 简单地修改
m的w,h,c等维度信息。 - 更新步长 (
m.cstep = ...): 重新计算cstep(通道步长),即在内存中从一个通道的开头跳到下一个通道的开头需要跨过多少个元素。这个新步长会根据对齐要求计算出来,正确地解释共享内存。
- 讲解:这是
reshape的最高境界——零拷贝 (Zero-Copy)。它不触碰任何实际的权重数据,只修改了几个描述数据形态的“标签”,因此速度极快。在im2col+GEMM的最后一步还原输出结果时,能否命中这条路径至关重要。
1)关键不变量:元素总数不变
w*h*d*c == _w*_h*_c,否则直接返回空Mat()(失败)。
reshape只是重解释形状,并不会增减元素。
2)为什么 16 字节对齐?
- 计算
cstep(每个通道的步长)时用
cstep = alignSize(w*h*elemsize, 16) / elemsize - 这保证了每个通道的起始地址是 16B 对齐,利于 SSE/AVX/AVX512 的向量装载、也方便后续以通道为单位的并行与打包。
- 若当前数据布局无法满足每通道都 16B 对齐,就需要重新分配一块满足对齐要求的缓冲,把各通道的实际数据复制过去(见上面
memcpy循环)。
直觉版:cstep 就是“每个通道在内存里占的槽位大小”。为了 SIMD 友好,这个槽位按 16 字节对齐到“整页”。
3)dims < 3 分支 vs. c != _c 分支
dims < 3(典型是 1D/2D)→ 你要变成(w,h,c)的 3D 视图:- 如果
(w*h*elemsize)本来就刚好 16B 对齐,直接零拷贝(改 header 即可)。 - 否则 新建对齐后的 3D 缓冲,逐通道 memcpy。
- 如果
dims >= 3且你还想改变通道数 → 必须先扁平化(变 1D,再 3D),中间可能触发一次性对齐复制,保证新的cstep合法。
4)“零拷贝 reshape”的条件
- 只有当你 不改
c(或从 <3D 变 3D 但w*h*elemsize已 16B 对齐)时,才能走header-only 的路径:- 直接复制
Mat头信息,改dims/w/h/c/d,重算cstep,不动数据指针。
- 直接复制
5)一个数字例子(看出 cstep 的变化)
- 假设
elemsize=4(float),w*h=225(比如 15×15)。- 原始每通道字节数:
225 * 4 = 900 B。 - 16B 对齐 →
alignSize(900,16) = 912 B。 cstep = 912 / 4 = 228(即每通道“占 228 个 float 的槽位”,其中最后 3×4=12B 是对齐 padding,不参与计算)。
- 原始每通道字节数:
- 如果对齐失败,就新建缓冲,使每个通道真实占用
cstep=228的空间,然后把 900B 的有效数据复制进去。
这就是你会在内存里看到**通道间有“空洞”*的原因——它们不是浪费,而是*为了后续 SIMD 和 tile 访问的性能。
三、reshape 与 im2col/GEMM 的关系
- im2col 需要把输入特征在 (kw,kh,inch) 维度上按固定顺序展开成列;
- 权重 pack 需要把 (outch, inch, kw, kh) 重排成 A 的 tile;
- 这些步骤都大量依赖于视图重构(
reshape/row/channel等)与对齐良好的步长(cstep)。 - 如果
reshape随意、不对齐,就会导致:- 向量装载跨界(unaligned load/store,多一条指令甚至多次访存);
- tile 访问跨 cache line;
- 甚至因为步长不整齐,不能走某些“整块转置”的微内核(性能大幅下滑)。
ncnn 在 reshape 层面把这些都兜住了:能零拷贝就零拷贝,不能就一次性对齐;上层算法就能假定“通道起点 16B 对齐、cstep 合法”。
四、和 elempack/elemsize 的微妙关系
elempack表示“每个元素里打了多少标量”(比如 pack4 = 4 个 float)。elemsize是“每个元素的字节数”,已经包含了 pack 带来的放大(例如 pack4 的 float,elemsize=16)。- 所以
alignSize(w*h*elemsize, 16)实际是在以“元素”为单位的线性 buffer上按 16B 对齐;只要elemsize设置正确,cstep的对齐就自然兼容 pack。
五、常见问题与排错建议
- reshape 返回空
Mat()
⇒ 先检查元素总数是否一致(是不是算错了_w*_h*_c)。 - 性能不稳定
⇒ 打印/断点看cstep是否按 16B 对齐;w*h*elemsize若大量不是 16 的倍数,会频繁触发“对齐复制”,前处理开销变大。 - pack 路径没命中
⇒ 看elempack是否能整除通道数,opt.use_packing_layout是否开启;reshape只负责“铺平且对齐”,命不中 pack 多是上层通道/形状的问题。 - 跨平台差异
⇒ 对齐边界选择(这里 16B)与 ISA 深度相关;在 AVX512 的权重打包中,后续通常再按 64B cacheline/512b 向量宽度做块内转置。
六、小结
- im2col+GEMM 的关键不在“数学变形”,而在数据布局:把 A/B 按 tile + 对齐 + 向量转置 的方式重排,才能让微内核高效吃数据。
Mat::reshape(w,h,c)的实现,是这条链上的“地基”:保证每个通道 16B 对齐的cstep,在必要时一次性复制把对齐问题处理干净,其余情况下则零拷贝视图重构。- 理解了
cstep、对齐与elemsize/elempack的关系,再看 ncnn 的权重/特征打包代码,就会顺很多:你会清楚每一次转置/打包,都是为了让下一层 按连续、满载、无跨界 的方式访问数据。
该封面图片由Erik Karits在Pixabay上发布





