去年年初LLM刚起步的时候,大模型的部署方案还不是很成熟,如今仅仅过了一年多,LLM部署方案已经遍地都是了。
而多模态模型相比大语言模型来说,发展的还没有很“特别”成熟,不过由于两者结构很相似,LLMs的经验还是可以很好地利用到VLMs中。
本篇文章中提到的多模态指的是视觉多模态,即VLM(Vision Language Models)。
以下用一张图展示下简单多模态模型的运行流程:
Text Embeddings即文本输入,就是常见LLM中的输入;
而Multomode projector则是多模态模型额外一个模态的输入,这里指的是视觉输入信息,当然是转换维度之后的;
将这个
转换维度之后
的视觉特征和Text Embeddings执行concat操作合并起来,输入decoder中(例如llama)就完成推理流程了;
Multomode projector负责将原始的图像特征转换下维度,输出转换后的图像特征;所以有个中文叫投射层,这个名字有点抽象,理解就行,其实就是个mlp层,转换下视觉特征的维度
多模态运作流程 from https://huggingface.co/blog/zh/vlms
引入VLM
VLM就是
视觉encoder加上语言decoder
,这么说可能有点抽象,我们先简单回顾下transformer的基本结构:
from huggingface
由编码器和解码器组成,最开始的transformer主要是处理机器翻译任务,结构是encoder+decoder。除了这个结构,还有一些其他结构适用于各种任务,比如
Encoder-only models:适用于需要理解输入的任务,例如句子分类和命名实体识别。
Decoder-only models:适用于生成性任务,如文本生成。
Encoder-decoder models or sequence-to-sequence models:适用于需要输入的生成性任务,例如翻译或摘要。
我们常见的llama属于Decoder-only models,只有decoder层;bert属于encoder-only;T5属于encoder-decoder。llama不多说了,BERT 是一种仅包含编码器的模型,它通过学习双向的上下文来更好地理解语言的深层含义。
BERT 通常用于理解任务,如情感分析、命名实体识别、问题回答等。T5 模型包括编码器和解码器,适用于同时需要理解和生成语言的任务。T5 被设计来处理各种“文本到文本”的任务,比如机器翻译、文摘生成、文本分类等。这种结构允许模型首先通过编码器理解输入文本,然后通过解码器生成相应的输出文本。基础知识就过到这里,我们先说enc-dec结构。
Encoder-Decoder (Enc-Dec)
注意,Enc-Dec模型
需要区别于多模态模型
,目前TensorRT-LLM官方支持的enc-dec模型如下:
T5v1.1
[2]
and
Flan-T5
[3]
第一个就是T5也就是上述提到的编码器+解码器结构。需要注意这些模型都是基于Transformer架构的自然语言处理(NLP)模型,区别于多模态模型,这些模型主要是处理文本,算是单模态。
VLM or multimodal
而多模态除了本文,就带上了图像:
VILA架构和训练流程
VILA
[9]
是nvidia推出的一个视觉语言模型,上图很清楚地介绍了其推理和训练流程。我知道的一些多模态模型有(这些模型来自lmdeploy官方github介绍):
InternLM-XComposer2 (7B, 4khd-7B)
InternVL-Chat (v1.1-v1.5)
这里我只简单介绍下llava,毕竟llava是较早的做多模态的模型,之后很多多模态的架构和llava基本都差不多。剩下的多模态模型大家可以自行查阅,结构基本都类似。
LLAVA
LLaVA(2304)
[10]
作为VLMs的代表性工作,号称只需要几个小时的训练,即可让一个LLM转变为VLM:
image
训练方式比较简单,主要操作是将视觉图像视作一种“外语”(相比于之前纯nlp,图像可以当做额外输入的外语),利用vision-encoder和
projection
[11]
将“图像翻译成文本信号”,并微调LLM从而可以适应图像任务,我们简单看下其推理代码:
llava的整体运行过程
其中processor就是图像预处理,处理后得到的input为:inputs.pixel_values,其shape是torch.Size([1, 3, 336, 336])。然后进入generate阶段:
image
第一步(stage-1)是执行encoder部分:
input_ids(torch.Size([1, 17])) -> input_embeds(torch.Size([1, 17, 4096]))
pixel_values(torch.Size([1, 3, 336, 336]))经过视觉模型 输出视觉特征(torch.Size([1, 576, 1024]))
视觉特征经过投影层(mlp)输出最终的图像特征(torch.Size([1, 576, 4096]))
第二步(stage-2)是执行文字embed和图像embed合并过程,也就是合并
inputs_embeds + image_features
,最终得到的inputs_embeds维度为
torch.Size([1, 592, 4096])
,具体在llava中是
pre_prompt + image + post_prompt
一起输入进来,其中image的特征替换prompt中的标记,对应的token id为32000。
而这个
_merge_input_ids_with_image_features
函数的作用主要是将
image_features
和
inputs_embeds
合并起来:在合并完两者之后,其余部分就和普通的decoder基本一致了。整体运行流程如下:
其他多模态模型拼
输入的方式和llava类似
,比如
InternVLChat
[12]
,输入的prompt是这样的:
image
转换为token_ids如下,其图像特征占位,img_context_token_id是92546:
image
上述prompt中的
之后会被实际的图像特征填上,目前只是占个位子。运行过程中的维度变化:
pixel_values.shape torch.Size([1, 3, 224, 224]) -> vision model -> torch.Size([1, 64, 2048])
input_ids torch.Size([1, 293]) -> language model embed -> torch.Size([1, 293, 2048])
核心代码如下,需要注意这里是先占位再填(input_embeds[selected]=...)的形式,不是llava中concat的形式,最终实现效果是一样的。
image
最终也是将合并后的embeds送入language-decoder中。
包含cross-attention的多模态
上述介绍的多模态模型都是文本输入和图像输入转换为embeds合并后,输入language decoder中,这个decoder可以是常见的decoder-only models,比如llama。
还有些特殊的多模态模型的decoder部分会有cross-attention结构,比如meta推出的
nougat
[13]
。其视觉特征不会和input_ids合并,而是单独输入decoder,在decoder中和文本特征进行cross attention:
这种结构相对稍微复杂一些。
部署方案
部署方案的话,我们参考主流开源(TensorRT-LLM不完全开源)框架来窥探下。虽然一些大厂有自己的LLM部署方案,不过技术其实也多是“借鉴”,大同小异。这几个LLM框架应该是用的比较多的:
目前这三个框架都支持VLM模型,只不过支持程度不同,我们先看trt-llm。
TensorRT-LLM
trt-llm中多模态部分在Multimodal示例中,如果是跑demo或者实际中使用trt-llm的python session跑的话,直接看官方的example即可:
如果我们更进一步,想要部署在生产环境,也是可以搞的。如果我们想要使用triton inference server部署的话,TensorRT-LLM对多模态模型的支持限于将cv-encoder和decoder分离开,搞成两个模型服务。
即通过ensemble或者tensorrt_llm_bls(python backend)的方式串起来,整体运行流程和在普通模型中是一致的(先输入image和text,然后两者经过tokenizer转换为token id,最终拼接和encoder输出特征图一并传入decoder中)。
视觉端
视觉端模型转换和普通CV模型一致,可以通过onnx的方式或者直接通过trt-python-api搭建。
以llava举例子,在TensorRT-LLM中,首先把视觉模型(encoder)使用Wrapper类套一层,其中:
multi_modal_projector
是投影层,将特征维度转换为和input_embeds相匹配的维度:
转换好之后,模型信息如下:
[I] ==== TensorRT Engine ==== Name: Unnamed Network 0 | Explicit Batch Engine ---- 1 Engine Input(s) ---- {input [dtype=float16, shape=(-1, 3, 336, 336)]} ---- 1 Engine Output(s) ---- {output [dtype=float16, shape=(-1, 576, 4096)]} ---- Memory ---- Device Memory: 56644608 bytes ---- 1 Profile(s) (2 Tensor(s) Each) ---- - Profile: 0 Tensor: input (Input), Index: 0 | Shapes: min=(1, 3, 336, 336), opt=(2, 3, 336, 336), max=(4, 3, 336, 336) Tensor: output (Output), Index: 1 | Shape: (-1, 576, 4096) ---- 398 Layer(s) ----
语言端
decoder端需要额外加入一个合并input_ids 和prompt_table_data的embedding层(这里称为PromptTuningEmbedding),其余的和普通llama一致:
image
通过设置 use_prompt_tuning来确定该decoder是否需要额外的 prompt_tuning 输入:
class LLaMAModel (Module) : def __init__ (self, num_layers, num_heads, ...# 省略参数 use_prompt_tuning: bool = False, # !!! enable_pos_shift=False, dense_context_fmha=False, max_lora_rank=None) : super().__init__() self.mapping = mapping self.use_prompt_tuning = use_prompt_tuning EmbeddingCls = PromptTuningEmbedding if use_prompt_tuning else Embedding
其中PromptTuningEmbedding的forward代码如下,这个使用trt-python-api搭出来的layer主要作用就是将input_ids和视觉特征prompt_embedding_table进行embed并且concat,和上述一开始提到的concat流程大差不差:
# PromptTuningEmbedding def forward (self, tokens, prompt_embedding_table, tasks, task_vocab_size) : # do not use '>=' because internally the layer works with floating points prompt_tokens_mask = tokens > (self.vocab_size - 1 ) # clip tokens in the [0, vocab_size) range normal_tokens = where(prompt_tokens_mask, self.vocab_size - 1 , tokens) normal_embeddings = embedding(normal_tokens, self.weight.value, self.tp_size, self.tp_group, self.sharding_dim, self.tp_rank) # put virtual tokens in the [0, max_prompt_vocab_size) range prompt_tokens = where(prompt_tokens_mask, tokens - self.vocab_size, 0 ) # add offsets to match the concatenated embedding tables tasks = tasks * task_vocab_size # tasks: [batch_size, seq_len] # prompt_tokens: [batch_size, seq_len] prompt_tokens = prompt_tokens + tasks prompt_embeddings = embedding(prompt_tokens, prompt_embedding_table) # prompt_tokens_mask: [batch_size, seq_len] -> [batch_size, seq_len, 1] # combine the correct sources of embedding: normal/prompt return where(unsqueeze(prompt_tokens_mask, -1 ), prompt_embeddings, normal_embeddings)
服务整合
服务整合其实很容易,只不过官方一开始并没有给出示例,只有在最近才在tutorial中给出实际例子:
https://github.com/triton-inference-server/tutorials/blob/main/Popular_Models_Guide/Llava1.5/llava_trtllm_guide.md
[14]
其实我们可以很早发现
tensorrt_llm/config.pbtxt
中已经包含了这两个输入:
意味着在trt-llm的executor中包装着decoder-engine,可以接受这两个输入。而trt-llm提供的executor,在triton-trt-llm-backend去通过调用这个API实现inflight batching:
image
# inflight_batcher_llm/tensorrt_llm/config.pbtxt