從零開始山寨Caffe·柒:KV數據庫

你說你會關係數據庫?你說你會Hadoop?git

忘掉它們吧,咱們既不須要網絡支持,也不須要複雜關係模式,只要讀寫夠快就行。github

                                        ——論數據存儲的本質數據庫

淺析數據庫技術

內存數據庫——STL的map容器

關係數據庫橫行已久,彷佛你們已經忘了早些年那些簡陋的數據存儲模式。編程

在ACM選手中,流傳着「手艹數據庫」的說法,即利用map<string,type>或者map<int,type>,網絡

按照本身編碼規則,將數據暫存起來,等待調用。數據結構

這就是KV數據庫,最簡陋的數據庫,也是最實用的數據庫。機器學習

STL的map容器,底層實現由紅黑樹完成,訪問複雜度$O(logn)$,修改複雜度$O(logn)$。函數

在內存中,具備優良的速度,是很是廉價的內存數據庫實現方式。oop

硬盤數據庫——更復雜的B+樹

B樹是經典的多叉搜索樹,相比於在內存中使用的二叉搜索紅黑樹,在硬盤物理結構上訪問更具備優點。性能

現代關係數據庫,底層大部分都是由B+樹實現,因爲原始的B樹只支持單鍵,關係數據庫利用複雜的編碼,

由單鍵模擬出了多鍵,在IO效率上,是嚴重的倒退。

應用數據庫更關注複雜的數據關係,可是對於機器學習系統來講,顯然是多餘的。

單機數據庫——暴力、小而輕便

不是全部的數據庫都像Oracle、MySQL、SQL Server、Hadoop同樣,須要遠程技術支持。

實際上,單機數據庫從來在程序開發中,使用普遍。

Android開發中,一般會使用SQLite,在後來序列化APP中複雜的數據結構。

對於簡單的桌面程序而言,早期更是有手寫序列化數據存儲格式的習慣,這種習慣至今還在遊戲開發界保留着。

一個龐大的單機遊戲,好比我手裏佔用空間達35G的巫師3,主程序僅僅40M。

龐大的遊戲資源,其本質就是設計者人工設計的單機數據庫,沒什麼稀奇的。

再看Google Protocol Buffer

數據庫須要作的最後一步是存儲,存儲以前必須解決一個問題:如何存儲?

對於一個機器學習系統而言,其內部充斥着大量複雜的數據結構,如何存儲更是一個難題。

這裏大體有兩個方案:

①仿照關係數據庫,將數據結構與數據關係直接存儲。

②將複雜數據結構,編碼成簡單數據結構,間接存儲。

能夠說,這兩種方案各有優劣。

對於①來講,優點是無須後處理,讀取後完整復現數據結構,劣勢是IO緩慢。

對於②來講,優點是IO飛快,劣勢是IO以前,分別須要解碼和編碼。

從計算機性能角度分析,咱們不難發現,這兩種方案是IO與CPU的權衡。

①所需CPU壓力很小,可是在計算系統設計中,IO容易成瓶頸。

②所需CPU壓力很大,能夠說是犧牲CPU來救IO。

So,在機器學習系統設計中,到底是①合適,仍是②合適?很難說。

經典機器學習系統可能更傾向①,但深度學習系統顯然毫無爭議地選擇②。

由於複雜計算都被移到了GPU上,CPU淪爲了保姆,保姆就要作好本職工做,專心輔助。

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

Protocol Buffer的使用,實際上也是不推薦咱們使用①的。

Protocol Buffer全部message結構,都提供了一個核心函數SerializeToString,可以將任意複雜的數據結構,編碼成單字符串。

這就爲最暴力的單鍵單值KV數據庫提供了可能,在單鍵單值狀況下,IO的速度能夠說達到了極致。

KV數據庫

LevelDB

Caffe早期使用的KV數據庫,Jeff Dean出品。從百度的科普文章來看,應當是借用了Jeff大神的Bigtable技術。

LevelDB的設計目標是硬盤數據庫,而不是內存數據庫,於是在硬盤IO方面作了很多優化,不得不佩服Jeff大神。

MapReduce(Hadoop)的部分技術彷佛也被植入其中,Google宣稱支持十億級別規模的大數據。

LMDB(Lighting Memory DB)

大多數人估計不知道LMDB的全稱,M指的是Memory,顯然這玩意是瞄準了內存數據庫方向設計的。

