你說你會關系數據庫?你說你會Hadoop?
忘掉它們吧,我們既不需要網絡支持,也不需要復雜關系模式,只要讀寫夠快就行。
——論數據存儲的本質
淺析數據庫技術
內存數據庫——STL的map容器
關系數據庫橫行已久,似乎大家已經忘了早些年那些簡陋的數據存儲模式。
在ACM選手中,流傳着“手艹數據庫”的說法,即利用map<string,type>或者map<int,type>,
按照自己編碼規則,將數據暫存起來,等待調用。
這就是KV數據庫,最簡陋的數據庫,也是最實用的數據庫。
STL的map容器,底層實現由紅黑樹完成,訪問復雜度$O(logn)$,修改復雜度$O(logn)$。
在內存中,具有優良的速度,是非常廉價的內存數據庫實現方式。
硬盤數據庫——更復雜的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下會提示空間不足。
但是修正之后,創建數據時,你還是能看到,臨時文件占用了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