在实际的模型部署场景中,我们一般会先优化模型的性能,这也是最直接提升模型服务性能的方式。但如果从更全局方面考虑的话,除了模型的性能,整体的调度和pipeline优化对服务的性能影响也是很大。
比如LLM中提的很多的Continuous batching
,对整体LLM推理的性能影响就很大,这个不光光是提升kernel性能能够解决的问题。
这里总结下各种batching策略,以及各种batch策略对整体性能的影响,可能不够全面,也希望能够抛砖引玉,一起交流。
单batch
单batch就是不组batch,也就是一个图片或者一个sentence传过来,直接送入模型进行推理。
对于普通CV模型来说,我们的输入tensor大小可以是[1,3,512,512]
,以NCHW维度举例子,这里的N是1,即batch=1
。对于LLM来说,可能是一个input_ids,维度是[1,1]
,比如:
input_ids
tensor([[ 0, 376, 1366, 338, 263, 3017, 775, 6160]], device='cuda:0')
input_ids.shape
torch.Size([1, 8])
这种情况比较简单,在搭建服务的时候不需要额外处理什么,单batch比较适合dymamic shape的场景,模型尺寸是否是dynamic也就是NCHW中的HW是否是可变的。如果你的模型的推理场景需要dynamic shape,那么一般无法组batch(不过可以用ragged batch,后续介绍),只能设置为batch=1,一般我们batch的时候HW都需要固定,比如[8,3,256,256]
。
对于dynamic shape,不管是优化还是转模型都需要额外注意,比如tensorrt中转换dynamic shape需要设置动态范围:
polygraphy run model_sim.onnx --trt --onnxrt --fp16 \
--trt-min-shapes input:[1,64,64,3] \
--trt-opt-shapes input:[1,1024,1024,3] \
--trt-max-shapes input:[1,1920,1920,3]
dynamic shape的好处就是不需要padding了,避免了额外计算,适合那种请求shape变化特别剧烈的场景,不管是传输图片还是推理,少了无效的padding像素,自然变快了不少。
Static Batching
上述batch=1的情况虽然简单而且灵活,不过因为每次只能处理一张图,对GPU资源的利用率还是不如大batch,增大batch一般可以提升模型的FLOPS,同时也可以更多地利用tensor core,实际场景中表现一般就是这样:
# centernet res50
[1,3,1024,1024] # trt inference 需要10ms
[8,3,1024,1024] # trt inference 需要50ms
很显然增大batch可以提升throughput,所以某些场景,对于时延(latency)要求没有那么高的时候,可以尝试大batch来提升吞吐。
静态batch比较简单,对于图像来说,我们可以在模型推理前,输入将图像cat到一起,比如[4,3,1024,1024]
,送给模型推理,这时的batch=4。
对于非图像场景也一样,将输入tensor合并后就相当利用了batch。不过比较尴尬的是,LLM场景因为decode阶段batch中每个case最终结束时输出的长度不一样,所以会有有些case吐完字儿了,有些还在继续吐,早吐完的需要等还没吐完的。这种情况不管是速度还是GPU利用率都是比较低的:
不过当然有解决方案:
Dynamic Batching
除了在客户端直接送给模型大batch数据,也可以在triton服务端组batch,
Dynamic batching, i.e., server-side batching of incoming queries, significantly improves both latency and performance.
动态组batch在图像推理场景很常用,也可以配合static batch使用。
比如你的模型支持最大batch是16,然后客户端可以每次发送batch=x过来,服务端在收到这些请求后,可以根据规则去组batch,组成更大的batch送给模型:
当模型实际拿到的tensor batch越大,模型的性能就越强,找个例子压测看下:
$ perf_analyzer -m inception_graphdef --percentile=95 --concurrency-range 1:8
...
Inferences/Second vs. Client p95 Batch Latency
Concurrency: 1, throughput: 66.8 infer/sec, latency 19785 usec
Concurrency: 2, throughput: 80.8 infer/sec, latency 30732 usec
Concurrency: 3, throughput: 118 infer/sec, latency 32968 usec
Concurrency: 4, throughput: 165.2 infer/sec, latency 32974 usec
Concurrency: 5, throughput: 194.4 infer/sec, latency 33035 usec
Concurrency: 6, throughput: 217.6 infer/sec, latency 34258 usec
Concurrency: 7, throughput: 249.8 infer/sec, latency 34522 usec
Concurrency: 8, throughput: 272 infer/sec, latency 35988 usec
随着请求并行度的增加,triton服务端模型组的batch越来越大,服务的QPS也随之增加,不过时延也会有所增加,这个时候就要trade off了。
Continuing Batching
Continuing Batching,也可以叫做inflight batching或者Iteration batching。 不同于static batching,当一个输入的生成结束了,就将新的输入插进来,所以batch size是动态的。下图第三个输入先生成完,新的输入S5就插入进来了,一直到输出最长的S2的输出结束的时候,batch size由4变成了7,任意case跑完就能返回:
当然实际情况更为复杂,这里不进行详细讨论。另外Continuing Batching也有人称为dynamic batching,和上述不是一回事儿哈
之前static batching的时候也提过,对于LLM场景,假如batch中某个case已经提前结束了,但是其余batch还在decode,那么这个case只能等其他case而无法直接返回,导致某些请求时延变长了。
另外,假如遇到特别长的case,为了等这个case跑完,其他case都需要等它,其他请求进来也得等他,等来等去待请求队列就长了,服务也就崩了。
所以Continuing Batching这个调度策略很重要,相比模型本身的kernel性能,调度对整体性能的影响是很大的,夸张点,拿23倍的吞吐量轻而易举:
很多优秀的LLM推理框架都用到了这个技术,几乎是必用的:TensorRT-LLM、vLLM、Imdeploy等。
Ragged Batching
上文我们提到Triton有动态批处理功能,它可以将多个相同模型执行的请求合并以提供更大的吞吐量。
默认情况下,只有在每个请求中的输入具有相同shape时,才能进行动态批处理。如果我们想同时用上dynamic batching和dynamic shape,一般则需要客户端将请求中的输入tensor填充到相同的形状。
举个例子,比如输入[1,3,768,932]
和[1,3,1024,768]
,合并为batch的时候需要将两个tensor都padding到同样尺寸,比如[1,3,1024,1024]
,然后再cat起来为[2,3,1024,1024]
才可以传给服务端模型;又或者客户端分别发了两个padding后的[1,3,1024,1024]
过来,服务端自动组batch为[2,3,1024,1024]
送给模型。
咱们之前也说过,padding会带来不必要的传输和计算量,所以我们可以稍微修改下调度逻辑从而实现dynamic shape + dynamic batching,也就是所谓的ragged batching。
在triton中,ragged batching是一种避免显式padding的功能,它允许用户指定哪些输入不需要进行形状检查。用户可以通过在模型配置中设置allow_ragged_batch字段来指定这样的输入(不规则输入):
input [
{
name: "input0"
data_type: TYPE_FP32
dims: [ 16 ]
allow_ragged_batch: true
}
]
举个triton官方的例子。
如果我们有一个接受变长输入tensor INPUT 的模型,INPUT 的形状为 [ -1, -1 ]。第一个维度是 batch 维度,第二个维度是变长内容。当客户端发送三个形状为 [ 1, 3 ]、[ 1, 4 ]、[ 1, 5 ] 的请求时,为了利用动态 batching,最直接的实现方法是期望 INPUT 的形状为 [ -1, -1 ] 并假设所有输入都被填充到相同的长度,这样所有请求都变成形状为 [ 1, 5 ],因此 Triton 可以将它们 batch 并作为一个 [ 3, 5 ] 张量发送到模型中。在这种情况下,会有padding的开销和对padding进行额外模型计算的开销。以下是输入配置:
max_batch_size: 16
input [
{
name: "INPUT"
data_type: TYPE_FP32
dims: [ -1 ]
}
]
那如果使用 Triton 的 ragged batching,模型将被实现为期望 INPUT 形状为 [ -1 ],并且有一个额外的 batch 输入 INDEX,形状为 [ -1 ],模型应该使用它来解释 INPUT 中的 batch 元素。对于这种模型,客户端请求不需要padding,可以按原样发送(形状为 [ 1, 3 ]、[ 1, 4 ]、[ 1, 5 ])。上述后端将把输入 batch 成一个形状为 [ 12 ] 的张量,其中包含 3 + 4 + 5 个请求的连接。Triton 还创建了一个形状为 [ 3 ] 的 batch 输入张量,值为 [ 3, 7, 12 ],它给出了每个 batch 元素在输入张量中的结束位置,即索引。以下是输入配置:
max_batch_size: 16
input [
{
name: "INPUT"
data_type: TYPE_FP32
dims: [ -1 ]
allow_ragged_batch: true
}
]
batch_input [
{
kind: BATCH_ACCUMULATED_ELEMENT_COUNT
target_name: "INDEX"
data_type: TYPE_FP32
source_input: "INPUT"
}
]
使用ragged batch需要实际的模型支持才行(如何处理ragged batch和index)。实际LLM推理中用到的比较多,比如TensorRT-LLM中kernel实现的时候已经考虑到了这种情况:
// TensorRT-LLM/cpp/tensorrt_llm/kernels/gptKernels.cu
// This kernel also computes the padding offsets: Given the index (idx) of a token in a ragged tensor,
// we need the index of the token in the corresponding tensor with padding. We compute an array
// of numTokens elements, called the paddingOffsets, such that the position in the padded tensor
// of the token "idx" in the ragged tensor is given by idx + paddingOffset[idx].
//
// That kernel uses a grid of batchSize blocks.
在图像中,ragged batch一般用不上。不过我们也可以自行设计一个使用ragged batch的策略,简单来说,我们可以把padding操作放在服务端(只是举个栗子,实际场景padding最好在客户端做)。
当客户端分别请求[1,3,768,932]
和[1,3,1024,768]
这两个shape的时候,我们可以在服务端将这两个请求组batch,然后进行某种预处理(padding或者resize),将input处理好再传入模型,需要我们设计模块处理这种ragged input并且利用起来:
def execute(self, requests):
responses = []
num_request = len(requests)
input_tensors = []
time_start = time.time()
for idx, request in enumerate(requests):
input = pb_utils.get_input_tensor_by_name(request, "input")
input = input.as_numpy()
input = input.squeeze(0) # NHWC -> HWC
input = Image.fromarray(input)
# 这里可以将不同shape的input 搞成一个shape
input_tensor = self.prepare_input(input).unsqueeze(0)
input_tensors.append(input_tensor)
input_tensor = torch.cat(input_tensors, dim=0).to("cuda")
self.logger.log_info("input_tensor: {}".format(input_tensor.shape))
output_texts = self.model.run(input_tensor, num_request, 4096)
for output_text in output_texts:
output = pb_utils.Tensor(
'output',
np.array(output_text).astype(self.output_dtype))
inference_response = pb_utils.InferenceResponse(
output_tensors=[output]
)
responses.append(inference_response)
# You should return a list of pb_utils.InferenceResponse. Length
# of this list must match the length of `requests` list.
return responses
使用场景还有很多,需要我们自行探索了。
Custom batching
自定义的一种batch策略,一般就是有具体使用场景才会有目的性的去设计。
举个实际的例子,比如LLM中多模态推理中,有个nougat模型,会识别一幅图中所有的单词并且一个一个吐出来,这个模型是由两部分组成的:
- encoder(普通的cv模型,传入图像传出特征)
- decoder(可以理解为和llama一样的decoder模型,带有cross attention结构)
当decoder暂时不支持inflight batching的时候,我们只能使用static batching,但是显然在组batching的时候,字儿少的要等字儿多的都吐完才能一起返回。
怎么办,我们可以利用nougat的特性,提前用一个检测模型检测当前图片中字儿的数量,然后根据数量将数量相近的请求组成batch传入模型,并且将那种数量特别多的请求单独推理,这样就可以避免一些等待的情况。
这种根据数量进行组batch就是一种custom batching策略。