triton-inference-server的backend(一)——关于推理框架的一些讨论

本文接着上一篇 深度学习部署神器——triton-inference-server入门教程指北 的介绍,继续triton的讲解。

建议先看第一篇。对于部署的同学来说,或者之后想要不那么糊涂部署的同学来说,triton inference server可能是你必备的技能之一。在模型优化完毕之后,剩下的事情就要交给triton去办。

这里triton指的是triton inference server而不是OpenAI的triton,注意区分

本篇也算是triton系列第二篇,接下里我会借着triton这个库,一起讨论下什么是推理、什么是推理引擎、推理框架、服务框架等等一些概念,以及平常做部署,实际中到底会做些什么。同时也会借着triton的特性讲述下triton对我们的推理pipeline有多少加速作用blabla。

本文会讲述的两个triton特性:

  • 动态批处理 (Dynamic batching)
  • 并行模型执行 (Concurrent model execution)

实际场景中,这俩特性会帮助模型实现:更高的资源利用率来降低延迟 (Reduce latency),并增加吞吐量 (Increase throughput)。

灵魂问题?什么是推理

首先明确什么是推理,一般深度学习分为训练和推理两个阶段,也就是常见的train和inference。

训练涉及到模型的权重更新,梯度反向传播;而推理就是利用训练好的模型进行预测,输入数据然后把模型的每个op都过一遍输出结果:

model = centernet_res50()
dummy_input = torch.randn(1, 3, 1024, 1024)
output = model(dummy_input)
output_box = nms(output)

上述就是Pytorch中一个简单的推理,当然通常都会有一些后处理(比如nms),前后处理也是模型piepline的一部分。我们通过整体的pipeline去评估这个模型的精度是否符合要求(输入图片,经过预处理,经过模型,得到输出,经过后处理再得到框然后和label进行比较)。

那么验证完之后(对于目标检测模型,一般mAP符合要求即可),这个模型总得利用起来吧,要不然就白训了。

一般训练好的模型会有这几个用处:

  • 离线任务,比如某个时间段跑一批query去完成某一项任务(比如你想用一个在coco训练集上训练好的检测模型去筛一批包含人的图片)
  • 在线任务,部署到线上被别人使用
  • 本地调用,比如自动驾驶场景,在需要的时候调用这个模型

以上这些任务都涉及到模型的推理,也就是上述代码展示的样子。

实际中的推理

实际中的推理考虑的情况要比这个复杂一些,关键有几点要考虑:

  • 首先模型结果肯定要正确,指标达到使用要求
  • 模型推理速度越快越好
  • 模型推理过程中硬件利用率越高越好(意思就是别让GPU和CPU闲着)
  • 模型推理过程中越稳定越好(别搞core、别显存内存溢出)
  • 模型推理除了某些特殊场景(比如chatgpt),避免结果的随机性(输入同样的数据得到的结果一样)

以下几点再详细展开聊聊:

模型结果正确、指标达到要求

一般部署不会直接pytorch去部署,这样速度不是最优,一般都是转化为onnx或者tensorrt去部署,这种切换平台的过程中要保证模型的精度尽可能和之前训练的时候保持一致(pytorch端)。

模型速度越快越好

就是推理一次的latency越低越好,比如Pytorch训练好的模型在3080上跑一张1024x1024的图需要30ms,而转化为TensorRT只需要7ms,快了4倍,这个7ms就是延迟。

模型结果保持一致,没有随机性

就是同一张图片两次传入模型得到的结果是一致的,没啥好说的,普通的模型都是卷积、全连接、self-attention啥的,不会有一些随机数在里头。如果你的模型每次输出不一致,可以先考虑下输入和后处理是否有问题(有时候输出不一致可能是硬件问题,这个另说了)。

硬件利用率和稳定性

至于硬件利用率和稳定性,除了实际执行模型的推理框架外(其实关系最大的就是推理框架,推理框架决定了模型执行的速度,当然和利用率也有关系,比如TensorRT在GPU的利用率往往比直接使用pytorch跑要高很多),和triton也是有很密切关系的。

补充一点推理引擎,推理框架包着一层推理引擎,实际执行代码在推理引擎中,然后API处理输入输出则是推理框架负责…

举个小例子,比如我们训练好一个nanodet模型,然后导出为libtorch的形式,进行推理:

