卷积是CNN网络中一个非常重要的操作(Operation或Op),关于卷积的数学原理,大家可以参考维基百科:Convolution。但是我们这里给大家介绍的二维卷积又不同于传统数学的卷积,更像是correlation,因为我们这里的操作不需要进行kernel的旋转。
首发:https://zhuanlan.zhihu.com/p/72743784
作者:张新栋
本文中,我们跟大家介绍如何从tensorflow的graph中提取conv2d的参数,并以简单的约定格式进行保存。最后使用简单的C语言进行模型的加载和推断,最后和tensorflow的python调用结果进行比较,验证结果的正确性。本文我们仅做Op的准确性验证,后续的文章会再跟大家介绍如何针对嵌入式设备利用Halide、ACL(Arm Compute Library)、low-leval SIMD api等进行加速。
我们首先看看数据格式的约定,我们采用的数据排布格式为NCHW,仅考虑对float数据进行处理。Tensor的定义非常简单,可参考如下结构体。
typedef struct Tensor
{
float* data;
int dims[4];
int nums;
} Tensor;
- 卷积操作的基本实现
下面我们来看看卷积的操作,考虑一种非常平凡的实现方式,我们可以嵌套多层,以sliding-windows的方式来实现conv2d。其中,如下的OC、OH、OW指的是输出output的channel、height、width,FC、FH、FW同理。
int oc,oh,ow;
int fc,fh,fw;
for (oc = 0; oc < OC; ++oc)
{
for (oh = 0; oh < OH; ++oh)
{
for (ow = 0; ow < OW; ++ow)
{
float val = 0.0f;
int base_ow = ow * stride_w;
int base_oh = oh * stride_h;
int base_ss = oc * FC * FH * FW;
for (fc = 0; fc < FC; ++fc)
{
for (fh = 0; fh < FH; ++fh)
{
for (fw = 0; fw < FW; ++fw)
{
int input_idx =
(base_ow + fw) +
(base_oh + fh) * IW +
fc * IH * IW;
int filter_idx =
fw +
fh * FW +
fc * FH * FW +
base_ss;
val += (input->data[input_idx] * filter->data[filter_idx]);
}
}
}
int output_idx =
ow +
oh * OW +
oc * OH * OW;
output->data[output_idx] = val;
}
}
}
其中输出output高可以由如下公式进行计算,height为输入tensor的高度,filter\_height为卷积核的高度,padding为输入tensor在竖直方向的贴边长度,stride为滑动步长。
int getOutputHeight(
int height, int filter_height, int padding, int stride
)
{
int res = floor( (height - filter_height + 2 * padding) / stride) + 1;
return res;
}
- 解析tensorflow的卷积参数
在完成了卷积操作的开发后,我们看一下如何进行tensorflow的卷积参数获取。这一步,我们为了方便,使用python脚本进行处理,然后保存于自定义格式的文件中。最后我们只需要按照自定义的格式进行读取就可以复现tensorflow中的conv2d结果。下面我们先生成一个包含conv2d的graph,其实很简单,只需要如下两行代码。
## build graph
input = tf.placeholder(tf.float32,shape=(1,5,5,3),name="input")
conv1 = tf.layers.conv2d(
inputs=input, filters=3, kernel_size=[3, 3], padding='VALID',
strides=[1,1], activation=None, use_bias=False,trainable=True
)
此时tensorflow的default\_graph中就包含了conv2d的Op,下面我们看看如何从graph中提取卷积核的参数。下面是总的代码,其中graph\_val图的所有可训练的参数,graph\_def为包含graph的所有形状信息。随后我们从graph\_val中按照'conv2d/kernel:0'关键字就可以提取出conv2d的参数,最后在graph\_def中按照‘conv2d/Conv2D’关键字既可以提取出卷积的操作参数,如pad、stride、kernel shape等,最后写入文件。这里,我们一个Op保存两个文件,一个文件为values,存放Op的所有数值参数,如conv2d的卷积核参数;另一个文件为config,存放Op的操作参数,如conv2d的stride、padding和shape等;另外需要注意的是,tensorflow的数据排布格式为NHWC,所以输出的时候为了与后面我们的NCHW格式匹配,我们这里进行了数据通道的交换,a = np.transpose(a,(0,2,3,1))。
a = np.arange(75)
a = np.reshape(a, (1,3,5,5))
a = np.transpose(a, (0,2,3,1))
## parser parameter
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
tvars = tf.trainable_variables()
tvars_vals = sess.run(tvars)
graph_val = tf.get_default_graph()
graph_def = tf.get_default_graph().as_graph_def(add_shapes=True)
result = sess.run(conv1, feed_dict = {input: a})
result = np.transpose(result, (0, 3, 1, 2))
print result
values_file = open("./models/1_values.bat", "w")
config_file = open("./models/1_config.bat", "w")
weights = graph_val.get_tensor_by_name('conv2d/kernel:0').eval()
weights = np.transpose(weights, (3, 2, 0, 1))
shapes = list(weights.shape)
weights = weights.flatten()
N = len(weights)
config_file.write("CONV2D\n");
config_file.write(str(shapes[0]) + "\n")
config_file.write(str(shapes[1]) + "\n")
config_file.write(str(shapes[2]) + "\n")
config_file.write(str(shapes[3]) + "\n")
for i in range(0, N):
if i != N-1:
values_file.write(str(weights[i]) + "\n")
else:
values_file.write(str(weights[i]))
values_file.close()
for n in graph_def.node:
# print n
if n.name == "conv2d/Conv2D":
attrs = n.attr
padding = attrs['padding'].s
strides = np.array(attrs['strides'].list.i, dtype=np.uint8)
format = attrs['data_format'].s
config_file.write(padding + "\n")
config_file.write(str(strides[1]) + "\n")
config_file.write(str(strides[2]) + "\n")
config_file.write(format + "\n")
- 基于C的模型解析和测试
保存好的参数如下所示,values因为参数值比较多,我们这里省略(values的保存一行为一个值);我们看看config的参数,
CONV2D
3
3
3
3
VALID
1
1
NHWC
这里我们约定第一个为输入的Op类型,当我们判断输入的Op类型为conv2d时,我们按照如下约定进行模型解析:1. 读取第二行到第五行的五个参数,分别为NCHW,代表卷积核的形状;2. 读取卷积的类型,是valid或same;3. 读取数据的排布格式,NHWC;4. 基于初始好的tensor形状,读取values文件中的参数,注意数据排布为NCHW。可参考如下代码:
void extractOpConfig(OpConfig* opConfig, const char* filename)
{
assert(opConfig != NULL);
FILE* file = fopen(filename, "r");
char type[20];
fgets(type, sizeof(type), file);
if (strstr(type, "CONV2D") != NULL) {
opConfig->type = CONV2D;
char N_str[20];
fgets(N_str, sizeof(N_str), file);
int N = atoi(N_str);
char C_str[20];
fgets(C_str, sizeof(C_str), file);
int C = atoi(C_str);
char H_str[20];
fgets(H_str, sizeof(H_str), file);
int H = atoi(H_str);
char W_str[20];
fgets(W_str, sizeof(W_str), file);
int W = atoi(W_str);
char padding[20];
int pad_w;
int pad_h;
fgets(padding, sizeof(padding), file);
if(strstr(padding, "SAME") != NULL) {
pad_w = 0;
pad_h = 0;
} else {
pad_w = 0;
pad_h = 0;
}
char string_h_str[20];
fgets(string_h_str, sizeof(string_h_str), file);
int stride_h = atoi(string_h_str);
char string_w_str[20];
fgets(string_w_str, sizeof(string_w_str), file);
int stride_w = atoi(string_w_str);
char format[30];
fgets(format, sizeof(format), file);
opConfig->type = CONV2D;
opConfig->dims[0] = N;
opConfig->dims[1] = C;
opConfig->dims[2] = H;
opConfig->dims[3] = W;
opConfig->pad_w = 0;
opConfig->pad_h = 0;
opConfig->stride_w = stride_w;
opConfig->stride_h = stride_h;
}
fclose(file);
}
下面读取values的代码逻辑非常简单,每一行为一个值,按照NCHW的格式进行排布。
void extractOpWeights(Tensor* tensor, const char* filename)
{
assert(tensor != NULL);
FILE* file = fopen(filename, "r");
char line[50];
float* data = tensor->data;
while (fgets(line, sizeof(line), file))
{
float val = atof(line);
*data = val;
data ++;
}
fclose(file);
}
下面我们需要写业务代码进行模型测试,逻辑很简单,首先解析模型的config文件,然后根据config的参数进行values的初始化、conv2d的操作,最后跟python的输出结果进行精确性的对比。
void test_conv2d()
{
// conv2d operation
Tensor* input = NULL;
Tensor* kernel = NULL;
Tensor* output = NULL;
OpConfig* config = (OpConfig*) malloc(sizeof(OpConfig));
// init op parameters
int pad_h = config->pad_h;
int pad_w = config->pad_w;
int stride_h = config->stride_h;
int stride_w = config->stride_w;
//init kernel
extractOpConfig (config, "./models/1_config.bat");
int kernel_n = config->dims[0];
int kernel_c = config->dims[1];
int kernel_h = config->dims[2];
int kernel_w = config->dims[3];
initTensor(kernel, config->dims, 0.0f);
extractOpWeights(kernel, "./models/1_values.bat");
//init input
int input_h = 5;
int input_w = 5;
int input_c = 3;
int input_dims[4] = {1,input_c,input_h,input_w};
initTensor(input, input_dims, 1.0f);
for (int i = 0; i < input->nums; ++i)
input->data[i] = i;
//init output
int OH = getValidOutputHeight(input_h, kernel_h, stride_h);
int OW = getValidOutputWidth(input_w, kernel_w, stride_w);
int output_dims[4] = {1,config->dims[0],OH,OW};
initTensor(output, output_dims, 0.0f);
// Conv2d
Conv2d(input, output, kernel, stride_h, stride_w, pad_h, pad_w);
printTensor(output);
// free Tensor
freeTensor(input);
freeTensor(kernel);
freeTensor(output);
free(config);
}
- 最后
本文我们对应tensorflow中的二维卷积操作实现了简单的conv2d操作,然后用python脚本解析tensorflow的graph模型中的conv2d参数,最后保存成自定义的文件格式;最后按照自定义的文件格式,读取和解析Op及Op对应参数,在自己的框架中实现conv2d的数值计算,跟tensorflow的数值计算结果一致。后续的文章,我们还会沿用这样的方式进行Dense、Pooling等Op的开发。最后将tensorflow中训练的MNIST网络用自己的框架进行inference,后续我们也会将本教程代码的github链接同步上来。
推荐阅读
专注嵌入式端的AI算法实现,欢迎关注作者微信公众号和知乎嵌入式AI算法实现专栏。
更多嵌入式AI相关的技术文章请关注极术嵌入式AI专栏