书接上回

在之前的文章《GPU服务器初体验:从零建立Pytorch GPU开发环境 》中,我经过Github上一个给新闻标题做分类的Bert项目,演示了Pytorch模型练习与猜测的进程。我其实也不是机器学习的专业人士,对于模型的结构、练习细节所知有限。作为后台开发而非算法工程师,我更重视的是模型布置的进程。

小议Online Serving

前文中,咱们虽然有了python版猜测脚本,但在实践出产进程中,咱们还需将模型给服务化,能对外经过接口供给在线猜测、推理的才能。这一进程称为Online Serving 或许 Online Inference,即在线Serving、在线推理。俗称模型布置。

若将pyhton代码服务化,在功能方面是不能满足要求的,无法做到低延时和高吞吐。因此出产环境一般运用编译型言语来加载模型供给猜测推理服务。这个范畴最常用的编程言语便是C++,比如TensorFlow配套的TF-Serving。但Pytorch官方没有供给线上Serving的计划,常见的解决计划是将Pytorch模型转为ONNX模型,再经过ONNX模型的服务化计划来布置到线上。

ONNX 与 ONNX Runtime

ONNX是Open Neural Network Exchange的缩写,翻开它的官网:onnx.ai/ 几个大字赫然而出:

Open Neural Network Exchange The open standard for machine learning interoperability

敞开神经网络交换(格局),机器学习互操作性的敞开标准。

ONNX是2017年9月由微软与Facebook、AWS合作推出的敞开的神经网络交换格局。致力于将不同模型转化成统一的ONNX格局,然后再经过统一的计划完结模型布置。不由得让人想起那句计算机范畴的经典结论:

在软件工程中,没有一个中心层解决不了的问题