與傳統內存數據庫不一樣,它並非真正在用物理內存,而用的是虛擬內存。

虛擬內存,又名操做系統分頁文件,在Linux下,又叫作交換分區(Swap分區)。

虛擬內存的文件結構是被操做系統優化過的,速度介於普通硬盤介質緩衝文件(LevelDB)和物理內存之間。

得益於此,LMDB的總體IO能力較LevelDB有所提高,彷佛國外友人認爲LMDB是LevelDB的Killer。

如何選擇?

默認狀況下,你應該選擇LMDB而不是LevelDB,這是新版Caffe主導的一個概念。

LMDB對虛擬內存(交換分區)大小有必定要求,若是你不喜歡設置虛擬內存分頁文件,LevelDB或許是你的選擇。

注意,虛擬內存是用你的硬盤(SSD更佳)轉化的空間,和物理內存沒有任何關係。

設置虛擬內存,須要長期佔用你的寶貴存儲空間,使用前須要三思。

默認狀況下,應該保證虛擬內存在4G以上,對於ImageNet等更大數據集,則看狀況繼續加大。

教程

本教程本着與時俱進和燒硬件的原則,不對LevelDB接口實現,請自行參考Caffe源碼。

LMDB

體系結構

LMDB的主體分爲三個部分,數據庫、遊標、事務。

數據庫爲基層,首先必須打開,根據打開方式的不一樣,分爲如下兩種操做:

①讀操做:依賴遊標的偏移,獲取數據。

②寫操做:依賴數據接口,填充數據。

LMDB內部提供了四種結構負責:MDB_env、MDB_dbi、MDB_txn,MDB_cursor

Caffe全部代碼,都是參考自LMDB開發文檔,這四個東西講起來是沒有意義的。

代碼實戰

通用接口

Caffe默認須要兼容兩種數據庫,另外LMDB的API實在是比較難用,因此設計一個通用接口是個不錯的主意。

創建db.hpp

class DB{
public:
    enum Mode { NEW, READ, WRITE };
    DB() {}
    virtual ~DB() {}
    virtual void Open(const string& source, Mode mode) = 0;
    virtual void Close() = 0;
    virtual Cursor* NewCursor() = 0;
    virtual Transaction* NewTransaction() = 0;

};

在上圖中,咱們發現,不管是Cursor,仍是Transaction,工做都須要txn句柄。

而txn句柄,須要由DB的env建立,能夠視爲是與DB創建靈魂連接。

因此在邏輯結構上,DB應當包含Cursor與Transaction。

另外,須要注意,對於一個DB而言,能夠有多個Cursor和Transaction。

不管是LevelDB,仍是LMDB,多個Cursor將變成並行讀,多個Transaction將變成並行寫。

這也是數據庫系統(DBMS)應當提供的核心功能,要否則人人都能寫數據庫系統了。

class Cursor{
public:
    Cursor() {}
    virtual ~Cursor() {}
    virtual void SeekToFirst() = 0;
    virtual void Next() = 0;
    virtual string key() = 0;
    virtual string value() = 0;
    virtual bool valid() = 0;
};

Cursor在嵌入式關係數據庫編程中,是常常見到的,如其名「遊標」,負責在數據庫中亂跑。

儘管咱們使用的是KV數據庫,但實際上對於深度學習迭代數據過程而言,Key幾乎是沒用的。

大部分狀況下,數據都是序列Read。一遍讀完以後,遊標移動到文件頭,從新再讀。

因此,默認的Cursor並無提供按Key讀取的接口,讀者能夠自行翻閱LMDB開發文檔實現。

序列讀取,核心函數只須要Next和SeekToFirst,以及基於當前遊標下,對Key和Value的訪問接口。

還有一個判斷文件尾EOF的函數vaild,每次遇到EOF以後,應該調用SeekToFirst,讓大俠從新來過。

class Transaction{
public:
    Transaction() {}
    virtual ~Transaction() {}
    virtual void Put(const string& key, const string& val) = 0;
    virtual void Commit() = 0;
};

Transaction至關簡陋,實際上,它只會用數據轉換階段,好比官方源碼著名的convert_cifar10_data.cpp。

Put接口用於數據灌入,以LMDB爲例,Put後首先會被轉移到虛擬內存,當最後執行Commit,才封裝成文件。

LMDB接口

