19

张新栋 · 2020年04月10日

自己动手写CNN Inference框架之 (二) conv2d

卷积是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算法实现专栏

WX20200305-192544.png
更多嵌入式AI相关的技术文章请关注极术嵌入式AI专栏

推荐阅读
关注数
17022
内容数
1238
嵌入式端AI,包括AI算法在推理框架Tengine,MNN,NCNN,PaddlePaddle及相关芯片上的实现。欢迎加入微信交流群,微信号:aijishu20(备注:嵌入式)
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息