从零开始山寨Caffe·玖:BlobFlow

听说Google出了TensorFlow,那么Caffe应该叫什么?

                          ——BlobFlow

神经网络时代的传播数据结构

我的代码

我最早手写神经网络的时候,Flow结构是这样的:

struct Data
{
    vector<double> feature;
    int y;
    Data(vector<double> feature,int y):feature(feature),y(y) {}
};
vector<double> u_i,v_i,u_j,v_j;

很简陋的结构,主要功能就是利用vector存一下每层正向传播的值。

Word2Vec

后来我看了Google的Mikolov大神的Word2Vec的源码,它的Flow结构是这样的:

real *neu1 = (real *)calloc(doc->layer1_size, sizeof(real));

然后我吐槽了一下,这功能不是比我还弱么,vector起码还能提供STL的基础功能。

(注:Word2Vec源码是以CPU多线程和内存操作快而著称的,简陋但速度快)

Theano

再后来,我学习了Theano,它的Flow结构是这样的:

input=theano.tensor.matrix('x')
class DataLayer(object):
    def __init__(self,input,batch_size,size):
        self.batch_size=batch_size
        self.size=size
        self.input=input
        self.params=None
    def get_output(self):
        output=self.input
        if type(self.size) is tuple: #Mode: 2D
            output=output.reshape((self.batch_size,self.size[2],self.size[1],self.size[0]))
        else: #Mode: 1D
            output=output.reshape((self.batch_size,self.size))
        return output

Bengio组模仿物理学的张量(Tensor)的概念,创建了Theano的Tensor系统。

Dim为0的叫常量,Dim为1的叫向量,Dim=2的叫矩阵,Dim>2就没名字了,且Dim可以无限扩大。

Tensor的出现,很好地规避了机器学习研究者不会写代码的问题(比如上节出现的简陋结构)。

同时,随着mini-batch、conv等方法在深度学习中的大规模使用,我们的Flow结构显然需要多维化。

由于是操作多维空间,经常需要维度切换,reshape函数自然成了Tensor的核心函数。

(reshape的概念最早应该来自Python的科学计算库numpy,Theano的Tensor系统,很大程度上在重写numpy)

TensorFlow

再后来,Google把Andrew Ng开发的一代深度学习框架DistBelief给换掉了,第二代叫TensorFlow。

按照官方的说法,取名TensorFlow(2015)的原因是因为系统里主要是Tensor在Flow。

推测一下DistBelief(2011)和Theano(NIPS2012)的公布时间,我们大概推测,DistBelief的Flow结构估计相当Low。

按照Caffe(2013)作者贾大神的说法,他参与了TensorFlow的主体开发。

所以,TensorFlow里的Tensor结构,不难看出来,是借鉴了Theano(2012)和Caffe(2013)的综合体。

符号系统

尽管Caffe(2013)具有类似Tensor的Blob结构,但是和Theano(2012)、TensorFlow(2015)的Tensor相比,

还是比较弱的。核心原因是,Tensor的出发点是建立在符号系统上的,而Caffe(2013)只是最暴力的执行代码。

按照MXNet的陈天奇大神在MS研究院内部的讲座说法:

Caffe(2013)属于Imperative Programme(命令程序)

Theano(2012)、TensorFlow(2015)、MXNet(2015)属于Declaretive Programme(声明程序)

符号系统需要内建一套数学式语法解析结构,针对原始的命令语句做一个深度的Wrapper,从白盒变成黑盒。

其难度和代码量还是有的。与之相比,Blob读起来,还是要比Tensor要简单地多的。

浅析Blob设计原理

存储性质

无论是正向传播的输出,还是反向传播的残差,还是神经元参数,这些都需要不同的结构去存储。

Blob广义上极力规避设计多种结构的问题,这点上是参考Tensor的。

你可以自由规划1D、2D、3D、4D甚至nD的多维数组存储空间,这种存储具有相当不错的灵活性。

功能性质

不幸的是,操作多维数组在编程中是件麻烦事。

朴素C语言提供的多维数组,功能很弱,比如你想获知大小(size)就是一件难事。

使用STL是一个不错的注意,嵌套STL,从数据结构角度就变成了广义表。

尽管广义表的功能较朴素C语言多维数组要多,不过看起来也不尽如人意。

——————————————————————————————————————————————————

另外,最恼人的是CUDA不推荐GPU操作多维数组,最多可以申请到3维数组的显存优化。

如果不使用CUDA提供的多维数组内存对齐优化,那么IO指令取址将会非常频繁,导致IO速度严重退化。

从内存角度理解,显然线性内存空间访问便捷,nD内存空间就十分糟糕了。

——————————————————————————————————————————————————

从SyncedMemory的设计中,几乎就可以推测,Caffe为了速度,完全使用线性内存/显存。

