TensorRT-LLM 源码完全拆解(十九):多模态输入——视觉编码器、Placeholder Token 与嵌入回填

19479 字
97 分钟
TensorRT-LLM 源码完全拆解(十九):多模态输入——视觉编码器、Placeholder Token 与嵌入回填

1. 为什么多模态给推理引擎带来的是「第二套调度」

前面十八篇里,TensorRT-LLM 的运行轨迹被拆成了一条很清晰的直线:请求进来 → CapacityScheduler 准入 → KVCacheManager 分配块 → PyTorchModelEngine 组装输入 → Attention Backend 跑内核 → Sampler 出 token → 输出回传。这条链路里所有的资源都是同一种资源:token 在 KV 池里占一个块槽位,显存预算按块算,调度按 token 数算。

多模态把这条直线打断了。一张 448×448448 \times 448 的图,经过视觉编码器之后会变成几百甚至几千个 embedding 行,每一行都要塞进 LLM 的输入序列里占一个位置——但它们不是 token,没有词表 ID,也不能走 embedding lookup。它们占据位置却不在 KV 池里预分配,它们要经过一次独立的、算力特征完全不同的前向(卷积 + 窗口注意力 vs 因果注意力),而这次前向的输入尺寸取决于用户上传的图片分辨率,是一个请求级别的、不可预测的、不可切分的变量。

于是引擎里出现了第二套调度面。源码里对这件事的描述非常直白,在 tensorrt_llm/_torch/pyexecutor/engine/multimodal.py 的文件头:

MM encoder item scheduling for decoder-family multimodal engines.
The second driving surface of a multimodal LLM: the executor encodes
scheduler-selected multimodal items through an LRU read-through cache under a
byte budget before each decode step. This is a capability facet, orthogonal to
the runner family axis -- it has no warmup or graph-capture phase.

这段话里有两个词值得单独拎出来:item(模态项)与 byte budget(字节预算)。前者说明这套调度的粒度不是 token 也不是请求,而是「一个图片/一段视频」这样的原子项;后者说明它记账的单位是字节而不是 token 数。第三篇拆 CapacityScheduler 时我们看到,LLM 那条线的准入算术全部建立在 max_num_tokens 与 KV 块数上;而多模态这条线要额外叠一层「编码器能同时吞吐多少模态项、它们的输出 embedding 一共占多少字节」。

这篇文章要回答的就是这套第二调度面在源码里的完整形状:

  • 一张图片从 API 消息进来,经过输入处理器,如何变成 MultimodalParams 里的 image.pixel_values 与一串掩码(第 2、3 节);
  • 引擎如何为视觉编码器单独解析出一套资源预算(第 4 节);
  • 两条编码路线——老式的 inline 编码与新式的 item scheduling + LRU 编码缓存——分别怎么工作、为什么需要后者(第 5 节);
  • 编码出来的 embedding 具体怎么「回填」进 LLM 的输入,包括那个被反复优化过的散列写入路径(第 6 节);
  • chunked prefill 与 KV 前缀复用同时发生在多模态请求上时,引擎靠什么知道「这次前向该取第几行到第几行」(第 7 节);
  • 视觉编码器自己的 CUDA Graph 策略,以及为什么它和 LLM 的图捕获不是一回事(第 8 节);
  • 最后是多模态特有的 P/D 分离形态 EPD(第 9 节)。

一个前置提醒:这篇文章里的 image / video / audio 统称模态项(multimodal item)。源码里 mm_item_order、get_mm_max_tokens_per_item()、forward_multimodal_encoder_items() 这一串命名都建立在「项」这个粒度上,理解了这个粒度,后面的预算表就不难看懂。

2. 数据形态:从 API 消息到 MultimodalParams

2.1 两套并存的容器

TensorRT-LLM 的多模态数据在源码里有三个层次,理解它们的分工是读懂后面所有代码的前提:

层次类型存在哪一侧内容
协议层MultimodalInputC++/执行器边界张量化的像素值、位置与长度,可跨进程传输
Python 层MultimodalParamsPython 引擎侧multimodal_data 字典、运行时记账、编码事件
模型层各模型的 pixel_values / image_grid_hw模型内具体编码器需要的几何信息

MultimodalParams 是整条链路的中枢,它的 multimodal_data 字典结构在源码 docstring 里写得很明确:

{
"mrope_config": {
"mrope_rotary_cos_sin": torch.Tensor, # Qwen2/2.5-VL
"mrope_position_deltas": torch.Tensor, # Qwen2/2.5-VL
},
"multimodal_embedding": torch.Tensor | List[SharedTensor handle dict],
"image": {
"pixel_values": torch.Tensor,
"image_height": torch.Tensor | List[int],
"image_width": torch.Tensor | List[int],
},
...
}

这个设计里有两处细节值得说明。

第一,multimodal_embedding 字段是「同一个槽位,两种含义」。在编码还没发生时它是空的;编码完成后它是编码器输出的 embedding 行;而在 P/D 分离的 E→P 交接里,它还可能临时存的是 SharedTensor 的句柄字典,要由 BaseWorker.to_tensor("multimodal_data") 还原成真张量。同一个键承担三种语义,是因为这三件事在生命周期上互斥——源码注释直接点明了这一点。

第二,哪些键能上 GPU 是白名单制。MultimodalParams.to_device() 把数据搬到设备上时,有一个明确的排除清单:

_CPU_ONLY_MULTIMODAL_DATA_KEYS = frozenset({
"multimodal_embed_mask_cumsum",
"multimodal_embedding_lengths",
MULTIMODAL_ENCODER_ITEM_METADATA_KEY,
})

为什么这三个必须留在 CPU?因为它们都是记账数据,而且都在关键路径上被 Python 代码按「第几个元素」读取。multimodal_embed_mask_cumsum 是一个 int64 的前缀和,用来 O(1) 算出「这次前向要取哪几行 embedding」(第 7 节展开);如果它落在 GPU 上,int(cs[idx]) 这种标量读取就会触发一次 D2H 同步,把一次本该纯异步的调度卡住。源码里 MultimodalRuntimeData.__post_init__ 用断言把这条约束钉死了:

cs = self.embed_mask_cumsum
# int(cs[idx]) below would D2H-sync if cs lived on CUDA.
assert cs.device.type == "cpu", f"embed_mask_cumsum must be CPU, got {cs.device}"

「把记账留在 CPU、把张量放上 GPU」这个分界,在整篇里会反复出现。多模态推理的性能瓶颈很多时候不在 kernel 里,而在这种不该发生的同步上。

2.2 缓存键必须描述「模型能看到的内容」

MultimodalParams 上还有一个容易被忽略但非常关键的字段组合:multimodal_input(带哈希信息)与 multimodal_runtime(带复用记账)。多模态请求能不能复用 KV 前缀缓存、能不能复用编码器输出,取决于一个哈希值;而这个哈希必须等价于「模型实际看到的像素内容」。

tensorrt_llm/inputs/multimodal_data.py 里为此写了一套自描述的序列化编码 serialize_item()。它的注释说明了动机:

The encoding is canonical and self-describing: every value is
[1-byte type tag][typed metadata][length-prefixed payload] with all
multi-byte integers big-endian. This prevents cache-key hash collisions
between distinct values that happen to share a raw byte payload (for
example transposed image dimensions or reshaped arrays).

关键在「transposed image dimensions」这个例子:一张 W×HW \times H 的图和一张 H×WH \times W 的图,如果只是把像素字节直接拼进哈希,两者可能哈希相同——但模型看到的是两张不同的图,缓存会给出错误的 embedding。所以每一段都要先写类型标签、再写形状、最后写长度前缀化的载荷。对 torch.Tensor 与 np.ndarray 还有一条额外规则:容器类型不参与内容身份,两者会先归一化成连续的 NumPy 数组,保证「同样的内容哈希相同」。

视频的哈希规则更讲究。VideoData.update_hash() 只把四个元数据字段纳入哈希:

_VIDEO_HASH_METADATA_FIELDS = (
"frames_indices",
"fps",
"duration",
"total_num_frames",
)

理由是注释里那句:frames_indices 是「源字节 + 采样参数」的确定性函数,所以元数据 + 源字节摘要就等价于把解码后的每一帧扫一遍。于是当加载器能提供 raw_bytes_hash(源视频字节的 BLAKE3 摘要)时,哈希直接走摘要;只有在帧列表是手工构造、没有源字节可锚定时,才退化成逐帧哈希像素。这是一个典型的「用上游的确定性换来避免遍历像素」的优化。

顺带一提,这里的哈希不是随便算的:apply_mm_hashes() 产出 MMHashResult,而 hexdigest_to_int32() / int32_to_hexdigest() 这对函数说明哈希最终要以 int32 数组的形式出现在请求里——因为请求的多模态哈希要能被调度器与 KV 缓存管理器当作普通的整数键来比较,而十六进制字符串跨进程传递不是它们想要的形态。

3. Placeholder Token:占位符的三种身份

3.1 为什么需要占位符

LLM 的输入是一个整数序列 input_ids。多模态 embedding 是浮点矩阵,它没有整数 ID。要把浮点矩阵插进整数序列,唯一的办法是在序列里留出位置——那些位置上的 token ID 只是一个记号,实际送进模型的是 embedding 而不是 embedding lookup 的结果。这个记号就是 placeholder token。

TensorRT-LLM 里的 placeholder 有三种身份,区分清楚它们能省掉很多困惑:

身份一:越界哨兵(OOV sentinel)。 最老的实现方式。placeholder 的 ID 取 vocab_size + 1 这类大于等于词表大小的值。好处是判别极其廉价——input_ids >= vocab_size 一个比较就能找出所有多模态位置;坏处是这个 ID 本身不能拿去查 embedding 表(会越界),所以文本侧必须先 index_select 把文本位置挑出来,再 index_copy_ 写进一个新分配的空缓冲。多一次 gather、一次 torch.empty 分配、一次 scatter。

身份二:词表内的真实 token。 现代 VLM 大多在词表里额外训练了一个 image token(例如 image_token_id),它本身是一个合法词表条目。这条路可以省掉「挑文本位置」的三件套:直接 embedding_layer(input_ids) 把整条序列查一遍,多模态位置查出来的结果随后会被覆盖掉。代价是浪费 mm 占比那部分 lookup 算力,收获是少约 2 个 GPU kernel 和一次额外分配。

身份三:特殊标记(special tokens)。 图像分隔符、换行符这类 token 既不是「要被 embedding 替换的槽位」,也不是普通文本——它们有真实的 text embedding,但需要被单独识别出来(例如 Qwen-VL 的 image_break、DeepSeek-V4.1 的 image_newline)。它们参与 mm_mask 但被排除在 embed_mask 之外。

fuse_input_embeds 的 docstring 把前两套身份的取舍写得很清楚:

- In-vocab fast path: when ``mm_token_ids`` is provided, the caller is
declaring that mm placeholder IDs are real vocabulary entries. In that
case the text path skips its ``index_select`` + ``torch.empty``
+ text scatter and embeds the full ``input_ids`` once, overwriting
mm rows afterwards. This is the path taken by every VLM currently
in tree EXCEPT Hyperclovax, which keeps the legacy ``vocab_size + 1``
OOV remap in its input processor and therefore passes
``mm_token_ids=None`` so the OOV branch is taken — embedding an OOV
id would be out-of-bounds.

一个模型只要 override multimodal_token_ids 返回自己的 placeholder ID 列表,就等于选择了身份二。DeepseekV41ForConditionalGeneration 就是这么做的:

@property
def multimodal_token_ids(self):
return torch.tensor([self.image_token_id], dtype=torch.int32, device="cpu")

返回到 CPU 上、int32 类型——这个 dtype 和设备不是随意选的,后面第 6 节会解释它为什么要走 CPU 预计算再异步拷贝。

3.2 三种掩码:mm_mask / embed_mask / special_mask

placeholder 一旦确定,输入处理器就要在 prompt 上算出掩码。_compute_mm_masks() 一次扫描产出三个布尔张量:

