【Caffe】从C++的角度源码剖析深度学习框架Caffe -- Blob篇

写在前面

本系列所剖析的caffe源码来自于master分支, commit: 9b891540。
本系列不会特别着重于深度学习的网络层构造的实现方式,而是从工程角度看C++的语言特性及设置。

首先介绍一下caffe的代码布局:
头文件放在: include
而源文件则在: src/caffe/
这种设置的原因很简单,src文件的源码会被编译成库去被调用,而头文件的存在则作为api提供。

Blob类

Blob.hpp

特点1:尽量用const, enum, inline, 替换#define

const int kMaxBlobAxes = 32;

上来 Line12 就是const定义, 注意哦,别小看这一句哦。
Scoott Meyers著,侯捷老师译的条款02:尽量以Const, enum, inline, 替换#define

原因很简单,如果把代码写成如下:

#define kMaxBlobAxes 32

那么编译器可能从来都没有见过kMaxBlobAxes记号是什么,在程序进入预编译期的时候,记号名称就被32替换了,如果后续出现了使用这个变量导致的代码错误,那么编译器报错只会提示32 xxxxx, 而不是提示kMaxBlobAxes记号名称,这会在后期加重debug的难度。

特点2. 构造列表初始化及explict 关键字在构造函数的意义

// L23 - 32
template <typename Dtype>
class Blob {
 public:
  Blob()
       : data_(), diff_(), count_(0), capacity_(0) {}

  /// @brief Deprecated; use Blob(const vector& shape).
  explicit Blob(const int num, const int channels, const int height,
      const int width);
  explicit Blob(const vector<int>& shape);

Blob是一个模板类,他有三个构造函数。

  • a) 第一个构造函数,也是默认构造函数

    Blob()
       : data_(), diff_(), count_(0), capacity_(0) 
    

    这里使用了C++类构造函数初始化列表
    知识点:“类构造函数初始化列表”:这个的形式就是一个冒号:然后跟一串数据成员,每个数据成员有个小括号对传入参数进行初始化复制。

    类构造函数初始化列表的特性: 与一般显示的初始化并没有什么明显不同,但又如下两种情况是一定要用初始化列表的构造函数

    • case1: 若数据成员存在没有默认构造函数的类。
    • case2: const 成员或引用类型的成员
    //一个简单的例子
    class T1{
    public:
        T1(const string& str)
            std::cout << str << std::endl;
    };
    
    class T2{
    public:
    	//只能进行类成员初始化列表初始化
        T2() : t("123"), cnt(3) {}
    public:
    	//数据成员T1不存在默认构造函数,
    	//cnt为const数据
        T1 t;
        const int cnt;
    };
    

    case1原因:如果在类构造函数里面显示初始化,则会出现编译器默认调用T1的默认构造函数,而把t()视作了伪函数实现。会出现如下两个错:

    1. error: no matching function for call to ‘T1::T1()’。
    2. no match for call to ‘(T1) (const char [4])’

    case2 原因: 若在构造函数里面显示初始化,因为cnt是const数据只能初始化,不能赋值。否则:error: assignment of read-only member ‘T2::cnt’

    类构造函数初始化列表还有一个特性则是:其初始化的顺序仅仅与声明顺序有关,跟后面冒号跟着的一坨代码的顺序是无关的。

  • b) 第二个及第三个构造函数,则显示的将N,C,H,W作为参数进行传入参数。此处值得关注的是explicit关键字。这个关键字顾名思义就是要进行显示的构造函数,隐式的构造函数将被拒绝。
    知识点: explicit。 由于C++的构造函数支持自动的类型转换,在某些场合会引起confuse。若Blob类中的

    explicit Blob(const vector<int>& shape);
    

    并非是explicit, 那么将一个vector赋值给Blob类依然会生效。其会将vector隐式的转成Blob,然后再进行赋值。

特点3. 常量引用传参