因而,为使线性内存模拟出nD内存,就需要在内存访问上做点偏移(Offset)计算。

Blob的大部分功能,便是扩展线性SyncedMemory的逻辑功能,使其变成逻辑上的多维数组。

张量·轴设计

在早期神经网络编程中,通常采用的是1D空间,每个样本拥有一个输入向量。

上个世纪末,LeCun等人倡导在SGD中,替代单样本为mini-batch,才使得轴设计得以派上用场。

axis=0用于batch_size,batch中每个样本的向量移到axis=1。

这种空间在今天的神经网络NLP(NNNLP)任务中,仍然是主要采用的。

上个世纪90年代初,LeCun将Fukushima的神经机结合导师Hinton的BP算法,演化成可以训练的CNN,使得轴进一步扩展。

CNN所扩展的轴,称之为空间轴(spatial axes),放置于axis=2,....之后。

原神经网络的axis=1轴,结合图像文件的通道(channels)概念、CNN的特征图概念,被替换成channels axis。

这样,在Blob中,就构成了使用最频繁的4轴空间(batch_size,channels,height,width)。

在Caffe中,batch_size用num替代,这个名字理解起来更泛性一点。

各轴都具有一定的轴长,描述轴空间需要shape功能,轴空间变形则需要reshape功能。

代码实战

从Blob开始,为了便于阅读,代码将在不同章逐步扩展,以下仅提供适用于本章的精简代码。

完整代码见本章最后的Github链接。

建立blob.hpp

数据结构

