从零开始山寨Caffe·捌:IO系统(二)

生产者

双缓冲组与信号量机制

在第陆章中提到了,如何模拟,以及取代根本不存的Q.full()函数。

其本质是:除了为生产者提供一个成品缓冲队列,还提供一个零件缓冲队列。

当我们从外部给定了固定容量的零件之后,生产者的产能就受到了限制。

由两个阻塞队列组成的QueuePair,并不是Caffe的独创,它实际上是生产者与消费者的编程方式之一。

在大部分操作系统教材中,双缓冲区free、full通常由两个信号量empty、full实现。

 

信号量(Semaphore)由操作系统底层实现,并且几乎没有人会直接使用信号量去编程。

因为在逻辑上,可以由信号量可由mutex+计数器模拟得到。

信号量的名字很有趣,它实际上由两部分组成,信号(激活信号)、量(计数器)。

汉语的博大精深恰当地诠释的信号量的语义精神,而从Semaphore中,你读不出任何精华。

激活信号掩盖了mutex的功与名,信号量的第一大功能,就是mutex锁。

量,显然表明信号量可以计数,实际上,信号量经常会被拿来为临界资源计数。

下面的伪代码摘自我的操作系统课本,《计算机操作系统 <第四版> 汤小丹等 著》:

int in=0,out=0;
item buffer[n];
semaphore mutex=1,empty=n,full=0;
void wait(S){
    while(S<=0);
    S--;
}
void signal(S) {S++;}
void producer{
    while(1){
        produce an item in nexp;
        ...
        wait(empty);
        wait(mutex);
        buffer[in]=nexp;
        in=(in+1)%n;
        signal(mutex);
        signal(full);
    }
}

可以看到,除了mutex履行其互斥锁的职责之外,empty和full用来计数。

作为生产者,每次生产时,都要让empty减1,让full加1。

当empty小于等于零时,形成第二把锁,当然,这把锁不是为了互斥,只是为了阻塞。

为了增加效率,这第二把锁可以修改成条件阻塞,让生产者交出CPU控制权,当然这需要操作系统的支持。

 

信号量在现代编程中是多余的,事实上,也没有哪个线程库会提供。

当"量"为1时,信号量通常是去实现互斥锁功能。

当"量"为临界资源数量时,信号量通常是去实现资源计数、并且条件阻塞的功能。

这两部分的精神内涵都在Blocking Queue中实现了,So,忘记信号量吧。

多生产者单缓冲区

作为一般的机器学习玩家,你是用不着考虑多生产者的。

如果你比较有钱,经常喜欢摆弄4-way泰坦交火,那么就需要考虑一下多生产者的模型了。

在第肆章中,介绍了多GPU的基本运行原理,给出了如下这张图:

对于每个GPU而言,它至少需要一个对它负责的DataReader,每个DataRedaer应当有不同的数据来源。

Caffe中,将控制一个数据来源的类对象称为Body,默认有一个类静态成员的Body关联容器:

class DataReader
{
public:
    .....
private:
    static map<string, boost::weak_ptr<Body> > global_bodies;
};

值得注意的是,此处应该使用weak_ptr,而不是shared_ptr,因为Body本身将由一个shared_ptr控制。

将Body的shared_ptr存入map容器,将会导致指针计数器永远为1。

这样,当我们准备将Body从map容器中清除时,无法获知它是否已经被释放。

而weak_ptr指向shared_ptr时,不会增加指针计数器计数,当计数为0时,即可将其从map里清除。

每一个DataReader只能拥有一个Body,而每个Body可以有多个成品存储缓冲区(非用于零件缓冲,下节讲)。

每个Body控制一个数据来源,不同的数据来源可以用关键字来hash,默认Caffe提供的关键字是:

static string source_key(const LayerParameter& param){
    return param.name() + ":" + param.data_param().source();
}

即Layer名,加上数据库路径。

多生产者主要用于多数据库同时并行训练,这是一种非常经典的模型。

一部分代码涉及到上层的DataLayer,将后续详解。

另外一种模型是单生产者,以单数据库,不同数据区域同时并行训练,该方法也可以采用。(下节讲)

Caffe的默认源码中,既没有完整实现多生产者并行模型,也没有完整实现单生产者并行模型,这点令人遗憾。

不过,从源码中仍然可以看出一点端倪,本教程只介绍大体思路,同样并不提供具体代码。

单生产者多缓冲区

在这种模型下,将只有一个DataReader,一个Body,但是有多个Pair,如图:

有趣的是,Body结构体中,提供了QueuePair数组容器:

