转载:实践演练BERT Pytorch模型转ONNX模型及预测 - 知乎 (zhihu.com)
使用bRPC和ONNX Runtime把BERT模型服务化 - 知乎 (zhihu.com)
1.安装anaconda
一般有图形界面的个人电脑上装Anaconda比较好,因为有GUI,各种操作比较方便。但是云服务器上就没必要装Anaconda了,直接装无图形界面miniconda就好了
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
chmod a+x Miniconda3-latest-Linux-x86_64.sh
./Miniconda3-latest-Linux-x86_64.sh
source ~/.bashrc
创建一个conda环境
接下来我们要跑pytorch程序,所以使用conda创建一个环境,名称任意,我这里叫 pytorch_gpu
conda create -n pytorch_gpu conda activate pytorch_gpu
conda命令完成执行,测试是否安装成功。可以编写如下python脚本来执行,或者直接打开python交互式命令:
import torch print(torch.cuda.is_available())
bert模型进行gpu上推理
ids = torch.LongTensor([token_ids]).cuda()
seq_len = torch.LongTensor([seq_len]).cuda()
mask = torch.LongTensor([mask]).cuda()
//模型预测
import torch
from importlib import import_module
key = {
0: 'finance',
1: 'realty',
2: 'stocks',
3: 'education',
4: 'science',
5: 'society',
6: 'politics',
7: 'sports',
8: 'game',
9: 'entertainment'
}
model_name = 'bert'
x = import_module('models.' + model_name)
config = x.Config('THUCNews')
model = x.Model(config).to(config.device)
model.load_state_dict(torch.load(config.save_path, map_location='cpu'))
// bert输入向量化过程
def build_predict_text(text):
token = config.tokenizer.tokenize(text)
token = ['[CLS]'] + token
seq_len = len(token)
mask = []
token_ids = config.tokenizer.convert_tokens_to_ids(token)
pad_size = config.pad_size
if pad_size:
if len(token) < pad_size:
mask = [1] * len(token_ids) + ([0] * (pad_size - len(token)))
token_ids += ([0] * (pad_size - len(token)))
else:
mask = [1] * pad_size
token_ids = token_ids[:pad_size]
seq_len = pad_size
ids = torch.LongTensor([token_ids]).cuda() # 改了这里,加上.cuda()
seq_len = torch.LongTensor([seq_len]).cuda() # 改了这里,加上.cuda()
mask = torch.LongTensor([mask]).cuda() # 改了这里,加上.cuda()
return ids, seq_len, mask
def predict(text):
"""
单个文本预测
"""
data = build_predict_text(text)
with torch.no_grad():
outputs = model(data)
num = torch.argmax(outputs)
return key[int(num)]
if __name__ == '__main__':
print(predict("备考2012高考作文必读美文50篇(一)"))
简单回顾一下,这个模型是对新闻标题进行分类,共有10类,编号0 ~ 9,模型预测的过程分位如下几步:
1. 首先将新闻标题的文本,在前面拼上字符串[CLS],然后经过切词得到token,再把token转数字得到token_ids。这里还需要再做一下padding。因为模型输入是定长的,本模型中 pad_size 是32,即token_id个数不足32的时候,要通过0补全到32。
2. 模型的第二个输入是mask,它和token_ids拥有相同的shape(形状)。比如token_ids是1行32列,那么mask也是1行32列。mask内部元素的取值只有0和1。它表征的是token_ids哪些位置是token,哪些是padding。如果是token则为1,是padding则为0。
3. 将token_ids和mask转成torch.Tensor,这里需要的是二维结构,所以套上了一层 [] 。
4. 使用加载好的模型,传入构造好的Tensor,做预测,这个过程其实就是跑一遍模型的前向(forward)传播的过程。seq_len其实不需要,只不过模型训练的forward函数,接收了它,所以也一并传过去。其实forward函数内部又把seq_len丢掉了。
5. 拿到模型的输出,它输出的是这个新闻标题对应10个分类的概率。所以它返回的是一个浮点型的数组。此时使用argmax函数,也就是找到这个数组中值最大的元素的下标。再拿下标去10个分类中找到该分类的名称(代码中是变量key),就是最终的预测结果了。
把Pytorch模型导出成ONNX模型
torch.onnx.export()基本介绍
pytorch自带函数torch.onnx.export()
可以把pytorch模型导出成onnx模型。官网API资料: https://pytorch.org/docs/stable/onnx.html#torch.onnx.export 针对我们的得模型,我们可以这样写出大致的导出脚本 to_onnx.py:
import torch
from importlib import import_module
model_name = 'bert'
x = import_module('models.' + model_name)
config = x.Config('THUCNews')
model = x.Model(config).to(config.device)
model.load_state_dict(torch.load(config.save_path))
def build_args():
pass #... 先忽略
if __name__ == '__main__':
args = build_arg()
torch.onnx.export(model,
args,
'model.onnx',
export_params = True,
opset_version=11,
input_names = ['ids','seq_len', 'mask'], # the model's input names
output_names = ['output'], # the model's output names
dynamic_axes={'ids' : {0 : 'batch_size'}, # variable lenght axes
'seq_len' : {0 : 'batch_size'},
'mask' : {0 : 'batch_size'},
'output' : {0 : 'batch_size'}})
对export函数的参数进行一下解读:
参数 | 解读 |
---|---|
model | 加载的pytorch模型的变量 |
args | tuple类型参数,表示模型输入的shape(形状) |
'model.onnx' | 导出的onnx模型的文件名 |
export_params | 是否导出参数 |
opset_version | ONNX的op版本,这里用的是11 |
input_names | 模型输入的参数名 |
output_names | 模型输出的参数名 |
dynamic_axes | 动态维度设置,不设置即只支持固定维度的参数。本例子其实可以不设置,因为我们传入的参数都是自己调整好维度的。 |
args用于标识模型输入参数的shape。这个可以好好谈谈一下。
args参数错误?
在pytorch模型预测脚本中,build_predict_text()函数会把一段文本处理成模型的三个输入参数,所以它返回的对象肯定是符合模型输入shape的。:
def build_predict_text(text):
token = config.tokenizer.tokenize(text)
token = ['[CLS]'] + token
seq_len = len(token)
mask = []
token_ids = config.tokenizer.convert_tokens_to_ids(token)
pad_size = config.pad_size
if pad_size:
if len(token) < pad_size:
mask = [1] * len(token_ids) + ([0] * (pad_size - len(token)))
token_ids += ([0] * (pad_size - len(token)))
else:
mask = [1] * pad_size
token_ids = token_ids[:pad_size]
seq_len = pad_size
ids = torch.LongTensor([token_ids]).cuda()
seq_len = torch.LongTensor([seq_len]).cuda()
mask = torch.LongTensor([mask]).cuda()
return ids, seq_len, mask
torch.onnx.export()调用的时候,其实只关心形状,而不关心内容。所以我们可以去掉token化的过程。直接用0:
def build_args1():
pad_size = config.pad_size
ids = torch.LongTensor([[0]*pad_size]).cuda()
seq_len = torch.LongTensor([0]).cuda()
mask = torch.LongTensor([[0]*pad_size]).cuda()
return [ids, seq_len, mask]
if __name__ == '__main__':
args = build_args1()
... ...
但如果把这个args传入export()函数,运行会报错:
File "/home/guodong/github/guodong/Bert-Chinese-Text-Classification-Pytorch/to_onnx.py", line 64, in <module>
torch.onnx.export(model,
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/onnx/utils.py", line 504, in export
_export(
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/onnx/utils.py", line 1529, in _export
graph, params_dict, torch_out = _model_to_graph(
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/onnx/utils.py", line 1111, in _model_to_graph
graph, params, torch_out, module = _create_jit_graph(model, args)
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/onnx/utils.py", line 987, in _create_jit_graph
graph, torch_out = _trace_and_get_graph_from_model(model, args)
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/onnx/utils.py", line 891, in _trace_and_get_graph_from_model
trace_graph, torch_out, inputs_states = torch.jit._get_trace_graph(
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/jit/_trace.py", line 1184, in _get_trace_graph
outs = ONNXTracedModule(f, strict, _force_outplace, return_inputs, _return_inputs_states)(*args, **kwargs)
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1190, in _call_impl
return forward_call(*input, **kwargs)
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/jit/_trace.py", line 127, in forward
graph, out = torch._C._create_graph_by_tracing(
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/jit/_trace.py", line 118, in wrapper
outs.append(self.inner(*trace_inputs))
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1190, in _call_impl
return forward_call(*input, **kwargs)
File "/home/guodong/miniconda3/envs/onnx_gpu/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1178, in _slow_forward
result = self.forward(*input, **kwargs)
TypeError: Model.forward() takes 2 positional arguments but 4 were given
报错显示,forward()函数预期传入两个参数,但是实际传入了4个。 看一下模型的forward函数的定义(models/bert.py中)
class Model(nn.Module):
... ...
def forward(self, x):
context = x[0] # 输入的句子
mask = x[2] # 对padding部分进行mask,和句子一个size,padding部分用0表示,如:[1, 1, 1, 1, 0, 0]
_, pooled = self.bert(context, attention_mask=mask, output_all_encoded_layers=False)
out = self.fc(pooled)
return out
函数定义确实是两个参数,一个self,一个x,x存储的(ids, seql_len, mask)这一tuple。 模型训练的时候,训练过程调用forward()函数,传入这个tuple,不会出问题,export()内部调用forward()的时候为何会报错呢?其实很多人都遇到过这个问题,比如:
https://github.com/pytorch/pytorch/issues/11456github.com/pytorch/pytorch/issues/11456 https://github.com/onnx/onnx/issues/2711github.com/onnx/onnx/issues/2711源码差异我也没有去深究,对我的意图而言有点舍本逐末,这里其实无需纠结。 解决方法如下:
args写法一
可以给它再套上一层。
if __name__ == '__main__':
args = build_arg1()
torch.onnx.export(model,
(args,), # 这里
'model.onnx',
... ...
args写法二
如果你实在不想给args再套一层,可以让build_args1返回list而非,实测也能解决问题。
def build_args1():
pad_size = config.pad_size
ids = torch.LongTensor([[0]*pad_size]).cuda()
seq_len = torch.LongTensor([0]).cuda()
mask = torch.LongTensor([[0]*pad_size]).cuda()
return [ids, seq_len, mask]
如果你不想用0,也可以转换成另外一种写法:
args写法三
def build_args2():
pad_size = config.pad_size
ids = torch.randint(1, 10, (1, pad_size)).cuda()
seq_len = torch.randint(1, 10, (1,)).cuda() # 第三个参数中逗号不能少
mask = torch.randint(1, 10, (1, pad_size)).cuda()
return [ids, seq_len, mask]
if __name__ == '__main__':
args = build_args2()
torch.randint()函数是用来构造随机整形Tensor的,这里不展开介绍它的参数释义了。
用ONNX Runtime做预测
好了,经过前面的步骤,顺利的话,你已经得到一个onnx的模型文件model.onnx了,现在我们可以用ONNX模型执行预测任务了。但我们不能一口气吃成一个胖子,在真正使用C++将ONNX模型服务化之前,我们还是需要先使用Python完成ONNX模型的预测,一方面是验证我们转换出来的ONNX确实可用,另一方面对后续转换成C++代码也有参考意义!
这个过程我们就需要用到ONNX Runtime了。库名是:onnxruntime了,通常简称ort。
安装onnxruntime-gpu
onnxruntime不会随onnx一起安装,需要单独安装。因为我们整个实际都是基于GPU展开的,这里推荐用pip安装,因为conda似乎没有onnxruntime的gpu的包,conda默认安装的是CPU版本。pip安装命令如下:
pip install onnxruntime-gpu -i https://pypi.tuna.tsinghua.edu.cn/simple
如果去掉-gpu,则安装的也是CPU版本。
执行一下下面脚本,检查一下是否有安装成功:
import onnxruntime as ort
print(ort.__version__)
print(ort.get_device())
在我的环境上会输出:
1.13.1
GPU
创建InferenceSession对象
onnxruntime的python API手册在这:
https://onnxruntime.ai/docs/api/python/api_summary.htmlonnxruntime.ai/docs/api/python/api_summary.htmlonnxruntime中执行预测的主体是通过InferenceSession
类型的对象进行的。InferenceSession
的构造参数不少,但常用的构造参数只有2个,示例:
sess = ort.InferenceSession('model.onnx', providers=['CUDAExecutionProvider'])
第一个参数就是模型文件的路径,第二个参数指定provider,它的取值可以是:
- CUDAExecutionProvider
- CPUExecutionProvider
- TensorrtExecutionProvider
顾名思义,CUDAExecutionProvider
就是用GPU的CUDA执行,CPUExecutionProvider
就是用CPU执行,TensorrtExecutionProvider
是用TensorRT执行。没有安装TensorRT环境的话,即使指定它也不会生效,会退化成CPUExecutionProvider·。
预测函数
预测过程就是InferenceSession对象调用run()方法,它的参数声明如下:
run(output_names, input_feed, run_options=None)
- output_names – 输出的名字,可以为None
- input_feed – 字典类型
{ 输入参数名: 输入参数的值 }
- run_options – 有默认值,可以忽略
它的返回值是一个list,list里面的值是这段预测文本对于每种分类的概率。和pytorch的预测脚本一样,再经过argmax()等处理就是最终结果了。
好了,现在唯一的问题就是构造第二个参数了。这是一个字典数据结构,key是参数的名称。我们可以通过InferenceSession的get_inputs()函数来获取。get_inputs()返回一个list,list中NodeArg类型的对象,这个对象有一个name变量表示参数的名称。 写个小代码测试一下:
a = [x.name for x in sess.get_inputs()]
print(a)
输出:
['ids', 'mask']
可以看到两个输入参数的名称ids和mask,其实就是我们导出ONNX模型的时候指定的输入参数名,这我们不适用get_inputs()来获取参数名,直接硬编码,其实也可以。
input_feed 的 key解决了,接下来我们来获取input_feed的value。
pytorch的预测脚本中,build_predict_text()把一段文本转换成了三个torch.Tensor。onnx模型的输入肯定不是torch中的Tensor。它只需要numpy数组即可。 偷懒的做法是,我们直接引入build_predict_text(),然后把Tensor类型转换成numpy数组。可以在网上找的转换的代码:
def to_numpy(tensor):
return tensor.detach().cpu().numpy() if tensor.requires_grad else tensor.cpu().numpy()
然后就可以:
def predict(sess, text):
ids, seq_len, mask = build_predict_text(t)
print(type(ids))
input = {
sess.get_inputs()[0].name: to_numpy(ids),
sess.get_inputs()[1].name: to_numpy(mask),
}
outs = sess.run(None, input)
num = np.argmax(outs)
return key[num]
其实这有点绕弯子了,我们并不需要通过Tensor转numpy,因为Tensor是通过list转出来的,我们直接用list转numpy就可以了。
先改一下pred.py将原先的build_predict_text拆成两部分:
def build_predict_text_raw(text):
token = config.tokenizer.tokenize(text)
token = ['[CLS]'] + token
seq_len = len(token)
mask = []
token_ids = config.tokenizer.convert_tokens_to_ids(token)
pad_size = config.pad_size
# 下面进行padding,用0补足位数
if pad_size:
if len(token) < pad_size:
mask = [1] * len(token_ids) + ([0] * (pad_size - len(token)))
token_ids += ([0] * (pad_size - len(token)))
else:
mask = [1] * pad_size
token_ids = token_ids[:pad_size]
seq_len = pad_size
return token_ids, seq_len, mask
def build_predict_text(text):
token_ids, seq_len, mask = build_predict_text_raw(text)
ids = torch.LongTensor([token_ids]).cuda()
seq_len = torch.LongTensor([seq_len]).cuda()
mask = torch.LongTensor([mask]).cuda()
接着我们onnx的预测脚本(onnx_pred.py)中就可以直接调用build_predict_text_raw()
,再把它的结果转numpy数组就可以了。注意,我们训练得到的Bert模型需要的是一个二维结构,所以和Tensor的构造方式一样,还需要再套上一层[]
。 好了,完整的onnx预测脚本可以这么写:
#!/usr/bin/env python
# coding=utf-8
import numpy as np
import onnxruntime as ort
import pred
def predict(sess, text):
ids, seq_len, mask = pred.build_predict_text_raw(text)
input = {
'ids': np.array([ids]),
'mask': np.array([mask]),
}
outs = sess.run(None, input)
num = np.argmax(outs)
return pred.key[num]
if __name__ == '__main__':
sess = ort.InferenceSession('./model.onnx', providers=['CUDAExecutionProvider'])
t = '天问一号着陆火星一周年'
res = predict(sess, t)
print('%s is %s' % (t, res))
最终输出:
天问一号着陆火星一周年 is science
性能对比
模型转换为ONNX模型后,性能表现如何呢?接下来可以验证一下。 先写一些辅助函数:
def load_title(fname):
"""
从一个文件里加载新闻标题
"""
ts = []
with open(fname) as f:
for line in f.readlines():
ts.append(line.strip())
return ts
def batch_predict(ts, predict_fun, name):
"""
使用不同的预测函数,批量预测,并统计耗时
"""
print('')
a = time.time()
for t in ts:
res = predict_fun(t)
print('%s is %s' % (t, res))
b = time.time()
print('%s cost: %.4fs' % (name, (b - a)))
新建一个文件news_title.txt,里面存放多条新闻标题,我们来让pytorch模型和onnx模型分别做一下预测,然后对比耗时,main函数如下:
if __name__ == '__main__':
model_path = './model.onnx'
cuda_ses = ort.InferenceSession('./model.onnx', providers=['CUDAExecutionProvider'])
ts = load_title('./news_title.txt')
batch_predict(ts, lambda t: predict(cuda_ses, t), 'ONNX_CUDA')
batch_predict(ts, lambda t: pred.predict(t), 'Pytorch_CUDA')
最终结果:
杭州购房政策大松绑 is realty
兰州野生动物园观光车侧翻事故新进展:2人经抢救无效死亡 is society
4个小学生离家出走30公里想去广州塔 is society
朱一龙戏路打通电影电视剧 is entertainment
天问一号着陆火星一周年 is science
网友拍到天舟五号穿云而出 is science
ONNX_CUDA cost: 0.0406s
杭州购房政策大松绑 is realty
兰州野生动物园观光车侧翻事故新进展:2人经抢救无效死亡 is society
4个小学生离家出走30公里想去广州塔 is society
朱一龙戏路打通电影电视剧 is entertainment
天问一号着陆火星一周年 is science
网友拍到天舟五号穿云而出 is science
Pytorch_CUDA cost: 0.0888s
可以看到ONNX比Pytorch的性能提高了一倍。当然TensorRT的性能会更好,不过这是后话了,本文暂且不表。
C++中文文本向量化
FullTokenizer
所谓“他山之石,可以攻玉”。把中文文本向量化,这一步基本是琐细且模式化的操作,网上肯定有现成的代码,比如这个Github gist:
https://gist.github.com/luistung/ace4888cf5fd1bad07844021cb2c7ecfgist.github.com/luistung/ace4888cf5fd1bad07844021cb2c7ecf这是一个完整的可运行的代码片段,编译并运行它需要两个依赖:boost和utf8proc
boost对于C++程序员来说应该都不陌生,utf8proc是一个处理UTF-8字符的C语言库,在Github上:
GitHub - JuliaStrings/utf8proc: a clean C library for processing UTF-8 Unicode datagithub.com/JuliaStrings/utf8proc
我们不必把过多精力放到这个实现的源码上,这多少有点本末倒置。我们主要关注它如何使用就可以,看一下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即可。
FullTokenizer构造函数
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);
C++版与Python版本对比
现在我们使用我们Bert项目中词汇文件,来初始化一个FulTokenizer,看看它的向量化结果是否符合预期:
auto tokenizer = FullTokenizer("/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脚本中的向量化函数,来对同一段文本进行一下向量化。看一下结果:
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:
Releases · microsoft/onnxruntimegithub.com/microsoft/onnxruntime/releasesOrt::Session初始化
ORT的C++版本API文档:
https://onnxruntime.ai/docs/api/c/namespace_ort.htmlonnxruntime.ai/docs/api/c/namespace_ort.htmlOrt::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 = "/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::vector<Ort::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>();
int idx = 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 constgithub.com/microsoft/onnxruntime/pull/13362struct 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的基础,可以参考我之前的这两篇文章:
brpc最新安装上手指南mp.weixin.qq.com/s/UYbTfQRY9JsonOyCBqxyHA 通过echo_server带你入门brpc!mp.weixin.qq.com/s/nmLruEd_nUkC7Dj5EHyWLw定义接口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 = "/home/guodongxiaren/vocab.txt";
const char* model_path = "/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++服务化呢?
一个原因在于,这个模型并不是特别复杂的模型,所以对比并不明显,另外一个重要原因在于我并没有使用高qps来压测,我们把模型用C++服务化,也并不是奢求能获得更多的推理过程的加速,而是希望达到作为一个后台服务本身的高并发、高吞吐。
我拿来对比的python代码是单次执行的脚本,而非python服务。这些测试都是单条单条进行的,如果是高qps下,用C++服务和python服务做对比,差异会更明显。
标签:onnx,torch,mask,ids,c++,token,版本,model,size
From: https://www.cnblogs.com/qiaoqifa/p/17014912.html