本文是《大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术》系列的第 10 篇(共十四篇)。上一篇:模型适配:如何跟上变化极快的模型世界?;下一篇:硬件解耦:如何不让芯片差异污染 Serving 核心?

NOTE 本文基于 vLLM v0.27.1(tag 6e448d0, 2026-08-11)源码剖析。文中文件路径、类名和函数名均以该版本为准;vLLM 迭代很快,阅读时请以你手上的版本对照。

上一篇讲的是”一个新模型如何接进来”——ModelConfig 收敛意图、ModelRegistry 找到实现、ModelLoader 装配权重、Worker + ModelRunner 组织执行。整条链路有一个没有说出口的假设:服务里只有一个模型、一份权重,每个请求就是一串 token id。调度器按 token 分预算,KV Cache 按 token 分块,model runner 把所有请求的 token 拍平成一条 input_ids 送进同一个 forward。

这个假设在两种很常见的请求上破了:

  • multi-LoRA:同一个 batch 里,请求 A 用客服 adapter,请求 B 用代码 adapter,请求 C 不用 adapter。基座权重是同一份,但每一行 token 要乘的”权重”不再相同;
  • 多模态:请求带了两张图。prompt 里对应位置的 token 不是从 embedding 表里查出来的,而是一个 ViT 算出来的;这个 ViT 有自己的计算预算和显存,它的输出要在 prefill 时正好接到 decoder 的输入上。

两者看起来是两个功能,但它们破坏的是同一组假设、也都在同一组位置被缝回去:请求对象多了几个字段,prefix cache 的哈希多了几个键,调度器多了一种预算,model runner 在 input_ids → embeddingembedding → linear 两处各插了一层。所以本篇的核心问题是:

当一个 batch 里的请求各带不同的 LoRA、各带几张图片时,”一个模型、一份权重、一串 token”的假设在哪里破了?vLLM 用什么把它重新缝起来,代价是多少?

一、总览:假设在哪里破了

1. 三个隐含假设

把上一篇那条执行链路上的隐含假设摆出来:

假设 它在哪里被依赖 谁打破它
① batch 内所有 token 乘同一份权重 Linear 层一次 GEMM 处理整个 [num_tokens, hidden];CUDA graph 按 batch 形状录图 multi-LoRA:每行 token 还要额外乘它自己的 B·A
② 输入是 token id 序列,embedding 是查表 embed_input_ids(input_ids);调度器只数 token 多模态:一部分位置的 embedding 来自 encoder;encoder 有自己的预算
③ 相同的 token 前缀 ⇒ 相同的 KV prefix cache 的链式哈希只看 token id 两者都打破:同一串 token 在不同 adapter 下 KV 不同;同一串 <image> 占位符对应不同图片时 KV 不同

第五篇讲 hash_block_tokens() 时留了一个 extra_keys 参数,说它”是隔离用的”——本篇就是它存在的理由。

2. 两种扩展分别惊动了谁

  调度器(第四篇) KV Cache(第五篇) Model Runner / 执行(第六篇) 模型适配(第九篇)
multi-LoRA 一步内活跃 adapter 数 ≤ max_loras,超了的 waiting 请求跳过 块哈希加 lora_name 每步算 token → adapter 的映射,交给 Punica kernel;每个 LoRA 层多两次 kernel;CUDA graph 按”有无 LoRA / 几个 LoRA”分别录 模型声明 SupportsLoRA;线性层被 *WithLoRA 包一层
多模态 多一种预算(encoder compute budget)和一种缓存(encoder cache);chunk 边界不能切开一张图;prefix cache 跳过了图但 encoder 没算过时 num_new_tokens=0 块哈希加 (mm_hash, 块内偏移) prefill 前先跑 encoder,输出按 hash 缓存;embedding 后把 encoder 输出按 is_mm_embed 掩码散射进去 模型声明 SupportsMultiModal,提供 embed_multimodal()MultiModalRegistry 注册 processor

3. 回到我们的例子

回到我们的例子(Llama-3-70B、8×H100、TP=8、2050 token prompt、生成 300 token,每 token KV 320 KB、每卡 40 KB)。本篇会给它加两样东西:

  • 8 个 rank-16 的 LoRA 槽位:静态显存约 1.44 GB / 卡——相当于每卡 36K token 的 KV,或者 15 个这样的请求;每步多 1120 次 kernel launch;
  • 把 2000 token 的 system prompt 换成一张图:LLaVA 类 336×336 → 576 个 token,Qwen2-VL 类 1024×1024 → 约 1300 个 token。encoder 输出 9–21 MB,但这些 token 的 KV 是 184–426 MB——图片贵的不是 encoder 输出,是它占的 KV

4. 本文的章节安排

第二章  multi-LoRA        一段回顾;同 batch 异构 adapter 的 Triton kernel;槽位与 LRU;显存账;映射如何进入调度与执行;动态加载;CUDA graph;量化 + LoRA
第三章  多模态            输入处理流水线与 processor 缓存;占位符与 embedding 合并;encoder 的独立执行与预算;EncoderCacheManager;多模态 prefix cache;显存账;视频与音频
第四章  叠加与向后        LoRA + 多模态;留给硬件抽象(11)与 PD 分离(12)的问题
第五章  本文小结

二、multi-LoRA:同一个 batch,每一行乘不同的权重

1. 一段回顾

LoRA 把权重更新约束为低秩:W' = W + (α/r)·B·AA ∈ ℝ^{r×in}B ∈ ℝ^{out×r}r ≪ min(in, out)。训练完可以把 B·A 合并进 W,推理时零开销——但 serving 不能合并:合并后一份 W' 只服务一个 adapter,服务 8 个 adapter 就要 8 份 70B 权重。所以 serving 必须保持 unmerged 形式:

y = W·x + (α/r)·B·(A·x)
    ───┬──   ─────────┬────────
   基座 GEMM        LoRA 路径:先 shrink(in → r)再 expand(r → out)
   全 batch 共享     每一行按自己的 adapter 选 A、B

LoRA 路径的 FLOPs 是 2·r·(in + out) 对比基座的 2·in·outr=16hidden=8192 时约 0.2%——计算上几乎免费。问题从来不在 FLOPs,而在”每一行选自己的 A、B”这件事怎么在一个 kernel 里做,以及这些 A、B 放哪里、怎么换。

