序列化与反序列化之Flatbuffers(一):初步使用

心已赠人 2023-01-13 01:41 374阅读 0赞

序列化与反序列化之Flatbuffers(一):初步使用

一: 前言

在MNN中, 一个训练好的静态模型是经过Flatbuffers序列化之后保存在硬盘中的. 这带来两个问题: 1.为什么模型信息要序列化不能直接保存 2.其他框架如caffe和onnx都是用Protobuf序列化, 为什么MNN要用Flatbuffers, 有啥优势? 在解答这两个问题之前, 我们先要了解什么是序列化和反序列化.

二: 什么是序列化和反序列化

什么是序列化和反序列化:

序列化是指把一个实例对象变成二进制内容,本质上就是一个byte[]数组。 为什么要把实例对象序列化呢?因为序列化后可以把byte[]保存到文件中,或者把byte[]通过网络传输到远程,这样,就相当于把实例对象存储到文件或者通过网络传输出去了。 有序列化,就有反序列化,即把一个二进制内容(也就是byte[]数组)变回实例对象。有了反序列化,保存到文件中的byte[]数组又可以“变回”实例对象,或者从网络上读取byte[]并把它“变回”实例对象

  • 序列化:把对象转换为字节序列的过程。
  • 反序列化:把字节序列恢复为对象的过程。

对象的序列化主要有两种用途:

  • 把对象的字节序列永久地保存到硬盘上,通常存放在一个文件中;(持久化对象)
  • 在网络上传送对象的字节序列。(网络传输对象)

举例来说, 比如我用C++训练好一个模型, 然后在代码里是用一个类来描述这个模型的:

  1. class net{
  2. private:
  3. string name;
  4. vector<layer> layers;
  5. ...
  6. }

我可以把这个类的指针指向的内存块整个保存到硬盘中么? 要恢复的时候直接load到内存中, 这样不是最快的嘛? 但是这样会引入几个问题

  • 我从32位机器保存的文件用64位机器打开就还原不了, 因为里面一些类型的sizeof不一样
  • 直接保存文件大小会比较大, 有些信息其实是可以进行压缩减小所需占用的硬盘空间
  • TODO:

总之没有人会这样做, 大家都是通过某种序列化协议, 将要保存的对象经过”某种转换”变成包含同样信息的不同形式保存到硬盘中或者进行传送.

三: 为什么要使用Flatbuffers

明白了保存文件时序列化的必要性后, 我们在选择序列化协议的时候主要考虑以下几点:

  • 协议是否支持跨平台
  • 序列化的速度
  • 序列化出来的大小

而Flatbuffers官网对自己的介绍是这样的:

  • Access to serialized data without parsing/unpacking
  • Memory efficiency and speed
  • Flexible
  • Tiny code footprint
  • Strongly typed
  • Convenient to use
  • Cross platform code with no dependencies

其实Flatbuffers与Protobuf相比有以下几个优势:

  • 最大的一个特点是序列化和反序列化速度更快. 这是由于Flatbuffers将数据序列化成二进制buffer,之后的数据反序列化的时候直接根据一些偏移信息读取这个buffer即可, 就是完善版的”我可以把这个类的指针指向的内存块整个保存到硬盘中么? 要恢复的时候直接load到内存中,这样不是最快的嘛?”. 因此Flatbuffers经常用于游戏中与服务器频繁的通信, 但是感觉用于保存加载神经网络模型时, 相比于Protobuf应该优势不明显, 因为模型的load过程weight的访问占据了主要时间, 而反序列化模型的结构的时间减少应该对load过程加速不明显. 下次有空弄几个模型测一下.mark一下
  • 占用空间小, 使用简单, 适合移动端使用. Protobuf的头文件和库文件加起来有十几兆, 而Flatbuffers使用的时候只需要include一个头文件即可, 更加省空间. 同时简易程度简直是新手福音
  • 再加一个两者都有的优点. 代码的自动化生成. 编写一个fbs或者proto文件来描述需要管理的对象结构, 就可以一行命令生成所有对应的cpp类代码, 十分易于管理和修改. 可以节省很多头发. 我觉得这点才是这些开源神经网络框架使用Protobuf和Flatbuffers的最重要的原因吧.

四: 如何使用Flatbuffers

下面的重点是用于个人记录如何使用Flatbuffers来描述一个神经网络模型(其实就是MNN的方案)以及如何用C++代码进行序列化保存和反序列化读取. 本文的相关代码已经上传到仓库中, 欢迎使用和star
详细内容强烈推荐阅读官方文档

4.1 安装

  1. git clone https://github.com/google/flatbuffers
  2. cd flatbuffers
  3. mkdir build
  4. cd build
  5. cmake .. && cmake --build . --target flatc -- -j4