該部分大部分源於LMDB開發文檔,不作過多解釋。

創建db_lmdb.hpp

class LMDB :public DB{
public:
    LMDB() :mdb_env(NULL) {}
    virtual ~LMDB() { Close(); }
    virtual void Open(const string& source, Mode mode);
    virtual void Close(){
        if (mdb_env != NULL){
            mdb_dbi_close(mdb_env, mdb_dbi);
            mdb_env_close(mdb_env);
            mdb_env = NULL;
        }
    }
    virtual LMDBCursor* NewCursor();
    virtual LMDBTransaction* NewTransaction();
private:
    MDB_env* mdb_env;
    MDB_dbi  mdb_dbi;
};

 從DB接口派生過來,注意Close以後,須要先釋放dbi,再釋放env。

同時注意,dbi不是指針,是實體。

class LMDBCursor :public Cursor{
public:
    LMDBCursor(MDB_txn *txn, MDB_cursor *cursor) :
        mdb_txn(txn), mdb_cursor(cursor), valid_(false) {SeekToFirst(); }
    virtual ~LMDBCursor(){
        mdb_cursor_close(mdb_cursor);
        mdb_txn_abort(mdb_txn);
    }
    virtual void SeekToFirst(){ Seek(MDB_FIRST); }
    virtual void Next() { Seek(MDB_NEXT); }
    virtual string key(){
        return string((const char*)mdb_key.mv_data, mdb_key.mv_size);
    }
    virtual string value(){
        return string((const char*)mdb_val.mv_data, mdb_val.mv_size);
    }
    virtual bool valid() { return valid_; }
private:
    void Seek(MDB_cursor_op op){
        int mdb_status = mdb_cursor_get(mdb_cursor, &mdb_key, &mdb_val, op);
        if (mdb_status == MDB_NOTFOUND) valid_ = false;
        else{ MDB_CHECK(mdb_status); valid_ = true; }
    }
    MDB_txn* mdb_txn;
    MDB_cursor* mdb_cursor;
    MDB_val mdb_key, mdb_val;
    bool valid_;
};

LMDBCurosr在構造時,須要傳入MDB_txn和MDB_cursor,句柄和遊標的初始化都要依賴DB自己。

Key和Value中,mdb_val默認返回的是void*,須要強轉換爲char*,再用string封裝。

Seek函數中,檢測是否到達文件尾EOF,修改vaild狀態。SeekToFirst將在外部被調用,重置遊標位置。

析構函數我是看不懂的,官方文檔即視感。

class LMDBTransaction : public Transaction{
public:
    LMDBTransaction(MDB_dbi *dbi,MDB_txn *txn):mdb_dbi(dbi), mdb_txn(txn) {}
    virtual void Put(const string& key, const string&val);
    virtual void Commit() { MDB_CHECK(mdb_txn_commit(mdb_txn)); } 
    MDB_dbi* mdb_dbi;
    MDB_txn* mdb_txn;
};

LMDBTransaction一樣須要傳入MDB_txn和MDB_dbi。

實現

創建db_lmdb.cpp

const size_t LMDB_MAP_SIZE = 1099511627776;        //1 TB
void LMDB::Open(const string& source, Mode mode){
    MDB_CHECK(mdb_env_create(&mdb_env));
    MDB_CHECK(mdb_env_set_mapsize(mdb_env, LMDB_MAP_SIZE));
    if (mode == NEW) CHECK_EQ(_mkdir(source.c_str()), 0);
    int flags = 0;
    if (mode == READ) flags = MDB_RDONLY | MDB_NOTLS;
    int rc = mdb_env_open(mdb_env, source.c_str(), flags, 0664);
#ifndef ALLOW_LMDB_NOLOCK
    MDB_CHECK(rc);
#endif
    if (rc == EACCES){
        LOG(INFO) << "Permission denied. Trying with MDB_NOLOCK\n";
        mdb_env_close(mdb_env);
        MDB_CHECK(mdb_env_create(&mdb_env));
        flags |= MDB_NOLOCK;
        MDB_CHECK(mdb_env_open(mdb_env, source.c_str(), flags, 0664));
    }
    else MDB_CHECK(rc);
    LOG(INFO) << "Open lmdb file:" << source;
}

LMDB的Open接口,我以爲是整個Caffe裏面寫的最爛的函數,爛在兩點:

①讓人看不懂的LMDB的Lock鎖