2. 同 batch 异构 adapter 的 kernel

朴素做法是按 adapter 把 batch 切开、逐个做小 GEMM:8 个 adapter 就是 8 次 launch,每次只处理几行——decode 阶段本来就是 launch-bound(第六篇),这条路走不通。Punica(vllm/lora/punica_wrapper/punica_gpu.py 的文件头引用了论文 Punica: Multi-Tenant LoRA Serving)的思路是一个 kernel 处理全部 adapter:把 token 按 adapter 分组,grid 的一个维度遍历 adapter,每个 program 只加载自己 adapter 的权重块和自己那组 token。

vLLM v0.27.1 的实现是两个 Triton kernel(vllm/lora/ops/triton_ops/):

kernel 做什么 形状
_lora_shrink_kernellora_shrink_op.py buffer[slice] += x @ A[slice, lora_id]ᵀ · scale x: [tokens, in]buffer: [num_slices, tokens, r]fp32
_lora_expand_kernellora_expand_op.py y[:, offset:offset+out_slice] += buffer[slice] @ B[slice, lora_id]ᵀ buffer → 加回基座输出 y: [tokens, out]

kernel 靠五张元数据张量知道”哪些行属于哪个 adapter”,它们由 LoRAKernelMetalora_kernel_metadata.py)维护:

@dataclass
class LoRAKernelMeta:
    token_lora_mapping: torch.Tensor              # [tokens],每个 token 的 adapter 槽位,-1 表示无 LoRA
    token_indices_sorted_by_lora_ids: torch.Tensor  # 按槽位排序后的 token 下标
    active_lora_ids: torch.Tensor                 # [max_loras + 1],本步活跃的槽位,尾部 -1
    num_tokens_per_lora: torch.Tensor             # 每个活跃槽位有多少 token
    lora_token_start_loc: torch.Tensor            # 每个槽位在排序数组里的起点(前缀和)

prepare_tensors() 每步做一次 torch.sort(stable=True) 和一次 torch.unique(return_counts=True),把上面五张表填好。kernel 里的关键几行(_lora_shrink_kernel):

slice_id = tl.program_id(axis=1)
lora_idx = tl.program_id(axis=2)                       # grid 的第三维遍历活跃 adapter
lora_id = tl.load(lora_ids + lora_idx)
if lora_id == -1:
    return                                             # 没有 LoRA 的那组 token:整个 program 直接退出
lora_m_size = tl.load(num_tokens_per_lora + lora_idx)  # 这个 adapter 有几行
cta_m_offset = pid_m * BLOCK_M
if cta_m_offset >= lora_m_size:
    return                                             # 超出这个 adapter 的行数:早退
lora_m_indices_start = tl.load(lora_token_start_loc + lora_idx)
ram = tl.load(token_indices_sorted_by_lora_ids + lora_m_indices_start + cta_m_offset + tl.arange(0, BLOCK_M) % cta_m_len)
                                                       # 这个 CTA 要处理的原始行号(gather)

于是一次 launch、grid 大小 [M/BLOCK_M × N/BLOCK_N × SPLIT_K, num_slices, max_loras + 1],每个 program 用 ram 从原始 x 里 gather 自己那几行——不需要真的把 token 按 adapter 重排,只是按排序后的索引读。SLICE_NUM 维度让 QKV、gate/up 这种合并的投影(MergedQKVParallelLinearWithLoRAMergedColumnParallelLinearWithLoRA)一次 launch 处理多个切片。

调用链:BaseLinearLayerWithLoRA.apply()vllm/lora/layers/base_linear.py)→ 基座 quant_method.apply()outputpunica_wrapper.add_lora_linear(output, x, lora_a_stacked, lora_b_stacked, ...)PunicaWrapperGPU)→ 分配 fp32 buffer [num_slices, tokens, r]add_shrink()add_expand()每个带 LoRA 的线性层每步多两次 kernel launch 加一块 fp32 中间缓冲——第 4 节算账时会回到这里。

LoRAMapping.is_prefill 这个字段在 CUDA 上被忽略(LoRAModelRunnerMixin._set_active_loras() 注释:”On cuda platforms we use the same kernels for prefill and decode”)——早期 Punica 区分 SGMV(prefill,按段)和 BGMV(decode,按行)两套 kernel,v0.27.1 的 Triton 实现统一了。

3. 槽位与 LRU:adapter 在 GPU 和 CPU 之间怎么换

“每一行选自己的 A、B”要求所有活跃 adapter 的权重已经在 GPU 上、按槽位堆好BaseLinearLayerWithLoRA.create_lora_weights() 在模型加载时为每个 LoRA 层一次性分配:

self.lora_a_stacked = tuple(torch.zeros(max_loras, 1, lora_a_out_size, self.input_size, dtype=lora_dtype, device=device)
                            for _ in range(self.n_slices))
self.lora_b_stacked = tuple(torch.zeros(max_loras, 1, lora_b_out_size, max_lora_rank, dtype=lora_dtype, device=device)
                            for _ in range(self.n_slices))

第 0 维是槽位max_loras 个),第 2/3 维按 max_lora_rank 分配——一个 rank-8 的 adapter 也占一个 rank-16 的槽,多出的部分是零。set_lora(index, lora_a, lora_b) 把一个 adapter 的权重 copy_ 进槽位 index(TP 下先 slice_lora_a/b 切出本卡的分片),reset_lora(index) 清零。

槽位由 LoRAModelManagervllm/lora/model_manager.py)分配,它维护两层缓存:

_registered_adapters: AdapterLRUCache[LoRAModel]   容量 = max_cpu_loras   ← CPU 上已加载的 adapter(LoRAModel 对象,权重张量)
_active_adapters:     AdapterLRUCache[None]        容量 = max_loras       ← 已 copy 进 GPU 槽位的 adapter
lora_index_to_id:     list[int | None]             长度 = max_loras       ← 槽位号 → adapter id

activate_adapter(lora_id):找第一个空槽(lora_index_to_id 里的 None),遍历 self.modules 里每个 LoRA 层调用 set_lora()——激活一个 adapter = 对每一层做一次 H2D 拷贝LRUCacheLoRAModelManager.activate_adapter() 在槽满时先 _active_adapters.remove_oldest(),其 _on_remove 回调把槽位清空。两层缓存的 LRU 序在每次访问时 touch()pin_adapter() 可以把某个 adapter 钉在两层里不被淘汰。