最终在目录flatbuffers/build下得到一个flatc的可执行文件即可. 安装过程一气呵成, 对比Protobuf的版本问题和一堆依赖库问题, 简直不要太舒服

4.2 编写fbs

使用Flatbuffers和Protobuf很相似, 都会用到先定义一个schema文件, 用于定义我们要序列化的数据结构的组织关系. 下面我们以描述一个极简神经网络模型PiNet为例(没错, 就是浓缩版的MNN), 介绍Flatbuffers常用结构int, string, enum, union, vector, table的使用方法

  1. namespace PiNet;//命名空间千万不能与内部的对象重名!!!!!!
  2. table Pool {
  3. padX:int;
  4. padY:int;
  5. // ...
  6. }
  7. table Conv {
  8. kernelX:int = 1;
  9. kernelY:int = 1;
  10. // ...
  11. }
  12. union OpParameter {
  13. Conv,
  14. Pool,
  15. }
  16. enum OpType : int {
  17. Conv,
  18. Pool,
  19. }
  20. table Op {
  21. type: OpType;
  22. parameter: OpParameter;
  23. name: string;
  24. inputIndexes: [int];
  25. outputIndexes: [int];
  26. }
  27. table Net {
  28. oplists: [Op];
  29. tensorName: [string];
  30. }
  31. root_type Net;

我们的根类型是Net代表一个神经网络模型, 是net是table类型, 该类型应该是最常用的类型, 类似Python中的字典, 冒号左边是名key, 右边是数据类型value. []代表是数组vector. 由此可见一个Net包含多个层Op, 多个tensor. 然后我们重点看Op, 用一个enum来代表Op的类型, 以及一个union来表示该Op的parameter. enum的概念在C/C++中也有, 这里也是一样的, 就是内存空间复用. 而union其实就是非int的enum的, 这里是一个table的enum. 对每种Op的parameter都定义了一个table来描述, Conv层这里只包含了两个参数kernelX和kernelY. 其他都好理解, 这里稍微需要注意的是这个union概念的理解.

4.3 产生generated.h文件

  1. /flatbuffers/build/flatc net.fbs --cpp --binary --reflect-names --gen-object-api --gen-compare

将编写好的fbs文件转换成可用的.h文件. 命令中–gen-object-api表示.h文件中会产生方便使用的xxT类. —gen-compare表示.h文件中每个类都会产生Operator==方法, 用于比较各对象是否相等.
我们来大致看一下产生的.h文件中有哪些内容.

  1. enum OpType : int32_t {
  2. OpType_Conv = 0,
  3. OpType_Pool = 1,
  4. OpType_MIN = OpType_Conv,
  5. OpType_MAX = OpType_Pool
  6. };

fbs中的enum就转换成了C/C++的enum, 这个好理解不用多说

  1. struct Op;
  2. struct OpBuilder;
  3. struct OpT;

fbs中定义的table结构都变成了struct, 以Op为例, 产生了3个结构, 其中Op是用于描述序列化后的Op对象, OpT是用于描述未序列化的Op对象, 这个XXT是需要我们在编译的时候加上选项–gen-object-api才会产生

  1. struct OpT : public flatbuffers::NativeTable {
  2. typedef Op TableType;
  3. PiNet::OpType type = PiNet::OpType_Conv;
  4. PiNet::OpParameterUnion parameter{ };
  5. std::string name{ };
  6. std::vector<int32_t> inputIndexes{ };
  7. std::vector<int32_t> outputIndexes{ };
  8. };

OpT的结构其实就是我们想要描述Op的样子. 由此可见, 使用Flatbuffers的一大好处就是方便, 只需要用fbs几行描述好即可自动产生对应的类对象代码. 抛开序列化不说, 光这代码自动生成就够让人心动了, 简直是懒人福音

  1. struct Op FLATBUFFERS_FINAL_CLASS : private flatbuffers::Table {
  2. enum FlatBuffersVTableOffset FLATBUFFERS_VTABLE_UNDERLYING_TYPE {
  3. VT_TYPE = 4,
  4. VT_PARAMETER_TYPE = 6,
  5. VT_PARAMETER = 8,
  6. VT_NAME = 10,
  7. VT_INPUTINDEXES = 12,
  8. VT_OUTPUTINDEXES = 14
  9. };
  10. PiNet::OpType type() const ;
  11. PiNet::OpParameter parameter_type() const ;
  12. const void *parameter() const ;
  13. template<typename T> const T *parameter_as() const;
  14. const PiNet::Conv *parameter_as_Conv() const;
  15. const PiNet::Pool *parameter_as_Pool() const;
  16. const flatbuffers::String *name() const ;
  17. const flatbuffers::Vector<int32_t> *inputIndexes() const ;
  18. const flatbuffers::Vector<int32_t> *outputIndexes() const ;
  19. bool Verify(flatbuffers::Verifier &verifier) const ;
  20. OpT *UnPack() const;
  21. void UnPackTo() const;
  22. static flatbuffers::Offset<Op> Pack();
  23. };

