之前实习的时候训练一个给ASR文本添加大小写和标点的模型,框架用的是tensorflow r1.2(本文其实和tensorflow版本无关)。模型训好后mentor说要转成C++上线,当时差点崩溃,由于太懒,不想换框架重写就只好试试tensorflow的C++ API了,由于公司服务器的权限问题也是躺了不少的坑,这里简单总结一下TF模型转C++ API以及转gRPC服务的基本步骤和遇到的一些很迷的Errors。
关于Tensorflow模型到gRPC服务,tensorflow有个神奇API叫Tensorflow Serving,大家可以试一试。不过本文不是采用这种方式,而是先转C++接口,再用gRPC写接口服务,其实原理是一样的。
-
Tensorflow C++ API
Tensorflow提供的C++API能够恢复python训练好的模型计算图和参数到C++环境中;通过向Placeholder传入数据便可以得到Eigen::Tensor类型的返回。python下的Tensorflow依赖numpy矩阵运算库,而在C++下依赖Eigen::Tensor库,所以在使用C++ API之前需要先安装好对应版本的Eigen库;
-
下载Tensorflow源码
下载最新的Tensorflow源码,这个和你使用什么版本Tensorflow训练模型没有关系。之后就需要把Tensorflow编译成我们需要的动态链接库;
$ git clone https://github.com/tensorflow/tensorflow.git
-
安装Bazel
这里需要注意一下,版本太新和太旧的Bazel在编译Tensorflow的时候都会报错,这里举例我用过的版本组合:Bazel-0.10.0(Tensorflow-r1.7);Bazel-0.8.0(Tensorflow-r1.5);Bazel-0.4.5(Tensorflow-r1.2)。以上组合并不固定,经供参考(本文是Tensorflow1.7)。由于在公司服务器上工作,所有的third-party都需要安装在自己的目录。
1 . 非root安装JDK8:jdk-8u161-linux-x64.tar.gz。Bazel依赖JDK8,wget下载后解压,把jdk添加到环境变量,把以下代码添加到$HOME/.bashrc
:
export JAVA_HOME="$HOME/tools/java/jdk1.8.0_161"
export JAVA_BIN=$JAVA_HOME/bin
export JAVA_LIB=$JAVA_HOME/lib
export CLASSPATH=.:$JAVA_LIB/tools.jar:$JAVA_LIB/dt.jar
export PATH=$JAVA_BIN:$PATH
2 . 安装Bazel:各版本地址release,本文是bazel-0.10.0,下载好.sh
文件之后执行一下命令:
chmod +x bazel-<version>-installer-linux-x86_64.sh
./bazel-<version>-installer-linux-x86_64.sh --user
bazel被装到了$HOME/bin
目录下,添加到环境就OK了;之后输入bazel version
看看版本是否安装成功;
-
安装Eigen3
之前提到了Tensorflow依赖Eigen矩阵运算库,在编译之前需要安装对应的版本;关于Eigen同样是一个坑,不对应的版本依然会让Tensorflow编译失败,这里提供一个最保险的方法,就是去tensorflow的tensorflow/tensorflow/workspace.bzl
里下载;在 workspace.bzl
中找到:
tf_http_archive(
name = "eigen_archive",
urls = [
"https://mirror.bazel.build/bitbucket.org/eigen/eigen/get/2355b229ea4c.tar.gz",
"https://bitbucket.org/eigen/eigen/get/2355b229ea4c.tar.gz",
],
下载其中任何一个链接都可,下载好之后解压,将Eigen添加到环境变量:
export CPLUS_INCLUDE_PATH="$HOME/tools/include/:$CPLUS_INCLUDE_PATH"
export CPLUS_INCLUDE_PATH="$HOME/tools/include/eigen3/:$CPLUS_INCLUDE_PATH"
-
安装Protobuf
protocbuf是一种很强大的跨平台的数据标准,可以用于结构化数据序列化,用于通讯协议、数据存储等领域的语言无关、平台无关的序列化结构数据格式,在之后的gRPC中也会用到;
同样Protobuf的版本也会直接决定tensorflow是否编译成功,和安装Eigen同样的方法,去workspace.bzl
中找protobuf下载对应的版本;下载好后进入protobuf目录输入以下命令安装,并添加到环境:
./autogen.sh
./configure --prefix=$HOME/tools/bin
make
make install
-
安装nsync
和Eigen同样的方式下载,添加环境路径即可:
export CPLUS_INCLUDE_PATH="$HOME/tools/include/nsync/public:$CPLUS_INCLUDE_PATH"
跳过这步安装会出现:fatal error : nsync_cv.h: No such file or dictionary
的错误;
-
编译Tensorflow
经过以上的充足准备,终于可以编译Tensorflow啦,进入tensorflow下载目录,输入以下命令:
./configure
bazel build //tensorflow:libtensorflow_cc.so
其中./configure
之后没有选择CUDA支持,全部为no。经过4/5分钟之后,在bazel-bin/tensorflow下就会看到libtensorflow_cc.so
和libtensorflow_framework.so
两个动态库;之后需要把这两个库复制到$HOME/tools/lib
中,这样就可以连接来编译我们的模型了,之后的任务就是写Tensorflow的C++ API接口啦。
-
C++重写python API
在python API中主要有以下三步骤:
1 . 创建Session,读入计算图,恢复参数;
2 . 获取需要的输入,输出Tensor (graph.get_tensor_by_name);
3 . 给输入Tensor传值,run模型,得到输出结果;
C++ API也是相同的步骤;这里先给出python API的代码,Tensor的名字最好提前设定好,如果没有的话也可以直接Tensor.name
查看:
def model_restore(self,model_file):
sess = tf.Session()
ckpt_file = tf.train.latest_checkpoint(self.model_file)
saver = tf.train.import_meta_graph(ckpt_file+".meta")
saver.restore(sess,ckpt_file)
return sess
def recover(self,sess,paragraph):
# 输入 : 无标点,大写字符串
# 输出 : 带标点,大写字符串
char_paragraph = get_char_id(paragraph)
graph = tf.get_default_graph()
#读入Tensor
inputs = graph.get_tensor_by_name('word_id:0')
logits_c = graph.get_tensor_by_name('Capt-Softmax/Reshape:0')
logits_p = graph.get_tensor_by_name('Punc-Softmax/Reshape:0')
tm_c = graph.get_tensor_by_name('loss/crf_capt/transitions:0')
tm_p = graph.get_tensor_by_name('loss/crf_punc/transitions:0')
feed_dict[inputs] = char_paragraph
#运行模型
logits_capt,logits_punc,transition_matrix_capt,transition_matrix_punc = sess.run([logits_c,logits_p,tm_c,tm_p],feed_dict=feed_dict)
return self.sequence_viterbi_decode(label_pred_capt,label_pred_punc,word)
同样的结构用C++重写之后的代码如下,恢复模型部分:
void RecoverTool::modelLoader(const string& checkpoint_path){
const string graph_path = checkpoint_path+".meta";
// 读入模型的计算图
tensorflow::MetaGraphDef graph_def;
tensorflow::Status status = tensorflow::ReadBinaryProto(tensorflow::Env::Default(), graph_path, &graph_def);
if(!status.ok())
cout<<"Graph restore failed from "<<checkpoint_path<<endl<<status.ToString())<<endl;
// 创建session
status = session->Create(graph_def.graph_def());
if(!status.ok())
cout<<"Session created failed"<<endl<<status.ToString())<<endl;
// 恢复模型参数
tensorflow::Tensor checkpointTensor(tensorflow::DT_STRING,tensorflow::TensorShape());
checkpointTensor.scalar<string>()() = checkpoint_path;
status = session->Run(
{{graph_def.saver_def().filename_tensor_name(), checkpointPathTensor},},
{},
{graph_def.saver_def().restore_op_name()},
nullptr);
if(!status.ok())
cout<<"Model restore failed from "<<checkpoint_path<<endl<<status.ToString())<<endl;
}
API核心函数,C++中Tensor返回的是Eigen::Tensor
类型;
string RecoverTool::recover(const string& paragraph){
// placeholder vector
vector<pair<string, tensorflow::Tensor>> input = utils.get_input_tensor_vector(paragraph);
// 模型输出
vector<tensorflow::Tensor> outputs;
// 运行model
tensorflow::Status status = session->Run(input, {"Capt-Softmax/Reshape:0","Punc-Softmax/Reshape:0","loss/crf_capt/transitions:0","loss/crf_punc/transitions:0"}, {}, &outputs);
if(!status.ok())
cout<<"Model run falied"<<endl<<status.ToString()<<endl;
tensorflow::Tensor log_capt = outputs[0];
tensorflow::Tensor tran_capt = outputs[2];
auto logits_capt = log_capt.tensor<float,3>();
auto trans_capt = tran_capt.tensor<float,2>();
Eigen::Tensor<float,2> logit_capt(logits_capt.dimension(1),logits_capt.dimension(2));
Eigen::Tensor<float,2> transitions_capt(trans_capt.dimension(0),trans_capt.dimension(1));
for(int num_step(0);num_step<logits_capt.dimension(1);++num_step){
for(int char_step(0);char_step<logits_capt.dimension(2);++char_step){
logit_capt(num_step,char_step) = logits_capt(0,num_step,char_step);
}
}
for(int tag_ind1(0);tag_ind1<trans_capt.dimension(0);++tag_ind1){
for(int tag_ind2(0);tag_ind2<trans_capt.dimension(1);++tag_ind2){
transitions_capt(tag_ind1,tag_ind2) = trans_capt(tag_ind1,tag_ind2);
}
}
stack<int> captLabel = viterbi_decode(logit_capt,transitions_capt);
return paragraphDecode(captLabel,puncLabel,paragraph);
}
如果模型输出不是最终结果,还需要进行行加工,这时就需要对Eigen的API有稍微的了解了,我用Eigen写了一个简单的CRF-Viterbi_decode
代码,分享在这里供大家参考:
stack<int> Utils::viterbi_decode(Eigen::Tensor<float,2> score,Eigen::Tensor<float,2> trans_matrix){
//score: [seq_len,num_tags]
//trans_matrix: [num_tags.num_tags]
stack<int> viterbi;
Eigen::Tensor<float,2> trellis = score.constant(0.0f);//创建和score相同大小的全零数组
Eigen::Tensor<int,2> backpointers(score.dimension(0),score.dimension(1));
backpointers.setZero();
trellis.chip(0,0) = score.chip(0,0);
for(int i(1);i<score.dimension(0);++i){
Eigen::Tensor<float,2> v = trans_matrix.constant(0.0f);
for(int j(0);j<trans_matrix.dimension(1);++j)
v.chip(j,1) = trellis.chip(i-1,0);
v+=trans_matrix;
Eigen::array<int, 1> dims({0});
Tensor<float,1> maxCur = v.maximum(dims);
trellis.chip(i,0) = maxCur+score.chip(i,0);
backpointers.chip(i,0) = argmax(v,0);
}
viterbi.push(argmax_Dim1(trellis.chip(trellis.dimension(0)-1,0),0));
for(int i(backpointers.dimension(0)-1);i>0;--i){
viterbi.push(backpointers(i,viterbi.top()));
}
return viterbi;
}
-
编译TF模型
通过以上步骤,我们就可以编译C++ API了,这里我们用make进行编译,链接上之前编译的libtensorflow_cc.so和libtensorflow_framework.so,命令如下,也可以写一个Makefile;
g++ -std=c++11 -g -Wall -D_DEBUG -Wshadow -Wno-sign-compare -w `pkg-config --cflags --libs protobuf`\
-I/home/xiaodl/tensorflow/bazel-genfiles -I/home/xiaodl/tensorflow/ -L/home/xiaodl/tools/lib\
-ltensorflow_framework -ltensorflow_cc -lprotobuf Utils.cc Recover.cc main.cc -o recover
之后在当前目录会生成一个可执行文件,这样就大功告成啦~输入一句没有标点的句子试一试,得到如下结果,试验成功;
We are living in the New York City now, and how is it going recent, Tom?
-
Tensorflow gRPC服务
有了C++ API就可以愉快的写gRPC服务了,那gPRC服务到底是什么呢?google家的RPC,传送官方文档:定义一个服务,指定其能够被远程调用的方法(包含参数和返回类型)。客户端应用可以像调用本地对象一样直接调用另一台不同的机器上服务端应用的方法。
-
安装gRPC
直接github clone就好了:
$ git clone https://github.com/grpc/grpc.git
进入grpc目录,更新三方依赖源码,由于我们安装了protobuf
,所以可以进入.gitmodules
文件,删掉protobuf项,之后将已经安装的protobuf目录放入grpc/third_party
就好:
$ git submodule update --init
更新完之后进入Makefile查找:ldconfig
,把动态链接库指向自己的目录;
把ldconfig替换成
ldconfig -r $HOME/tools/bin
,之后执行安装:
make
make install prefix=$HOME/tools/
如果不是在grpc/third_party
中安装的protobuf,在make过程中很有可能出如下错误:
In file included from src/compiler/php_generator.cc:23:0:
./src/compiler/php_generator_helpers.h: In function ‘grpc::string grpc_php_generator::GetPHPServiceFilename(const FileDescriptor*, const ServiceDescriptor*, const string&)’:
./src/compiler/php_generator_helpers.h:51:23: error: ‘const class google::protobuf::FileOptions’ has no member named ‘has_php_namespace’; did you mean ‘has_csharp_namespace’?
if (file->options().has_php_namespace()) {
^~~~~~~~~~~~~~~~~
has_csharp_namespace
可以通过如下方式来解决这个错误:
make clean
make HAS_SYSTEM_PROTOBUF=false
最新版本的grpc需要protobuf的版本是3.5.0
,安装成功之后可以去/grpc/examples/cpp/
下测试grpc是否能正常工作;如果安装的protobuf版本不对会报错,更新protobuf到3.5.0
即可,注意还要和Tensorflow要求的protobuf版本匹配才行;
-
Tensorflow C++ API 的gRPC服务
到这里总算可以开始写服务了,在实际运用中要求服务的client和server端都能够异步工作,也就是请求不产生阻塞;gPRC提供了很强的异步服务机制来实现客户和服务之间的异步无阻塞,这里将简单分析一下client和server端的异步机制:
1 . Client端:客户端会生成一个队列
CompletionQueue
,并用CallData类来记录RPC的状态和标签,每个request对应一个Calldata,在接收到请求的时候将其放入CompletionQueue中,并调用Finish函数向服务器端发送请求,寻求应答后立即返回处理新的待发送请求(无阻塞);另开一个线程去等待处理CompletionQueue中的服务端应答;2 . Server端:服务端有两个任务:接收
request
和处理request
并返回Client
;服务端用ServerData
类来接收request
,为了不让Service
处理请求过程中有新的request
到来产生阻塞,服务端将ServerData
放入CompletionQueue
队列后新建一个ServerData
去接收新的请求(无阻塞);另开一个线程处理队列中各种状态的ServiceData
,并实现应答;
以上是我自己的理解,如果有错误请大家指出;根据这种理解实现Tensorflow的gRPC服务就不难了,服务端在创建Service之前先restore model,服务启动之后直接调用API即可,异步的实现和上面提到的流程一样,grpc有一个官方的案例非常不错/grpc/examples/cpp/helloworld/helloworld_async_client2.cc
。
测试Tensorflow gRPC的结果如下:
-
总结
这么折腾下来总算是完成了Mentor的任务了,感觉大部分的时间都花在安装三方库和配环境上,不过也是有收获的,这篇文章作为这一套工作的简单总结,文章中的错误或者过时的东西请各位看官大神们大声说出来呀~~时间不早了,明儿还要实习,晚安~