Edited at
ChainerDay 14

C++でCPUのみでonnx-chainerで出力したONNX形式のDNNモデルの推論(Inference)をするライブラリ「Instant」を作った

More than 1 year has passed since last update.


背景

DNNを用いた一般的な機械学習の手法は学習フェーズと推論フェーズに分かれます.学習フェーズでは対象のデータを元にモデルのパラメータを調整し,推論フェーズでは学習フェーズで得られたモデルを実際の使用したい環境に組み込んで,実際に未知のデータを入力として結果を得ます.学習フェーズと推論フェーズの両方で用いることができるDNNライブラリは数多くあります(Chainer, Tensorflow, PyTorch, CNTK, Caffe2など).

しかし,そのほとんどどれもPythonの使用を前提としているため,実際の推論フェーズの環境でPythonを使えない,もしくは使いたくないという場面は,そういったライブラリを直接使うことができません.

そうした場合には,推論フェーズの環境では実行速度の面からC++が用いられている場合が多いので,大抵のところ何らかの形で出力したモデルパラメータを使って,実際の環境で推論するためのコードをC++でスクラッチで書くことになります.(@kaityo 2016).

また,仮にPythonを使える環境であったり,ライブラリがC++にも対応していたりする場合にも問題があります.

推論フェーズでは学習を行わないにも関わらず,そういったライブラリを用いる場合は学習用のコードが推論フェーズの環境に紛れ込んでしまうという問題です.ほとんどのライブラリは推論ではなく学習に重点を置かれて設計されているため,これは無用にコードの複雑さを増やす原因となります.

さらに,学習フェーズではGPUが利用できても,推論フェーズではCPUしか利用できない場合もよくあります.

以上から,推論フェーズのために,通常のPythonで書かれた学習用のDNNライブラリとは別に,学習済のDNNのモデルをCPUだけの環境で推論用に手軽に使えるC++のライブラリがあると便利です.

最大の問題は,DNNライブラリから学習したモデルパラメータをどのように出力するかです.例えば,Chainerでは学習したモデルをnpz形式で出力することができますが,npz形式はNumpyが定めるバイナリフォーマットであるnpy形式のデータをzip圧縮したものであり,これをC++で読むのは手間がかかります.また,これを手間をかけて読んだとしても,Chainer以外のライブラリで学習したモデルを利用しようとすると,また別途そのライブラリ専用の読み出し用コードが必要となります.

そこで,最近MicrosoftとFacebookが提唱し,Chainerが既に対応しているDNNモデルパラメータの共通フォーマット規格であるONNXフォーマットでモデルパラメータを出力することにします.こうすることで,C++から読みやすく,特定のDNNライブラリに依存しない形でモデルパラメータを得ることができます.

CPUで高速にDNNの処理を行うことについては,MKL-DNNが利用できます.MKL-DNNはC/C++のライブラリなので,その点からも推論用途には適しています.

(なお,余談ですが,ChainerではPythonを使ってCPUで高速にDNNの学習・推論するためにniboshi氏によるideepの対応のためのPRが進んでいます)


ONNXとは

ONNXとはMicrosoftとFacebookが提唱するDNNモデルを記述するためのオープンソースフォーマットです.

ONNXフォーマットはProtocolBufferで定義されており,各種言語用にモデルパラメータをシリアライズ・デシリアライズできるようになっています.

Caffe2, PyTorch, MXNetなどがサポートしており,Chainerもmitmul氏の開発したonnx-chainerアドオンによってONNXによるモデル出力に対応しました.

onnx-chainerについては18日の記事に作者のmitmul氏による詳しい解説があります.

onnx-chainerでVGG16をONNX形式で出力するPythonコードです.(chainer-onnxのサンプルそのままです)

import numpy as np

import chainer
import chainer.links as L
import onnx_chainer

model = L.VGG16Layers()

# Pseudo input
x = np.zeros((1, 3, 224, 224), dtype=np.float32)

# Don't forget to set train flag off!
chainer.config.train = False

onnx_chainer.export(model, x, filename='VGG16.onnx')

これでVGG16のモデルパラメータを記述したONNXフォーマットのVGG16.onnxファイルが出力されます.


Instantの使い方

現時点ではInstantはまだ全てのONNXで定義されているOperatorに対応していませんが,VGG16を動かせるだけのOperator(Conv,Relu,MaxPool,Reshape,FC,Dropout,Softmax)には対応しています.

chainer-onnxで出力したONNX形式のVGG16のモデルを読み込んで実際に推論してみます.