mm_mask[i] True iff 位置 i 是任意 MM token(embed 槽位 或 special)
embed_mask[i] True iff 位置 i 是 embed 槽位(排除 special)
special_mask[i] True iff 位置 i 是 special(未提供 mm_special_token_ids 时为 None)

docstring 给的例子最能说明三者的关系:

input_ids=[1, 5, 5, 6, 5, 7, 2], mm_token_ids=[5], mm_special_token_ids=[6, 7]
→ embed_mask=[F, T, T, F, T, F, F]
→ mm_mask =[F, T, T, T, T, T, F]

位置 3 是 6(special),它在 mm_mask 里是 True 但 embed_mask 里是 False。这个区分不是学术洁癖,而是直接决定 embedding 行数的。

3.3 embed 行数与占位跨度为什么可以不相等

一个图像项在 prompt 里占的 token 位置数(占位跨度)和它贡献的 embedding 行数(embed_mask 为 True 的位置数)可以不一样,差额就是 special token。_find_mm_embedding_lengths_from_masks() 就在算这个差额:

embedding_lengths: List[int] = []
offset = 0
for item_length in num_mm_tokens:
item_positions = mm_positions[offset:offset + item_length]
offset += item_length
embedding_lengths.append(int(embed_mask[item_positions].sum().item()))

先按 mm_mask 把位置切成「每个模态项的连续段」,再在每个段内数 embed_mask 的个数。这个函数里有一条断言非常值钱:

assert mm_positions.numel() == lengths_t.sum().item(), (
f"Number of multimodal tokens ({mm_positions.numel()}) does not match "
f"sum of per-unit lengths ({lengths_t.sum().item()}): "
f"num_mm_tokens={num_mm_tokens}")

两边必须严格相等:prompt 里实际出现的多模态 token 数,必须等于输入处理器按几何信息算出来的逐项长度之和。这条断言是整条多模态链路最重要的正确性守卫之一。假设它不成立,后面的 fuse_input_embeds 会抛出另一个错误:

if mm_token_indices.shape[0] != mm_embed.shape[0]:
raise ValueError(
f"Multimodal token count mismatch: found {len(mm_token_indices)} image tokens in input_ids "
f"but received {mm_embed.shape[0]} image embeddings.")

两个错误指向同一个不变量:placeholder 数量 ≡ embedding 行数。这条不变量来自「图片被切成多少个 patch / 合并后剩多少行」这个几何计算,而几何计算由输入处理器和视觉编码器两侧分别独立完成。它们一旦不一致,要么是预处理参数在不同请求间串了,要么是 encoder 输出的 merge 比例和 processor 假设的不同。

3.4 几何计算的例子:DeepSeek-V4.1

看一个具体模型怎么把「一张图」算成「多少 token」。DeepseekV41ForConditionalGeneration.encode_multimodal_inputs() 里有一段检查:

if features.shape[0] != rows * columns or length != rows * (columns + 1) + 2:
raise ValueError("DeepSeek-V4.1 image grid does not match its placeholder span")

把它翻译成人话。图片被预处理成 rows×columnsrows \times columns 个 patch 块,编码器输出的 feature 数必须是 rows⋅columnsrows \cdot columns。占位跨度则是:

L=rows⋅(columns+1)+2L = rows \cdot (columns + 1) + 2

那个 +1+1 是每行末尾插一个换行标记 image_newline;那个 +2+2 是整张图最外层的 image_start 与 image_end。于是 embedding 的构造是:先 features.reshape(rows, columns, d),再把 image_newline 沿列方向 expand 成 (rows,1,d)(rows, 1, d) 拼在右边,flatten(0, 1) 压平,最后在图的最前和最后各接一个 dd 维向量:

embeddings.append(
torch.cat((
self.image_start.to(features.dtype).unsqueeze(0),
torch.cat((features, newline), dim=1).flatten(0, 1),
self.image_end.to(features.dtype).unsqueeze(0),
))
)

注意 image_start / image_end / image_newline 是三个注册成 Parameter 的可学习向量,在 load_weights 里有专门的加载分支:

for name in ("image_start", "image_end", "image_newline"):
value = load_image_weight(name, (self.embedding_dim,), self.embedding_dtype)
setattr(self, name, nn.Parameter(value))

也就是说,这三个分隔标记不是「特殊 token 的 embedding」,而是独立于词表的外挂向量——它们根本不走 input_ids,是直接拼进 embedding 序列的。这解释了为什么 DeepSeek-V4.1 的 multimodal_token_ids 只返回 [image_token_id] 一个值:换行和首尾标记在 prompt 里各有对应的 token 位置(用来计数),但那些位置的 embedding 来自这三个 Parameter,而不是词表的 embedding 行。

由此也能理解第 3.3 节那个不等式的实际含义:DeepSeek-V4.1 的 embed_mask 数量是 rows⋅columnsrows \cdot columns,mm_mask 数量是 rows(columns+1)+2rows(columns+1)+2。两者差 rows+2rows + 2,正是换行与首尾标记的数量。find_mm_token_lengths() 在算 num_mm_tokens 时明确说明它包含 special:

Returns the total token count for each multimodal item, including any special tokens
(e.g., image_begin, image_end, image_break) that may be mixed with the actual
multimodal content tokens. This mm_token_lengths represents the full chunk from beginning
to end, not just pure image/video/audio tokens.

3.5 惰性与「无载荷请求」的识别

不是每个带 multimodal_params 的请求都真有图片。mrope warmup 请求会带着位置元数据但没有视觉内容;某些上下文请求只带 special_token_offsets。引擎需要一个廉价的判别函数把它们过滤掉,否则编码器会对空载荷做一次前向。

_has_mm_payload_keys() 用「键集合减去元数据键集合」来实现:

_MM_METADATA_ONLY_KEYS = frozenset({
"mrope_config",
"multimodal_embed_mask_cumsum",
"multimodal_embedding_lengths",
MULTIMODAL_ENCODER_ITEM_METADATA_KEY,
"special_token_offsets",
"layout_metadata",
})
def _has_mm_payload_keys(py_multimodal_data: Optional[dict]) -> bool:
if not py_multimodal_data:
return False
return bool(set(py_multimodal_data.keys()) - _MM_METADATA_ONLY_KEYS)

只有 image / video / audio 这类真载荷键才会让函数返回 True。这个判别在 MultimodalEncoderRunner._forward_step 里被逐请求使用,也在 check_mm_embed_cumsum_if_needed() 里用作早退条件——后者是下一节要讲的 chunked prefill 前置检查。

4. 引擎如何给视觉编码器单独算一份预算

4.1 四种资源,四种单位

把视觉编码器接进推理引擎,最难的部分不是「怎么调它」,而是「怎么给它算资源」。MultimodalItemScheduler.create() 的 docstring 把这件事整理成了四条,值得逐字读:

Item scheduling bounds four distinct MM encoder resources. They are owned in
different places and measured in different units, so they are enumerated here once:
* (A) encoder batch cardinality -- ``encoder_batch_size``, counting atomic MM items
rather than model-internal attention sequences.
* (B) encoder-forward workspace -- ``encoder_max_num_tokens``, in encoder attention
tokens, clamped up to the largest atomic item; profiled by a direct full-budget
encoder warmup.
* (C) resident output bytes -- ``output_budget_bytes``, the maximum post-encoder
embeddings produced by one legal encoder iteration, converted to bytes. Enforced
by the scheduler; any capacity not materialized by warmup is reserved in
KV-capacity estimation.
* (D) reuse cache bytes -- ``encoder_cache_max_bytes`` may make the same
``TensorLRUCache`` larger than (C); it does not create a second pool.

四条的要点分别是:

(A) 批的基数是「项」而不是「序列」。 LLM 侧的 batch 基数是序列(max_num_seqs),因为每条序列有独立的 KV。编码器侧的批基数却是模态项:一个请求带三张图,就是三个项;一个项无论多大都不可拆。这个区别决定了后面所有的预算算法。

(B) 编码器前向的工作区按「编码器注意力 token」算。 注意这里说的是 encoder attention tokens——是 ViT 在 self-attention 里实际要 attend 的 patch 数,也就是合并(merge)之前的 token 数。Qwen 系 ViT 会把相邻的 2×22 \times 2 个 patch 合并成一个输出 token,所以一个项在编码器里消耗的 attention token 数远大于它最后贡献的 embedding 行数。

(C) 编码器输出的常驻字节数,是「一次合法编码迭代最多能产出多少 embedding 行」乘上每行字节数。 这是唯一以字节为单位的预算,因为 embedding 不在 KV 池里,它需要在显存里另开一块缓存。

(D) 复用缓存可以与 (C) 共用同一块内存。 源码强调 encoder_cache_max_bytes 只是让同一个 TensorLRUCache 变大,「it does not create a second pool」——不会凭空多出第二份池子。

理解了 (B) 和 (C) 用的是两种不同的单位,后面那条容易迷惑人的「原子项抬升预算」规则就顺理成章了。

4.2 单位换算:为什么 token 预算会被「抬升」

def resolve_mm_encoder_token_budget(base_budget: int, model_max_atomic_item_tokens: int) -> int:
"""Keep the model's largest indivisible MM item schedulable."""
return max(base_budget, model_max_atomic_item_tokens)

create() 里使用它的地方:

max_tokens_per_item = input_processor.get_mm_max_tokens_per_item()
model_max_atomic_item_tokens = max(max_tokens_per_item.values())
encoder_token_budget_base = encoder_max_num_tokens
effective_encoder_token_budget = resolve_mm_encoder_token_budget(
encoder_token_budget_base, model_max_atomic_item_tokens
)
if effective_encoder_token_budget > encoder_max_num_tokens:
logger.warning_once(...)
encoder_max_num_tokens = effective_encoder_token_budget

逻辑只有一行:有效预算 = max(用户配置的预算, 模型最大的单个原子项)。为什么要这么写?

假设用户配 encoder_max_num_tokens=4096,但模型的 get_mm_max_tokens_per_item() 报告「一张最大合法图片在编码器里要 8192 个 attention token」。如果按 4096 执行,会发生什么?调度器在挑项时会因为「这一项放不进预算」而永远不选它——这个请求会永久饥饿。因为一个原子项不可拆分,所以预算低于最大项的时候,唯一正确的选择是把预算抬上去,并在日志里说清楚为什么。这就是 max() 的全部含义。

同一个函数还会报告另外三个数到日志里,这行日志本身是一份很好的现场诊断材料:

Multimodal encoder token budget: configured=..., base=...,
effective=..., model_atomic_max=..., attention_capacity=...

configured 是用户写的,base 是引擎传入的,effective 是抬升后的真实生效值,model_atomic_max 是模型自己报告的最大项,attention_capacity 是输入处理器把 token 预算换算成「模型内部 attention 序列容量」的结果。

4.3 从 token 预算到 attention 元数据容量

上一段最后那个 attention_capacity 来自:

attention_metadata_capacity = input_processor.get_mm_encoder_attention_metadata_capacity(
encoder_max_num_tokens
)

它为什么需要单独一步换算?因为 AttentionMetadata 里的 max_num_requests 计的是序列条数,而编码器的 token 预算计的是 token 数,两者之间要经过一次「几何换算」。编码器内部可能存在多种 attention 视野,各自的容量也不一样。MultimodalEncoderMixin.get_encoder_attention_metadata_capacity() 的 docstring 举了例子:

Keys name this encoder's attention metadata objects, so they are
model-specific: this default declares a single `attention` object,
while a windowed encoder such as Qwen2.5-VL declares `full_attention`
and `window_attention`. There is no fixed superset to enumerate.

Qwen2.5-VL 的 ViT 里,大部分层用窗口注意力(每个 patch 只看局部窗口),少数层用全注意力。这两类层的「一次前向要处理多少条序列」上限不同,所以容量配置要分成两个键。

当输入处理器没有实现这个换算时,编码器自己有一个保守兜底:

_ENCODER_FALLBACK_MAX_NUM_REQUESTS = 8192
def get_encoder_attention_metadata_capacity(self, max_num_tokens: int) -> dict[str, int]:
return {"attention": max(max_num_tokens, _ENCODER_FALLBACK_MAX_NUM_REQUESTS)}