Worker 侧是 LRUCacheWorkerLoRAManagervllm/lora/worker_manager.py):_apply_adapters(lora_requests) 先检查本步请求的不同 adapter 数 ≤ lora_slots(超了直接 RuntimeError——但调度器保证了不会超,见第 5 节),然后对每个 add_adapter():不在 CPU 缓存里就 _load_adapter()——用 PEFTHelper.from_local_dir()vllm/lora/peft_helper.py)读 adapter_config.jsonvalidate_legal()(rank 不能超过 max_lora_rank),LoRAModel.from_local_checkpoint()vllm/lora/lora_model.py)读 safetensors;CPU 缓存满则 remove_oldest_adapter();最后 activate_adapter()。源码注释特意说明先加载再淘汰是为了”确保新 adapter 有效后再驱逐旧的”,代价是 CPU 侧短暂超过 max_cpu_loras

每一步的映射由 LoRAModelRunnerMixin.set_active_loras()vllm/v1/worker/lora_model_runner_mixin.py)驱动:InputBatch.make_lora_inputs()request_lora_mapping[req_index] 展开出 token_lora_mapping(每个调度 token 一个槽位号)和 prompt_lora_mapping(每个采样位置一个,给 LogitsProcessorWithLoRA 用),打包成 LoRAMappingset_active_adapters()LoRAModelManager._set_adapter_mapping()punica_wrapper.update_metadata()LoRAKernelMeta.prepare_tensors()。注意映射里放的是槽位号lora_index_to_id 的下标 + 1,0 表示无 LoRA),不是 adapter id——kernel 只认槽位。

4. 显存账:max_loras × max_lora_rank 买了什么

回到我们的例子:Llama-3-70B、TP=8、max_loras=8max_lora_rank=16、adapter 覆盖 q/k/v/o/gate/up/down 七个投影。逐层算每张卡上一个槽位的参数量(默认 fully_sharded_loras=False:列并行层 A 不切、B 按输出切;行并行层 A 按输入切、B 不切):

模块 基座形状(in → out) 本卡 A(r × in 本卡 B(out × r 合计
q_proj(列并行) 8192 → 8192 16 × 8192 = 131072 (8192/8) × 16 = 16384 147456
k_proj(列并行) 8192 → 1024 131072 (1024/8) × 16 = 2048 133120
v_proj(列并行) 8192 → 1024 131072 2048 133120
o_proj(行并行) 8192 → 8192 16 × (8192/8) = 16384 8192 × 16 = 131072 147456
gate_proj(列并行) 8192 → 28672 131072 (28672/8) × 16 = 57344 188416
up_proj(列并行) 8192 → 28672 131072 57344 188416
down_proj(行并行) 28672 → 8192 16 × (28672/8) = 57344 131072 188416
每层每槽每卡       ≈ 1.13 M 参数

80 层 → 90 M 参数 → bf16 ≈ 180 MB / 槽 / 卡;8 个槽 ≈ 1.44 GB / 卡,在 create_lora_weights() 时一次性 torch.zeros 出来,无论实际加载了几个 adapter、实际 rank 是多少。对比:这 1.44 GB 等于每卡 36K token 的 KV(40 KB/token),或者 15 个我们例子里的请求(2350 token 各 94 MB/卡)。max_lora_rank 从 16 提到 64,这个数字乘 4。

再看 CPU 侧:一个完整的 rank-16 adapter(不切分)约 207 M 参数、414 MB bf16;max_cpu_loras 默认等于 max_loras,即 8 × 414 MB ≈ 3.3 GB 主机内存,每个 worker 进程各一份(TP=8 就是 8 份,每份存的是切分前的完整权重再切)。

计算与 launch:

说明
额外 FLOPs 每 token 每层 2 × 16 × (8192 + 8192) × 2(q、o)+ … ≈ 基座的 0.2% 忽略
额外 kernel 7 个模块 × 2(shrink + expand)× 80 层 = 1120 次 / 步 与第六篇”一步上千 kernel”同量级,必须进 CUDA graph
fp32 中间缓冲 [num_slices, tokens, r],每层 torch.empty 一次 batch=64 decode 时每层 3 × 64 × 16 × 4 B = 12 KB,忽略;prefill 2050 token 时 393 KB
元数据 每步一次 sort + unique + 若干 H2D CPU-GPU 同步点,微秒级

结论:multi-LoRA 的代价是显存(静态、按 max_loras × max_lora_rank 买断)和 launch 次数,不是 FLOPs。

5. 请求到 adapter 的映射如何进入调度与 KV

请求带的是一个 LoRARequestvllm/lora/request.pylora_namelora_int_idlora_pathlora_int_id 必须 > 0 且全局唯一)。它在三个地方被消费:

调度器Scheduler.schedule()vllm/v1/core/sched/scheduler.py):先收集本步 running 请求的 scheduled_loras 集合并 assert len(scheduled_loras) <= max_loras;遍历 waiting 队列时,若某请求的 adapter 不在集合里且集合已满,跳过这个请求step_skipped_waiting.prepend_request)继续看下一个。这是本篇里 multi-LoRA 对调度公平性唯一但真实的影响:max_loras 成了一种新的准入约束,一个冷门 adapter 的请求可能在 8 个热门 adapter 的持续流量下长时间等不到槽位——FCFS 在这里被打破了,而且没有 aging 机制。

KV Cache_gen_lora_extra_hash_keys()vllm/v1/core/kv_cache_utils.py)把 lora_request.lora_name 加进每个块的 extra_keys。同一段 system prompt 在两个 adapter 下会得到两条完全不同的哈希链、两份物理块——第五篇算的”125 块全部命中”只在同一个 adapter 的请求之间成立。这是正确性要求:不同 adapter 的 K、V 投影本来就不同。

InputBatchvllm/v1/worker/gpu_input_batch.py):add_request() 时把 lora_int_id 记进 request_lora_mapping[req_index]lora_id_to_lora_request 保留请求对象供 worker 加载。

6. 动态加载