std::vector<BoxInfo> NanoDet::detect(cv::Mat image, float score_threshold, float nms_threshold)
{
   // input 就是输入 对image进行预处理得到需要输入模型中的input
    auto input = preprocess(image);
    // Net就是模型,forward就是将输入传到模型得到outputs即为模型的输出
    auto outputs = this->Net.forward({input}).toTensor();
    // 后续就是一些模型的后处理了
    torch::Tensor cls_preds = outputs.index({ "...",torch::indexing::Slice(0,this->num_class_) });
    torch::Tensor box_preds = outputs.index({ "...",torch::indexing::Slice(this->num_class_ , torch::indexing::None) });
    std::vector<std::vector<BoxInfo>> results;
    results.resize(this->num_class_);
    this->decode_infer(cls_preds, box_preds, score_threshold, results);
    std::vector<BoxInfo> dets;
    for (int i = 0; i < (int)results.size(); i++)
    {
        this->nms(results[i], nms_threshold);
        for (auto box : results[i])
        {
            dets.push_back(box);
        }
    }
    return dets;
}

这一段代码涉及到了:预处理+模型forward+后处理,返回的就是最终坐标框。这个函数已经包含了模型运行的pipeline了。

乍看一下,感觉推理代码也不难写,选择好推理框架,按照框架中的API要求将输入输出确定好,自己再将预处理后处理的代码用C++写好貌似就可以跑了。

确实是这样,简单的demo可以这样搞,我们也可以简单写个计时函数看下这个推理速度有多快:

#include <chrono>

auto startTime = std::chrono::high_resolution_clock::now();
nanodet.detect(image, 0.5, 0.4);
auto endTime = std::chrono::high_resolution_clock::now();
float totalTime = std::chrono::duration<float, std::milli>(endTime - startTime).count()

一般要先预热,然后再for循环取平均值,可以发现for循环benchmark的时候显卡利用率没有打满,也就60-70的水平,说明libtorch跑这个模型肯定不是最优的,最基本的显卡利用率都没占满,显卡不就浪费了么。那怎么搞,最简单的将模型转为TensorRT,速度快利用率也会打满,然后开多线程同时请求这个模型,之前for循环一个一个请求打不满,那就同时开俩呗。

大概就是这么个意思,简单的推理demo,需要高性能和高吞吐的时候就得好好设计一番了,除了推理框架,前后处理代码也要快,显卡利用率也要打满,cpu也不能浪费;另外,输入是否也可以做点优化,比如组batch啥的,这个时候需要考虑的东西就多了。

所以推理框架(libtorch)外一般还会包一层服务框架(triton)

服务框架啥的,其他叫法也行,就是那么个意思。

像一开始使用pytorch进行推理的例子,和第二个使用libtorch进行推理的例子,都可以用triton包一层去调用(也就是用triton去部署),教程可以看官方的,这里就不赘述了:

triton-inference-server中的Concurrent Model Execution

说回triton,将刚才的推理代码用triton包起来,就可以实现高性能推理部署了。

Triton架构允许在同一系统上并行执行多个模型和/或同一模型的多个实例。这里的实例对应一个线程,也对应上述的一个NanoDet::detect函数的执行过程。当然,我们一般使用场景中可能有多个模型(比如检测模型和分类模型)。

下图示展示了一个包含两个模型的示例:model0(检测模型)和model1(分类模型)。

假设Triton当前没有处理任何请求,当两个请求同时到达,每个模型一个,Triton会立即将它们都调度到GPU上,并且GPU的硬件调度程序开始并行处理两个计算

CPU模型(在系统的CPU上执行的模型)也同理,也由Triton以类似的方式处理,除了每个模型的CPU线程执行的调度由系统的操作系统搞。

Triton Mult-Model Execution Diagram

默认情况下,如果同时到达多个针对同一模型的请求(比如同时有两个请求分类模型model1),Triton会通过在GPU上一次只调度一个来序列化它们的执行,如下图所示。

Triton Mult-Model Serial ExecutionDiagram

Triton提供了一个称为实例组(instance-group)的模型配置选项,允许每个模型指定该模型应允许的并行执行数。每个这样启用的并行执行称为一个实例 。默认情况下,Triton为系统中的每个可用GPU提供每个模型一个实例。通过在模型配置中使用instance_group字段,可以更改模型的执行实例数。下图显示了当model1配置为允许三个实例时的模型执行。如图所示,前三个model1推断请求立即并行执行。第四个model1推断请求必须等待前三个执行中的一个完成后才能开始。

Triton Mult-Model Parallel ExecutionDiagram

上述例子可以理解为开了4个线程,一个线程用于处理model0的请求,三个线程用于处理model1的请求,也就是说可以同时执行4个模型(因为开了四个线程同时执行)。GPU和CPU都充分利用起来了。

注意,此时你的模型已经定型了,用triton执行速度不会变快,这里并行执行模型仅仅是让你的模型尽可能不空闲,多点利用率多点吞吐量罢了,模型速度不会变