上面第二点我们讲述了Blob的三个构造函数,但是其均调用了Reshape成员函数:

  /// @brief Deprecated; use Reshape(const vector& shape).
  void Reshape(const int num, const int channels, const int height,
      const int width);
  /**
   * @brief Change the dimensions of the blob, allocating new memory if
   *        necessary.
   *
   * This function can be called both to create an initial allocation
   * of memory, and to adjust the dimensions of a top blob during Layer::Reshape
   * or Layer::Forward. When changing the size of blob, memory will only be
   * reallocated if sufficient memory does not already exist, and excess memory
   * will never be freed.
   *
   * Note that reshaping an input blob and immediately calling Net::Backward is
   * an error; either Net::Forward or Net::Reshape need to be called to
   * propagate the new input shape to higher layers.
   */
  void Reshape(const vector<int>& shape);
  void Reshape(const BlobShape& shape);
  void ReshapeLike(const Blob& other);

这里有两个值得关注的点,一个是C++的重载特性,另一个则是常量引用。
重载是基础的C++知识就不对介绍了,常量引用则需要注意几点。一般来说,为了提升C++的传递效率,所以会采用传地址的方式来提升,但是引用是不接受右值传递的。C++11出现了右值引用,这是另一码事情。在传递右值,将亡值的时候,常量引用依然可以正确的传递参数,这是跟普通引用的重要区别。同时常量引用会使得函数内对数据进行只读操作,保证数据的安全性。

特点4. inline内联函数

  /// @brief Deprecated legacy shape accessor num: use shape(0) instead.
  inline int num() const { return LegacyShape(0); }
  /// @brief Deprecated legacy shape accessor channels: use shape(1) instead.
  inline int channels() const { return LegacyShape(1); }
  /// @brief Deprecated legacy shape accessor height: use shape(2) instead.
  inline int height() const { return LegacyShape(2); }
  /// @brief Deprecated legacy shape accessor width: use shape(3) instead.
  inline int width() const { return LegacyShape(3); }

inline函数需要在头文件进行声明并定义。
在编译过程中,inline函数则是将代码函数体进行替换,嵌入到.cpp代码段中,因此如果不事先在头文件定义很容易会在连接的时候找不到函数定义。
一般来说,如果要暴露.h文件给客户的话,就不要在.h里面声明一些关键的函数作为内联函数。其次,把大长段的函数作为内联函数,编译器也不一定会进行内联优化。

  inline Dtype data_at(const int n, const int c, const int h,
      const int w) const {
    return cpu_data()[offset(n, c, h, w)];
  }

  inline Dtype diff_at(const int n, const int c, const int h,
      const int w) const {
    return cpu_diff()[offset(n, c, h, w)];
  }

  inline Dtype data_at(const vector<int>& index) const {
    return cpu_data()[offset(index)];
  }

  inline Dtype diff_at(const vector<int>& index) const {
    return cpu_diff()[offset(index)];
  }

这些函数类似于Opencv的数据索引方法。实现可能比较简单,注意此处有个offset函数,看一些offset的定义。

  inline int offset(const int n, const int c = 0, const int h = 0,
      const int w = 0) const {
    CHECK_GE(n, 0);
    CHECK_LE(n, num());
    CHECK_GE(channels(), 0);
    CHECK_LE(c, channels());
    CHECK_GE(height(), 0);
    CHECK_LE(h, height());
    CHECK_GE(width(), 0);
    CHECK_LE(w, width());
    return ((n * channels() + c) * height() + h) * width() + w;
  }

  inline int offset(const vector<int>& indices) const {
    CHECK_LE(indices.size(), num_axes());
    int offset = 0;
    for (int i = 0; i < num_axes(); ++i) {
      offset *= shape(i);
      if (indices.size() > i) {
        CHECK_GE(indices[i], 0);
        CHECK_LT(indices[i], shape(i));
        offset += indices[i];
      }
    }
    return offset;
  }

offset函数就是通过传递的(n,c,h,w)来索引该点的位置。
之所以这么进行索引,我们得注意caffe的内存管理单元。
SyncedMemory,这个内存单元在申请内存的时候,是申请的是一维数组,类似于reshape(-1)。因此其offset函数的目的则是在一维数组中计算四维数据(n,c,h,w)的位置差。

