《vLLM 内核探秘》完整目录
- 前言
- 第1章 架构总览
- 第2章 EngineCore:引擎的心脏
- 第3章 调度器:Token 的交通指挥
- 第4章 PagedAttention:虚拟内存的启示
- 第5章 KV Cache 管理:寸土寸金的显存
- 第6章 Worker 与 Executor:GPU 军团
- 第7章 模型加载与权重管理
- 第8章 前向计算与 CUDA Graph
- 第9章 采样与输出处理
- 第10章 前缀缓存:零开销的加速
- 第11章 分块预填充与混合批处理
- 第12章 投机解码:以小博大
- 第13章 量化引擎:精度与速度的平衡
- 第14章 张量并行与流水线并行
- 第15章 多模态推理(当前)
- 第16章 LoRA 适配器热切换
- 第17章 API 服务器与生产部署
- 第18章 设计模式与架构哲学
第15章 多模态推理
"A picture is worth a thousand words --- and a thousand tokens."
:::tip 本章要点
- 理解视觉语言模型(VLM)的推理流程与纯文本 LLM 的差异
- 掌握图像编码器缓存的设计:为什么要缓存编码器输出
- 深入多模态输入的预处理管线:从原始图片到 Token embedding
- 理解多模态输入对调度器的影响
- 认识 vLLM 支持的主流 VLM 架构 :::
15.1 VLM 推理的特殊挑战
源码版本 :本章基于 vLLM v0.8.5。多模态注册表
vllm/multimodal/registry.py,模型编码器执行vllm/v1/worker/gpu_model_runner.py:1043-1061。
视觉语言模型(如 Qwen2-VL、LLaVA、Pixtral)在 Transformer 之前增加了一个视觉编码器(通常是 ViT),将图片转换为一组"视觉 Token",然后与文本 Token 一起送入 Transformer。
这带来了三个新挑战:
- 编码器计算------视觉编码器的前向传播是 CPU/GPU 密集型操作,可能耗时数十毫秒
- 变长视觉 Token------不同分辨率的图片产生不同数量的视觉 Token
- KV Cache 膨胀------一张图片可能产生几百甚至上千个视觉 Token,大量消耗 KV Cache
让我们用具体数字理解"KV Cache 膨胀"的严重程度。以 Qwen2-VL 为例,它支持动态分辨率输入:
| 图片分辨率 | 视觉 Token 数 | 等价文本 Token | KV Cache 占用(70B FP16) |
|---|---|---|---|
| 336×336 | 576 | 576 | ~47 MB |
| 672×672 | 2,304 | 2,304 | ~188 MB |
| 1344×1344 | 9,216 | 9,216 | ~752 MB |
一张高分辨率图片产生的 KV Cache 占用(752 MB)相当于一个 9000 Token 的纯文本请求------但用户可能只发了一句"这张图片里有什么?"(10 个文本 Token)。这意味着调度器必须将视觉 Token 纳入资源预算计算,否则会导致 KV Cache OOM。
这也解释了为什么 VLM 场景的并发能力通常远低于纯文本场景------每个请求的资源消耗被图片大幅放大了。
15.2 编码器缓存
V1 引入了编码器缓存(Encoder Cache),将视觉编码器的输出缓存起来:
(CPU)"] PP --> ENC["视觉编码器
(GPU)"] ENC --> CACHE["编码器缓存"] CACHE --> TF["Transformer
(与文本 Token 合并)"] style IMG fill:#f59e0b,color:#fff,stroke:none style CACHE fill:#10b981,color:#fff,stroke:none style TF fill:#3b82f6,color:#fff,stroke:none
为什么需要缓存?因为分块预填充。
当一个包含图片的请求被分块预填充时,图片的视觉 Token 可能跨越多个块。如果不缓存编码器输出,每个块都要重新运行视觉编码器------这是巨大的浪费。
有了编码器缓存,视觉编码器只运行一次,输出被存储在缓存中,后续的预填充块直接从缓存中读取视觉 embedding。
15.3 多模态输入的预处理管线
从一张原始图片到 Transformer 能消费的 embedding,要经过四步处理:
JPEG/PNG"] --> B["图片预处理
Resize · Normalize
(CPU, 非阻塞)"] B --> C["视觉编码器
ViT Forward
(GPU)"] C --> D["投影层
对齐维度
(GPU)"] D --> E["Token 拼接
视觉 + 文本
送入 Transformer"] style A fill:#f59e0b,color:#fff,stroke:none style B fill:#8b5cf6,color:#fff,stroke:none style C fill:#3b82f6,color:#fff,stroke:none style D fill:#10b981,color:#fff,stroke:none style E fill:#ec4899,color:#fff,stroke:none
步骤 1:图片预处理(CPU) ------缩放到模型要求的分辨率、像素归一化、转换为 Tensor。V1 中这一步是非阻塞的------在独立线程中执行,不影响 EngineCore 的主循环。预处理结果可以被缓存,相同图片不需要重复处理。
步骤 2:视觉编码器(GPU)------通常是一个预训练的 ViT(Vision Transformer)。输入一张 336×336 的图片,输出 576 个视觉 Token(每个 14×14 patch 一个 Token)。对于高分辨率图片(如 Qwen2-VL 支持的动态分辨率),输出的 Token 数量可能更多------一张 1344×1344 的图片会产生约 9,000 个视觉 Token。
步骤 3:投影层------视觉编码器输出的维度可能与 LLM 的隐藏维度不匹配。投影层(通常是一两个 Linear 层)将视觉 Token 的维度对齐到 LLM 的 hidden_size。
步骤 4:Token 拼接 ------视觉 Token 被插入到文本 Token 序列中。不同架构的插入位置不同:LLaVA 替换 <image> 占位符的位置;Qwen2-VL 在特殊标记 <|vision_start|>...<|vision_end|> 之间插入。
15.4 多模态输入对调度的影响
视觉 Token 对调度器的影响比看起来大得多。
KV Cache 预算 ------视觉 Token 和文本 Token 一样消耗 KV Cache 块。一张高分辨率图片产生 9,000 个视觉 Token,需要 ⌈9000/16⌉ = 563 个 KV Cache 块。这相当于一个 9,000 Token 的纯文本请求的显存消耗------但用户可能只发了一张图片加一句话。
调度器需要在分配预算时将视觉 Token 计入总 Token 数:
python
# 简化
total_tokens = len(text_tokens) + num_image_tokens
num_blocks_needed = ceil(total_tokens / block_size)
预填充时间------视觉编码器的计算 + 大量视觉 Token 的注意力计算,使得多模态请求的预填充显著慢于纯文本。调度器的分块策略需要为多模态请求预留更大的时间窗口。
V1 的性能优势 ------V1 的多进程架构在 VLM 场景下优势更加明显。图片预处理(CPU 密集)在 API Server 进程中完成,不阻塞 EngineCore 的调度循环。V0 中所有这些都在同一个 Python 进程内,GIL 导致图片预处理会阻塞 GPU 计算的编排。vLLM 团队报告 V1 在 VLM 工作负载上的吞吐量提升超过 1.7 倍。
15.5 支持的 VLM 架构
vLLM 支持多种 VLM 架构,它们在视觉 Token 的注入方式上有差异:
拼接型(如 LLaVA、Qwen2-VL、PaliGemma)------视觉 Token 在 embedding 层直接替换文本序列中的占位符,之后所有 Token 一起进入 Transformer。这种方式最简单,也最常见。对 vLLM 来说,视觉 Token 就是普通的 Token,只是 embedding 值来自编码器而非词表。
交叉注意力型(如 Flamingo、一些 BLIP2 变体)------视觉 Token 不进入主序列,而是通过专门的交叉注意力层与文本 Token 交互。这种方式需要在注意力计算中增加额外的 KV 来源。
多模态处理代码位于 vllm/multimodal/,采用了可插拔的预处理管线设计。核心是 MultiModalRegistry(registry.py:80):
python
# vllm/multimodal/registry.py:80-89
class MultiModalRegistry:
"""A registry that dispatches data processing according to the model."""
def __init__(self):
self._processor_factories = ClassRegistry[nn.Module, _ProcessorFactories]()
self._processing_cache = ProcessingCache(VLLM_MM_INPUT_CACHE_GIB)
每种模态(图片、音频、视频)通过注册表注入自己的处理器工厂。ProcessingCache 缓存了已处理的多模态输入------如果相同的图片被多个请求引用,预处理只执行一次。
15.6 VLM 推理的实践建议
| 场景 | 建议 | 原因 |
|---|---|---|
| 单图对话 | 默认配置即可 | 单图的视觉 Token 通常 < 1000 |
| 多图/高分辨率 | 降低 max_num_seqs |
视觉 Token 大量占用 KV Cache |
| 视频理解 | 限制帧数 + 降低分辨率 | 避免 KV Cache OOM |
| 批量图片标注 | 启用编码器缓存 | 相同图片模板不重复编码 |
| 延迟敏感场景 | 增大 max_num_batched_tokens |
减少分块次数降低 TTFT |
GPU 上的多模态执行流程
在 GPUModelRunner.execute_model()(gpu_model_runner.py:1043-1061)中,多模态请求的处理流程与纯文本不同:
python
# gpu_model_runner.py:1043-1061 (简化)
if self.is_multimodal_model:
# 1. 运行多模态编码器(如 ViT)
self._execute_mm_encoder(scheduler_output)
# 2. 收集编码器输出
mm_embeds = self._gather_mm_embeddings(scheduler_output)
# 3. 将文本 token 和视觉 embedding 统一为 inputs_embeds
input_ids = self.input_ids[:num_scheduled_tokens]
if mm_embeds:
inputs_embeds = self.model.get_input_embeddings(input_ids, mm_embeds)
else:
inputs_embeds = self.model.get_input_embeddings(input_ids)
和视觉 embedding 拼接 MR->>LLM: forward(inputs_embeds, positions, kv_caches) LLM-->>MR: logits MR->>MR: 采样 → 输出 token
关键观察:视觉编码器只在预填充阶段运行一次(或分块预填充的第一块)。之后的解码步骤中,视觉 Token 的 KV Cache 已经存在于 GPU 显存中,不需要再运行视觉编码器。这就是编码器缓存(15.2 节)的价值。
15.7 音频与视频支持
除了图片,vLLM 还在扩展对其他模态的支持:
音频------语音模型(如 Whisper 系列的多模态变体)需要将音频波形转换为频谱图,再通过音频编码器产生音频 Token。处理流程与图片类似,但预处理步骤不同(重采样、分帧、STFT)。
视频------视频输入本质上是多帧图片。处理策略有两种:均匀抽帧(每 N 帧取 1 帧),或关键帧提取。每帧独立通过视觉编码器,然后所有帧的视觉 Token 拼接后送入 Transformer。一段 10 秒 30fps 的视频如果抽取 8 帧,可能产生 8 × 576 = 4608 个视觉 Token------这对 KV Cache 是巨大的压力。
多模态推理是 vLLM 快速发展的方向。随着 GPT-4o、Gemini 2.0 等全模态模型的出现,推理引擎对多模态的支持越来越关键。
15.7 本章小结
- VLM 挑战------编码器计算、变长视觉 Token、KV Cache 膨胀
- 编码器缓存------运行一次,缓存输出,支持分块预填充复用
- 调度影响------视觉 Token 计入显存预算,预填充更慢
- 可插拔架构------支持交叉注意力、拼接、MoE 等多种注入方式
源码导航
- 多模态处理:
vllm/multimodal/- VLM 模型实现:
vllm/model_executor/models/(带_vl后缀的文件)