默认值故意取得很大(一条序列只算一个 token,最少也给 8192 条),代价是 AttentionMetadata 里为每条序列准备的元数据缓冲区会按 8192 条分配。源码的注释承认这是一个临时方案:

Legacy fallback for encoders that do not yet opt into atomic-item scheduling
or provide a model-specific item/token-to-segment mapping. Their encoder
forward is not runtime-budgeted, so configured item/token limits cannot
safely size fixed ``AttentionMetadata`` per-segment buffers.

换句话说:没有实现几何换算的编码器,只能用「大到不会出错」的容量。这是把新调度机制逐步迁移到存量模型时常见的取舍——先让正确性成立,再逐个模型收紧容量。

4.4 引擎在模型加载后接管 AttentionMetadata 的构造

LLM 主干侧的 AttentionMetadata 由 DecoderMetadata.setup_attention 建立(第五篇拆过)。编码器侧的对应物是一个模块级函数:

def setup_mm_encoder_attn_metadata(
model, input_processor, encoder_max_num_tokens, attention_metadata_capacity,
) -> None:
encoders = [module for module in model.modules() if isinstance(module, MultimodalEncoderMixin)]
if not encoders:
return
max_seq_len = encoder_max_num_tokens
if isinstance(input_processor, BaseMultimodalDummyInputsBuilder):
max_tokens_per_item = input_processor.get_mm_max_tokens_per_item()
max_seq_len = max(max_seq_len, max(max_tokens_per_item.values(), default=0))
for module in encoders:
setup_kwargs = dict(max_num_tokens=encoder_max_num_tokens)
if attention_metadata_capacity is not None:
setup_kwargs["attention_metadata_capacity"] = attention_metadata_capacity
module.setup_attn_metadata(**setup_kwargs)
module.set_attn_max_seq_len(max_seq_len)

三个设计点:

  1. 用 model.modules() 扫描而不是让模型主动上报。 编码器通过继承 MultimodalEncoderMixin 来「申报」自己是编码器,引擎按类型过滤一遍就能找到所有编码器模块。这比在每个模型里写一个 list_my_encoders() 更不容易漏。

  2. 构造时机在模型加载之后,而不是编码器 __init__ 里。 注释说明了原因:尺寸必须匹配「引擎解析出来的编码器 token 预算」,而不是每个编码器在自己的构造函数里硬编码一个值。这是一个很实际的工程考虑——编码器构造函数运行时,引擎还不知道用户配了多大的 encoder_max_num_tokens。

  3. set_attn_max_seq_len 与 setup_attn_metadata 分成两个钩子。 源码解释是「保留 setup_attn_metadata 已有的覆盖契约,供外部编码器实现使用」。第一个提供「按预算算出的容量映射」,第二个提供「单个段的稳定上限」。把可变部分和稳定部分分开,避免每加一个维度就要改一次所有外部实现的签名。

4.5 每行 embedding 占多少字节:一条四级回退链

有了「能产出多少行」,还要有「每行多大」,才能算出字节预算。resolve_bytes_per_mm_encoder_embedding() 的答案是四级回退:

# 1. 模型显式声明的 embedding_dim + embedding_dtype
embedding_dim = model.embedding_dim # 可选契约
embedding_dtype = model.embedding_dtype # 可选契约,缺省时用 model_config.torch_dtype
return embedding_dim * torch.empty((), dtype=embedding_dtype).element_size()
# 2. 文本 embedding 层的权重形状
weight = model.text_embedding_layer.weight
return weight.shape[-1] * weight.element_size()
# 3. 预训练配置里的 hidden_size(含 text_config.hidden_size 的嵌套情况)
hidden_size = getattr(pretrained, "hidden_size", None) or getattr(pretrained.text_config, "hidden_size", None)
return hidden_size * model.model_config.torch_dtype.itemsize
# 4. 都拿不到就报错,并且错误信息把三条路都列出来

注意第 1 步用的是 torch.empty((), dtype=...) 再取 element_size(),而不是写一张 dtype 到字节数的映射表——这样任何 torch 认识的 dtype 都能自动支持,包括将来新增的格式。

注释里有一句很实在的提醒:「both mixin properties are optional and most VLMs implement neither」。也就是说真实世界里,绝大多数模型走的是第 2 或第 3 条路。回退链的存在不是为了优雅,而是因为这条链上的模型成熟度参差不齐。

MultiModalEncoderItemMetadata.output_embedding_lengths 与这条链是同一个契约的两端:前者说「一个项产出多少行」,后者说「一行多少字节」,两者相乘才是 MultimodalItemScheduler 在字节层面记账的依据。MultimodalModelMixin.embedding_dim / embedding_dtype 的 docstring 也明确写了返回值含义就是「cached multimodal embedding row」的宽度与 dtype。

5. 两条编码路线:inline 编码与 item scheduling + LRU 编码缓存

5.1 老路线:跟着 LLM 前向一起编码

最直观的做法是:LLM 要做一次前向,发现这批上下文请求里有图片,就地调一次视觉编码器,拿到 embedding,然后继续做 LLM 前向。这条路线在源码里叫 inline encode(内联编码),它的特点是:

  • 编码与 LLM 前向在同一个执行步骤里完成,没有额外的调度;
  • 编码器的输入尺寸直接由「本次 LLM 批里有哪些请求」决定,没有任何批级上限——一张 4K 图可以独占整个编码器;
  • 编码结果不跨步保存,请求结束后就丢。

inline 编码的问题有三个。第一,视觉编码器的显存峰值不可控。视觉编码器的激活值通常比 LLM 单层大得多,一张大图的 ViT 激活可能直接把 KV 池的余量吃光,导致此前算好的显存预算失效。第二,编码时间落进关键路径。用户的第一段输出要等编码完成,TTFT 被编码时间拉长;而编码时间随图片分辨率变化,是个请求级的随机变量。第三,没有复用。同一张图片被两个请求引用(比如多轮对话里反复上传同一个截图),要编码两次。

5.2 新路线:item scheduling + 编码缓存

MultimodalItemScheduler 这套机制就是为了解决上面三个问题。它的执行模型是:

  1. 调度器先选「项」:每个解码步之前,引擎从活跃请求里挑出还没编码的模态项,装成一个编码批;
  2. 编码批受三个上限约束(编码器批基数、编码器 token 预算、输出字节预算,见第 4 节);
  3. 编码结果写进 LRU 缓存,键由内容哈希 + 处理参数哈希组成;
  4. LLM 前向时从缓存取,如果命中就直接用,不再编码。

源码把第 3、4 步描述成一个「read-through cache」(读穿透缓存):读的时候先查缓存,未命中才回源计算并写入。这是经典缓存模式,但这里有两个多模态特有的难点。

难点一:缓存的粒度是「项」而不是「请求」。 一个请求可能带三张图,其中两张在缓存里、一张要新编码。所以缓存键必须是逐项的:

def _encoder_cache_item_key(...):
...
def build_encoder_cache_item_keys(
self, multimodal_hashes, item_refs, output_embedding_lengths, mm_processor_kwargs_hash,
):
...

四个入参分别回答四个问题:这张图内容是什么(multimodal_hashes)、它是这批里的第几项、它产出多少行 embedding、它被怎么处理过(mm_processor_kwargs_hash,例如视频抽帧数)。四个都相同,才算缓存命中。

难点二:部分命中时的顺序重排。 模型的输入序列里,图片的位置是固定的。假设一个请求有三张图,编译器只编码第 2 张,那么第 1、3 张必须从缓存取,然后按原始顺序拼回去。MultimodalModelMixin 的 docstring 把这条契约写得很明白:

A partially cached `MultimodalParams` is handled by encoding only its miss items
and interleaving cached items back in original per-item order. The default
`build_multimodal_encoder_input` handles stacked-on-dim-0 and packed-with-grid-thw
layouts; models with other layouts override that method.

「stacked-on-dim-0」指的是 pixel_values 沿第 0 维堆叠(每项占若干行);「packed-with-grid-thw」指的是 Qwen 系那种把所有 patch 打包成一维、再用 grid_thw 描述每项占多少的结构。这两种布局的切片方式不同,所以默认实现只能覆盖这两种,模型要用别的布局就得自己 override。

5.3 forward_items:一次编码批的完整流程

forward_items() 是这个调度器的执行核心。它的形状是「先收集、再校验、最后统一提交」,这个顺序不是随意的。

第一步:收集待编码项与输出目标。

for item_idx in item_indices:
cache_key = state.item_cache_keys[item_idx]
if cache_key is None:
raise MultimodalEncoderRequestError(
f"Scheduled MM item {item_idx} has no cache key",
request_ids={request_id})
scheduled_cache_keys.add(cache_key)
encoder_items.append((multimodal_param, item_idx))
output_targets.append((item_idx, cache_key, state.embedding_lengths[item_idx]))

output_targets 三元组是「这个输出要写回哪一项、写到哪个缓存键、预期多少行」。注意 state.embedding_lengths[item_idx] 也参与校验——它和输入处理器算出的占位跨度必须是同一个数(第 3.3 节的不变量)。

第二步:把原始载荷搬上 GPU。

for encoder_input, _, _ in encoder_inputs:
encoder_input.to_device(
"multimodal_data",
"cuda",
pin_memory=prefer_pinned(),
target_keywords=getattr(self.model, "multimodal_data_device_paths", None),
)

这里的 target_keywords 是一个模型声明的白名单。看 DeepSeek-V4.1 的声明:

@property
def multimodal_data_device_paths(self) -> list[str]:
return ["image.pixel_values", "multimodal_embedding"]

只有这两条路径会被搬到 GPU。为什么只搬这两条?因为第 2.1 节那份 CPU-only 清单里的记账数据必须留在 CPU,而其他元数据(例如 image_grid_hw)在编码器里是按 Python 整数列表用的,搬上 GPU 反而要同步取回。「哪些字段上设备」这件事被模型显式声明,而不是由 to_device 猜——这是避免 D2H 同步的第二道防线。

第三步:先全校验,再统一提交。 这一步的设计动机在源码注释里写得非常清楚:

# Validate the entire batch before publishing any output. Otherwise a
# bad later item could leave earlier items READY and later requests
# admitted to prefill without committed outputs.
for output, (item_idx, cache_key, expected_rows) in zip(
outputs, output_targets, strict=True
):
if (not isinstance(output, torch.Tensor)
or output.shape != (expected_rows, expected_width)
or output.dtype != expected_dtype):
...
raise MultimodalEncoderRequestError(...)

expected_width 是用第 4.5 节那条字节回退链反推出来的:

expected_width = self.bytes_per_embedding // expected_dtype.itemsize

这个校验的意义在于原子性:如果编码批里第 5 项产出的张量形状不对,不能只报第 5 项的错误、让前 4 项已经进缓存。因为一旦前 4 项被标成 READY,引用它们的那几个请求就会被调度器放行到 prefill,而预填充时它们引用的 embedding 行数可能是错的。所以要么整批成功,要么整批失败。

第四步:提交并标记就绪。

for output, (_, cache_key, _) in zip(outputs, output_targets, strict=True):
encoder_cache.commit(cache_key, output)
for request in requests:
state = request.py_mm_encoder_state
if state is None:
continue
state.mark_cache_keys_ready(scheduled_cache_keys)
if state.progress is MultimodalEncoderProgress.READY:
strip_mm_encoder_inputs(request.py_multimodal_data)

最后那个 strip_mm_encoder_inputs 值得注意。当一个请求的所有项都就绪时,原始像素载荷就没用了:

def strip_mm_encoder_inputs(mm_data: Dict[str, Any]) -> None:
"""Drop raw encoder payloads while preserving cached MM embeddings.
Item-level encoder scheduling keeps the original request payload on CPU
and transfers only selected items to GPU. Once every item is encoded, the
raw modality dictionaries are no longer needed and must not be moved to
GPU by the subsequent LLM input-preparation path.
"""
for modality in ("image", "video", "audio"):
mm_data.pop(modality, None)

