从零开始山寨Caffe·伍:Protocol Buffer简易指南
你为Class外访问private对象而苦恼嘛?你为设计序列化格式而头疼嘛?
——欢迎体验Google Protocol Buffer
面向对象之封装性
历史遗留问题
面向对象中最矛盾的一个特性,就是“封装性”。
在上古时期,大牛们无聊地设计了三种访问域:
public、private、protected。
大多数C++初学者都是疑惑的,甚至是对于传统C程序员而言。
在C规范中,没有class(类)的概念,只有struct(结构体)的概念。
面向对象的C++中,尽管将C规范的struct移植过来了,但是这个struct是相当特殊的。
C++中的struct,和class没有多大区别,可继承/封装/多态,也支持public/private/protected。
它只有一点不同,那就是默认访问域是public,该设计仅仅是为了兼顾熟悉C规范的程序员。
C规范里之所以没有public/private/protected,因为它不是面向对象语言,没有必要遵从OO的封装性。
如果偏要让C规范服从面向对象,那么一切皆是public,这是C++中struct存在的意义。
编程规范
第壹章讲到了Google程序员必须遵从的代码可读标准,该标准主要体现在对变量的访问上。
对于一次变量访问行为,它是常(const)访问,还是修改(mutable)访问,这显然是两种行为。
由于变量只有一个,但访问方式却有两种,于是软件工程大师们认为,面向对象的访问要以函数为载体。
这就产生了一种面向对象封装性编程规范:
一切成员变量皆private,一切访问方法皆public。
中间还有一个protected。protected的含义在不同语言里是不同的(C++与Java就不同)。
在C++中,甚至在Caffe中,我们更鼓励使用protected替代private。
具体来讲,protected既包含private对外部访问的屏蔽,又包含对继承类的开放。
Caffe中广泛使用继承类设计,而private成员变量是不会被继承的。
想象一下,Layer定义了参数W,但是继承Layer的ConvLayer居然用不了参数W,这不是反人类么?
让我们来考虑一下代码量,设变量A在C规范中,声明与定义占用一行,
那么在C++规范中,声明与定义占一行,const访问至少占一行(平均3行),mutable访问至少占一行(平均3行)。
这样,为了这个装逼的封装性,我们的代码量平均要上去5倍左右。尤其是在机器学习系统中,大量数据结构的情况下,
源码中将会充斥着大量这类无聊的get(const访问)函数,set(mutable访问)函数,不得不说,是挺无奈的事。
序列化
文本数据与序列化
喜欢玩游戏的,应该都改过类似于config.ini的文件。
比如我手里的《辐射4》根目录下的Ultra.ini,就提供了编辑显示配置的高级方式。
大部分Application Framework都提供了对INI文件的解析(Parse)。
其实这并不是难事,学过《编译原理》的人,应该都做过词法分析器的实验。
编译器的词法分析,论本质,它其实也是人工智能(AI),只不过它的智能必须基于特定规则。
归根结底,还是没有超出冯诺依曼的存储程序智能范畴,离图灵的无敌图灵机还远得很。
解析平面结构的文本是简单的,如图,INI文件只由域[XXX],和域下配置项组成。
如果是层次结构呢,比如XML?当然XML有其专门的语法树。
XML语法相当冗繁,看起来就像是机器写的(实际上大部分XML真是机器写的)。
在一个机器学习系统中,显然我们需要层次数据结构的配置。
比如Caffe中经典的层次结构:
solver{
net{
layer{
blob{
考虑一个更特殊的情况,solver配置和net配置显然需要写在不同文件里,增强迁移性。
XML解析器显然没有这么高级的功能,能够整合多个XML文件。
这样,XML解析器之上,起码还需要二次编程,相当坑爹。
格式化数据与序列化
何为格式化数据?简而言之,就是:
C++写的东西,Python能用,MATLAB也能用。
目前广泛使用的格式化数据主要有两种,Binary(C++、Python)、HDF5(MATLAB)。
你肯定会问,ACM比赛不都是用文本格式存数据,为什么不用文本格式做格式化数据?
答案其实很无语:文本格式的体积要比二进制格式体积大5倍左右,读取速度也要相应慢上几倍。
所以,一个机器学习系统,可以从文本IN数据,但是千万不要尝试将数据OUT成文本格式。
文本格式除了体积问题,还存在安全性问题。文本型数据很容易被逆向破解掉。
相反,二进制等格式易于做位运算的特点,非常适合,且基本支持二进制序列化的API,
都对二进制数据进行了加密(比如Qt的QDataStream),当然安全性不是我们考虑的重点。
二进制虽然体积小,但是需要人工设计封装格式。这给序列化(编码),反序列(解码),带来麻烦。
在传统C++大型程序中,我们都能看到序列化和反序列化代码相当冗长。
程序员写到最后,都不知道自己到底IN进了什么数据,OUT出了什么数据,代码显得十分笨拙。
尤其是在机器学习系统中,考虑到我们需要将参数W保存到硬盘。
首先,参数W有多少个?是什么格式?顺序是什么?这些都要先记录。
记录完了之后,才能将最宝贵的参数W写到文件,是不是很蠢,很蠢,很蠢?
Google Protocol Buffer
不错的工具
Protocol Buffer是由Jeff Dean领衔开发的神奇工具。
它不仅有着非常不错的格式化数据的序列化/反序列速度,同时也支持文本格式。
更重要的是,它在自动生成序列化格式的同时,也封装了部分变量的访问接口。
使得Caffe的整体源码中,不必充斥着大量的get/set。
最后,Jeff Dean出品,速度必然是有保障的。
这位Google首席技术员,PHD专攻编译器优化,被誉为是地球上让代码跑的最快的男人。
使用方法
这玩意在墙外,在第零章提供的包里,3rdparty\bin下protoc.exe就是在Windows下本体。
确保3rdparty\bin在环境变量中,编辑proto-make.cmd脚本:
@echo off set SRC_DIR=C:\PROTO set DST_DIR=C:\PROTO set PROTO_NAME=dragon echo Check Source Proto Path: %SRC_DIR% echo Check Destination Proto Path: %DST_DIR% echo Check Proto Files Name : %PROTO_NAME%.proto echo —————————————————————————————————— echo Protocol Buffer:Compliing for dragon.proto..... start protoc -I=%SRC_DIR% --cpp_out=%DST_DIR% %SRC_DIR%\%PROTO_NAME%.proto echo Protocol Buffer:Compliing complete! pause
SRC_DIR为proto脚本的源路径,DST_DIR为生成路径。
proto脚本是操纵protoc.exe的唯一方式,Google为proto脚本设计了一种新的语言,非常类似于C/C++。
protoc版本会根据proto脚本生成h和cc文件,分别是数据结构的声明和定义,随时可以嵌入到你的代码中。
protoc的命令参数摘自墙外的官网,我们通常只需要设置源目录、目标目录、以及proto脚本路径:
protoc -I=%SRC_DIR% --cpp_out=%DST_DIR% %SRC_DIR%\%PROTO_NAME%.proto
第一步
在你喜欢的源目录下,新建dragon.proto,用文本编辑器打开它,
定义第一个数据结构Datum:
message Datum{ optional int32 channels=1; optional int32 height=2; optional int32 width=3; optional int32 label=4; optional bytes data=5; repeated float float_data=6; optional bool encoded=7 [default=false]; }
Datum算是最基本的存储单元了,它其实表示的就是一张图像。
proto语言与C语言差别不是很大,结构体struct字段换成message,
变量之前需要追加optional和repeated标记字段。分别表示的是单变量,还是容器数组变量。
值得一提的是,proto提供requireed字段,但是Google程序员都懒得用,经常会出现奇怪bug,
所以一律用optional替代requireed。
repeated标记之后,本质是数组,但实际实现可能是类似于STL容器,它提供了不少类似容器的操作。
[default]可以提供默认值,对于基本数据类型,不设默认值将会同C语言一样产生类似默认值。
但我们不推荐使用proto自身提供的默认值,通常会之前接一个has_xxx(),来检测该变量是否被设置。
人工指定的默认值,has_xxx()会返回true,而proto提供的自动默认值,则是false。
另外,对于repeated int32 or int64,使用[packed=true]似乎可以优化速度,对于float其实是无效的。
Caffe里有些repeat float也打上了[packed=true],其实没什么意义。
最后,所有数据结构变量,都需要一个唯一的id,id从1开始。
这与proto内部编码系统有关,1~20编码长度小,访问速度快。随着id值增加,后续变量访问速度会递减。
再看Datum本身,channels、height、width都是我们熟悉的。
data和float_data的区别在于,前者用于uint8数据,比如MNIST和cifar10/100,
它们的像素值可以被压缩为一个字符串,而bytes类型在C++里,恰好就是string类型。
float_data则用于存储散装的float值了。
最后的encoded可以被忽略,我还没见过什么图像需要编码的。
Caffe需要OpenCV,主要是由于考虑到图像需要解码,省略这一步,OpenCV可以无视掉。
第二步
我们还需要为Blob提供一个序列化容器,用于存储训练参数。
message BlobShape{ repeated int64 dim=1 [packed=true]; } message BlobProto{ optional BlobShape shape=1; repeated float data=2; repeated float diff=3; repeated double double_data=4; repeated double double_diff=5; }
BlobShape用于存储Blob Shape信息。
BlobProto才是我们需要关注的,除了shape,它由四个容器数组组成。
大部分情况下,我们只会使用其中两个。
因为只有Tesla系列显卡,才支持double运算,而GTX玩家显卡,只能使用float运算。
data用于存储参数数据,diff用于存储残差,实际上diff基本是不会用的,记录参数的残差没有多少意义。
完整代码
见:https://github.com/neopenx/Dragon/blob/master/proto/dragon.proto