启动时可以用 --lora-modules 预注册,但生产里更常见的是运行时加载:

  • HTTP APIPOST /v1/load_lora_adapter / POST /v1/unload_lora_adaptervllm/entrypoints/openai/models/serving.pyload_lora_adapter() / unload_lora_adapter()),需要 VLLM_ALLOW_RUNTIME_LORA_UPDATING=1
  • 按名字解析LoRAResolvervllm/lora/resolver.py)插件机制,请求里带一个未注册的 model 名时由 resolver 去对象存储或本地目录找 adapter,VLLM_LORA_RESOLVER_CACHE_DIR 指定缓存目录;
  • 原地替换LoRARequest.load_inplace=True 强制重新加载同 id 的 adapter(LRUCacheWorkerLoRAManager.add_adapter() 里先 remove_adapteradd)。

“加载”在 worker 上是懒的:API 只是注册了 LoRARequest,真正的磁盘读取、切分、H2D 拷贝发生在第一个使用它的请求被调度的那一步_apply_adapters())。这意味着一个新 adapter 的第一个请求会在 execute_model 里同步等待磁盘 I/O——414 MB 的 safetensors 从本地 NVMe 读大约几十到几百毫秒,从网络存储可能秒级,而且这段时间整个 batch 都在等。热门 adapter 常驻、冷门 adapter 预热,是运维层面必须做的事。

7. 对 CUDA graph 的影响

第六篇说 CUDA graph 要求”图内 kernel 序列和形状固定”。LoRA 路径的 1120 个 kernel 只在有 LoRA 请求时才存在——于是”有没有 LoRA”成了图的一个维度。BatchDescriptorvllm/forward_context.py)因此多了两个字段:has_lora: boolnum_active_loras: intCudagraphDispatcher._get_lora_cases()vllm/v1/cudagraph_dispatcher.py)决定录几套图:

配置 录的图 说明
没开 LoRA [0] 一套
cudagraph_specialize_lora=True(默认) [0, max_loras + 1],即”无 LoRA”和”有 LoRA”各一套 图的数量翻倍,捕获时间与显存也翻倍
再开 specialize_active_lora=True [0] + 2 的幂次直到 max_loras + [max_loras + 1] get_captured_lora_counts();kernel grid 的第三维按活跃 adapter 数取整到上一个 2 的幂,少跑空 program
cudagraph_specialize_lora=False 只录 [max_loras + 1] 无 LoRA 的 batch 也走带 LoRA 路径的图,kernel 靠 lora_id == -1 早退

默认选择是一个典型的取舍:多录一套图换来”纯基座请求不付 LoRA 的 launch 成本”。_lora_shrink_kernel 开头那个 if lora_id == -1: return 是让同一张图能安全跑在”部分请求无 LoRA”的 batch 上的保证——grid 始终按 max_loras + 1 开,没用到的 adapter 维度整片早退。

8. 量化模型 + LoRA

基座量化不影响 LoRA 路径:BaseLinearLayerWithLoRA._apply_sync() 先调 self._get_quant_method().apply(self.base_layer, x, bias) 得到基座输出——这里的 quant_method 可以是 FP8、GPTQ、AWQ 任何一种——再把 lora_dtype(默认跟基座 dtype,量化基座时通常是 bf16)精度的 B·A·x 加上去。LoRA 权重本身不量化,所以第 4 节的显存账在 W4A16 基座上不变,只是相对占比更高(基座每卡从 17.6 GB 降到约 4.4 GB,8 个槽位的 1.44 GB 就成了显眼的一块)。

两处延伸只点名:MoE 模型的 expert 权重也可以挂 LoRA(FusedMoEWithLoRAfused_moe_lora_op.pyenable_mixed_moe_lora_format / enable_moe_shared_loras 控制格式);多模态模型的视觉塔与 connector 也可以挂(enable_tower_connector_loraLoRAMappingType.TOWER / CONNECTOR),第四章会回到它。

三、多模态:一部分 embedding 不是查表得来的

1. 输入处理流水线:HF processor 的复用与缓存

文本请求的输入处理是 tokenizer 一步;多模态请求要先把图片变成像素张量、算出它会占多少 token、把占位符插进 prompt。vLLM 把这条链放在 vllm/multimodal/processing/,由 MultiModalRegistryvllm/multimodal/registry.py)按模型类找到对应的 BaseMultiModalProcessorprocessing/processor.py)。它的 apply() docstring 概括了三步:

1. 对 prompt 文本和多模态数据一起调用 HF processor,得到 token ids 和处理后的张量(pixel_values 等)
2. 在 token ids 里找到并更新占位序列:占位 token 的数量 = encoder 输出的 feature 数
3. 从处理后的 token ids 里提取占位符位置信息

第 1 步复用 Hugging Face 的 processor_call_hf_processor())——图片 resize、归一化、切 patch 的逻辑不重写。第 2 步用 PromptReplacement / PromptInsertion 描述”把 prompt 里的 <image> 换成 N 个 <image_token>“,N 由模型的 get_mm_max_tokens_per_item() 或实际输出决定。第 3 步产出 PlaceholderRangevllm/multimodal/inputs.py):

@dataclass(frozen=True)
class PlaceholderRange:
    offset: int                          # 占位符在 prompt 中的起点
    length: int                          # 占位符长度
    is_embed: torch.Tensor | None = None # 可选掩码:length 个位置里哪些真的要填 encoder 输出
                                         # (有的模型在图像 token 之间夹换行等文本 token)

一张图最终在请求里是一个 MultiModalFeatureSpecdata(处理后的张量,缓存命中时为 None 以省掉 IPC)、modalityidentifier(用于 encoder cache 的哈希)、mm_position(上面的 PlaceholderRange)、mm_hash(用于 processor cache 的哈希)。

哈希MultiModalHasher.hash_kwargs()vllm/multimodal/hasher.py)算,默认 blake3(mm_hasher_algorithm,FIPS 环境可换 sha256/sha512),对原始输入(PIL 图像的 mode + 像素数组、或带 io_config 的原始字节)而不是处理后的张量哈希——所以同一张图以不同 URL 传两次会命中,同一张图 resize 过再传不会。identifierInputProcessor._get_mm_identifier()vllm/v1/engine/input_processor.py)里生成:默认等于 mm_hash,开了 enable_tower_connector_lora 时前缀 lora_name:,因为此时 encoder 输出依赖 adapter。