这是一个显存与带宽的双重释放:像素值本来就常驻 CPU(为了不占显存),但如果不在编码完成后删掉它们,后续 LLM 输入准备阶段仍然会尝试把它们搬上 GPU,白占一份带宽。主动删除是最省事的办法。

5.4 错误处理的粒度:一批错,只罚生产者

这段代码里有个容易被忽略但很讲究的细节:

# A batch-level error cannot be traced to one output, so fail only the
# scheduled producers. Requests that only share a scheduled entry keep
# their references, and the next scheduling pass picks a new producer.
producer_request_ids = scheduled_items.keys()
try:
encoder_inputs = self.model.prepare_multimodal_encoder_inputs(encoder_items)
except MultimodalEncoderContractError as error:
raise MultimodalEncoderRequestError(str(error), request_ids=producer_request_ids) from error

「producer」(生产者)与「consumer」(消费者)的区分是编码缓存带来的新概念。一个缓存键可能被多个请求引用:请求 A 第一次上传了图片 X,缓存键 K 建立;请求 B 也上传了同一张图 X,它直接引用 K。编码是 A 干的活,B 只是沾光。

如果这次编码失败了,应该罚谁?源码的选择是只罚 A(producer),不罚 B(consumer)。理由是:B 并没有做错什么,它引用的缓存条目这次没被生产出来,下一次调度可以换一个 producer——比如让 B 自己去编码。如果连 B 一起罚掉,同一张图片在两个请求里都会被拒,而实际上只需要重试一次编码。

这个区分在源码的另一处也有对应实现。当某个输出形状不对时,错误要通知的是「正在用这个缓存键的请求」:

raise MultimodalEncoderRequestError(
f"MM item {item_idx} must produce a {expected_dtype} tensor "
f"with shape ({expected_rows}, {expected_width}), got {actual}",
request_ids=requests_using_cache_keys({cache_key}),
)

而 requests_using_cache_keys() 遍历的是所有请求的 item_cache_keys,不是只看生产者。也就是说:校验阶段的错误广播给生产者+消费者,而编码阶段(无法归因到单项)的错误只报给生产者。这个不对称是有意的——形状校验能精确定位到某个缓存键,而 encoder_fn 抛出的异常无法定位到哪一项。

5.5 一次编码批的资源账本

把第 4 节和本节合起来,一个完整的多模态编码批在引擎里是这样记账的:

资源单位上限来源超限时的行为
编码器批基数模态项数encoder_batch_size不选这一项,请求继续等待
编码器前向工作区编码器 attention tokenencoder_max_num_tokens(被最大原子项抬升)不选这一项
常驻输出字节字节output_budget_bytes = 最大 embedding 数 × 每行字节准入阶段直接拒绝请求
复用缓存字节字节encoder_cache_max_bytes触发 LRU 淘汰
编码器 AttentionMetadata 容量模型内部的 attention 序列数get_mm_encoder_attention_metadata_capacity 或保守兜底由处理器几何换算决定,不动态变

前两行是调度期的约束(选不选这一项),第三行是准入期的约束(收不收这个请求),第四行是运行期的约束(淘汰旧的),第五行是启动期就固定下来的静态容量。四道闸门分布在四个不同的阶段,这也是为什么多模态的预算解析函数要集中在 create() 里一次算清:分散计算必然导致某个阶段用了过时的数值。

6. Embedding 回填:fuse_input_embeds 的三条路径

6.1 问题定义:两种 token 混在一条序列里

到了 LLM 前向这一步,输入是一个扁平的 input_ids,长度是「本批所有请求的 token 数之和」。这里面既有普通文本 token,也有 placeholder token。目标是把 [text_total_length + mm_total_length] 的 ID 序列,变成同长度的 embedding 序列,其中 placeholder 位置放视觉编码器的输出。

fuse_input_embeds 的 docstring 把这个过程说得很直接:

Fuse text and multimodal embeddings. input_ids is [text_total_length + mm_total_length] and mm_embed is [mm_total_length, hidden_dim]. We just need to fuse them into [text_total_length + mm_total_length, hidden_dim] by slice-and-assign to the corresponding entries.

关键的一句话是「By the slice condition < vocab_size, we can easily separate text / multimodal tokens and naturally batched the LLM embedding lookup」——多模态 token 的判别条件就是「ID 是否越界」,所以文本侧的 embedding lookup 天然是一批做的,不需要逐请求拆分。

6.2 三条路径

源码里 fuse_input_embeds 有三个分支,选择依据是「调用方提供了什么」:

- Precedence: If kwargs provide indices (text_token_indices and mm_token_indices),
those are used. If any one of them is not provided, fallback to filtering method.
Sentinel-/OOV-based filtering (e.g., tokens >= vocab_size) is used only when
neither index tensor and mm_token_ids is provided.

路径一:调用方给了索引(最快)。 直接拿 text_token_indices / mm_token_indices 用,不做任何判别。这条路是引擎里的标准路径,原因见 6.3。

路径二:调用方给了 mm_token_ids,但没给索引。 走「词表内快路径」:

if mm_token_ids is not None:
# In-vocab fast path: caller declared mm tokens are real vocabulary
# IDs, so a single full embedding lookup is safe. Saves the
# ``index_select`` over text positions, the ``torch.empty`` alloc, and
# the text scatter (~2 GPU kernels per VLM forward) at the cost of
# ~mm-fraction extra embedding lookups whose result is overwritten.
input_embeds = embedding_layer(input_ids)
else:
# OOV path: input_ids holds placeholder IDs >= num_embeddings at mm
# positions (e.g. ``vocab_size + 1``), so embedding the full sequence
# would be out-of-bounds. Gather only the in-vocab text positions and
# scatter into a fresh buffer.
text_embed = embedding_layer(input_ids[text_token_indices])
input_embeds = torch.empty(input_ids.shape[0], mm_embed.shape[-1],
device=text_embed.device, dtype=text_embed.dtype)
input_embeds.index_copy_(0, _as_copy_index(text_token_indices, input_embeds), text_embed)

这里省下的是三个操作:index_select(挑文本位置)、torch.empty(分配新缓冲)、text scatter(写回)。源码注释说这大约是「每 VLM 前向约 2 个 GPU kernel」。付出的代价是「~mm-fraction extra embedding lookups」——placeholder 那部分位置的 lookup 结果会被覆盖,白算。当 mm token 占比不高时(图像 token 通常只占总序列的百分之几到十几),这笔交易是赚的。

路径三:什么都没给,只有 OOV 哨兵。 走 input_ids >= vocab_size 的比较,然后 index_select + torch.empty + index_copy_ 三件套。这条路只在 Hyperclovax 这样的存量模型上使用。

6.3 最重要的一处优化:把 torch.where 从 GPU 挪到 CPU

filter_mm_token_from_input_ids 里有一行醒目的注释:

# NOTE: torch.where() enforces a host sync
text_token_indices = torch.where(~mm_token_mask)[0]
mm_token_indices = torch.where(mm_token_mask)[0]

torch.where 要返回索引张量,而索引张量的形状(也就是有多少个文本 token、多少个多模态 token)在 device 上算出来之前是未知的。PyTorch 必须把结果同步回主机才能分配输出张量。这是一次 D2H 同步,会把之前流水线上积累的所有异步工作全部阻塞。

Triton-LLM 的解法不是在 GPU 上做更快的无同步实现,而是换一个地方算:在引擎准备输入的时候,在 CPU 的 input_ids 副本上算好索引,然后作为 pinned 内存异步 H2D 拷过去。这段契约写在 docstring 里:

- Sync-free contract: passing both ``text_token_indices`` and
``mm_token_indices`` skips the GPU ``torch.where`` host sync. The
executor (``DecoderInputPreparer.prepare_inputs`` / ``NoKVCacheRunner.prepare_inputs``)
precomputes them on a CPU ``input_ids`` copy via ``runners.prepare_multimodal_indices``
(which uses ``filter_mm_token_from_input_ids`` against ``self.model.mm_token_ids``
when present, else the OOV fallback ``>= vocab_size``) and ships them as
pinned async H2D tensors in the inputs dict. VLM forwards are expected to
forward these explicitly rather than relying on a ``**kwargs`` splat — if
they don't, this function falls back to the host-syncing
``filter_mm_token_from_input_ids`` on GPU ``input_ids``.

prepare_multimodal_indices 的实现很朴素——就是把同一段逻辑搬到了 CPU:

def prepare_multimodal_indices(input_ids: list[int], *, model: Any) -> tuple[torch.Tensor, torch.Tensor]:
input_ids = torch.tensor(input_ids, dtype=torch.int, device="cpu")
vocab_size = model.config.vocab_size
mm_token_ids = getattr(model, "multimodal_token_ids", None)
if mm_token_ids is None:
mm_token_ids = getattr(model, "mm_token_ids", None)
text_token_indices, mm_token_indices = filter_mm_token_from_input_ids(
input_ids, vocab_size=vocab_size, mm_token_ids=mm_token_ids)
return text_token_indices, mm_token_indices

同一份逻辑、同样的 torch.where,在 CPU 上跑就不产生 D2H 同步——因为数据本来就在主机侧。算完之后再 pin 内存异步拷上 GPU:

def ship_multimodal_indices(inputs, *, mm_token_indices_cpu, text_token_indices_cpu,
num_ctx_tokens, total_num_tokens) -> None:
mm_token_indices_cpu = maybe_pin_memory(mm_token_indices_cpu)
inputs["mm_token_indices"] = mm_token_indices_cpu.to("cuda", non_blocking=True)
if total_num_tokens > num_ctx_tokens:
extra_text = torch.arange(num_ctx_tokens, total_num_tokens, dtype=text_token_indices_cpu.dtype)
text_token_indices_cpu = torch.cat([text_token_indices_cpu, extra_text])
text_token_indices_cpu = maybe_pin_memory(text_token_indices_cpu)
...

注意那个 total_num_tokens > num_ctx_tokens 分支:当本次前向在上下文 token 之后还追加了 token(KV 复用路径的 extend token、或者投机解码的草稿 token),那些追加位置一定是文本,所以要补一段 arange 进去。docstring 也交代了当前的边界:

Current speculative decode paths do not append multimodal placeholders after the context tokens.

「索引在主机侧算完再异步送上设备」这个模式在多模态链路里出现了不止一次(第 2.1 节的 CPU-only 清单、第 5.3 节的 target_keywords、这里),它们要解决的其实是同一个问题:任何需要在 Python 里按下标读标量的数据,都不能落在 GPU 上。

6.4 散列写入:index_copy_

三条路径最终都汇到同一个收尾操作:

input_embeds.index_copy_(
0,
_as_copy_index(mm_token_indices, input_embeds),
mm_embed.to(dtype=input_embeds.dtype, device=input_embeds.device),
)

index_copy_(0, idx, src) 的语义是「把 src 的第 jj 行写到 input_embeds 的第 idx[j] 行」。它是原地写,不分配新张量。这里有一个 _as_copy_index 的小工具:

def _as_copy_index(index: torch.Tensor, destination: torch.Tensor) -> torch.Tensor:
"""Return ``index`` as the int64 index ``index_copy_`` needs on ``destination``.
Explicit indices can come from callers with another dtype or device; for the
executor's int64 device indices this is a no-op.
"""
return index.to(device=destination.device, dtype=torch.long, non_blocking=True)

为什么需要它?因为引擎预计算的索引是 int32(prepare_multimodal_indices 里 torch.tensor(input_ids, dtype=torch.int, device="cpu")),而 index_copy_ 要求 int64。转换是必需的,但代价很小,而且是 non_blocking=True。

还有一个容易漏掉的约束:

if mm_token_indices.shape[0] != mm_embed.shape[0]:
raise ValueError(
f"Multimodal token count mismatch: found {len(mm_token_indices)} image tokens in input_ids "
f"but received {mm_embed.shape[0]} image embeddings.")

这就是第 3.3 节那条不变量在融合阶段的第二道防线。第一道在 _find_mm_embedding_lengths_from_masks 的断言里,第二道在这里。两道都过不去的请求会被明确报错,而不是写出一个长度错位的 embedding 序列——后者会导致模型静默地产生错误输出,比崩溃难查得多。