class Body :public DragonThread{
public:
        .......
    BlockingQueue<boost::shared_ptr<QueuePair> > new_pairs;
};

但是,Caffe源码中的DataReader,默认只会使用该容器数组的第一个QueuePair,并没有完整实现多缓冲区:

class DataReader
{
public:
    DataReader(const LayerParameter& param){
           ........
         ptr_body->new_pairs.push(ptr_pair);
    }
    BlockingQueue<Datum*>& free() const  { return ptr_pair->free; }
    BlockingQueue<Datum*>& full() const  { return ptr_pair->full; }
private:
    boost::shared_ptr<QueuePair> ptr_pair;
    boost::shared_ptr<Body> ptr_body;
};

可以看到,尽管我们设置了Body,存储多个QueuePair,但是提供的外部访问接口,居然直接使用了ptr_pair。

当然,如果你要编程使用多缓冲区,一定要修改DataReader的访问接口。

对于单个数据库的顺序数据读取,如何将顺序资源,平摊到多个缓冲区?

Caffe使用了循环读取法:

void Body::interfaceKernel(){
    boost::shared_ptr<DB> db(GetDB(param.data_param().backend()));
    db->Open(param.data_param().source(), DB::READ);
    boost::shared_ptr<Cursor> cursor(db->NewCursor());
    vector<boost::shared_ptr<QueuePair> >  container;
    try{
         ...............
         while (!must_stop()){
            for (int i = 0; i < solver_count; i++) 
                read_one(cursor.get(), container[i].get());
        }
    } catch (boost::thread_interrupted&) {}
}

可以看到,在Body的线程函数中,利用全局管理器提供的solver_count,循环均摊数据到多个QueuePair中。

当你将solver_count设置成大于1时,将可以使用Body中的多个缓冲区QueuePair,这点需要注意。

单生产者单缓冲区(默认代码)

仔细思考一下,就会发现,单生产者多缓冲区方案是毫无意义的,看起来我们似乎模拟了多缓冲区。

但是实质只是一个线程,把资源分了一下组,多个组在DataLayer进行消费的时候,又会被合并成一个Batch:

如图,因为一个DataLayer只能有一个Prefetching Thread,所以必然是每次从各个Pair里取一次。

如果我们先把Pair0取完,再取Pair1,再取Pair2,这样也是可以的,是一种不错的shuffle,但是需要追加代码。

从计算角度分析,多缓冲区不会加速,反而会减速,如果是为了做上述的shuffle,是情有可原的。

如果不是,只是单纯地为了负载均衡,轮流从各个Pair里取,那么本质上,就会退化成单生产者单缓冲区。

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

这可能是Caffe源码的本意。在这种方案中,DataReader和DataLayer是无须改动代码的。

只要我们加大DataParameter里的prefech数值,让CPU多缓冲几个Batch,为多个GPU准备就好了。

三种速度方案排名:

多生产者单缓冲区>单生产者单缓冲区>单生产者多缓冲区

线程嵌套线程与Socket

Caffe的源码真的很有启发性,在DataReader的构造和析构函数中,可以发现贡献者悄悄加了mutex:

DataReader::DataReader(const LayerParameter& param){
    ......
    boost::mutex::scoped_lock lock(bodies_mutex);
        ......
}

DataReader::~DataReader(){
        ......
    boost::mutex::scoped_lock lock(bodies_mutex);
        ......
}

熟悉C++的人应该知道,在常规情况下,构造和析构函数是不会并行执行的,也就是不会被线程执行。

线程并行的仅仅是工作函数,工作之前主进程构造,工作之后,主进程析构。

如果偏要认为构造和析构可能并行的话,那么将出现一种好玩的情况:

由于DataReader本身是线程,线程并行线程,将导致线程嵌套线程。

在我的操作系统课上,我的老师这么说:

线程仅仅拥有进程的少部分资源,权限很小。

那么线程能够嵌套线程么?经过百度之后,我发现真还可以。

当今的操作系统,无论是Linux,还是Windows,线程的资源权限都是非常大的。

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

线程嵌套线程,会不会和多GPU有关?我认为无关。

每个GPU的监督线程,这里我们假设使用DragonThread,在需要工作时,

只需要传入:Solver::solve函数就可以了,Solver、Net、Layer的构造和析构,显然是在主进程里执行的。

那么,线程嵌套线程,有什么意义,有什么情况是必须在线程里触发构造函数?

很有趣,一般来讲,只有Socket线程是这样的。

Socket线程无须使用DragonThread,实际上,Boost的Socket也是由boost::asio而不是boost::thread实现的。

