最近一段时间,认真研究了一下caffe。但是,里面内容过多,集合了CPU版本和GPU版本的代码,导致阅读起来有些复杂。因此,特意对caffe代码进行了重构,搭建一个基于CPU版本的Caffe推理框架。

此简化的Caffe推理框架具有以下特点:

  1. 只有CPU推理功能,无需GPU;
  2. 只有前向计算能力,无后向求导功能;
  3. 接口保持与原版的Caffe一致;
  4. 精简了大部分代码,并进行了详尽注释。

通过对Caffe的重构,理解了如何搭建一个推理框架,如何从输入一张图片从而得到结果。注意:此框架只是用于教学使用,通过最简单的图片分类的方式来理解框架,不保证适合不同的任务。

项目地址为:https://gitee.com/dengshunge/simple-caffe-inference


一、SimpleCaffe的结构

SimpleCaffe中保持了与原版caffe同样的代码结构,整体结构始终贯穿着Blob、Layer和Net这3大类,分别负责数据的存储、网络的层次和网络骨架:

  • Blob:负责数据的传输,其中数据主要包括每层的输入输出数据、模型的权重等;
  • Layer:负责构建模型的每一层,主要处理如何将该层的输入转换成输出;
  • Net:负责搭建网络的骨架,将每层Layer进行组合。

其主要结构图如下所示

基于CPU版本的Caffe推理框架-LMLPHP

在SyncedMemory中,其主要负责CPU数据与GPU数据进行同步(此版本没有GPU,只是保留接口),其主要包含4个函数,分别用于读取CPU常量数据、设置CPU数据、获取CPU可变数据和将数据同步至CPU。

在Blob中,有两个重要的变量:$data\_$表示存储的数据,包括模型权重和每层的计算结果;$shape\_$表示这些数据的形状shape。而函数则包含:

  • $Reshape()$根据传入的shape信息,通过调用SyncedMemory来为$data\_$分配内存空间;
  • $count()$表示切片的容量,如果不传入参数的话,返回的结果是$N \times C \times H \times W$;
  • $cpu\_data()$负责读取cpu中的数据,为常量,只可读,其实现就是调用SyncedMemory中的$cpu\_data()$;
  • $set\_cpu\_data()$是为cpu中的数据空间进行赋值,其实现就是调用SyncedMemory中的$set\_cpu\_data()$;
  • $mutable\_cpu\_data()$是获取指向该数据的指针,可以通过该指针来修改内部数据。

在Layer_factory和Layer中,其主要是利用了工厂模式的方式来进行组合,将每个特定层Layer进行注册,通过Layer_factory的多态性进行调用。在Layer中,$blob\_$表示模型的权重,即可学习的参数,例如卷积中的卷积核;而$SetUp()$函数是Layer中最重要的函数,其包含着检查输入输出是否合规、构建特定层和为top层分配空间等作用;最后的$Forward\_cpu()$则是前向计算的接口,将bottom的数据进行计算,放在top层空间中。

在Net中,其主要的作用是将所有构建的层按照特定顺序进行组合,并保存每层计算结果,得到最终模型输出结构。其中$layers$是一个vector,里面保存着指向在创建的layer,可以理解成指向每一层;$blobs$是一个vector,里面保存着指向blob的指针,代表着每层计算的结果;$bottom\_vecs$和$top\_vecs$里面保存在指向bottom层或者top层的指针。其余成员函数的作用分别是:

  • $Init()$用于构建模型结构,可以理解成搭网络;
  • $Forward()$表示模型的前向计算,其主要就是调用每个layer的$Forward\_cpu()$;
  • $CopyTrainedLayersFrom()$的作用就是将训练好的caffemodel文件中的权重,拷贝到已经创建好的blob中。

二、SimpleCaffe的推理流程

接下来,结合具体的代码,讲述一下SimpleCaffe如何进行推理的,以图片分类为例。

main函数的入口为$tool/caffe.cpp$,其伪代码如下所示。这与人们常见的思维方式的流程一致,我写出了里面几个比较重要的函数,分别用于构建网络、加载权重和前向计算。接下来将会对这几部分进行注重讲解。

int main()
{
    ///设置prototxt和caffemodel的路径
    //..

    ///设置caffe的工作模型,cpu或者gpu
    //..

    ///根据prototxt文件来创建网络,并将caffemodel中的权重加载进网络中
    Net<float> caffe_net(model, caffe::TEST, 0, nullptr);
    caffe_net.CopyTrainedLayersFrom(weights);
    //..

    ///对图片进行处理,并将处理完的数据放入到网络的输入层对应的blob中
    //..

    ///进行模型的前向计算,并对结果进行后处理
    caffe_net.Forward();
    //..
}

2.1 构建网络

对于网络的构建,是通过构建Net的对象来完成的,其流程图如下所示。

基于CPU版本的Caffe推理框架-LMLPHP