再看Op这个结构, Op描述的是序列化后的对象, 成员函数中主要是包含了从Op直接访问成员变量的方法(其实就是反序列化), 还有一个VTableOffset的enum, 这个我们下篇详解的时候会用到, 这里暂且不表. 另外还包含了两个Pack和UnPack方法, 顾名思义, 这就是序列化和反序列化方法. UnPack可以将序列化后的对象Op转换成未序列化的对象OpT, Pack可以将OpT转换成Op.

  1. struct OpParameterUnion {
  2. OpParameter type;
  3. void *value;
  4. static void *UnPack();
  5. flatbuffers::Offset<void> Pack() const;
  6. PiNet::ConvT *AsConv() {
  7. return type == OpParameter_Conv ?
  8. reinterpret_cast<PiNet::ConvT *>(value) : nullptr;
  9. }
  10. const PiNet::ConvT *AsConv() const {
  11. return type == OpParameter_Conv ?
  12. reinterpret_cast<const PiNet::ConvT *>(value) : nullptr;
  13. }
  14. PiNet::PoolT *AsPool() {
  15. return type == OpParameter_Pool ?
  16. reinterpret_cast<PiNet::PoolT *>(value) : nullptr;
  17. }
  18. const PiNet::PoolT *AsPool() const {
  19. return type == OpParameter_Pool ?
  20. reinterpret_cast<const PiNet::PoolT *>(value) : nullptr;
  21. }
  22. };

再看Union, 包含了两个成员变量, 一个描述类型, 一个存放数据指针. 对于一个实例化的Union, 想要得到其代表的数据, 需要根据类型手动执行相应的AsXXX函数进行cast. 刚才Op的结构里包含的几个函数parameter_type(), parameter(), parameter_as_Conv(), parameter_as_Pool()就是封装了这个Union的成员函数

  1. bool operator==(const PoolT &lhs, const PoolT &rhs);
  2. bool operator!=(const PoolT &lhs, const PoolT &rhs);
  3. bool operator==(const ConvT &lhs, const ConvT &rhs);
  4. bool operator!=(const ConvT &lhs, const ConvT &rhs);
  5. bool operator==(const OpT &lhs, const OpT &rhs);
  6. bool operator!=(const OpT &lhs, const OpT &rhs);
  7. bool operator==(const NetT &lhs, const NetT &rhs);
  8. bool operator!=(const NetT &lhs, const NetT &rhs);

编译选项里开了–gen-compare后就会自动产生这些比较操作符重载代码, 这里再一次展示出了Flatbuffers的方便特性, 试想一下, 如果我有100种Op参数都要手动去写比较操作符重载, 那岂不是得累死.

4.4 序列化代码

  1. #include <fstream>
  2. #include <iostream>
  3. #include "net_generated.h"
  4. using namespace PiNet;
  5. int main() {
  6. flatbuffers::FlatBufferBuilder builder(1024);
  7. // table ConvT
  8. auto ConvT = new PiNet::ConvT;
  9. ConvT->kernelX = 3;
  10. ConvT->kernelY = 3;
  11. // union ConvUnionOpParameter
  12. OpParameterUnion ConvUnionOpParameter;
  13. ConvUnionOpParameter.type = OpParameter_Conv;
  14. ConvUnionOpParameter.value = ConvT;
  15. // table OpT
  16. auto ConvTableOpt = new PiNet::OpT;
  17. ConvTableOpt->name = "Conv";
  18. ConvTableOpt->inputIndexes = { 0};
  19. ConvTableOpt->outputIndexes = { 1};
  20. ConvTableOpt->type = OpType_Conv;
  21. ConvTableOpt->parameter = ConvUnionOpParameter;
  22. // table PoolT
  23. auto PoolT = new PiNet::PoolT;
  24. PoolT->padX = 3;
  25. PoolT->padY = 3;
  26. // union OpParameterUnion
  27. OpParameterUnion PoolUnionOpParameter;
  28. PoolUnionOpParameter.type = OpParameter_Pool;
  29. PoolUnionOpParameter.value = PoolT;
  30. // table Opt
  31. auto PoolTableOpt = new PiNet::OpT;
  32. PoolTableOpt->name = "Pool";
  33. PoolTableOpt->inputIndexes = { 1};
  34. PoolTableOpt->outputIndexes = { 2};
  35. PoolTableOpt->type = OpType_Pool;
  36. PoolTableOpt->parameter = PoolUnionOpParameter;
  37. // table NetT
  38. auto netT = new PiNet::NetT;
  39. netT->oplists.emplace_back(ConvTableOpt);
  40. netT->oplists.emplace_back(PoolTableOpt);
  41. netT->tensorName = { "conv_in", "conv_out", "pool_out"};
  42. netT->outputName = { "pool_out"};
  43. // table Net
  44. auto net = CreateNet(builder, netT);
  45. builder.Finish(net);
  46. // This must be called after `Finish()`.
  47. uint8_t* buf = builder.GetBufferPointer();
  48. int size = builder.GetSize(); // Returns the size of the buffer that
  49. //`GetBufferPointer()` points to.
  50. std::ofstream output("net.mnn", std::ofstream::binary);
  51. output.write((const char*)buf, size);
  52. return 0;
  53. }