6.5 deepstack:第二条 embedding 通路

有一段额外逻辑值得单独说:

if extra_embeds is not None and len(extra_embeds) > 0:
# only support single modality for deepstack features for now
for i, extra_feature in enumerate(extra_embeds):
extra_embed = torch.zeros(input_ids.shape[0], mm_embed.shape[-1],
device=extra_feature.device, dtype=extra_feature.dtype)
extra_embed.index_copy_(0, _as_copy_index(mm_token_indices, extra_embed), extra_feature)
extra_embeds[i] = extra_embed

extra_embeds 是给 Qwen3-VL / Qwen3-MoE-VL 这类带 deepstack 的模型用的。deepstack 的思路是:不只把视觉编码器最后一层的输出喂给 LLM,而是把多个中间层的特征也各接一路,分别加到 LLM 若干层的输入上。于是每个模态项产出的不是一个 embedding 张量而是一组,每一路都要按同样的 mm_token_indices 散列写入。注意文本位置的填充值是 torch.zeros 而不是文本 embedding——因为这一路是「额外叠加」的信号,文本位置应该是零增量。这与主 embedding 路径(文本位置放真 embedding)的处理方式截然不同。

6.6 模型侧如何接进前向:prepare_multimodal_inputs

上面这些工具函数被 MultimodalModelMixin.prepare_multimodal_inputs 串成一条固定的模板方法:

context_params = list(self.select_multimodal_params(multimodal_params or [], num_context_requests))
if not context_params:
return PreparedLlmInputs(input_ids=input_ids, inputs_embeds=None)
embeddings = self._get_or_encode_multimodal_embeddings(context_params)
input_ids, embeddings = self.after_full_multimodal_embeddings(...)
active_embeddings = find_input_mm_embeds([embeddings], list(context_params))
active_embeddings, extra_embeds = self.after_active_multimodal_embeddings(...)
fused_input_ids, inputs_embeds, fused_extra_embeds = self._fuse_multimodal_embeddings(...)
return PreparedLlmInputs(input_ids=fused_input_ids, inputs_embeds=inputs_embeds,
extra_embeds=fused_extra_embeds)

六个步骤的分工是:

  1. select_multimodal_params:从所有请求里挑出「要做多模态工作的那些」。它同时做了两层过滤:只取上下文请求(num_context_requests 之前的),并且只取 has_content() 或「本 chunk 确实有多模态 token」的。后一个条件很关键——一个请求的多模态 embedding 如果全在之前的 chunk 里处理过了(num_mm_tokens_in_chunk == 0),这一轮就不该再参与。
  2. _get_or_encode_multimodal_embeddings:取或算 embedding。如果走了第 5 节的 item scheduling,这一步只是从缓存里取;如果是 inline 模式,这里会真的调编码器。
  3. after_full_multimodal_embeddings:模型特有的钩子,拿到全量 embedding 时做点什么(例如 DeepSeek-V4.1 在这里处理 image_mask 相关的准备)。
  4. find_input_mm_embeds:把全量 embedding 切成「本次前向该用的那一段」,见第 7 节。
  5. after_active_multimodal_embeddings:模型特有的钩子,针对当前活跃段(例如产出 extra_embeds)。
  6. _fuse_multimodal_embeddings:调 fuse_input_embeds 完成散列写入。

这个「通用模板 + 两个模型钩子」的结构是有意设计的。 通用的六步骨架(选、取、切、融)对每个 VLM 都一样,但 deepstack、mrope、image_mask 这些东西是模型特有的,所以骨架在「全量」和「活跃段」两个位置各留了一个钩子。这样新增一个 VLM 时,只需要实现 encode_multimodal_inputs(编码器怎么跑)和可选的钩子,不用重写整条流水线。

6.7 清理时机:embedding 什么时候可以丢

最后看一个容易被忽略的生命周期细节。请求完成 prefill 之后,multimodal_data 里的东西要分两类处理:

def strip_mm_data_for_generation(mm_data: Dict[str, Any]) -> None:
"""Clear `mm_data` in place, retaining only `mrope_config.mrope_position_deltas`.
...
"""
mrope_config = mm_data.get('mrope_config')
mrope_deltas = None
if isinstance(mrope_config, dict):
mrope_deltas = mrope_config.get('mrope_position_deltas')
mm_data.clear()
if mrope_deltas is not None:
mm_data['mrope_config'] = {'mrope_position_deltas': mrope_deltas}

embedding 可以全丢,但 mrope 的 position delta 必须留。 为什么?因为 mrope(multimodal RoPE,Qwen2-VL 引入的位置编码方案)在解码阶段每一轮都要用位置偏移:图像的存在使得文本 token 的位置不再等于它在序列里的下标,这个差值是常量,解码的每一步都要加上它。所以它必须活到请求结束。

mrope 这套位置编码本身值得看一眼。它的做法是把位置拆成 (h,w,t)(h, w, t) 三个分量,文本 token 三个分量取同一个值,图像 token 分别取行、列、以及一个共享的时间分量:

Qwen2-VL 的 M-RoPE 位置编码:文本 token 三段位置相同,图像 token 分别取高度、宽度与时间分量
Qwen2-VL 的 M-RoPE 位置编码:文本 token 三段位置相同,图像 token 分别取高度、宽度与时间分量

图:M-RoPE 把一维位置拆成时间、高度、宽度三个分量,让图像在二维空间中有正确的位置关系。来源:Qwen2-VL 论文(arXiv:2409.12191)

相应地,图像在序列里占多少位置也不是固定的——Qwen2-VL 提出的 native dynamic resolution 让图片先按「最小像素数」与「最大像素数」之间的范围自适应缩放,再按 2828 的倍数对齐,从而让一张图产生的 token 数在 4 到 16384 之间连续变化:

Qwen2-VL 的原生动态分辨率:图像先被缩放到最小与最大像素边界之间并对齐到 patch 的整数倍,再由 MLP 把相邻 2×2 的 patch 合并为一个视觉 token
Qwen2-VL 的原生动态分辨率:图像先被缩放到最小与最大像素边界之间并对齐到 patch 的整数倍,再由 MLP 把相邻 2×2 的 patch 合并为一个视觉 token

图:Qwen2-VL 的原生动态分辨率流程——动态调整分辨率、对齐到 patch 大小的倍数,再经 MLP 压缩相邻 patch。来源:Qwen2-VL 论文(arXiv:2409.12191)

这两张图合起来解释了为什么引擎必须给编码器单独算一套预算:一张图的 token 数不是常数,它可以是 4 也可以是 16384,跨度三个数量级。get_mm_max_tokens_per_item() 报出的「最大原子项」正是为这个上限兜底。

7. 分块预填充与 KV 复用:前缀和如何回答「取哪几行」

7.1 问题:一个请求的 embedding 被切到多次前向里

Chunked prefill(分块预填充)与 KV 前缀复用这两个机制,在第三篇拆 CapacityScheduler 时已经讲过。它们对多模态的影响是特殊的:一个请求的 embedding 行会被切成好几段,分散在不同的前向前里。

举个具体例子。一个请求的 prompt 结构是:

[文本 120 token] [图像 576 行占位] [文本 80 token]

假设调度器决定分两块处理:第一块算 0..600,第二块算 600..776。那么第一块需要的是图像的前 600−120=480600 - 120 = 480 行 embedding,第二块需要的是剩下的 96 行。引擎必须知道这次前向要取第 480 到 576 行,不能每次都把 576 行全取出来再裁。

7.2 前缀和:用 O(1) 查询替代扫描

MultimodalRuntimeData 就是为这个问题设计的。它的核心是一个 CPU 上的 int64 前缀和:

@dataclass
class MultimodalRuntimeData:
"""Runtime data for tracking multimodal embedding caching and reuse per request sequence.
Constructed from `py_multimodal_data["multimodal_embed_mask_cumsum"]`
(int64 CPU cumsum populated by the producer); counts are derived via
three O(1) cumsum lookups. Handles non-contiguous embedding positions
(inline specials, interleaved text) natively.
"""
past_seen_token_num: int
chunk_end_pos: int
embed_mask_cumsum: torch.Tensor
num_cached_mm_tokens: Optional[int] = None
num_mm_tokens_in_chunk: Optional[int] = None
total_embeds_in_request: Optional[int] = None

embed_mask_cumsum 是 embed_mask(第 3.2 节那个「哪些位置是 embedding 槽位」的布尔张量)的前缀和。有了它,三个计数全部是一次下标查询:

self.num_cached_mm_tokens = (int(cs[self.past_seen_token_num - 1])
if self.past_seen_token_num > 0 else 0)
self.num_mm_tokens_in_chunk = (int(cs[self.chunk_end_pos - 1]) - self.num_cached_mm_tokens
if self.chunk_end_pos > 0 else 0)
self.total_embeds_in_request = int(cs[-1])

翻译一下这三个公式:

  • num_cached_mm_tokens:序列位置 past_seen_token_num - 1 之前一共有多少行 embedding = 已经缓存了多少行。前缀和在 past_seen_token_num - 1 处的值就是答案。
  • num_mm_tokens_in_chunk:本块末尾(chunk_end_pos - 1)的累计行数减去已缓存行数 = 本次前向要取多少行。
  • total_embeds_in_request:整个请求一共多少行 = 前缀和的最后一个元素。

为什么前缀和是必要的? 因为 embed_mask 可能不是「开头一段全是 True」的形式。特殊 token(image_break、image_newline)混在中间,文本也可能在图像之间(多图交错),所以 embedding 位置不连续。要数「前 kk 个位置里有多少个 True」,朴素做法是切片再求和 O(k)O(k);前缀和把它变成 O(1)O(1)——而且这个查询在每次前向、每个请求都要做。

7.3 那条必须被满足的前置条件

前缀和不是免费得来的,它在输入处理器里构建。所以引擎必须在两件事发生时检查它存在:

def check_mm_embed_cumsum_if_needed(py_multimodal_data, *, begin_compute, end_compute, prompt_len) -> None:
"""Raise iff chunked prefill or KV-cache reuse is in effect AND MM data is present without embed cumsum.
Triggers on the two cases where the scheduler advances less than the
whole prompt in one step:
* `begin_compute > 0` — KV-cache reuse: a prefix was served from cache, OR
* `end_compute < prompt_len` — chunked prefill: the scheduler split the
remaining tokens across iterations.
"""
if not _has_mm_payload_keys(py_multimodal_data):
return
if py_multimodal_data.get("multimodal_embed_mask_cumsum") is not None:
return
is_chunked_or_reused = (begin_compute > 0) or (end_compute < prompt_len)
if is_chunked_or_reused:
raise ValueError(
f"Request requires multimodal_embed_mask_cumsum for chunked prefill "
f"or KV-cache reuse (begin_compute={begin_compute}, "
f"end_compute={end_compute}, prompt_len={prompt_len}) but "
f"py_multimodal_data has keys {mm_keys} with no cumsum. The input "
f"processor may be missing a discriminator (override "
f"get_mm_token_ids or ensure get_vocab_size resolves).")

这段代码精确地刻画了「什么时候需要记账」:只要调度器没有一次性把整个 prompt 算完,就需要。两种情况:

  • begin_compute > 0:KV 缓存命中了一段前缀,本次从中间开始算;
  • end_compute < prompt_len:分块预填充,本次只算了一部分。

反过来,如果是一次性全量预填充(begin_compute == 0 且 end_compute == prompt_len),那么 MultimodalRuntimeData 保持 None,find_input_mm_embeds 会走「全量返回」的快速路径:

Full-prefill, no-reuse iterations don't: `MultimodalRuntimeData` stays `None`
and `find_input_mm_embeds` handles the full payload.

而如果 cumsum 缺失但又不属于这两种情况,引擎只警告一次然后继续——因为这种情况下不需要它,硬报错反而会误伤。

错误信息里那句 「The input processor may be missing a discriminator (override get_mm_token_ids or ensure get_vocab_size resolves)」 指向了一个很实际的坑:cumsum 是靠 _compute_mm_masks 算出来的,而 _compute_mm_masks 需要能判别哪些 token 是多模态 token。它有两个判别来源:

if mm_token_ids is not None and vocab_size is not None:
logger.debug("Both mm_token_ids and vocab_size are provided, using mm_token_ids and ignoring vocab_size")
if mm_token_ids is not None:
embed_mask = torch.isin(input_ids, mm_token_ids)
else:
embed_mask = input_ids >= vocab_size

新模型如果忘了实现 get_mm_token_ids,而它的 placeholder 又是真正的词表内 token(不是 OOV 哨兵),那么 input_ids >= vocab_size 就永远为 False —— embed_mask 全 False —— cumsum 变成一个恒为零的数组。 这时不会报错,但每块的 embedding 行数都是 0,最终表现是图像完全没进模型。这是一个静默失败模式,也因此引擎把「有没有 cumsum」当作一条显式的健康检查。

7.4 三个 key 在字节层的记账

_find_mm_embedding_lengths_from_masks 产出的逐项行数,最终会变成 MultimodalEncoderItemMetadata 的一个字段:

@dataclass / NamedTuple
class MultimodalEncoderItemMetadata(NamedTuple):
item_refs: List[Tuple[str, int]]
"""``(modality, local index)`` per atomic item, in prompt order."""
encoder_token_lengths: List[int]
"""Physical encoder attention-token cost of each item.
This is what one encoder forward actually attends over (for example,
pre-merger patch tokens for Qwen ViTs) and is the unit the scheduler
charges against ``encoder_max_num_tokens``."""
output_embedding_lengths: List[int]
"""Embedding rows each item contributes to the LLM prompt.
Must equal the item's placeholder span in the prompt (the
``multimodal_embedding_lengths`` contract); used to size and split the
encoder output back into per-item tensors."""

这三个字段的语义差别是理解整套记账的关键:

字段单位谁用它
item_refs模态 + 索引对缓存键构造(区分「这批里的第几项」)
encoder_token_lengths编码器 attention token(合并前的 patch 数)编码器调度(对着 encoder_max_num_tokens 收费)
output_embedding_lengthsLLM prompt 里的 embedding 行数(合并后)输出校验与切片(对着 output_budget_bytes 收费)

encoder_token_lengths 和 output_embedding_lengths 的比值就是视觉编码器的 merge 比例。Qwen 系 ViT 把 2×22 \times 2 个相邻 patch 合并成一个输出 token,所以比值是 4;DeepSeek-V4.1 的 aligner 有它自己的比例。这个比例是模型几何,不能跨模型复用,也解释了为什么这些长度必须由输入处理器逐模型实现而不是在引擎里统一算。

字段还有一条自我校验:

if not (len(self.item_refs) == len(self.encoder_token_lengths) == len(self.output_embedding_lengths)):
raise ValueError("Multimodal encoder item references and lengths must align")
if any(length <= 0 for length in self.encoder_token_lengths):
raise ValueError("Multimodal encoder token lengths must be positive")
if any(length <= 0 for length in self.output_embedding_lengths):
raise ValueError("Multimodal encoder output embedding lengths must be positive")

三个列表必须等长且全为正——一个产出 0 行 embedding 的模态项是不合法的。这条约束把 7.3 节那个静默失败模式在另一个位置挡住了:如果 get_mm_token_ids 忘了实现导致 output_embedding_lengths 全零,validate() 会直接报错,而不是让请求带着空 embedding 继续跑。

7.5 切片与拼接:find_input_mm_embeds 的三种批形态

到了要切片的时候,find_input_mm_embeds 面对的局面比想象中复杂——它要同时处理两种批形态和两种切法:

- Supports both individual batching (len(mm_embeds) == len(multimodal_params))
and pre-concatenated batching (len(mm_embeds) == 1)
- Handles chunked prefill by considering chunk boundaries and current chunk tokens
- Example: if a request has 8 MM embed rows, 2 cached rows, and 3 rows
in the current chunk, this keeps rows [2:5].

那个例子把语义说透了:8 行里已缓存 2 行、本块要处理 3 行 → 取第 2 到第 5 行(Python 切片的 [2:5] 是左闭右开,正好 3 行)。

切完后要拼成一个张量:

if len(mm_embeds) == 1: # pre-concatenated; advance global cursor
current_pos += runtime.total_embeds_in_request
if len(mm_embeds) == 1:
sliced = [mm_embeds[0][start:end] for start, end in slices]
return [_join_embeddings(sliced)]
return [mm_embeds[i][start:end] for i, (start, end) in enumerate(slices)]

预拼接形态下,所有请求的 embedding 已经在一个大张量里,所以 start/end 要加上全局游标 current_pos;逐请求形态下每个张量独立,切片直接用局部坐标。

这里有个容易踩的坑,源码专门用一段注释挡住:

if total_mm_tokens == sum(mm_embed.shape[0] for mm_embed in mm_embeds):
# Item-scheduled batches were already sliced by the producer/gather
# path. Do not apply the cached-prefix offset a second time.
return mm_embeds

item scheduling 路径下,embedding 在 build_multimodal_data_for_llm 里已经被切过了(第 5 节那个 start/end)。如果这里再按 num_cached_mm_tokens 切一次,就会双重偏移——错得还很隐蔽,因为行数刚好够。判据是一个数量等式:如果拿到的总行数恰好等于「本块应有的多模态 token 数」,说明上游已经切好了。

那个切分本身在 build_multimodal_data_for_llm 里:

start = runtime.num_cached_mm_tokens if runtime is not None else 0
end = start + runtime.num_mm_tokens_in_chunk if runtime is not None else None
# Slice before joining: a long request must not copy all of its
# cached output for every small LLM chunk. One segment stays a view.
multimodal_data["multimodal_embedding"] = _join_embeddings(segments, start=start, end=end)
multimodal_data["multimodal_embedding_is_chunk"] = True

注释里的「Slice before joining」是一条明确的性能纪律:一个长请求可能有几千行缓存 embedding,如果每个小块都先 torch.cat 全部再切片,就等于每次前向都复制一遍全部 embedding。先切后拼,单段还保持视图(view)语义,不产生拷贝。

而它设置的 multimodal_embedding_is_chunk = True 标记,是为了让批内混排时保持一致:

current_chunk_only = any(
param.multimodal_data.get("multimodal_embedding_is_chunk", False)
for param in multimodal_params)
...
if (current_chunk_only and runtime is not None
and not param.multimodal_data.get("multimodal_embedding_is_chunk", False)):
# Keep the full attached payload intact for later chunks/cache
# writes; only this forward's gathered tensor is chunk-local.
start = runtime.num_cached_mm_tokens
end = start + runtime.num_mm_tokens_in_chunk
if isinstance(embeds, list):
embeds = _join_embeddings(embeds, start=start, end=end)
else:
embeds = embeds[start:end]

问题场景是:同一批里,请求 A 走 item scheduling(embedding 已是块局部),请求 B 走 inline 路径(embedding 还是整请求)。如果直接拼接,A 是 480 行、B 是 576 行,行布局就乱了。解法是:只要批里有任何一个请求是块局部的,其他请求也要切成块局部,这样整批所有请求都只贡献「本块的行」,布局一致。同时注释强调不要修改 B 的原载荷(Keep the full attached payload intact)——因为 B 后面的块还要用、缓存写回也要用,只让本次前向的聚集结果变成块局部即可。

7.6 一次分块前向的完整行账

把第 7 节串起来,一个带图的多模态请求在分块预填充下,每次前向的行账是这样的:

输入处理器:为 prompt 算出 embed_mask(576 个 True,混着若干 special 的 False)
→ 前缀和 cumsum(长度 = prompt 长度)
→ MultimodalEncoderItemMetadata(逐项 encoder_token_lengths / output_embedding_lengths)
↓
调度器:分块,确定本块的 [begin_compute, end_compute)
构造 MultimodalRuntimeData(三个 O(1) 查询得到三个计数)
↓
编码阶段:只编码本块涉及的项(未命中缓存的),写进 LRU 缓存
↓
切片:segments[start:end],start = num_cached_mm_tokens,
end = start + num_mm_tokens_in_chunk
↓
融合:fuse_input_embeds 按 mm_token_indices 散列写入
(索引由 executor 在 CPU 侧预计算、pinned 异步 H2D)
↓
校验:mm_token_indices.shape[0] == mm_embed.shape[0]

每一层的数字最终必须一致:输入处理器数出的行数、编码器产出的行数、调度器切片用的行数、融合时散列写入的行数。任何一个环节错位,要么被断言挡住,要么产生一个静默的错误输出。这也解释了为什么整套设计里到处是「显式记账 + 显式校验」,而不是靠约定俗成的隐式假设。

8. 视觉编码器自己的 CUDA Graph

8.1 为什么编码器也需要图

第十二篇拆 piecewise compilation 与 CUDA Graph 时讲过:LLM 侧用 CUDA Graph 消灭 kernel 调度开销,做法是把「形状固定的一段计算」整段捕获成一张图,之后用不同的输入指针重放。视觉编码器面临同一类问题——一个 ViT block 栈里有几十层,每层好几个 kernel(LayerNorm、QKV 投影、注意力、MLP),解码步只有一两个 token 时 GPU 主要在等 kernel 发射而不是在算。

但编码器的图捕获和 LLM 侧有一个本质区别:LLM 侧的批形状变化由调度器产生(多少个 decode token、多少个 prefill chunk),而编码器的形状变化由用户上传的图片决定。这意味着编码器的形状空间更「脏」——2828 的倍数、任意长宽比、任意项数组合。

MultimodalEncoderGraphRunner 的 docstring 把它的边界划得很清楚:

The runner is intentionally narrow: it does not know how to build attention metadata, what
model it is wrapping, or how to assemble the eager prelude (patch embedding, positional
embedding, etc.). It only owns the captured block loop, the static buffers that loop reads from,
and the mapping from real-input shapes to captured graph keys.
The runner's existence is itself the enable signal; callers should only construct one when CUDA
graph capture is wanted. Captures are taken at startup for every configured bucket, and the
captured set is fixed for the runner's lifetime.

三个要点:只管 block 循环(patch embedding、位置编码这些「前奏」仍然是 eager 的)、启动期一次性捕获全部 bucket、捕获集合在运行期不变。

8.2 分桶键:EncoderGraphKey

class EncoderGraphKey(NamedTuple):
"""Internal key identifying an encoder graph workload size.
* As a configured bucket, `total_tokens` is the maximum number of real (post-encode) tokens
the bucket is meant to cover, and `num_contexts` is the number of real contexts.
Neither includes any padding reservation; the runner adds that internally.
* As a captured graph key, both fields reflect the padded state seen by the captured kernels.
"""
total_tokens: int
num_contexts: int

键是二维的:总 token 数和上下文条数。为什么需要第二个维度?因为编码器的 AttentionMetadata 要按「条数」准备(每条一个段),而计算量按「token 数」变化。两个维度独立变化,只按其中一个分桶会导致另一个维度上出现不必要的大幅 padding。

选择策略是最小适配:

# Field order is (total_tokens, num_contexts), matching the desired bucket ordering for
# smallest-fit selection in `maybe_run()`.
self._buckets: List[EncoderGraphKey] = sorted(
EncoderGraphKey(total_tokens=total_tokens, num_contexts=num_contexts)
for total_tokens, num_contexts in config.buckets
)

这里用了一个 .claude/skills 级别的实用技巧:NamedTuple 的字段顺序就是排序的优先级顺序,所以 (total_tokens, num_contexts) 的声明顺序让 sorted() 直接给出「先按 token 数、再按条数」的升序排列,正好是「最小适配」遍历需要的顺序。

8.3 padding:为什么必须留一个 dummy 上下文

# Single token kept aside for the dummy padding context when `enable_padding=True`.
# The attention backend rejects zero-length contexts, so the dummy must contain at least one token
# even when the workload exactly fills the bucket's real-token budget. This constant participates
# in both buffer sizing (`_padded_key_for_bucket`) and per-replay length assignment
# (`_padded_seq_lengths`, `_dummy_padded_seq_lengths`).
_PADDING_TOKEN_RESERVE: int = 1