processor cachevllm/multimodal/cache.py)解决的是”同一张图反复出现,HF processor 不要反复跑、张量不要反复过 IPC”:mm_processor_cache_gb 默认 4 GiB,mm_processor_cache_type 默认 lru(API 进程与引擎进程各一份镜像 LRU:MultiModalProcessorSenderCache / MultiModalReceiverCache)或 shmShmObjectStoreSenderCache / ShmObjectStoreReceiverCache,单写者共享内存环形缓冲,mm_shm_cache_max_object_size_mb 限制单对象大小)。文档特别提醒它的总占用是 mm_processor_cache_gb × (api_server_count + data_parallel_size)——CPU 内存,不是显存,但多进程部署时容易被忘掉。_cached_apply_hf_processor() 先算哈希、查缓存,只把未命中的项送进 HF processor,再 _merge_mm_kwargs() 合回来。

2. 多模态 token 与占位符如何进入 prompt

到 model runner 时,多模态请求的 prompt_token_ids 已经是一串普通 token id,其中 mm_position 指向的区间填的是模型的 image token id(重复 length 次)。它们和文本 token 一起走 embed_input_ids(),得到一个”错误但形状正确”的 embedding;然后在正确的位置覆盖成 encoder 输出:

# vllm/model_executor/models/utils.py(简化)
def _merge_multimodal_embeddings(inputs_embeds, multimodal_embeddings, is_multimodal):
    mm_embeds_flat = _flatten_embeddings(multimodal_embeddings)
    inputs_embeds[is_multimodal] = mm_embeds_flat.to(inputs_embeds.dtype)   # 布尔掩码就地散射
    return inputs_embeds

is_multimodal 这张 [total_num_scheduled_tokens] 的布尔掩码由 GPUModelRunner._gather_mm_embeddings() 构造:遍历本步每个请求、找出与 [num_computed_tokens, num_computed_tokens + num_scheduled_tokens) 窗口重叠的 mm_featuresget_mm_features_in_window()),对每个重叠的图计算本步覆盖的是它的第 start_idxend_idx 个占位——chunked prefill 可以把一张图切在两个 chunk 里,这一步 chunk 只取 encoder 输出的对应片段(pos_info.get_embeds_indices_in_range() 处理 is_embed 掩码下的下标换算)。掩码在 CPU pinned 内存上填好再传 GPU,避免 D2H 同步。

encoder 输出从 self.encoder_cache[mm_hash] 取——这是一个普通的 dict[str, torch.Tensor],不是预分配的显存池。取不到会 RuntimeError("Encoder cache miss"),唯一的例外是 EAGLE 的 draft 多看了一个位置、读到了尚未编码的下一张图(调度器与 runner 用 shift_computed_tokens=1 表达这个偏移,见第七篇的投机解码一章)。

3. encoder 的独立执行与预算

encoder(ViT / 音频编码器)不是 decoder forward 的一部分——它在 execute_model先于 decoder 单独跑(GPUModelRunner._execute_mm_encoder()):从 scheduler_output.scheduled_encoder_inputs 取出本步要编码的项,group_and_batch_mm_kwargs()vllm/v1/worker/utils.py)按模态分组、同模态的项拼成一个 batch,调用模型的 embed_multimodal(**kwargs)SupportsMultiModal 协议,vllm/model_executor/models/interfaces.py),输出按 mm_hash 写进 self.encoder_cache。视觉编码器可以有自己的 CUDA graph(EncoderCudaGraphManagervllm/v1/worker/encoder_cudagraph.py)。TP 下有两种切法(MultiModalConfig.mm_encoder_tp_mode):”weights” 按 TP 切 ViT 权重(默认),”data” 每卡持有完整 ViT、把图片分给各卡(run_dp_sharded_vision_model()vllm/model_executor/models/vision.py)——ViT 很小,切权重通信占比高,切数据往往更快。

既然 encoder 是单独的计算,调度器就得给它单独的预算。MultiModalBudgetvllm/multimodal/encoder_budget.py)在启动时算出两个数(compute_mm_encoder_budget()vllm/v1/core/encoder_cache_manager.py):

encoder_compute_budget = max(scheduler_config.max_num_encoder_input_tokens, max_tokens_per_mm_item)
encoder_cache_size     = max(scheduler_config.encoder_cache_size,            max_tokens_per_mm_item)

SchedulerConfig.max_num_encoder_input_tokensencoder_cache_size 都不可配置,__post_init__ 里直接等于 max_num_batched_tokens——即每步 encoder 最多算 max_num_batched_tokens 个 embedding,encoder cache 最多存 max_num_batched_tokens 个 embedding,如果单个项超过这个数则以单项为准。单位是”embedding 数”(PlaceholderRange.get_num_embeds()),不是像素也不是字节。

调度器在 Scheduler.schedule() 里对每个有 encoder 输入的请求调用 _try_schedule_encoder_inputs(),规则(照 docstring):一个 encoder 项在本步被调度,当且仅当它的占位区间与本步要算的 token 区间重叠、它没在 encoder cache 里、远端 encoder cache(EC connector)也没有、compute budget 够、cache 有空间。四种失败各有处理:

情况 调度器怎么做
budget 或 cache 不够,且图在本步窗口的后半 num_new_tokens = start_pos - num_computed_tokens只算图前面的文本,图留到下一步
budget 或 cache 不够,但 prefix cache 已经把 num_computed_tokens 推到了图中间 num_new_tokens = 0:这一步这个请求一个 token 也不算(源码注释解释了这个 prefix caching 造成的角落)
disable_chunked_mm_input=True 且窗口只覆盖图的一部分 回退到图之前,不切开图
请求被抢占 已扣的 encoder budget 加回去(encoder_compute_budget += num_embeds_to_restore

encoder 用双向注意力,一张图必须整体编码(注释:”the encoder usually uses bidirectional attention”)——所以 encoder 预算的粒度是”项”,与 decoder 的 token 预算不同:一张 1300 token 的图,要么这一步全算,要么不算。这是第四篇 Token Budget 模型的第一个真正例外。

4. EncoderCacheManager:encoder 输出的分配与释放

EncoderCacheManagervllm/v1/core/encoder_cache_manager.py)管的是 encoder cache 的,不是显存本身(显存就是 worker 上那个 dict):

