首页 > 编程语言 >pytorch模型onnx部署(python版本,c++版本)

pytorch模型onnx部署(python版本,c++版本)

时间:2022-12-30 15:12:29浏览次数:70  
标签:onnx torch mask ids c++ token 版本 model size

转载:实践演练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/11456​github.com/pytorch/pytorch/issues/11456 https://github.com/onnx/onnx/issues/2711​github.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.html​onnxruntime.ai/docs/api/python/api_summary.html

onnxruntime中执行预测的主体是通过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/ace4888cf5fd1bad07844021cb2c7ecf​gist.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 data​github.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/onnxruntime​github.com/microsoft/onnxruntime/releases

Ort::Session初始化

ORT的C++版本API文档:

https://onnxruntime.ai/docs/api/c/namespace_ort.html​onnxruntime.ai/docs/api/c/namespace_ort.html

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 = "/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 const​github.com/microsoft/onnxruntime/pull/13362
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的基础,可以参考我之前的这两篇文章:

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

相关文章

  • k8s1.25版本安装metrice
    在新版的Kubernetes中系统资源的采集均使用Metrics-Server,可以通过Metrics采集节点和Pod的内存、磁盘、CPU和网络的使用率。1 wget下载cd/opt/kubernetes/addonswget......
  • Go 语言的下一个大版本:Go 2.0
    引言今年8月Go开发团队公布了Go2.0的设计草案,包括错误处理和泛型这两大主题。现在备受瞩目的Go2.0又有了新动向——Go开发团队在其官方博客表示,Go2已经被......
  • MySQL 5.7 版本的安装及简单使用(图文教程)
    MySQL5.7版本的安装使用详细教程写得还是比较详细,甚至有些繁琐,有很多步骤在其他的教程文档里都是省略掉的,但是我还是要写出来,因为我当时走了很多弯路,我希望你们能够避免我......
  • 20 万字的 C++ 八股文&图解源码,发布!
    大家好,我是小贺。上周我放言说,这个礼拜整理出「C++八股文 」,给最近秋招季参加面试的读者朋友们,然后就有好几个读者追问小贺的C++八股文啥时候出,从大家的急切的言语中,能......
  • C++11:constexpr关键字
    1.C++常量表达式constexpr是C++11标准新引入的关键字,在学习其具体用法和功能之前,我们需要先搞清楚C++常量表达式的含义。所谓常量表达式,指的就是由多个(≥1)常量组......
  • C++进阶(位图+布隆过滤器的概念和实现+海量数据处理)
    位图概念位图:所谓位图,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景。通常是用来判断某个数据存不存在的。适用场景:如果我们需要对大量的数据进行处理,判......
  • C++中vector的遍历方法
    假设有这样的一个vector:(注意,这种列表初始化的方法是c++11中新增语法)vector<int>valList={0,1,2,3,4,5,6,7,8,9};需要输出这个vector中的每个元素,测试原......
  • 不同标记的C++函数的链接签名
    #pragmaonceextern"C"{intTestAdd(inta,intb);externintTestAdd3(inta,intb);__declspec(dllexport)intTestAdd4(inta,intb);_......
  • go-dongle 0.2.4 版本发布,一个轻量级、语义化的 golang 编码解码、加密解密库
    dongle是一个轻量级、语义化、对开发者友好的Golang编码解码和加密解密库Dongle已被awesome-go收录,如果您觉得不错,请给个star吧github.com/golang-module/dong......
  • 【C++11】lambda表达式
    什么是lambda表达式lambda表达式其实就是一个匿名函数对象。对象:即可以作为一个变量使用,进行复制和作为其他函数参数调用函数:即带有代码块,里面封装了一些操作语句匿名......