这个 1 解释了一个很容易被忽略的约束:注意力后端不接受零长度的上下文。当实际负载刚好填满某个 bucket 的真实 token 预算时,padding 部分就完全没有内容了——这时候必须有一个 dummy 上下文带一个 dummy token 顶上,否则捕获出来的图在重放时会在某处遇到长度为 0 的段而报错。

所以 bucket 与 captured key 之间有一层转换:

* As a captured graph key, both fields reflect the padded state seen by the captured kernels.
When `enable_padding=True` the key's `total_tokens` is `bucket.total_tokens + 1` and
`num_contexts` is `bucket.num_contexts + 1`.

配置的 bucket 加一,才是实际捕获的图的键。

8.4 元数据指针稳定性:捕获图的隐藏杀手

图捕获最经典的坑在这里被写成了一条显式的运行期断言:

class _CapturedGraph(NamedTuple):
...
# `data_ptr()` of each provider-declared graph-critical tensor on `metadata` after capture. The
# runner asserts these stay stable across replays; the captured kernels bake these addresses in,
# so a reallocation would silently read stale memory.
# See `EncoderMetadataProvider.graph_critical_attrs` for more detail.
metadata_tensor_ptrs: Mapping[str, int]

为什么这是个「隐藏杀手」? CUDA Graph 捕获时,kernel 参数里的张量地址被固化成常量。如果重放前某个被固化的张量被重新分配(换了地址),重放读到的就是旧地址上的内存——可能已经被别的数据覆盖,也可能已被释放。结果不是崩溃,而是静默地用了错误的元数据(比如错误的序列长度),输出看起来”像那么回事但不对”。

解决办法是在每次重放前对比 data_ptr():

def _snapshot_metadata_tensor_ptrs(self) -> ...: ...
def _assert_metadata_buffers_stable(self) -> ...: ...

这两个私有方法的名字直接说明了契约:快照一次、每次重放前断言。而 EncoderMetadataProvider 协议里那个 graph_critical_attrs 就是「哪些张量的地址被固化进图了」的声明清单——由模型侧提供,因为只有模型自己知道自己的 kernel 读了哪些元数据。

8.5 输出的弱引用与切片

最后一处设计:

class _CapturedGraph(NamedTuple):
...
# Each entry is a tensor view aliased to the captured output's memory via `make_weak_ref`; it
# stays valid as long as the captured graph holds the underlying allocation alive.
outputs: Dict[str, torch.Tensor]

输出张量不重新分配,而是用弱引用别名到捕获输出所在的内存。理由和 7.5 节「Slice before joining」同源:编码器输出可能有几百 MB,为每次重放 copy 一份是不可接受的。别名 + 在 token 轴上切片(_slice_token_axis)就能得到「本次真实 token 数」的视图:

`output_specs`: Token axis (`token_dim`) for each graph output, keyed by output name. Outputs
are not materialized by the runner - they are aliased from `encoder_fn`'s return - so
only the token axis is needed, to slice each output back to the real token extent.

EncoderGraphTensorSpec 的说明也体现了「token 轴特殊」这个约定:

`shape` describes the non-token dimensions only. The token-axis size is filled in from the
bucket and inserted at position `token_dim`. So for a ragged `(total_tokens, hidden_size)`
input, the spec is `EncoderGraphTensorSpec(shape=(hidden_size,), token_dim=0)`.

只描述非 token 维度,token 维度由 bucket 决定、由 token_dim 指出它的位置。这样同一个 spec 可以在所有 bucket 之间复用,而分桶只需要改一个数字。

8.6 与 LLM 侧 CUDA Graph 的对照

维度LLM 侧(第十二篇)视觉编码器侧(本节)
形状来源调度器(decode token 数、prefill chunk 数)用户上传的图片几何
分桶键一维(batch size × token 数)二维(total_tokens × num_contexts)
分段策略piecewise(把不支持的算子切出去单独跑 eager)整段 block 栈捕获,前奏保持 eager
元数据由 AttentionMetadata.prepare 每步重填需要显式 data_ptr 稳定性断言
输出logits / hidden statesembedding 行,按 token 轴切片
图数量多种 batch 配置配置化的 bucket 列表,启动期一次性捕获

两条线的共同点是核心:图捕获的价值在于把「几十个 kernel 的发射开销」摊成一次重放;而它的代价在于形状必须固定、地址必须稳定。LLM 侧靠调度器主动制造固定形状来满足前半个条件,编码器侧靠分桶 + padding 来满足;后半条两边都得靠「不要在图外面的地方重新分配被固化的张量」这条纪律,编码器侧选择把它变成运行期断言。

9. 多模态特有的 P/D 分离形态:EPD

9.1 从两段到三段

第十六篇拆过 TensorRT-LLM 的 P/D 分离:prefill 和 decode 拆到两组机器上,中间用 KV 传输通道把算好的 KV 从 P 实例搬给 D 实例。多模态把这条线拉成了三段,多了最前面的 E(Encoder):

E(视觉编码) → P(预填充) → D(解码)

这个拆分在多模态场景下比在纯文本场景下更自然,因为三段各自的算力特征完全不同:

阶段主导硬件特性并行度批的基
E卷积/窗口注意力的高 FLOPs,激活显存大图片数(项数)模态项
P因果注意力的 O(L2)O(L^2),KV 写密集token 数序列
D访存密集(KV 读取),低算术强度序列数序列

E 和 P 的最优硬件配置往往不一致。 一张大图的 ViT 前向是计算密集的,适合高算力 GPU;而 prefill 的注意力是二次复杂度但更受 KV 带宽约束。分离开之后,E 实例可以用便宜的算力卡堆数量,P/D 实例用大显存卡——这在纯文本 P/D 分离里是没有的维度。

9.2 EPD 论文的实测结论

TensorRT-LLM 的 EPD 支持是跟随 Efficiently Serving Large Multimodal Models Using EPD Disaggregation(arXiv:2501.05460)这项工作推进的。论文给出的整体架构把三个阶段放在三组独立资源上,中间传的不是只有 KV:

EPD 分离的整体架构:编码器、预填充与解码分别部署在独立资源上,encoder 输出与 KV 缓存分两条通路传递
EPD 分离的整体架构:编码器、预填充与解码分别部署在独立资源上,encoder 输出与 KV 缓存分两条通路传递

图:EPD 三段分离的整体架构,E/P/D 各占一组资源,中间需要传递编码器输出与 KV 缓存两类数据。来源:Efficiently Serving Large Multimodal Models Using EPD Disaggregation(arXiv:2501.05460)

论文的核心论点是:多模态负载下的 TTFT 瓶颈主要在编码阶段,而不是预填充阶段。它给出的工作流把请求拆成「图像编码」与「文本预填充」两条可并行的路径:

EPD 的请求工作流:图像编码与文本预填充可以并行推进,在解码开始前完成编码器输出的合并
EPD 的请求工作流:图像编码与文本预填充可以并行推进,在解码开始前完成编码器输出的合并

图:EPD 的请求工作流,编码与预填充可以并行推进。来源:Efficiently Serving Large Multimodal Models Using EPD Disaggregation

论文报告的关键数据是:在严格 SLO 约束下(TTFT 与端到端延迟都有上限)EPD 能把 GPU 需求降低、把 SLO 达成率提升;在离线吞吐场景下,收益主要来自「编码资源可以独立扩缩」这一点。这与第 5 节讲的编码缓存是互相补充的两件事:缓存解决「同一张图被反复编码」,EPD 解决「不同图之间的编码资源不够弹性」。

9.3 源码里的 EPD 分支

回到 TensorRT-LLM 的代码。前面几节的代码里已经反复出现 EPD 造成的分支,只是没有点名。把它们集中起来看:

第一个分支:P 侧没有编码器。 get_attached_multimodal_embeddings 的 docstring 直说了:

Use this on E/P prefill workers and cached-only paths. The encoder already ran somewhere else.

也就是说,在 P 实例上运行时,multimodal_embedding 字段里已经装好了别的实例算出来的 embedding,本机不能也不需要编码。这就是为什么这个函数的第一件事是「流等待」:

for param in multimodal_params:
if param.encoder_event is not None:
consumer_stream = torch.cuda.current_stream()
consumer_stream.wait_event(param.encoder_event)
embeds = param.multimodal_data.get("multimodal_embedding")
...
for embed in embeds:
if isinstance(embed, torch.Tensor) and embed.is_cuda:
embed.record_stream(consumer_stream)

wait_event 保证「另一条流上产生的 embedding 已经可用」。record_stream 则是更微妙的一步:它告诉 CUDA caching allocator「这张量还在被 consumer stream 使用,别过早回收它的显存」。为什么需要?因为注释里那句:

Register attached embeddings with the consumer stream as well: the request drops its Python
references after prefill, potentially before the asynchronous gather below has finished reading them.

请求在 prefill 结束后会丢掉它的 Python 引用,而下面那个 gather 是异步的、可能还没读完。如果 allocator 因为引用计数归零就回收了那块显存,异步 kernel 会读到已经被复用的内存。record_stream 是 PyTorch 里处理这类「跨流生命周期」问题的标准手段。

第二个分支:MultimodalEncoderRunner 是一个独立的 runner 家族。 源码第 8 类 runner 里有一个专门跑「只做编码」的引擎:

class MultimodalEncoderRunner(NoKVCacheRunner):
"""Run an engine whose only job is multimodal encoding.
This family is distinct from ``MultimodalItemScheduler``, which schedules vision items
inside a decoder engine. Its graph phase is a no-op because
``MultimodalEncoderGraphRunner`` belongs to, and is owned by, the vision model layer.
"""

注意它继承了 NoKVCacheRunner——这个 E 实例上根本没有 KV 池,因为它不做注意力解码。这是个很干净的设计:一个只做编码的引擎,不需要 KV 缓存管理器、不需要调度解码序列、不需要采样器。

它的 _forward_step 里有一段关于筛选的逻辑值得看:

mm_context_requests = [
(request_idx, request)
for request_idx, request in enumerate(scheduled_requests.context_requests)
if request.py_multimodal_data is not None
]
...
for (request_idx, request), multimodal_param in zip(mm_context_requests, multimodal_params):
if not _has_mm_payload_keys(request.py_multimodal_data):
# mrope-only warmup request (no actual vision content) -> skip.
continue
multimodal_embedding_lengths = get_multimodal_embedding_lengths(request)
if multimodal_embedding_lengths is None:
# Vision payload keys present but no pre-computed embedding
# lengths — skip to avoid a downstream sum(None) TypeError.
continue

两个 continue 分别挡两类「看起来像但实际不是」的请求:只有 mrope 元数据的 warmup 请求,以及有视觉键但没有 embedding 长度的请求。后者的注释很实在——如果不跳过,下游的 sum(None) 会抛 TypeError。这类「防御性跳过」在跨进程拆分之后变多,因为 E 侧的调度视图和 P 侧不再完全一致。

第三个分支:E→P 的传输内容。 第 2.1 节提过,multimodal_embedding 在 E/P 交接时可能临时存的是 SharedTensor 句柄字典:

"multimodal_embedding": torch.Tensor | List[SharedTensor handle dict],
# Pre-computed embeddings. In E/P handoff this may temporarily hold
# SharedTensorContainer dicts; BaseWorker restores them to tensors with
# to_tensor("multimodal_data") before PyTorch forward.

为什么用句柄而不是直接拷张量? 因为 E→P 传的是一块可能几百 MB 的 embedding。如果走「序列化 → 网络 → 反序列化」的路径,就要在主机侧过一次。SharedTensor 的语义是「共享显存映射」,让接收端直接拿到同一块设备内存的视图(或者在 RDMA 场景下直接映射)。to_tensor 的动作发生在 BaseWorker 里,也就是张量还没被任何人读过之前。

第四个分支:init_disagg_gen_requests。 第十八篇拆受约束解码时已经碰到过这个名字——decode 侧的请求没有 context 阶段,所以很多「上下文相关的初始化」对它们是空操作。多模态这里同理:一个从 P 接过 KV 的 D 实例上的请求,它的 multimodal_data 已经被 strip 过(第 6.7 节),只剩 mrope delta。

