01 背景
大模型时代是人工智能领域的一个重要发展阶段,在当今人工智能研究领域,基于 Transformer 架构的多模态视觉理解大模型(VLM)在全世界范围内引发了深度的技术关注。多模态视觉理解大模型的主要创新在于将语言和视觉两种模态进行有效的对齐,使其不仅能够进行基本的图像识别,还能执行基于视觉输入的动态内容推理和复杂问题解答。可以应用在房内家具家电识别、涉黄涉爆检测、商家店铺门头识别等多个场景,相比传统模型取得更好的效果。但是由于多模态视觉理解大模型的推理性能比传统模型低,导致整体成本高,严重阻碍了多模态视觉理解大模型的推广。提高多模态视觉理解大模型的推理性能成为研究重点。我们是多模态大模型技术部门,负责多模态大模型相关的模型研发、推理优化和推广的工作。我们在 58 的多模态视觉理解的项目场景中,对推理框架和模型进行优化,使用多种方法提高多模态视觉理解模型的推理性能。
02 场景介绍
在 58 的多模态视觉理解的项目中,都是后台提交任务对图片进行推理,没有与用户进行实时对话的场景,所以目前性能优化的重点是批量输出的场景。
- 场景一:长 token 输入、短 token 输出
多模态视觉大模型输入的是提示词 + 图片,输入的 token 通常都比较长,在 58 的场景内,98% 以上的推理场景是输出短 token,通常在 5 个 token 以内。比如在信安定制数据治理项目中,输出的 token 是只有 “是” 或者 “否”。我们重点对这种场景进行性能优化。
- 场景二:长 token 输入、长 token 输出
另外 2% 的推理场景是输出长 token,比如给一张简历的 pdf 图片,让大模型识别图片中的内容,输出的 token 一般是几百个以上。这种场景的占比很少,不是性能优化的重点方向。
03 性能指标
VLM 推理服务重点关注两个指标:吞吐量和时延
- 吞吐量:主要从系统的角度来看,即系统在单位时间内能处理的 tokens 数量。由于我们的主要场景是长输入 token,短输出 token,所以吞吐量的计算以单位时间内能处理的请求作为衡量指标,即模型推理的 qpm。
- 时延:主要从用户的视角来看,即用户平均收到每个 token 所需的时间。计算方法为用户从发出请求到收到完整响应所需的时间除以生成序列长度。一般来讲,当时延不大于 50 ms/token 时,用户使用体验会比较流畅。
由于我们的场景都是批量输出的场景,没有流式输出的场景,所以我们重点关注的性能指标是吞吐量。
04 优化内容
4.1 图像预处理优化
在多模态推理中 Vision Transformer (ViT) 是一个关键的模块,图像的预处理是将图像转换为适合 ViT 模型输入数据的过程。主要包括图像颜色空间转换、尺寸调整 (Resize)、划分图像块 (Patch Partitioning)、归一化(Normalize)等步骤。在 LMDeploy 框架中,图像预处理过程中主要通过 PIL (Pillow) 的 Image 模块在 CPU 上对图像进行处理,在图像 Resize 及 Partition 过程中,效率较低,耗时占整个 ViT 过程的 20% 以上。为了提升系统吞吐能力,减少图像预处理耗时,我们分别使用 Pillow 与 OpenCV 进行预处理测试,具体表现如下:
- CPU: Intel(R) Xeon(R) Silver 4410Y
- Python 3.10.12
- Pillow 10.2.0
- opencv_python 4.8.1.78
- 2000 张不同分辨率图像
图 1:Pillow 与 OpenCV 预处理耗时对比使用 OpenCV 可以极大的减少图像预处理的耗时,平均处理单张图片的耗时由 23.67ms 减少到 12.03ms,性能提升 49.18%。在 Resize 过程中,虽然两个处理库对应的插值方式均使用 BICUBIC,但当图像进行下采样时效果存在明显差异,使用 OpenCV 进行处理的图像存在波纹。如下:
图 2:Pillow 与 OpenCV 效果对比通过对比源码实现,发现二者在插值与边界处理实现上有所差异:
- 插值计算方式有差异:二者均使用 4x4 的卷积核进行插值计算,OpenCV 直接使用三次多项式公式计算每个像素的权重,并对周围 16 个像素进行加权平均;而 Pillow 将三次卷积操作分解为两个一维卷积,先对水平方向进行卷积,然后再对垂直方向进行卷积。
- 边界处理的差异:OpenCV 供多种边界处理方式,例如 BORDER_REPLICATE, BORDER_REFLECT, BORDER_WRAP 等;Pillow 通常使用边界复制的方式进行处理,即边缘像素值被复制到图像外部,以避免在边缘出现伪影。
针对这个问题,OpenCV 说明文档中提供了相应的解决方案:
To shrink an image, it will generally look best with INTER_AREA interpolation, whereas to enlare an image, it will generally look best with INTER_CUBIC (slow) or INTER_LINEAR (faster but still looks OK).于是我们根据不同的图像采样对插值方式进行动态调整,对图像降采样时,使用 INTER_AREA 插值,上采样时,使用 INTER_CUBIC (速度较慢,但效果最好),调整后,Resize 结果如下:图 3:OpenCV 优化前后与 Pillow 效果对比
4.2 ViT 模块支持 TensorRT
ViT 模块是多模态推理框架中一个必不可少的组成模块,主要负责图像相关处理及编码工作。ViT 模块的处理速度,直接影响整个框架的整体推理效率。为了进一步提升框架的推理效率,我们对 ViT 模块的耗时进行了分块分析,结果如下:
图 4:vision 模型推理耗时及内存占用情况
内存拷贝相关逻辑:
图 5:LMdeploy VIT 阶段内存拷贝代码截图经过验证,内存拷贝耗时主要是等待 GPU 异步处理结果,所以实际上主要耗时模块为图像预处理及特征提取两部分。具体定位步骤如下:
- 内存拷贝逻辑修改
-
- lmdeploy/vl/engine.py 取消结果拷贝至 cpu 操作
- lmdeploy/serve/vl_async_engine.py 取消拷贝到 cpu 及转换 numpy 操作
- lmdeploy/pytorch/message.py 中修改 InputEmbeddings 及类型为 Torch.Tensor (GPU)
- 内存拷贝逻辑修改引起异常的分析
逻辑调整后,推理结果异常。在 vl/engine.py forward 增加输出结果日志后,推理正常。经验证输出结果日志操作起到同步等待作用,使用 torch.cuda.synchronize () 或者 sleep 验证猜想正确。后续在模型内增加日志输出结果或者以上两个操作,推理结果均正常。推理结果正常后定位耗时模块,定位到 ViT 中 extract_feature 为主要耗时模块。为了进一步提升推理效率,我们借鉴了 TensorRT-LLM 中的推理加速方案 TensorRT。
TensorRT 是一个高性能的深度学习推理(Inference)优化器,可以为深度学习应用提供低延迟、高吞吐率的部署推理。TensorRT 可对多种应用场景进行推理加速,并且支持 TensorFlow、Caffe、Mxnet、Pytorch 等几乎所有的深度学习框架。将 TensorRT 和 NVIDIA 的 GPU 结合起来,能在几乎所有的框架中进行快速和高效的部署推理。
图 6:Tensorrt 优化过程图在对 ViT 模块进行 TensorRT 改造时,主要包含模型转换、模型优化和推理部署三个阶段。模型转化支持 TensorFlow、PyTorch、ONNX 等主流深度学习框架的模型转换和优化,本文以 ONNX 为例进行说明。1、模型转换
图 7 : ONNX 模型转换代码截图导出 ONNX 时可能会遇到不支持的算子,如在导出快速傅里叶变换(FFT)和快速傅里叶逆变换(IFFT)时会遇到如下错误,
Exporting the operator 'aten::fft_rfftn' to ONNX opset version 17 is not supported这时需要调整模型网络结构或者自定义算子。在对 ViT 模块进行 ONNX 转换过程中,部分多模态模型的 ViT 中使用了 FlashAttention2 进行注意力加速,而 FlashAttention2 中的 flash_attn_func 是作为独立的内核实现的,不是 torch.nn.Module 的实例,导致导出器无法捕获计算图,如下:/usr/local/lib/python3.10/dist-packages/flash_attn/flash_attn_interface.py:90: TracerWarning: Converting a tensor to a Python boolean might cause the trace to be incorrect. We can't record the data flow of Python values, so this value will be treated as a constant in the future. This means that the trace might not generalize to other inputs! 因此,对 Attention 模块进行了调整,使用 PyTorch 内部实现的缩放点积注意力(Scaled Dot-Product Attention, SDPA),如下图,至此模型便可成功转换成 ONNX 格式。图 8 : 缩放点积注意力 (SDPA) 代码截图2、模型优化该阶段主要完成模型优化,如下图所示,在模型优化过程中会完成层间融合,精度校准等。这一步的输出是一个针对特定 GPU 平台和网络模型的优化过的 TensorRT 模型,这个 TensorRT 模型可以序列化存储到磁盘或内存中,存储到磁盘中的文件为 TensorRT planfile。
图 9:Tensorrt 模型优化及系列化流程图3、推理部署
图 10:Tensorrt 部署及推理流程图部署阶段将上一个步骤中的 plan 文件反序列化,并创建一个 runtime engine,输入对应的图像数据,输出推理结果。4、推理效率经过 TRT 加速后,ViT 模块 feature_extract 速度缩减 45% 左右(不包含图片预处理),feature_extract 耗时在 ViT 中占比从 60% 减少至 45.36%,整体推理耗时耗时缩减在 70ms 左右。
4.3 ViT 模块支持 CudaGraph
推理框架 lmdeploy 在 0.6.0 版本引入了 CUDA Graph, 并提升了近 30% 的推理性能:
Employ CUDA graph to boost the inference performance (30%)不过受多方因素限制,目前 lmdeploy 只在语言模型中引入了 CUDA Graphs。为了进一步提升推理速度,我们在 ViT 模块中引入了 CUDA Graphs。CUDA Graphs 可以用于优化执行过程中的 CUDA 操作,在 GPU 上实现更加高效的深度学习模型推理。在使用 CUDA Graphs 时需要对 CUDA 操作进行录制(capture)和重放(replay),以此来减少 CPU 到 GPU 的调度开销,提高整体的执行效率。
如下图,简单展示了 CUDA Graphs 的优势。在顶部,CPU 逐个启动一系列短内核。CPU 启动开销导致内核之间出现明显间隙。如果我们用 CUDA 图替换此内核序列,最初我们需要花费一些额外的时间来构建图并在第一次启动整个图时一次性启动整个图,但后续执行将非常快,因为内核之间的间隙将非常小。当多次重复相同的操作序列时,例如在许多训练步骤中重复,差异会更加明显。
图 11:CUDA Graphs 性能优势图首先,在 ViT 支持 CUDA Graphs 时,需要 torch.cuda.CUDAGraph 创建对应的图,然后使用 torch.cuda.graph () 对 ViT 的推理过程进行录制,在推理过程中,使用刚创建的图对录制的过程进行重放 CUDAGraph.play ()。
但是要注意,由于 CUDA Graphs 不支持动态控制流(如条件语句和循环),因此在设计算法时应尽量避免使用这些结构;其次,确保输入张量的形状在图创建时是固定的,因为 CUDA Graphs 的设计是基于静态形状的张量结构,创建 Graph 时,所有操作及其输入输出的形状必须在图创建时确定。
而 ViT 模块在进行图像处理时,输入的图像数张量的形状是 [batch_size, channel, width, height],其中 batch_size 是可变的且各视觉模型均已限定最大值。于是,我们在框架内部维护了 Graphs Pool,推理时使用 batch_size 索引至相应的 graph,再执行重放操作。
增加 CUDA Graphs 后 ViT 模块平均耗时减少 30ms 左右。虽然 CUDA Graphs 可以在一定程度上提升推理的效率,但是在构建 graphs 也需要占用一些额外的显存,在使用时需要综合衡量具体的业务场景及硬件资源。
4.4 图像 Token 化处理
输入 token 的长度对推理耗时影响很大,多模态模型中,图像部分占据了很大比例的 token 数,降低图像转换的 Token 数可提升推理性能。如下是结果对比:图 12:Token 数和推理耗时基本成正比图像转换的 Token 数计算主要流程如下: (1)根据图像宽高比和分辨率大小将原图拆分成若干个 448*448 的 patch,拆分的原则是尽量保持图像不失真。拆分代码如下:
图 13:VLLM 中 InternVL2-8B 模型拆图代码截图 上述代码基本流程是,给定动态拆分的阈值范围,穷举出所有可能的目标比例,再根据原图比例匹配最佳的拆分规则,拆分逻辑图示如下图左上部分,图示中会被拆分成 6 个 path 块和一张缩略图。
图 14 : InternVL 模型整体框架图 (2)一个 448*448 的 patch 生成的 token 数计算方式如下: image_tokens_per_patch=(force_image_size // patch_size)2 * (downsample_ratio2)) force_image_size=448,patch_size=14,downsample_ratio=0.5, 这个计算后结果为 256。不同的模型值可能会有所差异。 (3)分辨率为 8961344 的图像,经过步骤 1 处理,会拆分成 23=6 个 patch,再加上一张缩略图(可选,有效果会更好),最终堆叠后 shape 是 [7,3,448,448],图像转换的 token 数为 7*256=1792。 部署到线上时,单卡吞吐量上不去,其中一个原因是拆图规则导致拆分后的图片数量比较多,如分辨率 612464,最合适的宽高比是 (4, 3),按模型的图片拆分规则,图像将被拆分成 [13,3,448,448],转化后的 token 数达到 3328,再加上 prompt 的 token,总 token 数会达到 3400+,太长的输入 token 对模型推理速度影响很大,再加上显存和算力的限制,无法做到更大 batch 的推理,使得单卡推理的吞吐量很低。基于此原因,我们的优化思路是降低图像的总 token 数,经实验分析,官方代码在实现上存在比较大的冗余设计,如图像分辨率为 480360,也会转换成 3328 个 token 数,对于低分辨率图像生成太多的 token 存在资源浪费。在保持图像内容不拉伸前提下,对图像的宽高比做调整,以适应 vit 的要求,优化后,480*320 的图像只转换成 512 个 token 数,这样在推理时能做到更大的 batch 处理。在我们实际落地场景中,处理后吞吐量能提升 1 倍。
4.5 prefixcache 在多模态模型里应用
在 PagedAttention 中,KV Cache 只是在一个请求内复用,而没有做到跨请求的 KV Cache 复用。长 prompt 的场景,prompt 在不同的请求中是相同的,KV Cache 的计算也是相同的,如果能把 prompt 的 KV Cache 保存下来,留给后续的请求复用,将会极大地降低首 Token 的耗时。在 LLM 模型里,prefixcache 分二个阶段,第一个阶段,当 prompt 第一次被推理时,是按 block_size (通常是 64) 大小对 input tokens 从前往后进行分块,计算每个分块的 hash 作为唯一标识,每个分块的 token_id 作为 key 进行缓存,这里不足 block_size 长度的块不会被缓存;第二阶段,当新 prompt 被推理时,会进行 prefix cache matching,命中就直接复用 kvcache,只计算未命中部分的 input tokens。多模态模型区别在于,一次任务的输入 tokens 组成由纯文本变成了文本 + 图片,由 system+prompt 变成了 system+image+prompt,在计算 prefix cache 时,image 对应的只是 padding tokens,那么在计算 prefix cache matching 时,不同图片可能匹配到一样的 prefix 上,这样推理结果就会出现错误。针对这个问题,在 input tokens 中对 image 进行范围标记,在计算 prefix cache 时不对 image token 进行 kvcache,只 cache image 之前的部分;在 prefix cache matching 时,也同样保证 image token 不会被复用。经实验验证,修改后能保证在开启 prefix cache 时,推理结果是正确的。需要注意,Prefix Caching 只节省了 prefill 阶段的耗时(也就是降低了 TTFT,Time To First Token),并不能节省解码阶段的耗时(也就是 TPOT,Time Per Output Token)。如果请求的主要耗时是在解码阶段(例如 prompt 很短而 completion 很长),或者多个请求的 prompt 并没有公共的前缀,那么 Prefix Caching 就对于整个 LLM 推理的性能提升帮助不大。
4.6 模型量化
量化是大模型领域中的一项关键技术,它通过降低模型参数的精度,将浮点数转换为整数或定点数从而实现模型的压缩和优化。模型量化可以减少模型尺寸,进而减少在推理时的显存消耗,并且在一些低精度运算较快的处理器上可以增加推理速度。量化分很多情况。从量化对象来说,量化可以是权重、激活、kv cache 和梯度;从量化的形式上来说分为线性量化和非线性量化,其中线性量化又分为对称量化和非对称量化;根据应用量化压缩模型的阶段,又可以将模型量化分为量化感知训练、量化感知微调、训练后量化。我们现阶段使用的量化方式是 AWQ 和 GPTQ,这两种量化都属于训练后量化,是针对权重的线性量化,其中 AWQ 采用对称量化,GPTQ 采用非对称量化。AWQ 量化的原理是对于 LLM,权重不是同等重要的,通过保留 1% 的显著权重可以大大减少量化误差。在此基础上采用激活感知的方法,考虑更大的激活幅度应该对应更重要的权重通道,在处理重要特征时起关键作用,逐通道确定最佳缩放因子。从而在量化所有权重的同时,最小化量化误差。GPTQ 对模型的每一层(通常是线性层或卷积层)进行单独处理,考虑了量化带来的误差,并通过调整未量化的权重来补偿这些误差。利用了二阶偏导 Hessian 矩阵的逆,来指导权重的调整,以减少整体的量化误差。将权重矩阵分成多个子矩阵(block),对每个子矩阵中的权重逐个进行量化,同时调整同一子矩阵内其他权重,以保持模型输出的相似性。其量化后的误差依赖一份高质量的校准数据。整体上来看,AWQ 相较于 GPTQ 量化的算法更直接,对校准数据依赖小;GPTQ 则更容易有比较好的量化效果,但是算法相对复杂,对校准数据依赖比较大,实际过程中用哪个更合适需要根据实际的场景选用。在实际测试中,不论是 AWQ 还是 GPTQ 实际采用的都是 w4A16 的量化策略,在推理的时候,性能差异比较小,在 RTX4090 显卡下,我们使用 vllm,对应不同参数,并且设置最优 batch,实际测试值如下:
针对单个请求的延时:图 15: 原始模型和量化模型的推理耗时比较从测试结果看:在 4090 下,大 batch 的计算,使用 gemm 内核,速度不如原精度,原因是在大 batch 的情况下,增加了反量化的时间。使用 marlin 内核,计算的速度有优化,但是在大 batch 下,优化速度不明显。低 batch 的计算原精度是计算最慢的,gemm 的内核计算与 marlin 计算差别不是很大,都比原生的有大幅提高。原因是 gemm 在低 batch 下,也做了内核优化,这一点可以从原代码中验证:
图 16:VLLM 中 awq 量化模型 mul 计算逻辑代码针对吞吐量:
图 17:原始模型和量化模型的吞吐量比较 从测试结果看,对于短输出,其实吞吐量并没有优化,还下降了一点,原因是,对于短输出,主要的耗时在 prefill,prefill 是大 batch 的计算,在推理过程中,吞吐量会下降。但是对于长输出,decode 阶段占比比较高,内核对于 decode 的优化比较明显,综合吞吐量会上升。
总结:在实际使用中对于 W4A16 量化后的模型来说,模型占用的显存一定能节省。但是推理的整体性能和吞吐量,需要根据不同的任务特点,部署的硬件环境,调整部署的参数,以达到最优。而不是量化后的整体性能一定会优于未量化的模型。
、、、、、优化数据
- 评测模型:InternVL2-8B
- 数据集:信安群租房检测 4524 张图片
- 提示词:图中有 3 张以上的床,或者是有双层床,请直接给出是或者否,然后给出详细的解释。注意 1 张双层床有 2 张床
- 输出 token 数量:max_tokens=1
- GPU: RTX4090
- 对比框架:LMDeploy-0.6.0
- 优化框架:LMDeploy-0.6.0 优化版本
- 吞吐量:由于我们的场景是长输入 token 和短输出 token,所以按单位时间内处理的请求数作为衡量指标。比较两个框架的推理 QPM
图 18:LMDeploy-0.6.0 优化前后召回率和吞吐量比较
LMDeploy-0.6.0 优化版本在推理效果不受影响的情况下,吞吐量提升到 LMDeploy-0.6.0 版本的 3.05 倍