cache_size / num_free_slots / num_freeable_slots     单位:embedding 数
cached:     dict[mm_hash → set[request_id]]          谁在引用这份输出
freeable:   OrderedDict[mm_hash → num_embeds]        引用数为 0、可以被驱逐的,FIFO
freed:      list[mm_hash]                            本步真正驱逐的,通过 SchedulerOutput.free_encoder_mm_hashes 通知 worker pop

生命周期:

  1. check_and_update_cache(request, i):图已在 cache 里(另一个请求算过,或本请求上一步算过)→ 加引用、从 freeable 摘出,不再调度 encoder。同一张图在两个请求里只编码一次;
  2. can_allocate(request, i, budget, already_scheduled):先看 compute budget,再看 num_free_slots,不够则从 freeable 头部(最早释放的)驱逐直到够,驱逐的 hash 记入 freed。”驱逐”只是记账,worker 在下一步的 execute_model 开头才真正 encoder_cache.pop()
  3. allocate(request, i):扣 num_free_slots,加引用;
  4. _free_encoder_inputs()(调度器 update_from_output() 里,每步之后):占位区间已经完全落在 num_computed_tokens 之前(开 EAGLE 时再多留 1 个 token 的 lookahead)→ free_encoder_input(),引用数归零则进 freeable。请求结束或被抢占 → free(request) 全部释放。

注意第 4 步:一张图的 encoder 输出在它的占位区间 prefill 完之后就可以释放——之后 decode 只依赖 KV,不再需要 encoder 输出。所以 encoder cache 的驻留时间是”从编码到该图 prefill 完”,通常只有几步;它更像一个跨 chunk、跨请求的短期缓冲,而不是 KV 那样伴随请求全程的状态。

5. 多模态 prefix cache:哈希与复用条件

第五篇的 hash_block_tokens() 只看 token id,而多模态请求里图片占位符的 token id 全是同一个 image token——两张不同的图会产生完全相同的 token 序列。_gen_mm_extra_hash_keys()vllm/v1/core/kv_cache_utils.py)为每个与占位区间重叠的块加入 (mm_feature.identifier, offset - start_token_idx):图的哈希,以及图的起点相对块起点的偏移(源码注释:确保同一张图出现在不同位置时块哈希不同)。

于是多模态下 prefix cache 的命中条件是:token 前缀相同 每个块里覆盖到的图相同 图在块内的位置相同。实际后果:

  • 同一张图 + 同一段前置文本 → 图的 KV 可以复用(多轮对话里反复引用同一张图的典型场景);
  • 同一段文本 + 不同的图 → 从图开始的所有块都不命中,即使图后面的文本一样;
  • 图之前的纯文本块不受影响。

need_extra_keys() 汇总了三个触发条件:有 mm_features、有 lora_request、有 cache_salt——本篇两个主角都在里面。

6. 显存账:encoder 输出与 KV 之争

回到我们的例子,把 2000 token 的 system prompt 换成一张图:

  LLaVA 类(336×336,14 px patch,24×24=576 token) Qwen2-VL 类(1024×1024,14 px patch,2×2 合并,≈1332 token)
占位 token 576 ≈ 1332
encoder 输出(hidden=8192,bf16) 576 × 16 KB ≈ 9.4 MB 1332 × 16 KB ≈ 21 MB
这些 token 的 KV(320 KB/token,全部 8 卡) 576 × 320 KB ≈ 184 MB 1332 × 320 KB ≈ 426 MB
KV / encoder 输出 ≈ 20× ≈ 20×
驻留时间 几步(prefill 期间) 全请求(2350 步的 decode 都要读)

一张图真正贵的地方是它的 KV,不是 encoder 输出——后者小 20 倍、活得短得多。这解释了为什么 encoder cache 的上限可以简单地绑到 max_num_batched_tokens16384 × 16 KB = 268 MB,相对 80 GB 的卡不值得精细管理;而图片占的 KV 直接进第五篇那套按块管理的体系,第四篇的 Token Budget 也直接把 1332 个占位 token 当普通 prefill token 计费。

encoder 激活的峰值是另一笔:ViT 对 1024×1024 图有 5329 个 patch,注意力矩阵 5329² × heads,比它的输出大得多。vLLM 不试图精确算它,而是在 profile_run() 里用 get_dummy_encoder_profile_inputs()encoder_budget.py)按 mm_max_items_per_batch 个最大尺寸的假图实测一次峰值,从可用显存里扣掉,剩下的才给 KV Cache。skip_mm_profiling=True 可以跳过以加快启动,代价是这部分显存需要用户自己预估——文档明说 “shifts the responsibility to users”。

CPU 侧还有 processor cache 的 4 GiB × (api_server_count + dp_size)

7. 视频与音频:差在哪里

图像是多模态的基本形态;视频与音频各在一个维度上把它推到极端。

视频是”很多帧图片 + 时间维”:token 数 = 帧数 × 每帧 token,很容易几千甚至上万。vllm/multimodal/video.py 提供多种解码后端(VideoLoader:OpenCV、PyAV、TorchCodec、PyNvVideoCodec 硬解),sample_frames_from_video() 抽帧。token 太多时的对策是剪枝video_pruning_rateMultiModalConfig)启用 EVS(vllm/multimodal/video_prune/evs.py)等算法,按帧间相似度丢掉冗余 token——这正是 PlaceholderRange.is_embed 掩码存在的原因:占位区间长度不变,但只有掩码为真的位置真的填 encoder 输出。model runner 里有一段注释承认的 hack:开启剪枝时多段视频逐个编码而不合 batch,因为调度器按剪枝后的 token 数扣预算,而 encoder 的峰值显存按剪枝前算——预算与真实成本对不上。

音频的差异在模型结构:Whisper 一类是 encoder-decoder,音频 encoder 的输出不是 embedding 而是 cross-attention 的 K/V。vLLM 用 EncoderDecoderCacheManager 代替 EncoderCacheManager:所有 encoder 输入的 start_pos=0,只在第一步调度一次(num_computed_tokens > 0 后跳过),没有跨请求缓存;SchedulerConfig.__post_init__ 对 encoder-decoder 模型直接关闭 chunked prefill 与 prefix cachingdisable_chunked_mm_input=Trueenable_chunked_prefill=False)——第四、五篇的两个核心机制在这类模型上都不可用。vllm/multimodal/audio.py 负责重采样等预处理。

