问题由来
之前模型移植都是使用mxnet的amalgamation, 单个文件纯c++代码。使用这种方式的好处当然是编译简单无依赖。缺点却也日益突出:
- mxnet发展迅速,各个版本的模型不一定兼容。使用最新代码训练模型,也就意味着需要使用最新的代码生成amalgamation, 而mxnet现在的amalgamation基本处于无人维护的情况下,每次更新都需要手动解决大量问题才可用
- mxnet为了效率引入各种“诡异”的函数,而某些函数在android上是没有的。比如说
shm_open
,这个函数是为了nccl加入的。所以mxnet可能会为了训练进行性能优化,导致客户端移植日益复杂。但是对于客户端来说,很多代码是没有必要的 - mxnet issue里面好像有提到要移除amalgamation,这也必然导致amalgamation之路日益艰难
- 每次修改一下编译选项,编译几万行文件也是挺痛苦的。
把训练和移动端部署分开才是王道。考察过caffe2,一方面依赖文件很多。另一方面不支持ssd等层。而且那个facebook github bot看起来也实在恶心,不像在做开源。在尝试期间用官方脚本居然跑不过,虽然Yangqing及时解决了编译问题, 但还是可以看出其android编译方式很落后,对stl的之前还很古老。回头我更新一下我的解决方案 :)
至于baidu的mobile deep learning,说实话就是拼凑,gemm和metal代码都是抄的,而且没有继续维护下去的意思。
最后之所以用ncnn,有以下几个原因:
- 我们优化过网络结构的yolo在ncnn上的速度是nnpack的2倍。虽然,这不能保证其它网络也有相似的加速效果。
- ncnn比较轻量,定制起来也容易。
理想很美好,现实很骨感。一般来说,轻量就是意味着功能匮乏。可能最后还是需要caffe2这种。
最终效果
本文基于ncnn的原始mxnet转ncnn工具,实现mxnet ssd转ncnn。最终效果如下:
跟mxnet的结果还有点差异,但基本上可以保证网络转换没有问题了。导致差异的原因可能是数据初始化造成的。
mxnet2ncnn
基本概念
先统一一下术语。模型一般包含两个文件,一个是描述文件,一个是权重文件。比如caffe的prototxt和caffemodel,mxnet的json和params文件。描述文件需要指出每个op的参数,以及给出输入依赖关系。权重文件就是具体的参数了。我们统一把描述文件的参数称为参数, 把权重文件的各op权重称为权重。
{
{
"op": "Convolution",
"name": "relu4_3_cls_pred_conv",
"attr": {
"kernel": "(3, 3)", //op 参数
"num_filter": "84",
"pad": "(1, 1)",
"stride": "(1, 1)"
},
"inputs": [[43, 0, 0], [44, 0, 0], [45, 0, 0]] //输入
},
对于一个前馈网络来说,一次前向操作就是每一层获取到对应的输入,将计算结果传给下一层。每一层各司其职,这样整个预测过程就结束了。
对于跨框架的格式转化来说,更大的难度在于,需要了解两边的网络构造方式,需要知道两边的持久化方式,需要相对一致的op实现: 参数差异,计算方式差异,输出差异。
-
参数有差异,则在读取参数的时候,需要进行对应的转化。
-
计算方式差异,输出差异。这两个一般是一体的。如果计算方式无法兼容,极端情况下,可能需要自己实现某些参数下的计算方式。如果只是输出格式不一样,则可能需要增加或者减少某些层。
mxnet模型加载,网络图构建,权重加载
这部分由ncnn作者@nihui实现,mxnet的json居然用c++代码来实现,任性。但这也导致了扩展性很差。在转ssd的过程中,解决了多个bug。不过,有了这个初始代码之后就不需要权重解析,权重layout不对的问题。也不用费心去构造网络图了。
整个流程就是:
- 读取mxnet模型文件
- 构建网络图,是否权重或者输入?第几个输入时时什么(修改输入顺序在这里)
- 写入对应ncnn名称
- 写入模型输入
- 写入模型参数。写入参数文件。
关于权重解析,权重hwc还是chw格式。我们以卷积层来距离。首先我们得知道mxnet的权重持久化方式是什么,layout是什么,然后知道ncnn需要的权重格式。在读取mxnet权重的时候转化为对应的格式即可。而卷积的操作,不管里面怎么绕,结果的物理意义都是一致的,无非是保持方式不一样。在下一层注意即可。
网络层不一致
关于网络层不一致的问题,我们只要时刻注意让其物理意义一致就好了。
有时候会碰到,mxnet的几层,在ncnn里面只需要一层就好了。这时候比较好办,只要用这个层进行计算就好了。那么其他几个层怎么办呢。不能删掉呀,删掉后就会导致网络图不对,现在我的做法就是直接让另外几个层的输出等于输入就好了。至于怎么做到。在网络层里面加参数咯,前向的时候遇到这个参数就知道不需要计算了。
mxnet一个层对应ncnn几个层的情况,目前没有碰到。如果有这种情况还是挺麻烦的。整个网络图都不一样了。需要需改网络图构建了,或者修改网络层实现。
如果layout不一致,就要在适当的时候转化为合适的layout了。这里面可能会涉及到硬编码。
就像这样:
if (nextop == "_contrib_MultiBoxDetection") {
fprintf(pp, " 3=-1"); //not reshape
} else {
fprintf(pp, " 3=0");// permute
}
说的是,下一个op如果是detection op的话,就不要reshape了。这是因为mxnet和ncnn前一层的op输出格式不一样啊。mxnet到这里还需要reshape,而ncnn已经是目标格式了。
tips
建议最好有对应的可运行模型用于观察每层的输入输出的shape。只要把每层的shape搞对了,基本上就没有问题了。
总结
模型转换最难的还是网络图不一致的情况下怎么办。本文基于ncnn工具修改的mxnet ssd转换工具存在大量的硬编码,显然不是很合适作为通用方案。不过可能也正是因为这样,tensorflow的模型转换才会这么匮乏。因为大家实现不一样啊,这就需要很多patch去调整了。而这些patch可能并不适合其他模型。
源码已上传到github。有兴趣的可以查看。
附录
我是带着以下问题完成整个ssd模型转换。记录在这里作为参考。
- 网络图怎么构建,权重怎么加载?
- mxnet,caffe的卷积层权重保存方式是什么?为什么两者转权重的时候都不需要转换成ncnn的格式,难道两者的形式是一样的?
- 对于分类的来说,最后输出层的格式是不一样的。caffe h w c, mxnet chw。比如squeezenet mxnet输出是1000 x 1 x 1, ncnn输出是1 x 1 x 1000。 注意softmax之前似乎需要进行transpose
- 注意最后输入到detection层的顺序需要调换一下
- 如果是整个channelwise的权重按照卷积层一起加载,那么其权重的hwc layout是否是正确的?
- l2normlize是什么鬼
- permute的layout似乎不是很一致