Dynamic Batching

batching是一个提升gpu利用率的通用方法,我们看很多NVIDIA的demo中,很多都用resnet34分类模型来演示,然后输入尺寸都设置为64x3x224x224。

为啥batch设置这么高。当然是因为大batch可以很容易打满显卡,可以很容易展示NVIDIA显卡的牛逼,GPU天生适合处理超多计算量,越多数据量cuda算法设计上就越简单,很容易就能用满SM。

大batch也有助于提升tensor core的利用率

所以尽可能大batch的模型推理有助于提升利用率和吞吐。Triton推理服务器支持动态批处理,能够将一个或多个推理请求合并成一个动态生成的batch,以此来提高处理效率。

当然咱们的模型前提需要支持batch,比如你的模型支持维度为(-1,3,256,256)。

我们可以通过一个例子来进一步理解这一点(详见下方图示)。

假设有5个推理请求A、B、C、D和E,它们的batch大小分别为4、2、2、6和2。每个batch被模型处理所需的时间为X毫秒(这里假设只要一个请求过来,不论batch等于多少,只要小于等于8,模型处理时间都是X毫秒)。该模型支持的最大batch大小为8。然后我们设定5个推理请求的顺序是:A和C在时间T = 0到达,B在T = X/3时候到达,D和E在T = 2*X/3的时候到达。

在不使用动态批处理的情况下,所有请求都会被依次处理,这意味着处理所有请求需要5X毫秒的时间。这种方式效率低下,因为每次批处理实际上有能力处理比顺序执行时更多的批次。

在这种场景下,采用动态批处理能够更高效地组织请求进入GPU内存,大大缩短处理时间至3X毫秒。这还减少了响应的延迟,因为在更少的时间内可以处理更多的query。

还有咱们可以设置一个延迟组batch的参数,叫做Delay。让triton在接收到请请求后等一段时间去尽可能拼更大的batch送给模型,比如下述延迟可以让triton收到多个请求后,最多等待100ms后拼batch:

dynamic_batching {
    max_queue_delay_microseconds: 100
}

如果考虑到使用延迟组batch方法的话,可以将A、B、C以及D、E分组进行批处理,进一步提高资源利用率,可以看上述dynamic batching / delay=X/2的情况。

需要注意上述描述的是理想情况下的极端例子。在实际应用中,并不是所有的执行环节都能完美并行,这可能会导致处理大批量时执行时间更长。

从上述分析可知,动态批处理在服务模型时可以同时提高响应速度和处理能力。这种批处理功能主要针对无状态模型(就比如目标检测模型,执行过程中不保持状态的模型,跑一次算一次)。对于有状态模型,Triton提供了序列批处理器来管理多个推理请求,这个之后再聊。

还记得之前提到的Concurrent Model Execution,如果这里为模型多开几个实例,比如两个,那么执行过程:

在“不使用dynamic batching”的情况下,由于存在多个模型需要执行,请求会被平均地分配到各个模型上。用户还可以设置优先级,以此提高或降低某些特定实例组的处理优先顺序。

在启用dynamic batching的多实例场景中,会出现以下情况。由于有另一个实例可用,稍后到达的query B可以使用第二个实例进行处理。通过为实例1分配一些Delay,它可以在时间T = X/2时被填满并启动。同时,由于query D和E积累足以填满最大批量大小,第二个模型可以立即开始推理,无需等待。

从上述例子可以看出,Triton推理服务器在制定更高效批处理策略方面提供了灵活性,这不仅提高了资源利用率,还降低了响应延迟,提高了处理吞吐量

你的模型这样子部署,才不算屈才。

至于例子,可以直接看官方的:

后记

A Triton backend is the implementation that executes a model. A backend can be a wrapper around a deep-learning framework, like PyTorch, TensorFlow, TensorRT or ONNX Runtime. Or a backend can be custom C/C++ logic performing any operation (for example, image pre-processing).

这篇先简单过了下triton对于推理pipeline的一些操作和优化,至于triton是如何封装咱们的推理代码,如何开启多线程去run的,这些都属于triton中backend的细节部分,我们下篇再说。

参考

这里讲错了,E在X/2到达,BD在X后2X前到达

这个没有错哦,我改了下描述,你再确认下

根据图来看不应该啊,E是在Time=0和Time=X中间,然后B和D都在Time=X之后呀,时间怎么会是B在T = X/3时候到达,D和E在T = 2*X/3的时候到达?

你看代表B和D的方块开头的位置在0和X的1/3的位置处

:joy:原来看的是方块的的位置,懂了,谢谢