还有一个角落:Qwen-Omni 类的 use_audio_in_video,同一段占位符同时属于视频和音频两个 feature,_gather_mm_embeddings()is_mm_embed|= 合并两个掩码——MultiModalBudget 的注释也为此专门过滤了”没有独立占位符的模态”。

四、两个扩展叠加,以及留给后两篇的问题

1. LoRA + 多模态

两者同时出现时有三个交叉点:

  • default_mm_lorasLoRAConfig):某个模态出现时自动挂指定 adapter,用于”只要有图就得用视觉微调”的模型——但一个请求只能有一个 adapter,多模态各带 adapter 时不生效;
  • enable_tower_connector_lora:LoRA 不只挂在语言模型上,也挂在视觉塔和 connector 上。_execute_mm_encoder() 为 encoder batch 单独构造 LoRAMappingLoRAMappingType.TOWER / CONNECTOR),因为 encoder batch 的结构(按图)与 decoder batch(按 token)不同;
  • 此时 encoder 输出依赖 adapter,所以 identifier = f"{lora_name}:{mm_hash}"——同一张图在两个 adapter 下是两份 encoder cache,也是两份 KV。

2. 留给第十一篇(硬件抽象)的问题

  • LoRA 的 kernel 是 Triton 写的,get_punica_wrapper()vllm/lora/punica_wrapper/punica_selector.py)按平台选 PunicaWrapperGPU / PunicaWrapperCPU / PunicaWrapperXPU,算子目录也分 ops/triton_opsops/torch_opsops/xpu_ops。一个新硬件要支持 multi-LoRA,是重写这两个 kernel,还是退回 torch_ops 的逐 adapter 循环?后者在 decode 下的 launch 成本,第六篇已经算过。
  • ViT 的注意力与 decoder 的 paged attention 是两套后端:get_vit_attn_backend()vllm/model_executor/models/vision.py)与 mm_encoder_attn_backend 单独选择,mm_encoder_attn_dtype="fp8" 单独量化。硬件抽象层要同时覆盖两种注意力形态。
  • 视频解码可以走 GPU 硬解(PyNvVideoCodec),这是 NVIDIA 特有的能力——平台抽象要不要把”预处理”也纳入?

3. 留给第十二篇(PD 分离)的问题

  • encoder 放哪一侧? 它的输出只在 prefill 时需要,自然属于 P 侧;但 P 侧的显存本来就要给大 batch 的 prefill 激活,ViT 的峰值激活会挤它。v0.27.1 已经有第三种选择的骨架:ECTransferConfigvllm/config/ec_transfer.py)与 vllm/distributed/ec_transfer/ 定义了 encoder cache 的 producer / consumer,mm_encoder_only=True 让一个实例只跑 encoder,调度器里 _try_schedule_encoder_inputs()external_load_encoder_input 分支对应”encoder 输出从远端来”。E/P/D 三池分离的问题是:encoder 输出(每张图 9–21 MB)值不值得走一次网络?
  • LoRA 在两个池怎么同步? KV 的块哈希包含 lora_name,P 侧算出的 KV 只对同一个 adapter 有效;D 侧必须有同一个 adapter 且槽位可用,否则传过来的 KV 无法使用。两个池的 max_loras、adapter 集合、LRU 状态如何保持一致,是 PD 分离下 multi-LoRA 的新问题。
  • 处理器缓存在哪一侧? mm_processor_cache 在 API 进程与引擎进程之间;PD 分离后请求要经过 P 和 D 两个引擎,图片张量是传两次、还是 D 侧根本不需要(只需要 KV)?
📂 本章源码导航

multi-LoRA

想看什么 从哪开始
配置 vllm/config/lora.pyLoRAConfigmax_lorasmax_lora_rankmax_cpu_lorasfully_sharded_loraslora_dtypespecialize_active_loraenable_tower_connector_lora
请求对象 vllm/lora/request.pyLoRARequest
Triton kernel vllm/lora/ops/triton_ops/lora_shrink_op.py_lora_shrink_kernel)、lora_expand_op.pylora_kernel_metadata.pyLoRAKernelMeta.prepare_tensors());MoE 版 fused_moe_lora_op.py
Punica wrapper vllm/lora/punica_wrapper/punica_gpu.pyPunicaWrapperGPU.add_lora_linear() / add_shrink() / add_expand() / update_metadata();基类 punica_base.py;平台选择 punica_selector.py
LoRA 层 vllm/lora/layers/base_linear.pyBaseLinearLayerWithLoRA.create_lora_weights() / set_lora() / apply()column_parallel_linear.pyrow_parallel_linear.py(含 *WithShardedLoRA)、vocal_parallel_embedding.pylogits_processor.pyLogitsProcessorWithLoRA)、fused_moe.pylayers/utils.pyLoRAMappingLoRAMappingType
槽位与 LRU vllm/lora/model_manager.pyLoRAModelManager.activate_adapter()LRUCacheLoRAModelManagerAdapterLRUCache
worker 侧加载 vllm/lora/worker_manager.pyLRUCacheWorkerLoRAManager.add_adapter() / _apply_adapters()vllm/lora/peft_helper.pyPEFTHelpervllm/lora/lora_model.pyLoRAModel.from_local_checkpoint()
runner 侧映射 vllm/v1/worker/lora_model_runner_mixin.pyLoRAModelRunnerMixin.set_active_loras()vllm/v1/worker/gpu_input_batch.pyInputBatch.make_lora_inputs()request_lora_mapping
调度器约束 vllm/v1/core/sched/scheduler.pyScheduler.schedule() 中的 scheduled_loras
prefix cache 隔离 vllm/v1/core/kv_cache_utils.py_gen_lora_extra_hash_keys()generate_block_hash_extra_keys()need_extra_keys()
CUDA graph vllm/forward_context.pyBatchDescriptor.has_lora / num_active_lorasvllm/v1/cudagraph_dispatcher.pyCudagraphDispatcher._get_lora_cases()vllm/lora/utils.pyget_captured_lora_counts()vllm/config/compilation.pycudagraph_specialize_lora
动态加载 vllm/entrypoints/openai/models/serving.pyload_lora_adapter() / unload_lora_adapter()vllm/lora/resolver.pyLoRAResolvervllm/envs.pyVLLM_ALLOW_RUNTIME_LORA_UPDATINGVLLM_LORA_RESOLVER_CACHE_DIR

