自制深度学习推理框架-第九节-Im2Col原理与卷积层的实现
课程介绍
本节课的代码
git clone https://github.com/zjhellofss/KuiperCourse
git checkout nine
# 国内备用git地址 https://gitee.com/fssssss/KuiperCourse
我做了一个开源项目,包括视频课程、课件和开源代码三部分,教大家《从零自制深度学习推理框架》,用来面试简直 6 翻了。 视频教程
课程中心
上游项目 https://github.com/zjhellofss/KuiperInfer 感谢大家点赞.
课程预期达到的效果,可以对图片中的物体进行识别 (现在已经在上游项目中实现了)
卷积算子的计算过程
考虑到有些同学没有学习过深度学习的概念基础, 所以我们以简单的图示来表示卷积算子的计算过程.
现在有一个大小为4 × 4 × 3
的输入特征图, 4表示特征图的长和宽, 3表示其通道数量, 不同颜色表示不同的通道. 左边是我们的输入特征图, 右边3个是3 × 3 × 3
的卷积核, 为了叙述方便, 我们将3个卷积核分别表示为kernel1
, kernel2
和kernel3
. 每个kernel
表示1个卷积核, 每个卷积核的尺度用3个维度来表示, 分别为它的通道数(channel), 卷积核的长度(height)和卷积核的宽度(width).
在上图中, 卷积核的通道数(channel)为3, 卷积核的长和宽分别也都是3. 需要注意的是卷积核和输入特征中的通道数量要相同, 在本图例中就都是3. 卷积操作逐个, 逐个通道并按照滑动窗口的方式进行运算. 如下图所示, 是第一个卷积核(kernel1
)对输入特征图按照通道顺序对应进行卷积运算的操作过程.
同一个卷积核中按通道维对输入特征图进行卷积运算, kernel1
中的第1个通道对输入特征图中的第1个通道(红色部分)进行卷积运算, kernel1
中的第2个通道对输入特征图中的第2个通道(绿色)部分进行卷积运算,kernel1
中的第3个通道同理. 最后将三个通道在第1个窗口内的卷积计算结果相加, 得到最后的结果,有如下的公式所示:
o
u
t
p
u
t
c
h
a
n
n
e
l
1
=
1
×
1
+
1
×
2
+
1
×
3
+
2
×
5
+
2
×
6
+
2
×
7
+
3
×
7
+
3
×
8
+
3
×
9
=
114
o
u
t
p
u
t
c
h
a
n
n
e
l
2
=
1
×
1
+
1
×
2
+
1
×
3
+
2
×
5
+
2
×
6
+
2
×
7
+
3
×
7
+
3
×
8
+
3
×
9
=
114
o
u
t
p
u
t
c
h
a
n
n
e
l
3
=
1
×
1
+
1
×
2
+
1
×
3
+
2
×
5
+
2
×
6
+
2
×
7
+
3
×
7
+
3
×
8
+
3
×
9
=
114
o
u
t
p
u
t
r
e
s
u
l
t
=
o
u
t
p
u
t
c
h
a
n
n
e
l
1
+
o
u
t
p
u
t
c
h
a
n
n
e
l
2
+
o
u
t
p
u
t
c
h
a
n
n
e
l
3
=
342
\begin{align*} &output\, channel_1= 1\times1+1\times2+1\times3+2\times5+2\times6+2\times7+3\times7+3\times8+3\times9 = 114\\ & output\, channel_2= 1\times1+1\times2+1\times3+2\times5+2\times6+2\times7+3\times7+3\times8+3\times9 = 114 \\ &output\, channel_3= 1\times1+1\times2+1\times3+2\times5+2\times6+2\times7+3\times7+3\times8+3\times9 = 114 \\ &output\,result = output\,channel_1+output\,channel_2+output\,channel_3 = 342\\ \end{align*}
outputchannel1=1×1+1×2+1×3+2×5+2×6+2×7+3×7+3×8+3×9=114outputchannel2=1×1+1×2+1×3+2×5+2×6+2×7+3×7+3×8+3×9=114outputchannel3=1×1+1×2+1×3+2×5+2×6+2×7+3×7+3×8+3×9=114outputresult=outputchannel1+outputchannel2+outputchannel3=342
由于卷积计算的方式是以滑动窗口的方式进行的, 一般是先按行滑动, 遇到一行的结束的时候再转到下一行, 所以下一个卷积窗口的计算位置如下图所示:
前两次滑动窗口下的卷积运算结果会放到输出特征图第1维上的(0,0)
和(0,1)
位置上.
第2次卷积计算如下公式:
o
u
t
p
u
t
c
h
a
n
n
e
l
1
=
1
×
2
+
1
×
3
+
1
×
4
+
2
×
6
+
2
×
7
+
2
×
8
+
3
×
8
+
3
×
9
+
3
×
10
=
132
o
u
t
p
u
t
c
h
a
n
n
e
l
2
=
1
×
2
+
1
×
3
+
1
×
4
+
2
×
6
+
2
×
7
+
2
×
8
+
3
×
8
+
3
×
9
+
3
×
10
=
132
o
u
t
p
u
t
c
h
a
n
n
e
l
3
=
1
×
2
+
1
×
3
+
1
×
4
+
2
×
6
+
2
×
7
+
2
×
8
+
3
×
8
+
3
×
9
+
3
×
10
=
132
o
u
t
p
u
t
r
e
s
u
l
t
=
o
u
t
p
u
t
c
h
a
n
n
e
l
1
+
o
u
t
p
u
t
c
h
a
n
n
e
l
2
+
o
u
t
p
u
t
c
h
a
n
n
e
l
3
=
396
\begin{align*} &output\, channel_1= 1\times2+1\times3+1\times4+2\times6+2\times7+2\times8+3\times8+3\times9+3\times10 = 132\\ & output\, channel_2= 1\times2+1\times3+1\times4+2\times6+2\times7+2\times8+3\times8+3\times9+3\times10 = 132 \\ &output\, channel_3= 1\times2+1\times3+1\times4+2\times6+2\times7+2\times8+3\times8+3\times9+3\times10 = 132 \\ &output\,result = output\,channel_1+output\,channel_2+output\,channel_3 = 396\\ \end{align*}
outputchannel1=1×2+1×3+1×4+2×6+2×7+2×8+3×8+3×9+3×10=132outputchannel2=1×2+1×3+1×4+2×6+2×7+2×8+3×8+3×9+3×10=132outputchannel3=1×2+1×3+1×4+2×6+2×7+2×8+3×8+3×9+3×10=132outputresult=outputchannel1+outputchannel2+outputchannel3=396
第3次的计算方式如下:
经过4次滑动窗口计算, 对应的输出特征图的第1个通道的4个位置的数据, 如下图所示:
随后再使用第2和第3个卷积核kernel1
和 kernel2
卷积核分别得到输出特征图的第2和第3个通道数据(output channel 1
和output channel 2
), 最后得到的结果如下图所示:
Tips:
- 卷积运算中需要注意的, 每一个卷积核的通道数要和输入特征图的通道数相同
- 输出特征图的通道数等于卷积核的数量
Im2Col的原理
简单来说, im2col
就是以一个矩阵乘法来代替窗口滑动.
原始卷积计算方法的回顾
我们从上面的分析可以知道, 卷积的原始计算方法需要以一个kernel size
大小的窗口逐行, 逐列移动, 并在一个窗口内进行卷积运算, 最后将所有输入通道上该窗口的值相加, 得到输出特征图对应位置的卷积值.
Im2Col方法计算卷积
我们将以图示的例子来展示im2col
的原理:
单通道输入特征图和单通道的卷积核
上方红色的是单通道的输入特征图, 右边灰色的是进行卷积操作的卷积核. 我们将输入特征图按照窗口的顺序展开. 什么是按照第一个窗口展开呢?
第一个窗口内的内容如下
1
2
3
5
6
7
7
8
9
\begin{matrix} 1 &2 &3 \\ 5 & 6 & 7\\ 7 & 8 &9 \end{matrix}
157268379
随后将窗口内的数据展开, 注意这里展开的顺序是在同一个窗口内, 一列结束后再放置另外一列, 最后得到的结果如下:
1
2
5
6
5
6
7
8
7
8
11
12
2
3
6
7
6
7
8
9
8
9
12
13
3
4
7
8
7
8
9
10
9
10
13
14
\begin{aligned} \begin{array} {c} 1 & 2 &5&6\\ 5 &6 &7&8\\ 7 &8 &11&12\\ 2 &3 &6&7\\ 6 &7 &8&9\\ 8 &9 &12&13\\ 3 &4 &7&8\\ 7 &8 &9&10\\ 9 & 10&13&14 \end{array} \end{aligned}
15726837926837948105711681279136812791381014
所有窗口内的输入都展开后, 随后再展开卷积核:
1
2
3
1
2
3
1
2
3
\begin{aligned} \begin{array} {c} 1&2&3&1&2&3&1&2&3 \end{array} \end{aligned}
123123123
随后我们对按窗口展开后的输入特征图和展开后的卷积核进行矩阵相乘, 也就是:
reshape后得到结果:
114
132
174
192
\begin{matrix} 114 &132 \\ 174 &192 \end{matrix}
114174132192
多通道输入特征图和多通道的卷积核
最左边的矩阵是通道数为3的特征图, 3个通道分别用红, 绿, 黄体现.
- 多通道输入特征图按逐个通道进行展开, 展开的方式还是和之前是一样的, 每个通道展开的顺序还是按红, 黄, 绿依次叠放.
- 我们将卷积核依次展开, 展开的顺序也是按通道进行的. 按照
channel 1
,channel 2
,channel 3
横向摆放. - 最后进行矩阵乘法
- reshape后得到结果:
342 396 522 576 \begin{matrix} 342 &396 \\ 522 &576 \end{matrix} 342522396576
多通道输入特征图和多个多通道的卷积核
多个卷积核只要叠加叠加即可, 如图所示:
- 多通道输入特征图按逐个通道进行展开, 展开的方式还是和之前是一样的, 每个通道展开的顺序还是按红, 黄, 绿依次叠放.
- 我们将卷积核依次展开, 展开的顺序也是按通道进行的. 按照
channel 1
,channel 2
,channel 3
横向摆放. 一个卷积核的不同通道横向摆放, 不同卷积核之间竖向叠放 - 矩阵相乘得到result
- reshape后得到结果
分组卷积
我们假设group
的数量为2, 此时输入特征图和卷积核的通道数为4, 共有4个卷积核.
group
等于2, 将输入特征图的4个通道一分为2
- 一分为2的特征图每1份各有2个通道
- 将4个卷积核一分为2, 分成两组, 分别是
group1
和group2
,group1
处理第1份通道数为2的输入特征图,group2
处理第2份通道数为2的输入特征图
- 我们总结一下, 共有4个卷积核,
group1
包括第1个和第2个卷积核,group2
包括第3和第4个卷积核. 但是在做卷积的时候,group1
中的卷积核只使用它的前两个通道特征,group2
中的卷积核只使用它的后两个通道特征.
- 矩阵相乘得到结果
代码讲解
代码重点讲解ConvolutionLayer
中的forwards
函数.
CHECK(this->op_ != nullptr && this->op_->op_type_ == OpType::kOperatorConvolution);
CHECK(!inputs.empty()) << "Input is empty!";
CHECK(inputs.size() == outputs.size());
const auto &weights = this->op_->weight();
CHECK(!weights.empty());
std::vector<std::shared_ptr<ftensor >> bias_;
if (this->op_->is_use_bias()) {
bias_ = this->op_->bias();
}
const uint32_t stride_h = this->op_->stride_h();
const uint32_t stride_w = this->op_->stride_w();
CHECK(stride_w > 0 && stride_h > 0);
const uint32_t padding_h = this->op_->padding_h();
const uint32_t padding_w = this->op_->padding_w();
const uint32_t groups = this->op_->groups();
以上为输入数据校验和卷积参数获取的部分, 分别得到stride_h
, stride_w
等卷积计算中所需要的参数.
进入到for batch_size
循环内
const std::shared_ptr<Tensor<float>> &input = inputs.at(i);
CHECK(input != nullptr && !input->empty())
<< "The input feature map of conv layer is empty";
std::shared_ptr<Tensor<float>> input_;
if (padding_h > 0 || padding_w > 0) {
input_ = input->Clone();
input_->Padding({padding_h, padding_h, padding_w, padding_w}, 0);
} else {
input_ = input;
}
const uint32_t input_w = input_->cols();
const uint32_t input_h = input_->rows();
const uint32_t input_c = input_->channels();
const uint32_t kernel_count = weights.size();
CHECK(kernel_count > 0) << "kernel count must greater than zero";
uint32_t kernel_h = weights.at(0)->rows();
uint32_t kernel_w = weights.at(0)->cols();
CHECK(kernel_h > 0 && kernel_w > 0)
<< "The size of kernel size is less than zero";
uint32_t output_h = uint32_t(std::floor((input_h - kernel_h) / stride_h + 1));
uint32_t output_w = uint32_t(std::floor((input_w - kernel_w) / stride_w + 1));
CHECK(output_h > 0 && output_w > 0)
<< "The size of the output feature map is less than zero";
继续获取卷积计算所需要的一系列参数, 包括输入输出大小output_h
,output_w
等.
将卷积核展开, 例如将
1
4
7
2
5
8
3
6
9
\begin{matrix} 1 &4 & 7\\ 2 & 5 &8\\ 3 & 6 &9\\ \end{matrix}
123456789
展开得到
1
2
3
4
5
6
7
8
9
\begin{matrix} 1 &2 & 3 & 4&5&6&7&8&9 \end{matrix}
123456789
通道数量为3的卷积核展开便有, 不同通道之间是横向摆过去的
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
\begin{matrix} 1 &2 & 3 & 4&5&6&7&8&9&1 &2 & 3 & 4&5&6&7&8&9&1 &2 & 3 & 4&5&6&7&8&9 \end{matrix}
123456789123456789123456789
如果有3个通道为数量的卷积核展开便有, 不同卷积核之间竖向摆放
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
1
2
3
4
5
6
7
8
9
\begin{matrix} 1 &2 & 3 & 4&5&6&7&8&9&1 &2 & 3 & 4&5&6&7&8&9&1 &2 & 3 & 4&5&6&7&8&9\\ 1 &2 & 3 & 4&5&6&7&8&9&1 &2 & 3 & 4&5&6&7&8&9&1 &2 & 3 & 4&5&6&7&8&9\\ 1 &2 & 3 & 4&5&6&7&8&9&1 &2 & 3 & 4&5&6&7&8&9&1 &2 & 3 & 4&5&6&7&8&9 \end{matrix}
111222333444555666777888999111222333444555666777888999111222333444555666777888999
反映到如下的代码:
for (uint32_t k = 0; k < kernel_count_group; ++k) {
const std::shared_ptr<Tensor<float>> &kernel =
weights.at(k + g * kernel_count_group);
// 一个卷积核内的多个通道数据是横向摆放的
for (uint32_t ic = 0; ic < input_c_group; ++ic) {
memcpy(kernel_matrix_c.memptr() + row_len * ic,
kernel->at(ic).memptr(), row_len * sizeof(float));
}
// 不同卷积核之间是竖向摆放的.
kernel_matrix_arr.at(k) = kernel_matrix_c;
}
先是按照卷积核个数kernel_count_group
, 再按照通道数量input_c_group
对卷积核进行展开.
随后代码将输入特征图进行展开, 假设输入特征图是3 * 3
大小的, 卷积核大小是2*2
的,则有如下的展开:
i
n
p
u
t
=
1
2
3
4
5
6
7
8
9
input = \begin{matrix} 1&2&3\\ 4&5&6\\ 7&8&9 \end{matrix}
input=147258369
我们以滑动窗口的方式进行展开, 第一个窗口为
1
2
4
5
\begin{matrix} 1 & 2\\ 4&5 \end{matrix}
1425
展开后有:
1
4
2
5
\begin{matrix} 1 \\ 4\\ 2 \\ 5 \end{matrix}
1425
下一个窗口展开后有, 它和上一个窗口的数据是相邻的:
1
2
4
5
2
3
5
6
\begin{matrix} 1 &2\\ 4&5\\ 2&3 \\ 5&6 \end{matrix}
14252536
所有窗口经过滑动后, 展开后有:
1
2
4
5
4
5
7
8
2
3
5
6
5
6
8
9
\begin{matrix} 1 & 2 & 4 &5\\ 4 & 5 & 7&8\\ 2 & 3 & 5 &6\\ 5 & 6 &8 &9\\ \end{matrix}
1425253647585869
// 存放展开后的输入特征图
arma::fmat input_matrix(input_c_group * row_len, col_len);
for (uint32_t ic = 0; ic < input_c_group; ++ic) {
// 拿到输入特征图的一个通道
const arma::fmat &input_channel = input_->at(ic + g * input_c_group);
int current_col = 0;
// 在一个通道上进行滑动,窗口滑动
for (uint32_t w = 0; w < input_w - kernel_w + 1; w += stride_w) {
for (uint32_t r = 0; r < input_h - kernel_h + 1; r += stride_h) {
float *input_matrix_c_ptr =
input_matrix.colptr(current_col) + ic * row_len;
current_col += 1;
// 处理一个[卷积核窗口]内的多列数据
for (uint32_t kw = 0; kw < kernel_w; ++kw) {
// 不同窗口内的数据是行相邻的
const float *region_ptr = input_channel.colptr(w + kw) + r;
// 将卷积核窗口内的一列数据展开
memcpy(input_matrix_c_ptr, region_ptr, kernel_h * sizeof(float));
// 在同一个卷积核窗口中,跳转到下一列数据,下一列数据拼接到上一列的下方
input_matrix_c_ptr += kernel_h;
}
}
}
}
本节实验
单通道, 单个卷积
请看视频操作
多通道, 多个卷积
请看视频操作