没错,ONNX也是一种“中心层”的概念,比如LLVM的IR,将编译器的工程分层,一层敞开给不同编程言语实现,一层对接不同的硬件OS,中心经过IR串联。ONNX作为中心层,的一头对接不同的机器学习模型框架,别的一头对接的是不同的编程言语(C++、Java、C#、Python……)、不同OS(windows、Linux……)、不同计算引擎(CPU、CUDA……)的模型布置计划。各种机器学习框架产出的模型,只需求转化成ONNX格局,就自然获得了在多种平台上运用多种编程言语做在线推理的才能。

精确的说ONNX的布置和服务化是由别的一个项目完结的,即ONNX Runtime。它有独立的产品品牌和官网:onnxruntime.ai/ 。当然它也是微软主导的项目。Onnx Runtime其实不只是单纯地完结模型的布置,也会对模型推理进程有一些优化。

回顾Pytorch猜测脚本

先回顾一下前文中的Pytorch模型猜测脚本pred.py,代码是从这个issue直接拿来主义的:单条文本数据的猜测代码 #72 感谢这位网友。 别的由于咱们的模型是GPU练习的,所以对代码做了修改,他的代码是是CPU模型做猜测的。

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'))
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篇(一)"))

把Pytorch模型导出成ONNX模型

torch.onnx.export()根本介绍

pytorch自带函数torch.onnx.export()能够把pytorch模型导出成onnx模型。官网API材料: pytorch.org/docs/stable… 针对咱们的得模型,咱们能够这样写出大致的导出脚本 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 指的是模型输入的shape(形状)
‘model.onnx’ 导出的onnx模型的文件名
export_params 是否导出参数
opset_version ONNX的op版别,这儿用的是11
input_names 模型输入的参数名
output_names 模型输出的参数名
dynamic_axes 动态维度设置,不设置即只支撑固定维度的参数。本比如其实能够不设置,由于咱们传入的参数都是自己调整好维度的。

args参数的讨论

args用于标识模型输入参数的shape。这个能够好好谈谈一下。

参数错误?

回顾一下前面的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()调用的时候,其实只关心形状,而不关心内容。所以咱们能够直接改成这样:

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()
    ... ...

但假如你这样调用了,会报错:

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存储的便是参数输入参数。 其实很多人都遇到过这个问题,比如:

github.com/pytorch/pyt…

github.com/onnx/onnx/i…

应该是tensor.onnx.export()内部把args这个tuple给unpack(打开)了,所以函数参数变多了。

解决办法如下:

写法一

能够给它再套上一层。

if __name__ == '__main__':
    args = build_arg1()
    torch.onnx.export(model, 
                      (args,),
                      'model.onnx',
                      ... ...

假如你真实不想给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]

从而也能够转化成别的一种写法:

写法二

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.Tensor,用randint(),而没有用randn(),是由于实测发现,不只shape要对齐,数据类型也需求匹配。randn()结构的Tensor是浮点型的。randint()则是整型,且参数和randn()不一样。 randint()的完好声明如下:

torch.randint(low=0, high, size, generator=None, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False)

疏忽带默认值的参数,只需重视:

torch.randint(low=0, high, size)

low和high便是随机整数的范围,size是这个Tensor的shape,需求用元组表明。比如(1, pad_size) 表明的行数为1,列数为pad_size。 值得一提的是,seq_len的shape不是二维的,它是标量,只要一维。但假如你写成

seq_len = torch.randint(1, 10, (1)).cuda()

则报错,需求写成

seq_len = torch.randint(1, 10, (1,)).cuda()

这倒不是pytorch或onnx的坑,而是python言语的坑,由于当元组只要一个元素的时候,它其实会直接退化成这个元素的类型。 翻开一个python交互式指令行一试便知:

>>> a=(1,2)
>>> type(a)
<class 'tuple'>
>>> a=(1)
>>> type(a)
<class 'int'>
>>> type(a)
<class 'tuple'>

用ONNX Runtime做猜测

好了,经过前面的步骤,顺利的话,现已得到一个onnx的模型文件model.onnx了,现在咱们能够加载这个模型并履行猜测任务。但咱们不能一口气吃成一个胖子,在真正运用C++将ONNX模型服务化之前,咱们仍是需求先运用Python完结ONNX模型的猜测,一方面是验证咱们转化出来的ONNX确实可用,另一方面临后续咱们换其他言语来服务化也有参阅含义!

这个进程咱们就需求用到ONNX Runtime的库:onnxruntime了。onnxruntime一般简称ort。

装置onnxruntime-gpu

onnxruntime不会随onnx一同装置,需求独自装置。由于咱们整个实践都是根据GPU打开的,这儿推荐用pip装置,由于conda似乎没有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手册在这:onnxruntime.ai/docs/api/py…

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里边的值能够理解成是这段猜测文本与每种分类的概率。咱们再找到概率最大的分类便是终究成果了。

好了,现在仅有的问题便是结构第二个参数了。这是一个字典数据结构,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模型的时候指定的输入参数名,前面我提到过seq_len其实没参加练习,所以不进模型。

好了,key搞定了,咱们再来搞定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

耗时对比

接下来,咱们能够对比一下onnxruntime运用CUDA和之前直接pytorch CUDA做猜测的耗时改变。 先写一些辅佐函数:

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: %.4f' % (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.0406
杭州购房方针大松绑 is realty
兰州野生动物园观光车侧翻事端新进展:2人经抢救无效逝世 is society
4个小学生离家出走30公里想去广州塔 is society
朱一龙戏路打通电影电视剧 is entertainment
天问一号着陆火星一周年 is science
网友拍到天舟五号穿云而出 is science
Pytorch_CUDA cost: 0.0888

能够看到ONNX为Pytorch的耗时快了一倍。当然TensorRT的耗时应该会更低,不过这是后话了,本文暂且不表。

下回分解

好了,到此为止咱们现已验证了转化后的ONNX模型可用性以及功能。下一步咱们将运用C++来布置ONNX模型完结一个在线猜测服务。但限于文章篇幅,咱们下次再聊!请我们继续重视。