template <typename Dtype>
class Blob{
public:
    Blob():data_(),diff_(),count_(0), capacity_(0) {}
    Blob(const vector<int>& shape) :count_(0),capacity_(0) { reshape(shape); }
    void reshape(int num, int channels, int height, int width);
    void reshape(vector<int> shape);
    void reshape(const BlobShape& blob_shape);
    void reshapeLike(const Blob& blob);
    const Dtype* cpu_data() const;
const Dtype *gpu_data() const;
const Dtype* cpu_diff() const; const Dtype* gpu_diff() const; Dtype *mutable_cpu_data(); Dtype *mutable_gpu_data(); Dtype *mutable_cpu_diff(); Dtype *mutable_gpu_diff(); int num() const { return shape(0); } int channels() const { return shape(1); } int height() const { return shape(2); } int width() const { return shape(3); } int count() const{ return count_; } int count(int start_axis, int end_axis) const { CHECK_GE(start_axis, 0); CHECK_LE(start_axis, end_axis); CHECK_LE(start_axis, num_axes()); CHECK_LE(end_axis, num_axes()); int cnt = 1; for (int i = start_axis; i < end_axis; i++) cnt *= shape(i); return cnt; } int count(int start_axis) const{ return count(start_axis, num_axes()); } const vector<int> &shape() const{ return shape_; } int shape(int axis) const{ return shape_[canonicalAxisIndex(axis)]; } int offset(const int n, const int c = 0, const int h = 0, const int w = 0){ 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; } int num_axes() const { return shape_.size(); } // idx ranges [-axes,axes) // idx(-1) means the last axis int canonicalAxisIndex(int axis) const{ CHECK_GE(axis, -num_axes()); CHECK_LT(axis, num_axes()); if (axis < 0) return axis + num_axes(); else return axis; } const boost::shared_ptr<SyncedMemory>& data() const { return data_; } const boost::shared_ptr<SyncedMemory>& diff() const { return diff_; } // change the shared_ptr object and will recycle the memory if need void shareData(const Blob& blob) { CHECK_EQ(count(), blob.count()); data_ = blob.data(); } void shareDiff(const Blob& blob) { CHECK_EQ(count(), blob.count()); diff_ = blob.diff(); }void FromProto(const BlobProto& proto, bool need_reshape = true); void ToProto(BlobProto* proto, bool write_diff = false); protected: boost::shared_ptr<SyncedMemory> data_, diff_; vector<int> shape_; int count_, capacity_; };

先说说几个成员变量:

count、capacity用于reshape中的计算,前者是新reshape的大小,后者是历史reshape大小。

Blob的任何构造函数中,一定要将这个两个值置0,否则reshape会失败。

线性内存空间以shared_ptr绑定,因此Blob不需要析构函数,Blob销毁后,指针空间会被自动回收。

默认有2个线性内存空间,data、diff,分别用于存储数据/残差。

vector<int> shape用于存各个轴的轴长。

——————————————————————————————————————————————————

然后看轴相关函数:

num、channels、height、width、count、shape都是简单的封装,注意设成常成员函数。

由于Blob会作为const引用的参数,比如sharedData/shareDiff,这些访问接口必须保证this指针一致。

这点在第壹章时,略微提醒过。

count和shape都是重载函数,提供不同的访问方式。

轴访问canonicalAxisIndex函数上,借鉴了Python的负轴访问方式,如果你没有Python的习惯,可以写简单点。

——————————————————————————————————————————————————

对SyncedMemory的封装,主要目的是将void*型内存转换为计算类型的内存。

void*型内存以数组下标方式访问时,每个单元占用8Bit(1字节),这种单元内存是不能直接使用的。

因为一个int/float单元占用32Bit(4字节),一个double单元占用64Bit(8字节)。

C/C++通过对数组首元素指针的强制转换,可以改变下标索引的单元访问模式。

——————————————————————————————————————————————————

reshape函数看起来重载了很多,实际上主体设在 void reshape(vector<int> shape)里。

其它都是简单的封装。

——————————————————————————————————————————————————

offset函数是非常重要的,它目的是计算相对偏移量,形成逻辑上的多维空间结构。

在DataLayer中,由Datum组织Blob一个例子如下:

for (int i = 0; i < batch_size; i++){
    // must refer use '&' to keep data vaild(!!!important)
    Datum &datum = *(reader.full().pop("Waiting for Datum data"));
    int offset = batch->data.offset(i);
    //    share a part of a blob memory 
    transformed_data.set_cpu_data(base_data + offset);
    //    transform datum and copy its value to the part of blob memory
    if (has_labels) base_label[i] = datum.label();
    ptr_transformer->transform(datum, &transformed_data);
    //let the reader to read new datum
    reader.free().push(&datum);
}

在这里,对batch里的每一个样本,每次偏移channels*height*width个单位,立刻跳转到下一张图的首元素。

更一般的,令base_data+=data.offset(0,1),就跳转到了下一个channel的首元素。

由于线性空间是连续的,这种偏移仅仅需要加法器一次运算,就能模拟出多维空间,十分廉价。

——————————————————————————————————————————————————

两个share函数用于直接替换掉data_,diff_,由于使用了shared_ptr,SyncedMemory会自动释放。

当神经网络需要交叉验证时,从训练网络copy参数到测试网络是没有必要的。

此时,只要将训练网络的全部参数Blob,一一对应share给测试网络即可。

——————————————————————————————————————————————————

FromProto和ToProto用于反序列化/序列化至protobuff格式。

唯一用处是对神经网络的参数Blob进行snapshot(截图),以便继续训练或者离线测试。

实现

给出几个比较重要的实现。

template<typename Dtype>
void Blob<Dtype>::reshape(vector<int> shape){
    count_ = 1;
    shape_.resize(shape.size());
    for (int i = 0; i < shape.size(); ++i) {
        count_ *= shape[i];
        shape_[i] = shape[i];
    }
    if (count_ > capacity_) {
        capacity_ = count_;
        data_.reset(new SyncedMemory(capacity_ * sizeof(Dtype)));
        diff_.reset(new SyncedMemory(capacity_ * sizeof(Dtype)));
    }
}

可以看到,reshape为SyncedMemory准备了capacity*sizeof(Dtype)个字节单元。

同时,你需要回忆一下,SyncedMemory(size)并不会立刻启动状态转移自动机申请内存/显存。

只有执行Blob:: cpu_data/gpu_data/mutable_cpu_data/mutable_gpu_data,才会申请。

这有点像函数式编程里的Lazy思想,胡乱写Blob其实问题不大,只要该Blob没有使用,就不会有内存空间损耗。

template<typename Dtype>
void Blob<Dtype>::ToProto(BlobProto* proto, bool write_diff){
    proto->clear_shape();
    proto->clear_data();
    proto->clear_diff();
    //do not use proto->shape() cause it is a const method
    for (int i = 0; i < shape_.size(); i++)  proto->mutable_shape()->add_dim(shape_[i]);
    const Dtype *data = cpu_data();
    const Dtype *diff = cpu_diff();
    for (int i = 0; i < count_; i++)  proto->add_data(data[i]);
    if (write_diff)
        for (int i = 0; i < count_; i++)  proto->add_diff(diff[i]);
}

ToProto里,首次出现了如何向protobuff结构写数据的例子。

以proto->mutable_shape()为例,切记不要写成proto->shape(),因为proto->shape()是常成员函数。

其内部不能修改,这点上,同Blob::cpu_data/mutable_cpu_data的原理是一致的。

对于message的repeated类型,使用add_name函数可以填充数组数据。

针对Caffe的精简

- 移除SyncedMemory形式的shape_data,与vector<int> shape_作用重复

- 移除基本没什么用的CopyFrom函数

完整代码

注:关于Blob中的update等在底层计算的函数会在后期补充讲解。

blob.hpp

https://github.com/neopenx/Dragon/blob/master/Dragon/include/blob.hpp

https://github.com/neopenx/Dragon/blob/master/Dragon/src/blob.cpp

posted @ 2016-03-19 11:38  Physcal  阅读(6371)  评论(0编辑  收藏  举报