②用了OS相關的API,並且很爛。

先說說Lock鎖,默認是以Lock訪問的,這意味着,一個DB只能被同時打開一次。

若是要並行打開,而且包含寫入操做,那麼這樣很是危險,但並非不能夠(NO_LOCK訪問)。

因此,後半部分代碼總體就在嘗試切換NO_LOCK訪問。若是你嫌麻煩,能夠刪掉,默認就用NO_LOCK。

再說這個很爛API函數的mkdir,首先它在Linux和Windows下,寫法略有不一樣,頭文件也不同。

其次,mkdir返回值只有倆種:建立失敗和建立成功。實際上咱們更須要第三種:目錄是已存在。

不少fresher在玩Caffe的時候,轉化數據都會失敗,被GLOG宏給Check到:

if (mode == NEW) CHECK_EQ(_mkdir(source.c_str()), 0);

當指定目錄存在時,就會被CHECK到。取消這個CHECK宏又不妥,不能排除錯誤路徑的狀況。

Linux提供opendir檢測目錄是否存在,建議改寫這步;Windows則沒有,不太好辦。

爲此,使用第三方庫是個好主意,Boost的filesystem封裝了跨平臺的文件系統解決方案。

先作include:

#include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp>

而後作替換:

void LMDB::Open(const string& source, Mode mode){
    ......
    // if (mode == NEW) CHECK_EQ(_mkdir(source.c_str()), 0);
    boost::filesystem::path db_path(source);
    if (!boost::filesystem::exists(db_path)){
        if (mode == READ)
            LOG(FATAL) << "Specified DB path is illegal [Read Operation].";
        if (mode == NEW){
            if (!boost::filesystem::create_directory(db_path))
                LOG(FATAL) << "Specified DB path is illegal [NEW Operation].";
        }
    }else{
        //    delete old dir and create new dir
        if (mode == NEW){
            boost::filesystem::remove_all(db_path);
            boost::filesystem::create_directory(db_path);
        }
    }
    ......
}

這樣,數據庫部分就能擺脫OS的依賴了,感謝Boost庫。

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

env的環境建立,須要指定最大虛擬內存緩衝區容量,默認是1TB,這形成了LMDB在Windows的惟一Bug。

NTFS分區不容許1TB這種容量存在,因此LMDB默認源碼在Windows下會提示空間不足。

willyd大神的LMDB項目修正了NTFS分區下的問題。

可是修正以後,建立數據時,你仍是能看到,臨時文件佔用了1TB,儘管你的分區沒有1TB,不知道是什麼原理。

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

LMDBCursor* LMDB::NewCursor(){
    MDB_txn* txn;
    MDB_cursor* cursor;
    MDB_CHECK(mdb_txn_begin(mdb_env, NULL, MDB_RDONLY, &txn));
    MDB_CHECK(mdb_dbi_open(txn, NULL, 0, &mdb_dbi));
    MDB_CHECK(mdb_cursor_open(txn, mdb_dbi, &cursor));
    return new LMDBCursor(txn, cursor);
}

LMDBTransaction* LMDB::NewTransaction(){
    MDB_txn *txn;
    MDB_CHECK(mdb_txn_begin(mdb_env, NULL, 0, &txn));
    MDB_CHECK(mdb_dbi_open(txn, NULL, 0, &mdb_dbi));
    return new LMDBTransaction(&mdb_dbi, txn);
}

void LMDBTransaction::Put(const string& key, const string& val){
    MDB_val mkey, mval;
    mkey.mv_data = (void*)key.data();
    mkey.mv_size = key.size();
    mval.mv_data = (void*)val.data();
    mval.mv_size = val.size();
    MDB_CHECK(mdb_put(mdb_txn, *mdb_dbi, &mkey, &mval, 0));
}

這些實現幾乎就是套文檔,沒什麼須要注意的。

最後創建db.cpp,利用C++的多態性,提供DB的獲取接口:

DB* GetDB(const string& backend){
    if (backend == "leveldb"){
        NOT_IMPLEMENTED;
    }
    if (backend == "lmdb"){
        return new LMDB();
    }
    return new LMDB();
}

直接用基類指針DB,指向LMDB,多態性的經典應用之一。

完整代碼

db.hpp

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

db_lmdb.hpp

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

db.cpp

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

db_lmdb.cpp

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

相關文章
相關標籤/搜索