不像多GPU,我们无法预估,在某一时刻,实际有多少个Socket在执行,有多少个用户发出了访问请求。

因此,不能直接把Solver、Net、Layer的构造,放在主进程当中。不然你知道你要构造多少份嘛?显然你不知道。

所以,从直觉上,将这些的构造,放在每一个启动的Socket线程里,用多少,构造多少,看起来不错,如图:

 

这样,假如这几个Solver使用了不同数据来源,那么global_bodies就有被几个Solver同时修改的可能。

这是构造和析构函数里,需要加mutex的直接原因。

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

Socket的意义何在?

①从训练角度,多个用户可以远程操控一台主机,训练不同的Net。

这点与多GPU训练一个模型是不一样的。一般而言,我们不会认为,多个用户通过Socket,居然想要训练同一个模型。

当然,这也是可以的。

②从测试角度,多个用户,可以利用同一个Net的参数,并行得到自己提供的数据的测试结果。

注意,这样就不要share整个Net,每个用户的solver使用独立的Net,独立读取训练好的参数。

否则,多个用户会在一个Net上卡半天。

代码实战

建立data_reader.hpp、data_reader.cpp。

QueuePair

class QueuePair{
public:
    QueuePair(const int size);
    ~QueuePair();
    BlockingQueue<Datum*> free; // as producter queue
    BlockingQueue<Datum*> full; // as consumer queue
};

QueuePair的结构在上一章已经介绍过,每一个QueuePair将作为一个缓冲区。

QueuePair只需要实现构造函数和析构函数:

QueuePair::QueuePair(const int size){
    // set the upbound for a producter
    for (int i = 0; i < size; i++) free.push(new Datum());
}

QueuePair::~QueuePair(){
    // release and clear
    Datum *datum;
    while (free.try_pop(&datum)) delete datum;
    while (full.try_pop(&datum)) delete datum;
}

在构造函数中,我们进行"零件"的填充,注意里面的Datum全是空元素,且存入队列的应该是指针。

切记勿存入实体对象Datum,这在应用程序开发中是大忌,因为C++并非Python,默认执行的深拷贝。

深拷贝大内存数据结构体,会严重拖慢程序执行,而且还是没有意义的,传递指针更恰当。

在析构函数中,实际上这是唯一一处对Protocol Buffer对象的主动析构,因为Datum没有用shared_ptr。

主动析构主要利用Blocking Queue提供的try,来控制循环进度。

此处切记不要把pop写成peek,否则会造成对空指针的delete,导致程序崩溃。

LayerParameter

DataReader的上层是DataLayer,它是DataLayer的成员变量之一,需要DataLayer提供proto参数。

在你的proto脚本中,追加如下项:

message DataParameter{
    enum DB{
        LEVELDB=0;
        LMDB=1;
    }
    optional string source=1;
    optional uint32 batch_size=2;
    optional DB backend=3 [default=LMDB];
    //4-way pre-buffering is enough for normal machines
    optional uint32 prefech=4 [default=4];
}

message LayerParameter{
    optional string name=1;
    optional string type=2;
    optional DataParameter data_param=8;
}

重新编译后,覆盖你的旧头文件和源文件。

DataParameter中,包含:数据库源路径、batch大小、数据库类型,以及预缓冲区大小。

比较特别的是预缓冲大小,默认是开4个Batch的预缓冲。如果你的GPU计算速度过快,明显大于

CPU供给数据的速度,消费者(DataLayer)经常提示缺数据,你得考虑加大预缓冲区数量。

将DataParameter嵌入到LayerParameter中去。

LayerParameter是一个巨型的数据结构,将包含所有类型Layer的超参数,你可以将其视为基类。

Body

class Body :public DragonThread{
public:
    Body(const LayerParameter& param);
    virtual ~Body();
    vector<boost::shared_ptr<QueuePair>> new_pairs;
protected:
    void interfaceKernel(); 
    void read_one(Cursor *cursor, QueuePair *pair);
    LayerParameter param;
};

Body实际上是一个线程,而DataReader却不是,尽管Body是DataReader成员变量。

Body的构造函数和析构函数就是启动线程和停止线程:

Body::Body(const LayerParameter& param) :param(param) { startThread();}
Body::~Body() { stopThread();}

线程工作函数比较复杂:

void Body::interfaceKernel(){
    boost::shared_ptr<DB> db(GetDB(param.data_param().backend()));
    db->Open(param.data_param().source(), DB::READ);
    boost::shared_ptr<Cursor> cursor(db->NewCursor());
    try{
        //    default solver_count=1
        int solver_count = param.phase() == TRAIN ? Dragon::get_solver_count() : 1;
        //    working period
        while (!must_stop()){
            for (int i = 0; i < solver_count; i++) 
                read_one(cursor.get(), new_pairs[i].get());
        }
        //  complex condition
    } catch (boost::thread_interrupted&) {}
}

该函数将会一直卡在循环里,直到训练结束,Body执行析构函数,将线程执行停止。

Body-DataReader构成了Caffe数据缓冲的第一级别:数据库->Datum

在DataLayer中,还会进行第二级别的缓冲:Datum->Blob->Batch,将在后续分析。

最后,还剩下一个read_one函数:

void Body::read_one(Cursor *cursor, QueuePair *pair){
    Datum *datum = pair->free.pop();
    datum->ParseFromString(cursor->value());
    pair->full.push(datum);
    cursor->Next();
    if (!cursor->valid()){
        DLOG(INFO) << "Restarting data prefeching from start.\n";
        cursor->SeekToFirst();
    }
}

read_one每次从一个双缓冲组的free队列中取出空Datum指针。

利用Protocol Buffer的反序列化函数ParseFromString,从数据库中还原Datum,再扔到full队列里。

感谢Protocol Buffer,否则这部分的代码估计不下200行。

当数据库跑完之后,需要回到开头,再次重读,为迭代过程反复提供数据。

这一步只适合训练过程,如果你要一次测试自己的数据,请忘记这个函数,重写一个不要反复读的版本。

DataReader

class DataReader
{
public:
    DataReader(const LayerParameter& param);
    BlockingQueue<Datum*>& free() const  { return ptr_pair->free; }
    BlockingQueue<Datum*>& full() const  { return ptr_pair->full; }
    ~DataReader();
    static string source_key(const LayerParameter& param){
        return param.name() + ":" + param.data_param().source();
    }
private:
    LayerParameter param;
    boost::shared_ptr<QueuePair> ptr_pair;
    boost::shared_ptr<Body> ptr_body;
    static map<string, boost::weak_ptr<Body> > global_bodies;
};

该结构上文已经全面解析过。

在cpp的实现中,首先完成类静态成员变量的外部初始化。

map<string, boost::weak_ptr<Body> > DataReader::global_bodies;

以及一个静态mutex的定义:

static boost::mutex bodies_mutex;

该mutex是Caffe挖的坑之一,虽然默认不会生效,倒是给出了不错的指导。

当构建多生产者单缓冲区时,我们将会有多个Body,即多个DataReader,即多个DragonThread。

这意味着,Body的Hash容器将成为一个互斥资源。

该Hash容器的存在不是没有必要的,由于:

每个数据来源只能用一次,为了避免重复路径,显然需要Hash。

DataReader::DataReader(const LayerParameter& param){
    ptr_pair.reset(new QueuePair(
        param.data_param().prefech()*param.data_param().batch_size()));
    boost::mutex::scoped_lock lock(bodies_mutex);
    string hash_key = source_key(param);
    boost::weak_ptr<Body> weak = global_bodies[hash_key];
    ptr_body = weak.lock();
    if (!ptr_body){
        ptr_body.reset(new Body(param));
        global_bodies[hash_key] = boost::weak_ptr<Body>(ptr_body);
    }
    ptr_body->new_pairs.push(ptr_pair);
}

DataReader的构造函数首先根据用户指定的预缓冲区大小,初始化默认的双缓冲队列组。

接下来,要在Body的Hash容器中登记,mutex锁住,修改之后解锁。

登记所使用的是weak_ptr,weak_ptr可看作shared_ptr的助手,通常视为观察者(Viewer)。

不可使用->,只能调用lock函数获得shared_ptr。

DataReader的析构,主要任务是析构Body,以及从Hash容器中反登记。

DataReader::~DataReader(){
    string hash_key = source_key(param);
    ptr_body.reset();
    boost::mutex::scoped_lock lock(bodies_mutex);
    if (global_bodies[hash_key].expired()) global_bodies.erase(hash_key);
}

析构体系

DataReader中涉及几个比较重要的析构,这里以图描述下:

完整代码

data_reader.hpp

https://github.com/neopenx/Dragon/blob/master/Dragon/data_include/data_reader.hpp

data_reader.cpp

https://github.com/neopenx/Dragon/blob/master/Dragon/data_src/data_reader.cpp

posted @ 2016-03-12 20:59  Physcal  阅读(4060)  评论(8编辑  收藏  举报