在上文《实践演练Pytorch Bert模型转ONNX模型及预测》中,我们将BERT的Pytorch模型转换成ONNX模型,并使用onnxruntime-gpu完成了python版的ONNX模型预测。今天我们来把预测搬到C++上,模拟一下模型的部署。
对于C++版本模型预测服务,只需要按部就班完成如下三步即可:
- 完成C++版本的中文切词,并向量化的过程
- 使用ONNX Runtime的C++ API,完成模型的推理预测过程
- 编写一个简单的bRPC服务,把前面两步集成进去
C++中文文本向量化
FullTokenizer
所谓“他山之石,可以攻玉”。把中文文本向量化,这一步基本是琐细且模式化的操作,网上肯定有现成的代码,比如这个Github gist:gist.github.com/luistung/ac…
我们不必把过多精力放到这个实现的源码上,这多少有点本末倒置。我们主要关注它如何使用就可以,看一下main函数:
int main() {
auto tokenizer = FullTokenizer("data/chinese_L-12_H-768_A-12/vocab.txt");
std::string line;
while (std::getline(std::cin, line)) {
auto tokens = tokenizer.tokenize(line);
auto ids = tokenizer.convertTokensToIds(tokens);
std::cout << "#" << convertFromUnicode(boost::join(tokens, L" ")) << "#" << "t";
for (size_t i = 0; i < ids.size(); i++) {
if (i!=0) std::cout << " ";
std::cout << ids[i];
}
std::cout << std::endl;
}
return 0;
}
这是一个可以循环从标准输入获取文本,然后转换成向量输出的程序。我们其实只需要关注如下API即可。
构造函数
auto tokenizer = FullTokenizer("data/chinese_L-12_H-768_A-12/vocab.txt");
需要传入一个词汇文件(vocab)作为输入。我们那个Bert项目,也是有自己的词汇文件的。
将文本切词成token
auto tokens = tokenizer.tokenize(line);
把token向量化
auto ids = tokenizer.convertTokensToIds(tokens);
安装依赖
这是一个完整的可运行的代码片段,编译并运行它需要两个依赖:boost和utf8proc
boost
boost对于C++程序员来说应该都不陌生,这里有个建议,安装boost的时候,不要下载Github上的boost项目的release包,因为里面缺乏submodule。直接去boost的官网 www.boost.org/ 下载。
编译方法:
./bootstrap.sh # 生成可执行文件b2
./b2 headers # 生成一个boost目录,可以复制到其他地方,都是header-only的库
utf8proc
这是一个处理UTF-8字符的C语言库,在Github上:github.com/JuliaString…
编译采用cmake,比较简单:
mkdir build
cmake -S . -B build
cmake --build build
测试
C++版本向量化
现在我们使用我们Bert项目中词汇文件,来初始化一个FulTokenizer,看看它的向量化结果是否符合预期:
auto tokenizer = FullTokenizer("https://b2.7b2.com/home/guodongxiaren/vocab.txt");
auto tokens = tokenizer.tokenize("李稻葵:过去2年抗疫为每人增寿10天");
auto ids = tokenizer.convertTokensToIds(tokens);
for (size_t i = 0; i < ids.size(); i++) {
if (i != 0) std::cout << " ";
std::cout << ids[i];
}
std::cout<<std::endl;
输出:
3330 4940 5878 131 6814 1343 123 2399 2834 4554 711 3680 782 1872 2195 8108 1921
Python版本向量化
我们使用之前文章中提到的python脚本中的向量化函数,来对同一段文本进行一下向量化。看一下结果:
101 3330 4940 5878 131 6814 1343 123 2399 2834 4554 711 3680 782 1872 2195 8108 1921
对比
可以看出python版本第一位多一个101,后面的数字基本相同。这个101就是Bert模型中[CLS]
标记对应的向量化后的数字。别忘了,python版本,都是要拼接这个前缀的:
token = config.tokenizer.tokenize(text)
token = ['[CLS]'] + token
mask = []
token_ids = config.tokenizer.convert_tokens_to_ids(token)
[CLS]
标记在Bert模型中表示的是这是一个分类(Classify)任务。因为Bert模型除了分类,还能执行其他任务。
当然如果你把[CLS]
传入我们C++版本的向量化函数,结果是不符合预期,比如输入改成:
auto tokens = tokenizer.tokenize("李稻葵:过去2年抗疫为每人增寿10天");
138 12847 8118 140 3330 4940 5878 131 6814 1343 123 2399 2834 4554 711 3680 782 1872 2195 8108 1921
结果是前面多了4个数字,并不会只是添加一个101,因为[CLS]
是在Bert模型中的tokenizer会特殊处理的字符串,和普通文本的向量化方式不同。
解决方案是我们只需要可以把C++向量化结果,手工拼上一个101就可以了。
ONNX Runtime C++
安装
强烈不推荐从Github源码编译产出ONNX Runtime(以下简称ORT)库,直接下载安装它的cuda的release包,里面有现成的so:
API文档
ONNX Runtime的C++版本API文档:
Ort::Session初始化
Ort::Session对应ORT的python API中 InferenceSession。
Ort::Session的构造函数有多个重载版本,最常用的是:
Ort::Session::Session(Env& env,
const char * model_path, // ONNX模型的路径
const SessionOptions & options
)
比如可以这构造Session:
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "test");
Ort::SessionOptions session_options;
OrtCUDAProviderOptions cuda_options;
session_options.AppendExecutionProvider_CUDA(cuda_options);
const char* model_path = "https://b2.7b2.com/home/guodongxiaren/model.onnx";
Ort::Session session(env, model_path, session_options);
session_options是用于设置一些额外的配置参数,比如上面设置成CUDA执行。它还有其他的设置,这里不展开。我们只需要实现一个最简单代码即可。
Ort::Value的构建
Ort::Value是模型输入的类型,也就是ORT C++ API中表示Tensor(张量)的类型。它通过CreateTensor函数构造,CreateTensor函数也有多个重载,这里介绍其中一个。
CreateTensor() API
template<typename T >
Value Ort::Value::CreateTensor(const OrtMemoryInfo* info,
T* p_data,
size_t p_data_element_count,
const int64_t* shape,
size_t shape_len
)
函数参数 | 描述 |
---|---|
info | p_data指向数据存储的内存类型,CPU或者GPU |
p_data | 核心数据(的地址) |
p_data_element_count | p_data指向数据的字节数 |
shape | p_data形状(的地址) |
shape_len | shape参数的维度 |
模板参数T
模板参数T表示Tensor中数据类型。对于我们这里就是int64_t
。
p_data 与 p_data_element_count
p_data
表示的就是核心的数据,是一段连续存储,可以使用vector来存储,通过data()函数获取其数据的指针。
p_data_element_count 表示的就是这段连续存储中有多少个元素了。
shape 与 shape_len
shape参数用来表示Tensor的形状。因为不管数学意义上的Tensor的形状如何,在ORT C++ API中p_data都是使用一度连续存储的空间表示,不会像python中一样套上层层的括号表达维度。比如数学意义上的一个2维矩阵:[[1,2,3],[4,5,6]]
,在这里只需要传入{1,2,3,4,5,6}
然后通过shape参数:{2, 3}
表示这是2*3
的矩阵。
通过上一篇文章,我们知道我们模型的输入是ids和mask两个Tensor,每个形状都是一个1*32
。所以可以这样表示这个shape:
std::vector<int64_t> shape = {1, 32};
shape.data()即可以获得一个int64_t*
的指针,因为我们这里维度是固定的,所以直接用int64的数组也以。
shape_len表示的就是shape中有几个元素(shape的维度),即2。
info
表示的是p_data是存储在CPU还是GPU(CUDA)上。这里我们用CPU来存储输入的Tensor数据即可,因为代码会比较简练:
auto memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
如果是GPU存储,则需要调用CUDA的API,稍微繁琐一点。
即使这里是CPU也不影响我们模型在GPU上跑推理的。
合并Tensor
假设我们已经得到了存储模型输入参数ids和mask向量的两个vector对象:input_tensor_values和mask_tensor_values,我们可以先这样获得表示各自Tensor的Ort::Value对象:
Ort::Value input_tensor = Ort::Value::CreateTensor<int64_t>(memory_info, input_tensor_values.data(), input_tensor_values.size(), input_node_dims.data(), 2);
Ort::Value mask_tensor = Ort::Value::CreateTensor<int64_t>(memory_info, mask_tensor_values.data(), mask_tensor_values.size(), input_node_dims.data(), 2);
接下来,将两个Tensor合并成一个。
std::vector<Ort::Value> ort_inputs;
ort_inputs.push_back(std::move(input_tensor));
ort_inputs.push_back(std::move(mask_tensor));
Ort::Session::Run() 与推理预测
Session的Run函数就是执行模型推理的过程。
参数梗概
参数如下:
std::vector<Value> Ort::Session::Run(const RunOptions& run_options,
const char* const* input_names,
const Value* input_values,
size_t input_count,
const char* const* output_names,
size_t output_count
)
参数 | 描述 |
---|---|
run_options | 可忽略 |
input_names | 模型输入的名称 |
input_values | 模型输入 |
input_count | 输入的个数 |
output_names | 输出的名称 |
output_count | 输出的个数 |
调用示例
std::vector<const char*> input_node_names = {"ids", "mask"};
std::vector<const char*> output_node_names = {"output"};
auto output_tensors = session.Run(Ort::RunOptions{nullptr},
input_node_names.data(),
ort_inputs.data(),
ort_inputs.size(),
output_node_names.data(),
1);
Run()的返回值是std::vectorOrt::Value类型,因为模型可能有多输出,所以是vector表示,但是对于我们的模型来说它的输出只有一个Tensor,所以返回值outout_tensors的size必为1。不放心的话,也可以额外检查一下。
所以outout_tensors[0]就是输出向量了,和python一样,表示的是输入Tensor对于每种分类下的概率,我们选取概率最高的那个,就表示最终预测的分类结果了。
自定义argmax
C++本身没有argmax的函数,但是利用STL,很容易写出一个:
template <typename T>
int argmax(T begin, T end) {
return std::distance(begin, std::max_element(begin, end));
}
最终结果
const float* output = output_tensors[0].GetTensorData<float>();
return argmax(output, output+10);
封装Model类
有了前面的铺垫,我们把文本向量化和ORT的预测功能整合成一个Model类,提供一个更简单便捷的使用方式。
类声明
class Model {
public:
Model(const std::string& model_path, const std::string& vocab_path);
~Model() {delete tokenizer_; delete ses_;}
// 执行文本预测,返回预测的分类名称
std::string predict(const std::string& text, float* score=nullptr);
// 执行文本预测,返回预测的分类ID
int infer(const std::string& text, float* score=nullptr);
protected:
// 将文本向量化,返回ids和mask两个向量
std::vector<std::vector<int64_t>> build_input(const std::string& text);
private:
FullTokenizer* tokenizer_ = nullptr;
Ort::Session* ses_ = nullptr;
Ort::Env env_; // 注意
};
构造函数
Model::Model(const std::string& model_path,
const std::string& vocab_path)
:env_(ORT_LOGGING_LEVEL_WARNING, "test") {
tokenizer_ = new FullTokenizer(vocab_path);
Ort::SessionOptions session_options;
OrtCUDAProviderOptions cuda_options;
session_options.AppendExecutionProvider_CUDA(cuda_options);
ses_ = new Ort::Session(env_, model_path.c_str(), session_options);
}
Ort::Env与coredump
通过前面的例子,Ort::Env参数应该只是构造Ort::Session时的临时变量,这里为什么要弄成Model类的成员变量呢?作为Model构造函数中的局部变量不行吗?在我的1.31的ORT版本上还真不行。因为如果env是一个局部变量,在后面infer函数中执行Session::Run()的时候,会coredump。
回看Ort::Session的构造参数定义:
Ort::Session::Session(Env& env,
const char * model_path, // ONNX模型的路径
const SessionOptions & options
)
Env是一个非常量的引用,也就是env如果定义成一个局部变量,那么在Model构造函数结束之后,env引用就失效了,出现引用悬空(可以理解成野指针)。但是在Session::Run()执行的时候,内部还会使用到env中的数据,从而出现非法的内存访问。
其实这属于API设计上的一个BUG,最近看到ORT的Github上已经做了修复。参见这个Pull Request:
Make ‘env’ argument to Session const
struct Session : detail::SessionImpl<OrtSession> {
- explicit Session(std::nullptr_t) {} ///< Create an empty Session object, must be assigned a valid one to be used
- Session(Env& env, const ORTCHAR_T* model_path, const SessionOptions& options); ///< Wraps OrtApi::CreateSession
- Session(Env& env, const ORTCHAR_T* model_path, const SessionOptions& options, OrtPrepackedWeightsContainer* prepacked_weights_container); ///< Wraps OrtApi::CreateSessionWithPrepackedWeightsContainer
- Session(Env& env, const void* model_data, size_t model_data_length, const SessionOptions& options); ///< Wraps OrtApi::CreateSessionFromArray
- Session(Env& env, const void* model_data, size_t model_data_length, const SessionOptions& options,
+ explicit Session(std::nullptr_t) {} ///< Create an empty Session object, must be assigned a valid one to be used
+ Session(const Env& env, const ORTCHAR_T* model_path, const SessionOptions& options); ///< Wraps OrtApi::CreateSession
+ Session(const Env& env, const ORTCHAR_T* model_path, const SessionOptions& options,
+ OrtPrepackedWeightsContainer* prepacked_weights_container); ///< Wraps OrtApi::CreateSessionWithPrepackedWeightsContainer
+ Session(const Env& env, const void* model_data, size_t model_data_length, const SessionOptions& options); ///< Wraps OrtApi::CreateSessionFromArray
+ Session(const Env& env, const void* model_data, size_t model_data_length, const SessionOptions& options,
OrtPrepackedWeightsContainer* prepacked_weights_container); ///< Wraps OrtApi::CreateSessionFromArrayWithPrepackedWeightsContainer
这里改成常量引用,是可以延长局部变量的声明周期的。
这个PR是2022.10.19 Merge到主干的,并没有包含到当前(2022.11)最新的版本(1.13.1)中。虽然这个版本是2022.10.25发布release的。下个版本应该能体现,到时候就不用再特殊处理Env参数了。
build_input()
用来封装文本向量化,以及最终返回ids和mask两个向量的过程。中间包含补101
,padding的操作。
std::vector<std::vector<int64_t>> Model::build_input(const std::string& text) {
auto tokens = tokenizer_->tokenize(text);
auto token_ids = tokenizer_->convertTokensToIds(tokens);
std::vector<std::vector<int64_t>> res;
std::vector<int64_t> input(32);
std::vector<int64_t> mask(32);
input[0] = 101; // Bert模型的[CLS]标记的
mask[0] = 1;
for (int i = 0; i < token_ids.size() && i < 31; ++i) {
input[i+1] = token_ids[i];
mask[i+1] = token_ids[i] > 0;
}
res.push_back(std::move(input));
res.push_back(std::move(mask));
return res;
}
infer()和predict()
infer用来执行推理,返回文本最接近的分类。
int Model::infer(const std::string& text, float* score) {
auto& session = *ses_;
// 调用前面的build_input
auto res = build_input(text);
std::vector<int64_t> shape = {1, 32};
auto& input_tensor_values = res[0];
auto& mask_tensor_values = res[1];
const static auto memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
Ort::Value input_tensor = Ort::Value::CreateTensor<int64_t>(memory_info, input_tensor_values.data(),
input_tensor_values.size(), shape.data(), 2);
Ort::Value mask_tensor = Ort::Value::CreateTensor<int64_t>(memory_info, mask_tensor_values.data(),
mask_tensor_values.size(), shape.data(), 2);
std::vector<Ort::Value> ort_inputs;
ort_inputs.push_back(std::move(input_tensor));
ort_inputs.push_back(std::move(mask_tensor));
const static std::vector<const char*> input_node_names = {"ids", "mask"};
const static std::vector<const char*> output_node_names = {"output"};
auto output_tensors = session.Run(Ort::RunOptions{nullptr}, input_node_names.data(), ort_inputs.data(),
ort_inputs.size(), output_node_names.data(), 1);
if (output_tensors.size() != output_node_names.size()) {
return -1;
}
const float* output = output_tensors[0].GetTensorData<float>();
int idx = argmax(output, output+10);
if (score != nullptr) {
*score = output[idx];
}
return idx;
}
predict()函数比infer()函数更进一步,用来返回分类的名称。
首先我们还是借用python中分类名:
const static std::vector<std::string> kNames = {
"finance",
"realty",
"stocks",
"education",
"science",
"society",
"politics",
"sports",
"game",
"entertainment"
};
然后:
std::string Model::predict(const std::string& text, float* score) {
int idx = infer(text, score);
return (idx >= 0 && idx < kNames.size()) ? kNames[idx] : "Unknown";
}}
bRPC
终于到了bRPC服务化的环节了,其实这部分已经比较简单了。直接用官方example中的echo_server改改就可以了。关于bRPC的基础,可以参考我之前的这两篇文章:
定义接口proto
syntax="proto2";
package guodongxiaren;
option cc_generic_services = true;
message NewsClassifyRequest {
required string title = 1;
};
message NewsClassifyResponse {
required string result = 1;
optional float score = 2;
};
service InferService {
rpc NewsClassify(NewsClassifyRequest) returns (NewsClassifyResponse);
};
Server代码
#include <gflags/gflags.h>
#include <butil/logging.h>
#include <brpc/server.h>
#include "infer.pb.h"
#include "util/model.h"
DEFINE_int32(port, 8000, "TCP Port of this server");
DEFINE_string(listen_addr, "", "Server listen address, may be IPV4/IPV6/UDS."
" If this is set, the flag port will be ignored");
DEFINE_int32(idle_timeout_s, -1, "Connection will be closed if there is no "
"read/write operations during the last `idle_timeout_s'");
DEFINE_int32(logoff_ms, 2000, "Maximum duration of server's LOGOFF state "
"(waiting for client to close connection before server stops)");
namespace guodongxiaren {
class InferServiceImpl : public InferService {
public:
InferServiceImpl() {}
virtual ~InferServiceImpl() { delete model; }
// 接口
virtual void NewsClassify(google::protobuf::RpcController* cntl_base,
const NewsClassifyRequest* request,
NewsClassifyResponse* response,
google::protobuf::Closure* done) {
brpc::ClosureGuard done_guard(done);
brpc::Controller* cntl =
static_cast<brpc::Controller*>(cntl_base);
float score = 0.0f;
auto result = model->predict(request->title(), &score);
LOG(INFO) << " " << request->title()
<< " is " << result
<< " score: " << score;
response->set_result(result);
response->set_score(score);
}
// 初始化函数
int Init(const std::string& model_path, const std::string& vocab_path) {
model = new Model(model_path, vocab_path);
}
Model* model = nullptr;
};
} // namespace guodongxiaren
int main(int argc, char* argv[]) {
gflags::ParseCommandLineFlags(&argc, &argv, true);
brpc::Server server;
guodongxiaren::InferServiceImpl service_impl;
// 初始化
const char* vocab_path = "https://b2.7b2.com/home/guodongxiaren/vocab.txt";
const char* model_path = "https://b2.7b2.com/home/guodongxiaren/model.onnx";
service_impl.Init(model_path, vocab_path);
if (server.AddService(&service_impl,
brpc::SERVER_DOESNT_OWN_SERVICE) != 0) {
LOG(ERROR) << "Fail to add service";
return -1;
}
butil::EndPoint point;
if (!FLAGS_listen_addr.empty()) {
if (butil::str2endpoint(FLAGS_listen_addr.c_str(), &point) < 0) {
LOG(ERROR) << "Invalid listen address:" << FLAGS_listen_addr;
return -1;
}
} else {
point = butil::EndPoint(butil::IP_ANY, FLAGS_port);
}
brpc::ServerOptions options;
options.idle_timeout_sec = FLAGS_idle_timeout_s;
if (server.Start(point, &options) != 0) {
LOG(ERROR) << "Fail to start InferServer";
return -1;
}
server.RunUntilAskedToQuit();
return 0;
}
测试
bRPC支持单端口多协议,一个bRPC服务默认除了可以提供protobuf类型的请求外,也只支持HTTP+JSON请求。所以我们可以直接使用curl来测试:
curl -d '{"title": "衡水中学:破除超限、内卷等现象"}' 127.0.0.1:8000/InferService/NewsClassify
输出:
{"result":"education","score":9.031564712524414}
性能
观察server端的日志,cost的单位是us:
I1120 00:33:32.330123 19267 server.cpp:38] 衡水中学:破除超限、内卷等现象 is education score: 9.03156 cost us:6615
I1120 00:33:32.974987 19267 server.cpp:38] 衡水中学:破除超限、内卷等现象 is education score: 9.03156 cost us:6605
I1120 00:33:33.465580 19269 server.cpp:38] 衡水中学:破除超限、内卷等现象 is education score: 9.03156 cost us:6635
I1120 00:33:34.030207 19269 server.cpp:38] 衡水中学:破除超限、内卷等现象 is education score: 9.03156 cost us:6640
可以看出平均耗时是6.6ms左右。在同一台机器上,使用我上次的python版onnx脚本来测试相同文本,平均耗时是7.2ms左右。C++和python的ORT都是没有设置额外参数的,单就推理本身而言,其实C++版本的推理本身性能优势并不大。因为即使是python版ORT,它真正执行的推理后端也是C/C++编译产出的库,而非python直接进行推理。
这就有个疑惑了,既然如此,那么把模型用C++服务化呢?一个原因在于,这个模型并不是特别复杂的模型,所以对比并不明显,另外一个重要原因在于我们把模型用C++服务化,也并不是为了获得更多的推理过程的加速,而是获得作为一个后台服务本身的高并发、高吞吐。我拿来对比的python代码是单次执行的脚本,而非python服务。这些测试都是单条单条进行的,如果是高qps下,用C++服务和python服务做对比,差异会更明显。