由于我们开启了–gen-object-api选项会产生XXT的结构, 我们只需要对各个层次的数据结构进行赋值即可, 最后只要对根节点进行一次Create即完成序列化, 很简单方便. 相比于官网的创建monster那样每个层次的数据都要Create序列化一下, 代码结构能精简不少.

4.5 反序列化

  1. #include <fstream>
  2. #include <iostream>
  3. #include <vector>
  4. #include "net_generated.h"
  5. using namespace PiNet;
  6. int main() {
  7. std::ifstream infile;
  8. infile.open("net.mnn", std::ios::binary | std::ios::in);
  9. infile.seekg(0, std::ios::end);
  10. int length = infile.tellg();
  11. infile.seekg(0, std::ios::beg);
  12. char* buffer_pointer = new char[length];
  13. infile.read(buffer_pointer, length);
  14. infile.close();
  15. auto net = GetNet(buffer_pointer);
  16. auto ConvOp = net->oplists()->Get(0);
  17. auto ConvOpT = ConvOp->UnPack();
  18. auto PoolOp = net->oplists()->Get(1);
  19. auto PoolOpT = PoolOp->UnPack();
  20. auto inputIndexes = ConvOpT->inputIndexes;
  21. auto outputIndexes = ConvOpT->outputIndexes;
  22. auto type = ConvOpT->type;
  23. std::cout << "inputIndexes: " << inputIndexes[0] << std::endl;
  24. std::cout << "outputIndexes: " << outputIndexes[0] << std::endl;
  25. PiNet::OpParameterUnion OpParameterUnion = ConvOpT->parameter;
  26. switch (OpParameterUnion.type) {
  27. case OpParameter_Conv: {
  28. auto ConvOpParameterUnion = OpParameterUnion.AsConv();
  29. auto k = ConvOpParameterUnion->kernelX;
  30. std::cout << "ConvOpParameterUnion, k: " << k << std::endl;
  31. break;
  32. }
  33. case OpParameter_Pool: {
  34. auto PoolOpParameterUnion = OpParameterUnion.AsPool();
  35. auto k = PoolOpParameterUnion->padX;
  36. std::cout << "PoolOpParameterUnion, k: " << k << std::endl;
  37. break;
  38. }
  39. default:
  40. break;
  41. }
  42. return 0;
  43. }

五: 总结

Flatbuffers的使用还是挺简单的, 理解常见的几种数据类型, 再把官网那个monster的例子看几遍就还挺好懂的. 本篇只是初步使用, 下一篇我们再做深入剖析.

序列化与反序列化之Flatbuffers(二):深入剖析

发表评论

表情:
评论列表 (有 0 条评论,374人围观)

还没有评论,来说两句吧...

相关阅读

    相关 序列序列

    因为TCP/IP协议只支持字节数组的传输,不能直接传对象。对象序列化的结果一定是字节数组!当两个进程在进行远程通信时,彼此可以发送各种类型的数据。无论是何种类型的数据,都会以二

    相关 java序列序列

     Java序列化是指把Java对象转换为字节序列的过程;而Java反序列化是指把字节序列恢复为Java对象的过程  为什么需要序列化与反序列化  我们知道,当两个进程

    相关 序列序列

    我们知道,和new创建对象,反射创建对象,序列化创建对象也是我们常用的一种对象创建方式,下面就详细的说一说序列化与反序列化。 一.序列化简述 为什么需要序列化与反序列

    相关 序列序列

    序列化:将对象转化为字节序列 反序列化:将字节序列转化为对象 序列化与反序列化的好处: 1. 进行远程通信传输对象 我们知道数据是以二进制的方式在网络上传输的

    相关 序列序列

    一、序列化的概念 序列化:首先,用日常生活中的例子来理解一下序列化。在我们日常生活中,运输一个整个的汽车总是不方便的,所以我们会把汽车拆开,当汽车变成一个个零件的时候,我们

    相关 序列序列

    概念 -------------------- 把对象的状态信息转换为字节序列的过程称为对象的序列化。 把字节序列恢复为对象的过程称为对象的反序列化。 对象的序列