多模态

想看什么 从哪开始
配置 vllm/config/multimodal.pyMultiModalConfigmm_processor_cache_gbmm_processor_cache_typemm_hasher_algorithmmm_encoder_tp_modemm_encoder_attn_backendskip_mm_profilingvideo_pruning_rate);vllm/config/scheduler.pymax_num_encoder_input_tokensencoder_cache_sizedisable_chunked_mm_input
输入处理 vllm/multimodal/processing/processor.pyBaseMultiModalProcessor.apply() / _cached_apply_hf_processor() / _call_hf_processor()PromptReplacementPromptInsertionvllm/multimodal/registry.pyMultiModalRegistry
数据结构 vllm/multimodal/inputs.pyPlaceholderRangeMultiModalFeatureSpecMultiModalKwargsItem
哈希 vllm/multimodal/hasher.pyMultiModalHasher.hash_kwargs()vllm/v1/engine/input_processor.pyInputProcessor._get_mm_identifier()
processor cache vllm/multimodal/cache.pyMultiModalProcessorSenderCache / MultiModalReceiverCache / ShmObjectStoreSenderCache
encoder 预算 vllm/multimodal/encoder_budget.pyMultiModalBudgetget_dummy_encoder_profile_inputs()vllm/v1/core/encoder_cache_manager.pycompute_mm_encoder_budget()
encoder cache 的账 vllm/v1/core/encoder_cache_manager.pyEncoderCacheManagercheck_and_update_cache() / can_allocate() / allocate() / free_encoder_input())、EncoderDecoderCacheManager
调度器侧 vllm/v1/core/sched/scheduler.py_try_schedule_encoder_inputs()_free_encoder_inputs()vllm/v1/core/sched/output.pySchedulerOutput.scheduled_encoder_inputs / free_encoder_mm_hashes
runner 侧 vllm/v1/worker/gpu_model_runner.py_execute_mm_encoder()_gather_mm_embeddings()encoder_cachevllm/v1/worker/utils.pygroup_and_batch_mm_kwargs()vllm/v1/worker/encoder_cudagraph.pyEncoderCudaGraphManager
embedding 合并 vllm/model_executor/models/utils.py_merge_multimodal_embeddings()vllm/model_executor/models/interfaces.pySupportsMultiModal.embed_multimodal() / embed_input_ids()
ViT 侧 TP / 注意力 vllm/model_executor/models/vision.pyrun_dp_sharded_vision_model()get_vit_attn_backend()
prefix cache 隔离 vllm/v1/core/kv_cache_utils.py_gen_mm_extra_hash_keys()
视频 / 音频 vllm/multimodal/video.pyVideoLoader 及各后端)、vllm/multimodal/video_prune/evs.pyvllm/multimodal/audio.py
encoder 分离 vllm/config/ec_transfer.pyECTransferConfigvllm/distributed/ec_transfer/

五、本文小结

  • 单模型 serving 隐含三个假设:batch 内所有 token 乘同一份权重、输入 embedding 是查表、相同 token 前缀有相同 KV。multi-LoRA 打破第一条,多模态打破第二条,两者都打破第三条——所以 hash_block_tokens()extra_keys 里同时有 lora_name(mm_hash, 块内偏移)
  • multi-LoRA 的核心是一个 kernel 处理全部 adapter:Triton 的 lora_shrink / lora_expand 用 grid 的第三维遍历 adapter,用排序后的 token 索引 gather 各自的行,lora_id == -1 的 program 早退。每个 LoRA 层每步多两次 launch 和一块 fp32 缓冲;FLOPs 只多 0.2%。
  • adapter 权重按槽位静态堆在 GPU 上(lora_a_stacked [max_loras, 1, r, in]),大小由 max_loras × max_lora_rank 买断而与实际加载无关:例子里 8 个 rank-16 槽位 ≈ 1.44 GB/卡,等于 15 个请求的 KV。LoRAModelManager 用两层 LRU(CPU max_cpu_loras、GPU max_loras)换入换出,激活 = 逐层 H2D 拷贝;第一个用到新 adapter 的请求会让整个 batch 同步等磁盘。
  • max_loras 成了调度器的新准入约束(超出的 waiting 请求被跳过,冷门 adapter 可能饿死);CUDA graph 按有无 LoRA 各录一套(默认),specialize_active_lora 再按活跃数分桶;量化基座不影响 LoRA 路径,LoRA 权重始终是 bf16。
  • 多模态复用 HF processor 做预处理,用 PlaceholderRange 记录占位符,MultiModalHasher 对原始输入哈希以驱动两级缓存(processor cache 与 encoder cache);embedding 阶段用 is_mm_embed 布尔掩码把 encoder 输出就地散射进去,chunk 边界可以切在一张图中间。
  • encoder 是 decoder forward 之前的一次独立计算,有自己的预算(encoder_compute_budgetencoder_cache_size,都等于 max_num_batched_tokens,单位是 embedding 数);一张图必须整体编码,预算不够就把 num_new_tokens 截到图之前——这是 Token Budget 模型的第一个例外。EncoderCacheManager 只记账,输出在图 prefill 完后即可释放,驻留只有几步。
  • 显存上,一张图真正贵的是它的 KV(≈ 20× encoder 输出、活全程),encoder 输出本身小且短命;encoder 激活峰值靠 profile_run() 实测扣除。视频用 is_embed 掩码支持剪枝,音频的 encoder-decoder 结构让 chunked prefill 与 prefix cache 双双失效。
  • 两者都给后两篇留下问题:Triton kernel 与 ViT 注意力后端如何跨硬件;encoder 放 P 侧还是独立成池(ECTransferConfig 已是骨架)、两个池的 adapter 集合如何一致。

下一篇

硬件解耦:如何不让芯片差异污染 Serving 核心?


×