特点5. 智能指针 shared_ptr

智能指针shared_ptr是C++11提出的特性。在C++中,内存管理一直是头疼的问题,经常会因为未及时释放内存导致内存持续增长以至于宕机。C++11提出了几种智能指针,其中一种则是 shared_ptr。该智能指针将 raw指针 进行封装成对象,利用RAII机制以达到通过析构函数进行自动释放内存。

  /**
   * @brief Set the data_ shared_ptr to point to the SyncedMemory holding the
   *        data_ of Blob other -- useful in Layer%s which simply perform a copy
   *        in their Forward pass.
   *
   * This deallocates the SyncedMemory holding this Blob's data_, as
   * shared_ptr calls its destructor when reset with the "=" operator.
   */
  void ShareData(const Blob& other);
  /**
   * @brief Set the diff_ shared_ptr to point to the SyncedMemory holding the
   *        diff_ of Blob other -- useful in Layer%s which simply perform a copy
   *        in their Forward pass.
   *
   * This deallocates the SyncedMemory holding this Blob's diff_, as
   * shared_ptr calls its destructor when reset with the "=" operator.
   */
  void ShareDiff(const Blob& other);

caffe在Blob中提供了数据及梯度share的方法。我们可以具体来看一下其实现方式:

template <typename Dtype>
void Blob<Dtype>::ShareData(const Blob& other) {
  CHECK_EQ(count_, other.count());
  data_ = other.data();
}

template <typename Dtype>
void Blob<Dtype>::ShareDiff(const Blob& other) {
  CHECK_EQ(count_, other.count());
  diff_ = other.diff();
}

其实就是将数据指针进行指向同一块内存。
我们可以想一下如果不用智能指针会出现什么问题,A指针指向了该块内存,B指针也指向了该块内存。那么最后释放的时候,很容易将A,B指针都释放了,那么同一块地址的内存被释放了两次,导致未定义行为。
因此通过shared_ptr, 则在内存释放的时候会变得更安全。

特点6. 不想要的函数请明确拒绝

这个题目依然来自于, 编译器在可能是因为好心,就算你没有定义默认构造函数拷贝构造函数拷贝赋值函数默认析构函数。他也会给你默默加上。

这几个里面“默认构造函数”的行为有点不同,如果你已经有了其他的构造函数,那么编译器就不会再给你增加默认构造函数

因为在caffe中,数据应该是独一份的,没有必要进行拷贝及赋值操作。因此,在caffe的Blob类中,
作者有下面一句实现,如果没注意可能就会被漏掉:

DISABLE_COPY_AND_ASSIGN(Blob);

该宏的定义如下:

#define DISABLE_COPY_AND_ASSIGN(classname) \
private:\
  classname(const classname&);\
  classname& operator=(const classname&)

拷贝构造函数和赋值构造函数被加入到了private,因此可以明确的告知编译器不要自作多情的加上这些默认函数。

特点7. 隐藏模板的实现

一般来说模板需要编译器自己去推导,因此一般需要放到.hpp或者.h上实现,放到.cpp上非常可能会出现链接不到的问题。
那么Caffe的Blob类也是个模板类,他是怎么把头文件和源文件分开的呢。
主要在blob.hpp中

INSTANTIATE_CLASS(Blob);
template class Blob<int>;
template class Blob<unsigned int>;

注意看这个INSTANTIATE_CLASS宏

// Instantiate a class with float and double specifications.
#define INSTANTIATE_CLASS(classname) \
  char gInstantiationGuard##classname; \
  template class classname<float>; \
  template class classname<double>

template class classname;相当于在cpp文件中已经声明了Dtype为float类型的数据,因此就无需将实现写在头文件上了,可以有效的隐藏自己的实现。

本文终结

好了,Caffe的Blob篇应该被剖析完了,作为一个算法工程师,首要还得是一个工程师。基本的C++能力不可缺少,而规整的Caffe虽然作为深度学习框架在现在已经不再流行,但是其在C++的写法上还是值得我们学习一波。

你可能感兴趣的:(c++,caffe,深度学习,c++,caffe,深度学习)