你说你会关系数据库?你说你会Hadoop?git
忘掉它们吧,咱们既不须要网络支持,也不须要复杂关系模式,只要读写够快就行。github
——论数据存储的本质数据库
关系数据库横行已久,彷佛你们已经忘了早些年那些简陋的数据存储模式。编程
在ACM选手中,流传着“手艹数据库”的说法,即利用map<string,type>或者map<int,type>,网络
按照本身编码规则,将数据暂存起来,等待调用。数据结构
这就是KV数据库,最简陋的数据库,也是最实用的数据库。机器学习
STL的map容器,底层实现由红黑树完成,访问复杂度$O(logn)$,修改复杂度$O(logn)$。函数
在内存中,具备优良的速度,是很是廉价的内存数据库实现方式。oop
B树是经典的多叉搜索树,相比于在内存中使用的二叉搜索红黑树,在硬盘物理结构上访问更具备优点。性能
现代关系数据库,底层大部分都是由B+树实现,因为原始的B树只支持单键,关系数据库利用复杂的编码,
由单键模拟出了多键,在IO效率上,是严重的倒退。
应用数据库更关注复杂的数据关系,可是对于机器学习系统来讲,显然是多余的。
不是全部的数据库都像Oracle、MySQL、SQL Server、Hadoop同样,须要远程技术支持。
实际上,单机数据库从来在程序开发中,使用普遍。
Android开发中,一般会使用SQLite,在后来序列化APP中复杂的数据结构。
对于简单的桌面程序而言,早期更是有手写序列化数据存储格式的习惯,这种习惯至今还在游戏开发界保留着。
一个庞大的单机游戏,好比我手里占用空间达35G的巫师3,主程序仅仅40M。
庞大的游戏资源,其本质就是设计者人工设计的单机数据库,没什么稀奇的。
数据库须要作的最后一步是存储,存储以前必须解决一个问题:如何存储?
对于一个机器学习系统而言,其内部充斥着大量复杂的数据结构,如何存储更是一个难题。
这里大体有两个方案:
①仿照关系数据库,将数据结构与数据关系直接存储。
②将复杂数据结构,编码成简单数据结构,间接存储。
能够说,这两种方案各有优劣。
对于①来讲,优点是无须后处理,读取后完整复现数据结构,劣势是IO缓慢。
对于②来讲,优点是IO飞快,劣势是IO以前,分别须要解码和编码。
从计算机性能角度分析,咱们不难发现,这两种方案是IO与CPU的权衡。
①所需CPU压力很小,可是在计算系统设计中,IO容易成瓶颈。
②所需CPU压力很大,能够说是牺牲CPU来救IO。
So,在机器学习系统设计中,到底是①合适,仍是②合适?很难说。
经典机器学习系统可能更倾向①,但深度学习系统显然毫无争议地选择②。
由于复杂计算都被移到了GPU上,CPU沦为了保姆,保姆就要作好本职工做,专心辅助。
——————————————————————————————————————————————
Protocol Buffer的使用,实际上也是不推荐咱们使用①的。
Protocol Buffer全部message结构,都提供了一个核心函数SerializeToString,可以将任意复杂的数据结构,编码成单字符串。
这就为最暴力的单键单值KV数据库提供了可能,在单键单值状况下,IO的速度能够说达到了极致。
Caffe早期使用的KV数据库,Jeff Dean出品。从百度的科普文章来看,应当是借用了Jeff大神的Bigtable技术。
LevelDB的设计目标是硬盘数据库,而不是内存数据库,于是在硬盘IO方面作了很多优化,不得不佩服Jeff大神。
MapReduce(Hadoop)的部分技术彷佛也被植入其中,Google宣称支持十亿级别规模的大数据。
大多数人估计不知道LMDB的全称,M指的是Memory,显然这玩意是瞄准了内存数据库方向设计的。
与传统内存数据库不一样,它并非真正在用物理内存,而用的是虚拟内存。
虚拟内存,又名操做系统分页文件,在Linux下,又叫作交换分区(Swap分区)。
虚拟内存的文件结构是被操做系统优化过的,速度介于普通硬盘介质缓冲文件(LevelDB)和物理内存之间。
得益于此,LMDB的总体IO能力较LevelDB有所提高,彷佛国外友人认为LMDB是LevelDB的Killer。
默认状况下,你应该选择LMDB而不是LevelDB,这是新版Caffe主导的一个概念。
LMDB对虚拟内存(交换分区)大小有必定要求,若是你不喜欢设置虚拟内存分页文件,LevelDB或许是你的选择。
注意,虚拟内存是用你的硬盘(SSD更佳)转化的空间,和物理内存没有任何关系。
设置虚拟内存,须要长期占用你的宝贵存储空间,使用前须要三思。
默认状况下,应该保证虚拟内存在4G以上,对于ImageNet等更大数据集,则看状况继续加大。
本教程本着与时俱进和烧硬件的原则,不对LevelDB接口实现,请自行参考Caffe源码。
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开发文档,不作过多解释。
创建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