具体的步骤如下描述:

  1. 读取并解析prototxt文件。
  2. 根据规则,加入或者排除某些层。因为在prototxt文件中里面包含着某些只有训练才用的层,例如loss层等。根据这些规则,在推理阶段可以将这些层给排除掉。
  3. 加入Split层。因为在我们搭建prototxt中,经常没有用split层,例如resnet中的的残差结构,没有专门写一份split层。这个split层的主要作用是将一个blob的数据复制成N份,方便接下来的层使用。所以,在这里需要判断哪些层需要进行Split,并在这些层之后创建Split层。
  4. 通过第1/2/3步,就已经能知道我们在这次推理中,需要哪些层了。接下来就是需要循环的创建每一层。
  5. 在第4步循环创建每一层中,首先需要调用工厂模式来创建一个指向该层的指针,但该指针只是指向该实例。
  6. 调用$AppendBottom()$和$AppendTop()$来为blob创建指针,但未开辟空间。这两个函数的配合方式是:在$AppendTop()$中创建指向blob的智能指针$blob\_pointer$ ,并将此指针加入到$top\_vecs$中,并记录下$blob\_name$,$layer\_id$和$blob\_id$;然后在$AppendBottom()$中,根据bottom的名字,找到上一层top的名字和上一层的$blob\_id$,由此得到在$AppendTop()$中的创建的$blob\_pointer$的指针,加入到$bottom\_vecs\_$。从而将同一层的bottom和top进行关联起来。
  7. 当把该层的bottom和top关联起来后,就需要调用在$include/caffe/layer.hpp$中的$SetUp()$函数,构建这一层,需要把bottom和top对应的指向blob的指针传入进去。
  8. 在$CheckBlobCounts()$函数中,会首先检查输入blob的数量和输出blob的数量是否满足在该层的定义的数量要求。例如relu层,需要满足一个输入和一个输入,如果你传入两个输入,则会报错。
  9. 调用$LayerSetUp()$函数,用于为为特定参数分配空间。例如在conv层中,需要记录下stride\pad\dilation等参数,同时为kernel分配空间大小。
  10. 在$Reshape()$中,需要为传入进行来top的指针(即top层对应的blob的指针)分配合适的空间。注意,这里只为top层分配空间,因为下一层的bottom会指向上一层的top。

通过上面的10步操作,就完成了网络的搭建,将每层的bottom和top关联起来,并分配了空间。

2.2 前向计算

当搭建完网络后,就需要进行前向计算。在前向计算中$net.cpp$会循环调用每一层的$Forward()$函数,在$layer.hpp$中的$Forward()$首先先进行$Reshape()$操作(即检查空间大小),然后执行虚函数$Forward\_cpu()$,这个虚函数$Forward\_cpu()$需要在每个层文件中自己定义如何进行实现,将bottom的数据计算得到top的数据。以普通的卷积conv层操作为例:

  1. 取出bottom和top的指针,循环每个batch size;
  2. 将特征图进行im2col转换,提高cache的命中率,加快计算速度;
  3. 将卷积核和特征图进行矩阵相乘的操作$caffe\_cpu\_gemm$,并将计算后的结果放入top指向空间。

通过$Forward()$函数,就完成了该层的前向计算操作。由于是循环每一层的$Forward()$函数,最终就得到模型的输出结果。

2.3 加载权重

加载模型权重是通过调用$net.cpp$文件中的$CopyTrainedLayersFrom()$函数。其主要逻辑是:

  1. 解析caffemodel文件,会得到一个字典(key为layer的名字,value为值);
  2. 根据caffemodel的key的名字来寻找已经建好的网络中对应的layer的名字;
  3. 如果找到相应的名字,则调用$blob.cpp$文件中的$FromProto()$函数,检查分配的空间大小是否一致,然后将caffemodel中该层的每个值复制到已经建好的blob中。

由于在实际空间中,数据是以一维的尺寸进行存放的,而且是连续的,所以能进行循环的复制。如此一来,网络中的每个blob就得到了训练好的权重,可以进行推理了。

三、其余辅助文件

在完成上述操作的基础上,还需要很多辅助函数的帮助,下面介绍一下:

  • $im2col$文件,这个文件的作用是将特征图进行im2col操作,提高cache命中率;
  • $io$文件,主要负责读取和解析prototxt与caffemodel;
  • $math\_functions$文件和$mkl\_alternate$文件,负责定义caffe中常用的数学操作,例如矩阵相乘,元素相加等;
  • $upgrade\_proto$文件,负责兼容旧的caffe版本的网络,用于将旧的层升级成新的层。

四、总结

上述只是以宏观的视角,大略介绍了一下SimpleCaffe的框架,具体更多的细节,需要仔细研读下项目。然而,在本次项目中,存在着很多不懂的地方,有待补充:

  • cmake文件的编写;
  • cblas或者openblas的用法;
  • proto的编写,和如何进行解析的。

参考资料:

  1. Caffe源码导读
  2. caffe源码深入学习5:超级详细的caffe卷积层代码解析
  3. caffe源码深入学习6:超级详细的im2col绘图解析,分析caffe卷积操作的底层实现
  4. Caffe源码(一):math_functions 分析
11-17 14:19