9.4 分离带来的新代价:三段之间的接缝

把三段拆开不是免费的。源码里能看到几处明显的接缝成本:

接缝一:编码预算与 KV 预算的解耦。 第 4 节的 output_budget_bytes 在 create() 里解析时,注释特别强调:

It is the minimum capacity of the unified encoder-output cache and is reserved during
KV-capacity estimation. Optional reuse may make the same cache larger. A request whose
total embedding exceeds this budget is rejected at admission.

「reserved during KV-capacity estimation」意味着编码器输出缓存和 KV 池抢同一份显存预算。在单实例部署里这可以直接扣减;在 EPD 部署里,E 实例的显存和 P 实例的显存是分开的,这个「扣减」变成了跨实例的容量规划问题——E 侧要留多少显存放输出缓存,取决于 P 侧一次能接纳多少请求,而这两边的最优值并不同步。

接缝二:strip_mm_encoder_inputs 的时机。 第 5.3 节看到一个请求所有项编码完成后会删掉原始载荷。在单实例里这个时机很清楚(本实例编码完就删)。在 EPD 里,删的动作应该发生在 E 侧(因为 P 侧本来就没有像素值),但「所有项都编码完成」这个判断在 P 侧的调度器上。这是 EPD 实现中容易出错的地方之一,也是源码里那些 _has_mm_payload_keys 防御性检查要处理的情况。

接缝三:编码缓存的作用域。 第 5.4 节的 TensorLRUCache 是引擎内的。在 EPD 部署下,E 实例的缓存对另一个 E 实例不可见(除非像 HiCache 那样额外做跨实例共享,那是 SGLang 的做法,见 SGLang 源码完全拆解(十六):HiCache)。所以 E 实例数量越多,同一个图片被跨实例重复编码的概率越高——这是 EPD 分离度与缓存命中率之间的直接权衡。

9.5 什么时候该上 EPD

把第 9 节收一下。EPD 的收益来源和第 5 节的编码缓存并不重叠,所以它们的适用条件也不同:

场景特征主要收益来自
大量用户上传相同的图(多轮对话、共享文档截图)item scheduling + 编码缓存(第 5 节)
图像占比高、分辨率大,编码时间主导 TTFTEPD(本节)
解码长、并发高,KV 带宽是瓶颈传统 P/D 分离(第十六篇)
前两者兼有EPD + 编码缓存

用一句话概括三者关系:编码缓存复用「一次计算的结果」,EPD 复用「一台机器的算力」,P/D 分离复用「一段时间里固定下来的 KV」。 三个优化的单位不同,所以能叠加。

10. 小结

这一篇把 TensorRT-LLM 的多模态输入链路从 API 消息追到了 embedding 散列写入,覆盖了整条第二调度面。按源码层次把结论收一遍:

数据形态(第 2 节)

  • 多模态数据分三层:协议层的 MultimodalInput(可跨进程)、Python 层的 MultimodalParams(multimodal_data 字典 + 运行时记账 + 编码事件)、模型层的几何信息。
  • multimodal_embedding 一个字段承担三种语义(空槽位 / 编码输出 / E-P 交接句柄),因为三者在生命周期上互斥。
  • _CPU_ONLY_MULTIMODAL_DATA_KEYS 白名单把三样记账数据钉在 CPU:multimodal_embed_mask_cumsum、multimodal_embedding_lengths、MULTIMODAL_ENCODER_ITEM_METADATA_KEY。理由是它们的读取方式是「Python 按下标取标量」,落 GPU 就会产生 D2H 同步。
  • 缓存键必须描述「模型能看到的内容」:自描述的 serialize_item() 用类型标签 + 形状 + 长度前缀防止转置图像与重塑数组之间的哈希碰撞;视频用「源字节摘要 + 采样元数据」代替逐帧像素哈希。

Placeholder(第 3 节)

  • placeholder 有三种身份:OOV 哨兵(>= vocab_size,要走三件套)、词表内真 token(走全量 lookup 快路径,省约 2 个 kernel)、特殊标记(有真 embedding 但不参与替换)。
  • 三张掩码分工:mm_mask(所有 MM 位置)、embed_mask(嵌入槽位)、special_mask(特殊标记)。占位跨度与 embed 行数可以不相等,差额就是特殊标记数。
  • DeepSeek-V4.1 的例子:占位跨度 L=rows⋅(columns+1)+2L = rows \cdot (columns + 1) + 2,embed 行数是 rows⋅columnsrows \cdot columns,差额 rows+2rows + 2 正是 image_newline 与 image_start/image_end;这三个是可学习的 Parameter,不占词表。

编码器预算(第 4 节)

  • 四类资源四种单位:批基数(项数)、前向工作区(编码器 attention token,合并前)、输出字节(embedding 行 × 每行字节)、复用缓存字节。后者只是让同一个 TensorLRUCache 变大,不新建池。
  • token 预算会被「最大原子项」抬升(max(base, max_atomic)),因为原子项不可拆——预算低于最大项会让该请求永久饥饿。
  • 每行字节走四级回退:embedding_dim/embedding_dtype → text_embedding_layer.weight → 预训练配置 hidden_size → 报错。

两条编码路线(第 5 节)

  • inline 编码的三个问题:显存峰值不可控、编码时间落关键路径、无复用。
  • item scheduling + LRU 编码缓存是解法,粒度是「项」而非请求,所以缓存键要逐项(内容哈希 + 项引用 + 输出行数 + 处理器参数哈希)。
  • 部分命中时按原始顺序交错拼回;默认实现只覆盖「dim 0 堆叠」与「grid_thw 打包」两种布局。
  • forward_items 的「先全校验、再统一提交」是原子性要求:否则一个坏项会让先前项被标 READY,引用它们的请求会带着错误行数进入 prefill。
  • 错误粒度不对称:编码阶段的批级错误只罚生产者(消费者换个 producer 重试),形状校验错误广播给所有引用该缓存键的请求。

嵌入回填(第 6 节)

  • fuse_input_embeds 三条路径,优先用调用方给的索引;mm_token_ids 存在时走词表内快路径(embedding_layer(input_ids) 一把查完再覆盖 mm 行)。
  • 把 torch.where 从 GPU 挪到 CPU 是这条链路上最关键的一次去同步:prepare_multimodal_indices 在 CPU 副本上算索引,ship_multimodal_indices 用 pinned 内存异步 H2D 送上去。
  • 散列写入用原地 index_copy_,索引需要 int64(引擎预计算的是 int32,靠 _as_copy_index 转换)。
  • deepstack 的 extra_embeds 走第二条通路,文本位置填 torch.zeros——它是叠加信号,不是替换。
  • 模型侧通过 prepare_multimodal_inputs 的六步模板接入,中间留两个模型钩子(全量 / 活跃段)。
  • 请求预填充结束后 strip_mm_data_for_generation 清空一切,只留 mrope_position_deltas——解码每一步都要它。

切块记账(第 7 节)

  • MultimodalRuntimeData 靠 CPU 上的 int64 前缀和把三个计数变成 O(1)O(1) 查询;前缀和能处理不连续的 embedding 位置。
  • 前置条件是 check_mm_embed_cumsum_if_needed 里那条精确的触发条件:begin_compute > 0(KV 复用)或 end_compute < prompt_len(分块)。全量预填充不需要记账。
  • MultimodalEncoderItemMetadata 的三个列表把三种单位分开记账:encoder_token_lengths(合并前)与 output_embedding_lengths(合并后)的比值就是 merge 比例,逐模型不同。
  • find_input_mm_embeds 要同时处理两种批形态与两种切法,并且用「总行数恰好等于本块应有行数」这个等式来防止 item scheduling 路径下的双重偏移。
  • 「Slice before joining」是明确的性能纪律:长请求不能每小块都整份复制 embedding。

编码器图(第 8 节)

  • 二维分桶键 (total_tokens, num_contexts),NamedTuple 字段顺序即最小适配的排序优先级。
  • _PADDING_TOKEN_RESERVE = 1:注意力后端不接受零长度上下文,所以必须留一个 dummy 上下文。
  • metadata_tensor_ptrs 的运行期断言:捕获图把张量地址烤进 kernel 参数,重放前必须确认没被重新分配,否则是静默读旧内存而不是崩溃。
  • 输出用 make_weak_ref 别名而非拷贝,按 token 轴切片得到真实 extent。

EPD(第 9 节)

  • 三段分离(E/P/D)在多模态下比纯文本两段更自然,因为三段的算力特征不同(卷积/二次注意力/访存密集)。
  • E 实例继承 NoKVCacheRunner——没有 KV 池。E→P 传 SharedTensor 句柄,避免 embedding 过主机。
  • 跨流生命周期的处理:wait_event 保证可用性,record_stream 保证不被提前回收(请求 prefill 后会丢 Python 引用)。
  • 三个接缝成本:编码预算与 KV 预算跨实例解耦、strip 的时机判断在另一侧、编码缓存作用域限于单实例(分离度与命中率的权衡)。

一条贯穿全篇的主线:多模态给推理引擎带来的真正复杂度,不是「多调一个编码器」,而是同一份资源预算要同时用三种单位记账(项数、编码器 token、embedding 行/字节),以及任何需要按下标读标量的数据都不能落在 GPU 上。前者解释了为什么预算解析要集中在 create() 一次算清、为什么到处是断言与形状校验;后者解释了 CPU-only 键清单、target_keywords、CPU 侧索引预计算这一系列看起来分散、实则同源的设计。

到这里,TensorRT-LLM 系列的主要模块基本覆盖完了:调度、KV、执行、注意力后端、量化、并行、MoE、采样、受约束解码,加上这篇的多模态。同类主题在不同框架下的对照可以在站内继续看:vLLM 源码完全拆解(十三):多模态输入 讲的是 vLLM 的镜像预处理缓存与 PlaceholderRange,SGLang 源码完全拆解(十四):多模态输入 讲的是占位 token 与前缀缓存哈希的交互,三条路线对「占位符如何记账」给出了不同的答案,对比着看能更清楚地看出各自的设计取舍。

下一篇将进入 TensorRT-LLM 还没覆盖的部分,讨论 LoRA 服务——adapter 池如何组织、批内多适配器如何共存、以及它与 KV 缓存和采样路径的交互。

参考资料

  1. TensorRT-LLM:mm encoder item scheduling(tensorrt_llm/_torch/pyexecutor/engine/multimodal.py)
  2. TensorRT-LLM:MultimodalModelMixin 与 MultimodalEncoderMixin
  3. TensorRT-LLM:多模态数据与融合工具(tensorrt_llm/inputs/multimodal.py)
  4. TensorRT-LLM:内容哈希与模态数据(tensorrt_llm/inputs/multimodal_data.py)
  5. TensorRT-LLM:视觉编码器 CUDA Graph(modeling/multimodal_encoder_graph.py)
  6. TensorRT-LLM:多模态编码器专用 runner(runners/mm_encoder.py)
  7. TensorRT-LLM:DeepSeek-V4.1 多模态模型实现
  8. Efficiently Serving Large Multimodal Models Using EPD Disaggregation(arXiv:2501.05460)
  9. Qwen2-VL: Enhancing Vision-Language Model’s Perception of the World at Any Resolution(arXiv:2409.12191)
  10. Qwen2.5-VL Technical Report(arXiv:2502.13923)
  11. TensorRT-LLM 示例:quickstart_multimodal.py
  12. TensorRT-LLM 多模态特性支持矩阵文档

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

TensorRT-LLM 源码完全拆解(十九):多模态输入——视觉编码器、Placeholder Token 与嵌入回填
https://pinghaoyang.com.cn/articles/insights/posts/tensorrt-llm-multimodal-input/
作者
平昊阳
发布于
2026-10-21
许可协议
CC BY-NC-SA 4.0

评论区

访客信息
--°天气加载中

正在获取所在地区…

💨 风力--
💧 湿度--

正在读取天气

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
「社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
448
分类
29
标签
451
总字数
6,178,652
运行时长
0 天
最后活动
0 天前

文章目录