なお,InstantはC++14で書きました.(ISOにもなったしC++17にしようか迷いましたが,時間がなかったので慣れているC++14で書きました.)

まず入力データの次元を設定します.今回読み込むVGG16の入力データの次元は(1, 3, 224, 224)です.

最初の次元はバッチサイズで,次が画像のチャンネル数,その次は縦幅,横幅です.

constexpr auto batch_size = 1;

constexpr auto channel_num = 3;
constexpr auto height = 224;
constexpr auto width = 224;

std::vector<int> input_dims{batch_size, channel_num, height, width};

入力と出力それぞれについてVGG16.onnxで定義されているTensorの名前のアライアスを作っておきます.

必要なTensorの名前はVGG16.onnxをonnx-chainerなどで解析してチェックしてください.

(実は単にエディタで開いてもなんとなく分かります)

(20171218追記

Tensorの名前を確認するためのinstant/tool/instant_onnx_viewerを用意しました.これを使うとONNXの中身を見ることができます.instant_onnx_viewerInstantをビルドすると一緒にビルドされます.

使い方としては,Terminalから呼び出して引数としてONNXのファイルパスを与えてください.

./tool/instant_onnx_viewer ../data/VGG16.onnx

追記ここまで)

auto conv1_1_in_name = "140326425860192";

auto fc6_out_name = "140326200777976";
auto softmax_out_name = "140326200803680";

ONNXファイルからモデルパラメータをロードします.

auto onnx_model = instant::load_onnx(onnx_model_path);

ロードしたパラメータからモデルを作成します.

必要な引数はONNXのモデルパラメータ,各入力データの属性パラメータのリスト,必要な出力Tensorの名前のリストです.

入力データの属性パラメータは,Tensorの名前,要素型,次元(numpyで言うところのshape),メモリフォーマットです.入力の数だけこれらをTupleに入れてリストに詰め込みます.

今回は入力はひとつだけなので要素がひとつだけのリストを作成しています.

要素型は今はfloat(float32)にだけ対応しているので,instant::dtype_t::float_を指定してください.

auto model = instant::make_model(

onnx_model,
{{conv1_1_in_name, instant::dtype_t::float_, input_dims,
mkldnn::memory::format::nchw}},
{fc6_out_name, softmax_out_name});

入力データをmodelの入力バッファにコピーします.

auto& input_array = model.input(conv1_1_in_name);

std::copy(image_data.begin(), image_data.end(),
instant::fbegin(input_array));

推論します.

auto const& output_table = model.run();

必要な出力データの名前を指定してoutput_tableから取り出します.

output_tableから値を取り出すにはfind_value関数を使って取り出したいTensorの名前を指定します.

instant::array型のオブジェクトが返されるので,fat関数や,fbegin関数,fend関数でデータにアクセスします.なお,total_size関数で全要素の数を得ることができます.

auto const& fc6_out_arr = instant::find_value(output_table, fc6_out_name);

std::cout << "fc6_out: ";
for(int i = 0; i < instant::total_size(fc6_out_arr); ++i) {
std::cout << instant::fat(fc6_out_arr, i) << " ";
}

std::vector<float> fc6_out_vect(instant::total_size(fc6_out_arr));
std::copy(instant::fbegin(fc6_out_arr), instant::fend(fc6_out_arr),
fc6_out_vect.begin());

使い方の流れとしては以上です.

exampleのコードで実際に推論させてみた結果を次に示します.

入力画像です.これは雌鳥の画像です.



Image from here

推論の結果です.

VGG16 Example

fc6_out: -0 -0 -0 24.2962 -0 ...
Top 5 categories are
8 0.96132 n01514859 hen
7 0.0369939 n01514668 cock
86 0.00122795 n01807496 partridge
82 0.000225824 n01797886 ruffed grouse, partridge, Bonasa umbellus
97 3.83678e-05 n01847000 drake

hen(雌鳥)が0.96132と最も高くなっているので,正しく判定ができています.


まとめ

Deep Learningというと,学習の部分が注目されがちです.しかし,実際に役立つものを作ろうとすると,学習したモデルを使ってどうやって推論するかもきちんと考える必要があります.実際に僕も業務で歯がゆい思いをすることがあったので今回このような推論専用のライブラリを作ってみました.

まだ荒削りですが,他のOperatorの対応や最適化など開発は続けていく予定です.今回開発したライブラリ「Instant」をぜひ使ってみてください.PR,ご意見,歓迎します.

InstantのコードはGithubにあります.