正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码完全拆解(九):视觉编码器 VE——图像预处理、ViT 编码与嵌入回填
背景:文本模型的地基已经讲完,这次从图像进来
前面八篇把 DeepInfer 的文本侧地基拆得差不多了:整体架构、异步调度与 ZOS、KV 内存与 SWA 有界重放、MoE 专家并行与 EPLB、投机解码、结构化输出与确定性、TileLang 特化算子。这条链路的共同前提是——喂给模型的是一个 input_ids 张量,模型把它查表变成 embedding,然后一路前向下去。
多模态把这个前提打破了。用户发过来的是一张 JPEG 的字节流,加上一段文本。推理引擎要做的事情变成:
- 把文本分词成
input_ids; - 把图像解码、缩放、切 patch,算成一组视觉特征;
- 把视觉特征塞回
input_ids对应的 embedding 位置; - 之后所有层(注意力、MoE、engram、mHC)都按纯文本的方式继续跑。
第 2 步和第 3 步合起来就是视觉编码器(Vision Encoder,下文简称 VE)链路。这是一段和文本路径完全不同的代码:它跑 CPU 上的 PIL 解码与缩放,它启动独立的子进程,它维护自己的一套显存/内存池,它还有一套”这张图在前缀缓存里已经被算过了,这次不用再算”的跳过逻辑。
DeepInfer 把这条链路放在两个地方:
deepinfer/ve/——预处理、几何规划、多进程 worker(约 1600 行)deepinfer/model_executor/models/vision_encoder/——ViT 本体、aligner、嵌入回填(约 500 行)
本文把这 2100 行按数据流顺序走一遍。走完你应该能回答:一张 4K 截图从字节流开始,经过多少次取整、多少次进程间拷贝、多少次 GPU kernel,最后变成 LLM 输入 embedding 里的哪一段。

上图是本系列第一篇拆过的 V4.1-Flash 前向结构图,可以把它当作本文的”下游地图”:VE 的所有工作,最终产物就是这张图最左侧那个 input_embeds。
VE 模块一览:三条边界与四个角色
先建立地形。DeepInfer 的 VE 代码按”谁在什么进程里、拿什么设备”分成四块:
| 角色 | 代码位置 | 进程 | 设备 | 产出 |
|---|---|---|---|---|
| 图像几何规划与 processor | ve/processor/image_processor.py、ve/processor/mm_processor.py | 任意 | CPU | 每图 token 数、网格尺寸、mm_token_type |
| 预处理 worker | ve/multimodal_processor.py 的 _processor() | fork 出的子进程 | CPU(绑 NUMA) | CPU-resize 后的裸 RGB 字节 + per-image 元数据 |
| ViT 与 aligner | model_executor/models/vision_encoder/ | engine 主进程 | GPU | 与 IMAGE token 一一对应的 embedding |
| 嵌入回填 | deepseek_vl.py 的 get_input_embeddings() | engine 主进程 | GPU | 融合后的 input_embeds |
三条边界值得先说清楚,因为后面每一节的”为什么”都落在这三条上:
边界一:进程边界。 图像解码、缩放是纯 CPU 工作,而且是对不可信输入做的解析(PIL 打开任意用户上传的图片)。DeepInfer 把它放进独立的 fork 子进程,理由有三:解码一张 4K 图要几百毫秒,放在 engine 主进程会直接卡住调度循环的关键路径(参考本系列第二篇讲的 ZOS 为什么要”延后一拍”);PIL 解码超大图可能触发内存爆炸或异常,子进程崩溃不影响引擎;以及最后一点——CPU 亲和性可以独立设置,让预处理线程和 engine 线程不抢核。
边界二:CPU/GPU 边界。 CPU 侧做的事被压缩到最小:只做 contain-resize,输出 uint8 的裸 HWC 像素。归一化((x/255 - mean) / std)和 pad 都不在 CPU 做,而是留到 GPU 上用 TileLang 写的 rgb_pad_to_patches 内核一次完成。这个分工的理由在第四节展开。
边界三:KV 缓存边界。 如果这张图的 token 已经被 KV 前缀缓存完全覆盖(同一张图在同一前缀下第二次出现),整段图的 token 不需要重新计算,ViT 也不该跑。但前缀缓存是字节级/长度级的匹配,它只告诉你”前 N 个 token 命中”,剩下”哪些图落在这 N 个 token 里”需要 VE 自己算。这就是第六节的内容。
第一段:图像预处理为什么在 worker 进程里做
从 MultimodalProcessor.__init__ 开始
deepinfer/ve/multimodal_processor.py 的构造函数做三件事,顺序不能换:
1def __init__(self, model_config, tokenizer, vocab_size, rank=-1, num_workers=1, image_token=None):2 self._resolve_processor(model_config, tokenizer, vocab_size, rank, image_token) # 先解析配置3 self.scheduler = None4 self.multi_modal_dict = {}5 self.to_abort_ids = []6 self._pending = set() # 挂起请求集合,用于 hang 检测7 self._last_output_ts = time.time()8 self._worker_timeout = envs.DEEPINFER_MULTI_MODAL_WORKER_TIMEOUT9 if rank <= 0 and envs.DEEPINFER_USE_MULTIMODAL_POOL:10 ... # 打印共享池配置11 self._start_workers(num_workers) # 最后再 fork注意 _start_workers 放在最后,注释里写得很直白:fork 捕获整个 self。Linux 的 fork() 复制的是调用瞬间的整个进程地址空间,子进程 _processor() 是 self 上的一个方法,它读到的 self.mm_processor、self.mm_image_token 等属性必须是已经初始化完的。如果先 fork 再赋值,子进程里看到的是 fork 那一刻的旧状态——这是 Python 多进程里非常经典的一类 bug,且症状是”偶尔算出来的 token 数不对”,极难调试。
_resolve_processor:两个独立来源的对齐断言
1model_class = _get_model_architecture(model_config)2candidates = [image_token] if image_token is not None else CANDIDATE_IMAGE_PLACEHOLDER_TOKENS3image_token_id = next((tid for token in candidates if (tid := tokenizer.vocab.get(token, -1)) != -1), -1)4assert image_token_id >= 0, f"no image token found in tokenizer, tried {candidates}"CANDIDATE_IMAGE_PLACEHOLDER_TOKENS 定义在 deepinfer/multimodal_tokens.py:
1CANDIDATE_IMAGE_PLACEHOLDER_TOKENS: tuple[str, ...] = ("<image>", "<|deepseek_image|>")两种写法都试,是因为 DeepSeek 系列不同代模型用了不同的图像占位符字符串。取到 id 之后,紧接着是一条交叉校验:
1assert self.mm_config.image_token_id == image_token_id, (2 f"config.json image_token_id={self.mm_config.image_token_id} 与 tokenizer 的 {image_token_id} 不一致"3)这条断言的注释说”config.json 与 tokenizer 是两个独立来源,错位会让图像 token 静默落在错误 id 上”。这是多模态推理里最危险的一类错误:如果 config.json 说图像占位符是 id 151665,而 tokenizer 实际把 <image> 映射到 151666,那么 overwrite_prompt 会去找 151665,一个都找不到,placeholders 变成空数组,图像被静默丢弃——模型收到一段没有任何视觉输入的文本,然后一本正经地回答。DeepInfer 选择在启动时直接 assert 死,宁可起不来也不要静默错。
两个哨兵 token:vocab_size + offset
1self.mm_image_token = vocab_size + ImageTokenOffset.IMAGE2self.mm_image_pad_token = vocab_size + ImageTokenOffset.IMAGE_PADImageTokenOffset 是一个 IntEnum:
1class ImageTokenOffset(IntEnum):2 IMAGE_START = 0 # <image_start>3 IMAGE_PAD = 1 # <image_pad>4 IMAGE = 2 # <image> 实际图像 token5 IMAGE_NEW_LINE = 3 # <image_new_line>6 IMAGE_END = 4 # <image_end>7 NUM_SPECIAL = 5为什么用 vocab_size + offset 而不是词表里真实存在的 id? 因为真实的 <image> token 只有一个,一张图要占据几百个 token 位置。LLM 的 embedding 表只有 vocab_size 行,这些”虚拟 token”并不查表;引擎侧的做法是:input_ids 里放一个超出词表范围的哨兵值,embedding 查表时越界的那部分被 gather kernel 置零,随后由 replace_input_embeds 内核在对应位置写入真正的视觉特征。
注释里写了这套 id 跨语言的同源关系:
1Python: deepinfer.multimodal_tokens.ImageTokenOffset (IntEnum)2C++: MMTokenOffset (enum class, sequence.hpp)这意味着 Python 侧和 csrc/ 的 C++ 序列化代码共享同一套偏移量。改动这个 enum 就是改 ABI,必须两边同时改。
mm_image_token 与 mm_image_pad_token 的区别在于首 token:注释说”首 token 必须是 IMAGE 才能支持 chunk prefill”。为什么?因为 chunk prefill 会把一段 prompt 按 token 区间切片,如果某张图的 token 段被切在某处,下游要靠 mm_token_type != UNDEFINED 判断”这段是图”。如果整段都是 pad 类型,切片时无法识别这段属于哪张图。保证首 token 是 IMAGE,就保证任何切片只要包含图像段的起点,就能被正确归类。这个设计在第五节 _chunk_mm_metadata 里会看到它的直接受益。
overwrite_prompt:把占位符展开成 token 段
这是 VE 链路里最关键的一个纯 CPU 函数,逐行看:
1def overwrite_prompt(self, token_ids: np.ndarray, multi_modal_data: dict) -> np.ndarray:2 image_bytes = multi_modal_data.get("image_bytes", [])3 image_sizes = multi_modal_data.get("image_sizes", [])4 num_images = len(image_sizes)5
6 token_ids = np.asarray(token_ids, dtype=np.int32)7 placeholders = np.flatnonzero(token_ids == self.mm_config.image_token_id)8 if len(placeholders) > num_images:9 logger.error(10 f"检测到出现了{len(placeholders)}次image_token_id ({self.mm_config.image_token_id})! "11 f"但只接收到了{num_images}张图片,多余的image_token_id作为纯token输入!"12 )13 placeholders = placeholders[:num_images]输入是一个 int32 的 token_ids 数组(文本已经分好词),输出是展开后的新数组。每个图像占位符位置被替换成 mm_image_token + N×mm_image_pad_token 的序列。
然后是按图循环:
1for image_index, position in enumerate(placeholders.tolist()):2 if position > cursor:3 text = token_ids[cursor:position]4 pieces.append(text)5 segments.append(TextLengthInput(length=len(text)))6 num_expanded_tokens += len(text)7 image_input = ImageInput(8 width=image_sizes[image_index][0],9 height=image_sizes[image_index][1],10 binary=image_bytes[image_index] if image_bytes else None,11 )12 num_image_tokens = self.mm_processor.image_processor.estimate_num_tokens(image_input)13 image_tokens = np.full(num_image_tokens, self.mm_image_pad_token, dtype=np.int32)14 image_tokens[:1] = self.mm_image_token15 image_spans.append((num_expanded_tokens, num_image_tokens))16 pieces.append(image_tokens)17 segments.append(image_input)18 num_expanded_tokens += num_image_tokens19 cursor = position + 1三处细节值得注意:
文本段只带长度,不带内容。 TextLengthInput(length=len(text)) 的注释解释得很清楚:
推理侧不消费文本 token 内容(引擎
input_ids已有):TextLengthInput只按长度产mm_token_type缺省值,免去 processor 对文本段input_ids/labels的组装。
这是个很务实的省内存设计。训练侧的 processor 需要完整 token 序列来算 label,推理侧不需要——input_ids 已经在引擎手里了,processor 只需要知道”这里有 137 个文本 token”,才能产出一个长度对得上的 mm_token_type 数组(文本位置全填 UNDEFINED)。
estimate_num_tokens 不用解码图像。 它内部调 compute_image_layout(...).num_tokens,只依赖 (width, height) 这两个整数,完全不碰 binary 字节。这一点至关重要:overwrite_prompt 在主进程里跑,而真正的像素解码在 worker 子进程里跑。主进程要立刻知道”这段要占多少 token”才能把数组拼对,但它不能为此等一次几百毫秒的解码。
image_spans 是后续一切的索引基础。
1multi_modal_data["segment_input"] = SegmentInput(meta=dict(image_spans=image_spans), segments=segments)2for key in RAW_IMAGE_KEYS:3 multi_modal_data.pop(key, None)image_spans[i] = (start, count)——第 i 张图在展开后序列里的起点与长度。把裸字节从 dict 里 pop 掉(RAW_IMAGE_KEYS = ("image_sizes", "image_bytes")),因为图像数据已经转进 SegmentInput.segments 了,留着会重复占内存。
最后:
1return np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.int32)注释特意说明”恒返回新数组(调用方可能持有 frombuffer 视图/共享缓冲区)“。这是 numpy 的经典陷阱:如果 token_ids 是从 mmap 或共享内存 frombuffer 出来的视图,就地改写会污染原缓冲区;而且改写后长度会变,视图根本无法容纳。返回新数组是最安全的做法。
fork 出去的 worker,一开始就绑 NUMA
_processor() 是子进程的入口。它做的第一件事不是干活,而是绑核:
1from deep_cluster.topology import bind_numa, set_cpu_affinity, which_numa2assert os.environ.get("CUDA_VISIBLE_DEVICES"), "no visible device for multimodal processor"3visible_index = 04bind_numa(which_numa(visible_index))5num_cpus_per_gpu = phy_cpus // get_actual_num_gpus()6smt_ratio = cpus // phy_cpus7assert smt_ratio in (1, 2), f"expect SMT off or 2-way SMT: {cpus=} {phy_cpus=}"8if smt_ratio == 2:9 # bind to sibling hyperthreads to stay off the main process's cores10 bind_cpus = list(range(visible_index * num_cpus_per_gpu + phy_cpus,11 (visible_index + 1) * num_cpus_per_gpu + phy_cpus))12else:13 bind_cpus = list(range(visible_index * num_cpus_per_gpu, (visible_index + 1) * num_cpus_per_gpu))14set_cpu_affinity(bind_cpus)15torch.set_num_threads(2)这里有一处很讲究的细节:SMT 开启时绑到”兄弟超线程”上。服务器上每进程单卡,物理核被切成 num_gpus 等份,进程号 visible_index 决定它属于哪一份。range(0, phy_cpus) 是全部物理核(对应 0 号超线程),range(phy_cpus, 2*phy_cpus) 是对应的兄弟超线程。把预处理 worker 绑到兄弟超线程,意味着它和 engine 主进程共享同一个物理核的执行单元,但不抢主进程的调度队列。注释写得很直白:stay off the main process's cores。
另外注意 assert smt_ratio in (1, 2)——DeepInfer 显式假设机器要么关 SMT,要么 2-way SMT,不做更复杂的拓扑推断。这是生产系统的常见取舍:把假设写成断言,环境不符就在启动时炸掉,而不是运行时表现诡异。
torch.set_num_threads(2) 也是同一个思路:worker 只做 PIL 缩放和 numpy 拷贝,不是计算密集,给 2 个线程足够,给多了反而制造线程争抢。
队列与背压
1self.input_queue = mp.Queue()2self.output_queue = mp.Queue(maxsize=envs.DEEPINFER_MULTI_MODAL_BUFFER_SIZE)output_queue 有界,input_queue 无界。注释解释了为什么这样也能形成背压:
output_queue满时下面的put会阻塞,等价于原先”积压到上限就先不取新输入”的背压。
worker 循环里 self.output_queue.put(...) 一旦阻塞,就再也不会执行下一轮的 self.input_queue.get()。于是 input_queue 虽然无界,但实际堆积量被 output_queue 的上限卡住了。这是用队列机制而不是信号量来做背压的常见写法——省一套状态,但依赖”worker 是单线程顺序循环”这个前提。
add / fetch / hang 检测
主进程侧接口很小:
1def add(self, id, multi_modal_data, num_cached_tokens=0):2 if multi_modal_data is None:3 return4 multi_modal_data["_num_cached_tokens"] = num_cached_tokens5 self._compute_and_strip_cached_images(multi_modal_data, num_cached_tokens)6 self.multi_modal_dict[id] = multi_modal_data7 if not self._pending:8 self._last_output_ts = time.time()9 self._pending.add(id)10 self.input_queue.put((id, multi_modal_data))注释点明”入 worker 队列异步处理,fetch() 收到结果才 set_schedulable”。也就是说:一个带图请求到达时不会被立刻调度,它先进 VE 队列,引擎在每一轮调度前调 fetch() 收割已完成的结果,只有收割到的请求才被标记为可调度。这是把”预处理延迟”和”GPU 调度”解耦的标准做法。
fetch() 里逐条结果:
1def fetch(self):2 while True:3 try:4 id, multi_modal_data = self.output_queue.get_nowait()5 except queue.Empty:6 break7 self._last_output_ts = time.time()8 self._pending.discard(id)9 if multi_modal_data is None:10 self.to_abort_ids.append(id)11 continue12 if id not in self.multi_modal_dict:13 free_multi_modal_pool_handles(multi_modal_data)14 continue15 if envs.DEEPINFER_USE_MULTIMODAL_POOL:16 for key, (handle, shape, dtype) in multi_modal_data["handle"].items():17 multi_modal_data[key] = get_multi_modal_pool().get_tensor(handle, shape, dtype)18 self.multi_modal_dict[id].clear()19 self.multi_modal_dict[id].update(multi_modal_data)20 self.scheduler.set_schedulable(id)21 self._check_worker_hang()三个分支分别对应:worker 报错(None → 记进 to_abort_ids)、请求已被 abort(结果直接归还池块)、正常结果(把共享池句柄换成 tensor 视图后交还调度器)。
hang 检测是一个很激进的策略:
1def _check_worker_hang(self):2 if self._worker_timeout <= 0 or not self._pending:3 return4 if time.time() - self._last_output_ts < self._worker_timeout:5 return6 logger.error(7 f"Multimodal worker made no progress for {self._worker_timeout:.0f}s; "8 f"{len(self._pending)} pending request(s): {list(self._pending)}. Aborting process."9 )10 os.abort()注意最后一行是 os.abort() 而不是重启 worker。整个引擎进程直接 abort。 这个选择需要解释:worker 卡死通常意味着它吃到了畸形输入(比如恶意构造的图片让 PIL 陷入病态解码),继续运行会持续占用一个 CPU 核并让请求队列永远不前进;重启 worker 又要重新 fork 并继承当前状态,复杂度高。生产系统的判断是——让进程管理器重启整个实例,比带着一个卡死的子进程继续服务更安全。日志里打出了挂起请求的 id 列表,便于事后定位是哪张图。
第二段:anyres 几何规划,每一处取整都是承重的
这一节讲 ve/processor/image_processor.py。文件开头的注释就定了调子:
每一处取整都承重:决定每图 token 数与 patch 布局。图像 token 只有一种排布:
[IMAGE_START] + ([IMAGE]*n_llm_w + [IMAGE_NEW_LINE])*n_llm_h + [IMAGE_END]
“每一处取整都承重”——因为 CPU 侧和 GPU 侧用的是两套代码去算同一个几何量。CPU 侧(estimate_num_tokens)算出的 token 数决定了 input_ids 的长度,GPU 侧的 rgb_pad_to_patches 决定了实际产出多少个 patch 特征。两边只要差 1,replace_input_embeds 就会错位,模型看到的图像就是乱序的——而且不会报错,只会答错。所以取整规则必须在两侧严格一致,且用 一致 的方式表达。
展开后的 token 数
1def expanded_image_token_len(n_llm_h: int, n_llm_w: int) -> int:2 """图像段展开后的 token 数:IMAGE_START + 每行(n_llm_w 个 IMAGE + 1 个 NEW_LINE)+ IMAGE_END。"""3 return n_llm_h * (n_llm_w + 1) + 2公式很简单:h 行,每行 w 个图像 token 加一个换行 token,首尾各一个 start/end。
为什么每行要加一个 IMAGE_NEW_LINE? 注释给了答案:
任意分辨率仍加 image_newline:尽管 vit 出来的特征有 2D 信息,但是 LLM 还是 1D RoPE
这句话是这整套设计的核心。ViT 内部我们知道每个 patch 的 (row, col),可以做二维 RoPE(第三节会看到)。但 patch 特征一旦被塞进 LLM 的 token 序列,它就变成了一维位置序列里的一段,LLM 的 RoPE 只知道”第 5837 个 token”,不知道”第 3 行第 12 列”。于是每一行末尾插入一个 IMAGE_NEW_LINE 的可学习 embedding,用来在序列里显式标出换行——这是让 LLM 恢复二维感知的最廉价方式:不加任何新机制,只加一种特殊 token。
代价是 token 数多了约 1/w。一张 14×14 网格的图,n_llm_w = 14,每行多一个 token,多出 14 个,占总数的 1/15 ≈ 6.7%。如果 n_llm_w 很小(极端窄图),这个开销比例会很大——但那种情况下本来 token 就少,绝对开销可以接受。
像素尺寸到完整几何量
1def _layout_from_pixels(best_width, best_height, patch_size, downsample_ratio) -> ImageLayout:2 n_vit_h, n_vit_w = best_height // patch_size, best_width // patch_size3 n_llm_h = math.ceil(n_vit_h / downsample_ratio)4 n_llm_w = math.ceil(n_vit_w / downsample_ratio)5 return ImageLayout(n_llm_h, n_llm_w, best_width, best_height, n_vit_w, n_vit_h,6 expanded_image_token_len(n_llm_h, n_llm_w))四层量级,从下到上:
best_width/best_height:像素数,已经对齐到patch_size(14)的整数倍。n_vit_w/n_vit_h:ViT 网格,等于像素数整除patch_size。这是 ViT 实际处理的 patch 数。n_llm_w/n_llm_h:LLM 侧的 token 网格,ceil(n_vit / downsample_ratio)。num_tokens:展开后的 token 总数。
注意第 3 步用的是 ceil 而不是 floor。downsample_ratio 在 V4.1 里是 3(由 HF config.json 的 vision_config.downsample_ratio 提供)。如果 n_vit_w = 14,ceil(14/3) = 5,意味着 5 个 LLM token 覆盖 14 个 patch 列——但 5×3 = 15 > 14,最后一块是不满的。为什么向上取整? 因为向下取整会丢掉靠右/靠下的补丁,图像边缘信息直接丢失;向上取整则是”最后一列多带一点 padding”,无损但多算一点。视觉任务里边缘细节往往正是 OCR/定位需要的,所以取整方向选向上。
downsampled_seqlens 是同一规则的批量化版本,供 prepare_input_tensor_vl 一次性算整批:
1def downsampled_seqlens(n_vit_w: torch.Tensor, n_vit_h: torch.Tensor, downsample_ratio: int) -> torch.Tensor:2 r = downsample_ratio3 return ((n_vit_w + r - 1) // r) * ((n_vit_h + r - 1) // r)(a + r - 1) // r 就是整数形式的 ceil(a / r)。注释强调”两个维度分别向上取整”——先各自 ceil 再相乘,而不是先乘再 ceil。这两种算法结果不同:ceil(14/3) * ceil(14/3) = 5*5 = 25,而 ceil(196/9) = 22。必须选前者,因为它和 _layout_from_pixels 一致。
max_n_token 约束下的长宽比保持求解
max_n_token 来自 HF config.json 的 vision_config.max_image_tokens。一张超高清图不能无限制地展开成 token——它既要占 KV 缓存,又要占 prefill 计算。所以要在”保持长宽比”和”总 token 数 ≤ max_n_token”之间求解。
1def solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token) -> ImageLayout:2 r = height / width # ·····(1)3 # h * (w + 1) = max_n_token - 2 # ·····(2)4 max_w_float = math.sqrt((max_n_token - 2) / r + 0.25) - 0.5 # 由(1)(2)可以解得5 max_h_float = max_w_float * r这两行是这个文件里唯一需要推导的地方,值得完整过一遍。
设 LLM token 网格是 h × w,由 expanded_image_token_len:
在最紧的情况下取等号:
h⋅(w+1)=max_n_token−2保持长宽比意味着 h / w = r,即 h = r \cdot w。代入:
解这个一元二次方程,取正根:
w=2−1+1+4⋅rmax_n_token−2=rmax_n_token−2+41−21这正是代码里的 math.sqrt((max_n_token - 2) / r + 0.25) - 0.5。注意 r = height / width,所以这里解出的 w 对应图像的 width 方向。 变量命名有点绕:max_w_float 是”宽度方向的 LLM token 数上限”,它会和图像宽度成正比。
注释里 由(1)(2)可以解得 指的就是这个推导。这是一个很标准但容易写错的步骤——直接 w = sqrt(max/r) 会漏掉 w+1 里那个 +1 带来的线性项(也就是 +0.25 那个修正),在小 token 预算下误差会很明显。
两个退化分支:
1if max_w_float < 1.0:2 # w 特别小的图,设置为最小的 1,直接根据(2)解出 h3 max_w = 14 max_h = (max_n_token - 2) // (max_w + 1)5 best_width = max_w * patch_size * downsample_ratio6 best_height = max_h * patch_size * downsample_ratio7elif max_h_float < 1.0:8 # h 特别小的图,设置为最小的 1,直接根据(2)解出 w9 max_h = 110 max_w = ((max_n_token - 2) // max_h) - 111 ...极宽或极高的图(比如一条 1×10000 的横幅)会让其中一个方向解出小于 1 的值。代码直接把该方向钉到 1,再从等式 (2) 反解另一个方向。这里的取整用 //(floor)而不是 ceil——因为这是约束方向,宁可比上限少一点也不能超。
非退化分支:
1else:2 max_w = math.floor(max_w_float)3 max_h = math.floor(max_h_float)4 beta = min(max_w * patch_size * downsample_ratio / width,5 max_h * patch_size * downsample_ratio / height)6 best_width = math.floor(width * beta / patch_size) * patch_size7 best_height = math.floor(height * beta / patch_size) * patch_sizebeta 是缩放系数,取两个方向约束的较小值——这样两个方向都满足上限。然后各自 floor(... / patch_size) * patch_size 对齐到 patch 网格。这里又用 floor:像素尺寸向下对齐到 patch 整数倍,宁可少一个 patch 也不超预算。
这套取整规则和 safe_resize 的组合是这套设计的防御层:
1def safe_resize(height, width, best_height, best_width, patch_size, downsample_ratio, max_n_token) -> ImageLayout:2 layout = _layout_from_pixels(best_width, best_height, patch_size, downsample_ratio)3 if layout.num_tokens > max_n_token:4 layout = solve_resize_ratio(height=height, width=width, patch_size=patch_size,5 downsample_ratio=downsample_ratio, max_n_token=max_n_token)6 assert layout.num_tokens <= max_n_token7 return layout先试简单的 patch 对齐(ceil(width / patch_size) * patch_size),如果算出来的 num_tokens 不超上限就采用;超了才走 solve_resize_ratio 那套求解。最后一道 assert 兜底。注释说”图像 token 能严格小于 max_n_token,任意长宽比均不会出现 h 或者 w=0 的情况”——这是对该函数契约的声明。
compute_image_layout:前置的两个夹逼
1def compute_image_layout(width, height, patch_size, downsample_ratio, max_n_token,2 min_pixels=0, max_wh_ratio=None) -> ImageLayout:3 # 超宽图先把宽度夹到 height*max_wh_ratio;该图的 resize 相应走 stretch 而非等比内接4 if max_wh_ratio is not None and width > height * max_wh_ratio:5 width = height * max_wh_ratio6
7 current_pixels = width * height8 if current_pixels < min_pixels and current_pixels > 0: # 计算缩放比例,保持长宽比9 ratio = (min_pixels / current_pixels) ** 0.510 width = int(width * ratio)11 height = int(height * ratio)12
13 best_width = math.ceil(width / patch_size) * patch_size14 best_height = math.ceil(height / patch_size) * patch_size15 return safe_resize(...)两个夹逼各有原因:
max_wh_ratio:超宽图(比如全景扫描、长条截图)如果按等比缩放,solve_resize_ratio 会把它压得很扁很扁,最终有效 patch 极少。代码主动把宽度砍到 height * max_wh_ratio,砍掉的部分靠 _is_stretched 判断后走拉伸而不是等比内接。这一点在 _prepare_image 里对应:
1def _is_stretched(self, width: int, height: int) -> bool:2 return self.max_wh_ratio is not None and width >= self.max_wh_ratio * height3
4def _prepare_image(self, image, target, *, stretch: bool = False) -> ResizedImage:5 if stretch:6 return ResizedImage(torch.from_numpy(np.asarray(image.resize(target)).copy()))7 try:8 image = ImageOps.contain(image, target)9 except ValueError:10 # 极端长宽比图片(如 1x100000): Pillow 等比例缩放会把短边舍入为 0,11 # 触发 "height and width must be > 0"。退化为直接拉伸到目标尺寸。12 image = image.resize(target)13 return ResizedImage(torch.from_numpy(np.asarray(image).copy()))那个 except ValueError 分支是一个非常”踩过坑”的写法。注释举了 1x100000 的例子:Pillow 的 contain 做等比缩放时,如果目标尺寸和源尺寸比极端,短边会被舍入为 0,然后 Pillow 抛 "height and width must be > 0"。代码不退让——直接 resize(target) 拉伸,反正这种图本来也没法保比例。
min_pixels:太小的图(比如 32×32 的图标)展开后只有寥寥几个 token,ViT 在这么小的网格上做 attention 几乎没有信息量。代码按面积比例放大到 min_pixels,等比放大 sqrt(min_pixels / current_pixels)。
load_image_anyres:几何量的缓存
1_load_image_anyres_cache = {}2
3def load_image_anyres(image_size, patch_size, downsample_ratio, max_n_token,4 min_pixels=0, max_wh_ratio=None) -> tuple:5 cache_key = (image_size, patch_size, downsample_ratio, max_n_token, min_pixels, max_wh_ratio)6 cached = _load_image_anyres_cache.get(cache_key)7 if cached is not None:8 return cached9 ...10 result = (num_tokens, best_width, best_height, images_spatial_crop,11 images_global_attn_pos_id, mm_token_type)12 if len(_load_image_anyres_cache) < 10000:13 _load_image_anyres_cache[cache_key] = result14 return result缓存键是完整的参数元组,注释说明”输出完全由输入参数决定(纯函数),下游只读这些张量”。这个缓存的实际命中场景是”同一尺寸的多张图”——比如一个 PDF 扫描页批量处理,每页都是 2480×3508,几何量完全一样,只有像素不同。缓存省下的是每次几百微秒的 compute_image_layout 调用加上张量构造。
上限 10000 条是一个朴素的防泄漏措施:超过就不再新增(而不是淘汰)。这个选择的理由是——几何量随图片尺寸分布,生产环境的尺寸种类通常远小于 10000;一旦超过,说明输入尺寸在恶意变化,此时”停止缓存”比”LRU 淘汰”更简单也不会引入淘汰开销。
三类元数据张量
load_image_anyres 产出的三个张量决定了 ViT 怎么跑:
1images_spatial_crop = torch.tensor([[n_vit_w, n_vit_h]], dtype=torch.int64)每图一行 (n_vit_w, n_vit_h),就是 ViT 网格尺寸。这个张量有两个用途:一是算出每图 patch 数 n_vit_w * n_vit_h(用于 cu_seqlens_vit),二是给 batch_downsample 做 reshape。
1# images_global_attn_pos_id 必须和 image 保持一致的 scanline 顺序2hpos_id = torch.arange(n_vit_h).unsqueeze(1).expand(-1, n_vit_w)3wpos_id = torch.arange(n_vit_w).unsqueeze(0).expand(n_vit_h, -1)4images_global_attn_pos_id = torch.stack([hpos_id, wpos_id], dim=-1).reshape(-1, 2)这是每个 patch 的 (行, 列) 坐标,scanline 顺序(行优先,从左到右从上到下)。注释里的”必须和 image 保持一致的 scanline 顺序”是硬约束:images_raw 里的 patch 是按这个顺序排列的,images_global_attn_pos_id[i] 必须是第 i 个 patch 的坐标,否则 ViT 的二维 RoPE 会把行坐标给列、把列坐标给行。这个 bug 的症状是”图像能识别但空间关系错乱”——很难通过 loss 发现。
1mm_token_type = torch.tensor(2 [TokenType.IMAGE_START] + ([TokenType.IMAGE] * n_llm_w + [TokenType.IMAGE_NEW_LINE]) * n_llm_h + [TokenType.IMAGE_END],3 dtype=torch.int32,4)5assert mm_token_type.shape[0] == num_tokens这个 assert 是整个几何规划的自检点:mm_token_type 的长度必须严格等于 expanded_image_token_len(h, w) 算出的 num_tokens。两条独立路径算出同一个数,相等才算过。
TokenType 的定义(multimodal_tokens.py):
1class TokenType(IntEnum):2 """multi-modal token 类型(mm_token_type 张量取值),与训练侧 processor 逐值一致。"""3 # wire format 不可改值:deepinfer/ve/processor 产出,MoE gate 按 >= IMAGE 判图像类,4 # replace_input_embeds kernel 按值替换 embeds5 UNDEFINED = 0 # 通常 text,以及非固定分辨率的 token6 TEXT = 17 IMAGE = 28 IMAGE_START = 39 IMAGE_END = 410 IMAGE_NEW_LINE = 511 IMAGE_PAD = 6注释里那行”wire format 不可改值”信息量很大,它列出了三个下游消费者:
- MoE gate 按
>= IMAGE判图像类——也就是说IMAGE(2)及其之后的取值(3/4/5/6)都算图像侧 token。MoE 路由可能对图像 token 有特殊处理(比如强制走某些专家)。 replace_input_embedskernel 按值替换 embeds——内核遍历mm_token_type,遇到IMAGE就写入下一个视觉特征,遇到IMAGE_NEW_LINE/IMAGE_START/IMAGE_END就写入对应的可学习参数。
值不能改,因为它是训练侧和推理侧共享的线上格式。改一个数字,训练出来的 checkpoint 就废了。
MMProcessor:段路由与合并
1class MMProcessor:2 def __init__(self, text_token_processor=None, image_processor=None):3 self.processors_map = {4 TextLengthInput: text_token_processor,5 ImageInput: image_processor,6 }7
8 def convert_segments_to_tensor_dict(self, seg: SegmentInput) -> dict:9 # 空段种子保证零段 / 全缓存 prompt 也有稳定的键集合(mm_token_type 恒产出)10 tensor_dicts: list[dict] = [{"mm_token_type": torch.zeros(0, dtype=torch.int32)}]11 for segment in seg.segments:12 processor = self.processors_map[type(segment)]13 tensor_dicts.append(processor.process(segment))14 return merge_tensor_dicts(tensor_dicts)用 类型 做路由键(type(segment)),而不是 if-else 判断——这样新增模态只要往 processors_map 里加一项。这是个小而干净的设计。
那个”空段种子”值得一提:如果一张图的 token 全部落在 KV 前缀缓存里(第六节会讲),segments 可能为空。此时必须有 mm_token_type 这个键存在(哪怕长度为 0),否则下游 batch_tensor_dict_list 会因为”同批键集合不一致”报错。用零长度张量做种子是最省事的做法。
merge_tensor_dicts:
1def merge_tensor_dicts(tensor_dicts):2 """将多个 tensor_dict 合并:images 列表按原顺序 chain,其余同名 tensor 按原顺序 concat(dim=0)。"""3 images = list(chain.from_iterable([tensor_dict.pop("images", []) for tensor_dict in tensor_dicts]))4 keys = set()5 for tensor_dict in tensor_dicts:6 keys.update(tensor_dict.keys())7 merged_tensor_dict = {}8 for key in keys:9 merged_tensor_dict[key] = torch.concat(10 [each_dict[key] for each_dict in tensor_dicts if key in each_dict])11 merged_tensor_dict["images"] = images12 return merged_tensor_dictimages 是惰性加载器列表(下一节讲),不能 concat,只能按序 chain;其余张量沿 dim=0 拼接。注意 if key in each_dict 这个过滤——文本段的 processor 只产 mm_token_type,不产 images_spatial_crop,直接对缺失键取值会 KeyError。
第三段:ViT——32 层、双路 RoPE 与 patch 级 attention
现在进 GPU 侧。model_executor/models/vision_encoder/vit.py 开头就是一组常量:
1# ViT 内全部 RMSNorm 的 eps2NORM_EPS = 1e-63
4# V41 唯一在用的 vision encoder(deepseek_vit_opt)结构,不随 cfg.vision 变化5PATCH_SIZE = 146EMBED_DIM = 10247DEPTH = 328NUM_HEADS = 169MLP_RATIO = 2.710MULTIPLE_OF = 12811# rope 表按最大网格边长预生成;一张图的 patch 网格宽高都不会超过它12MAX_GRID_SIZE = 4096注释”结构全由本文件常量固定,不随 cfg.vision 变化”很重要:ViT 的架构被硬编码在代码里,HF config.json 只提供几何参数(patch_size、downsample_ratio、max_image_tokens 等),不提供层数/维度。这是一种务实的做法——V4.1 只用一种视觉编码器(deepseek_vit_opt),把它写成常量比写成配置更不容易出错,也省掉了配置校验。
几个数字算一下:
- 每头维度
EMBED_DIM // NUM_HEADS = 1024 // 16 = 64 - 二维 RoPE 的维度是
EMBED_DIM // NUM_HEADS // 2 = 32(见下) - MLP 隐藏维度
int(1024 * 2.7) = 2764,再对齐到multiple_of=128:128 * ceil(2764/128) = 128 * 22 = 2816
位置编码:VisionRotaryEmbedding 与双路 RoPE
1class VisionRotaryEmbedding(nn.Module):2 def __init__(self, dim: int, theta: float = 10000.0) -> None:3 super().__init__()4 inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2, dtype=torch.float, device="cuda") / dim))5 self.register_buffer("inv_freq", inv_freq, persistent=False)6
7 def forward(self, seqlen: int) -> torch.Tensor:8 seq = torch.arange(seqlen, device=self.inv_freq.device, dtype=self.inv_freq.dtype)9 return torch.outer(seq, self.inv_freq)和标准 RoPE 一样,只不过这里的 dim 是 EMBED_DIM // NUM_HEADS // 2 = 32——只有一半。inv_freq 长度是 dim/2 = 16。原因在下一段。
1freqs = self.rotary_pos_emb(MAX_GRID_SIZE) # [4096, 16]2self.rope_table = torch.cat((freqs.cos(), freqs.sin()), dim=-1) # [4096, 32]3self.rope_half: int = freqs.shape[1] # 16表是按一维位置预生成的,最大 4096——因为位置是逐行/逐列的一维下标,最大不会超过网格边长。
1def _rope(self, pos_id: torch.Tensor) -> torch.Tensor:2 """gather 出 [n, 4*half] 的 cos_sin,列序为 (cos_row, cos_col, sin_row, sin_col)。"""3 n = pos_id.shape[0]4 return self.rope_table[pos_id].view(n, 2, 2, self.rope_half).transpose(1, 2).reshape(n, -1)这段是二维 RoPE 的核心。逐步拆:
pos_id 形状 [n, 2],每行是 (row, col)。self.rope_table[pos_id] 用高级索引 gather,得到 [n, 2, 32]——每个 patch 有两行 32 维的 (cos, sin) 表项,第一行对应行坐标,第二行对应列坐标,每行内部是 [cos(16), sin(16)]。
.view(n, 2, 2, 16) 把最后 32 维拆成 [cos/sin][16] 两组。.transpose(1, 2) 交换”行列”和”cos/sin”两个轴,得到 [n, 2, 2, 16],语义变成 [cos/sin][row/col][16]。最后 .reshape(n, -1) 展平成 [n, 64],列序为:
为什么是”行、列”两路? 因为视觉 patch 是二维的。一维 RoPE 给第 i 个 patch 一个位置 i,模型只能知道”顺序”;双路 RoPE 给第 i 个 patch 一对 (row, col),模型能知道”第 3 行第 12 列”。这直接决定了模型能否理解二维空间关系(上下左右、行列对应),对 OCR 的版面理解、grounding 的空间定位是必需的。
但这里有个实现细节:标准 RoPE 是把 head 维度两两配对做 2D 旋转,每对的旋转角由位置决定。这里的列序是 (cos_row × 16, cos_col × 16, sin_row × 16, sin_col × 16)——也就是前 32 维用行旋转、后 32 维用列旋转,而不是行/列交错。头维度 64 被均匀劈成两半,各 32 维(内部再分 cos/sin 各 16)。
这个布局的选择由 apply_rotary(q, rope, k, interleaved=False) 决定:
1from tile_kernels.transform import apply_rotary2...3apply_rotary(q, rope, k, interleaved=False)interleaved=False 对应的是”相邻两维为一组”的经典 RoPE 布局((x0,x1) 一起转、(x2,x3) 一起转……)。在这种布局下,把 head 维前半段用行角度、后半段用列角度,就等于前半段头维编码行位置、后半段编码列位置。这套写法在 Qwen2-VL 的 M-RoPE 里也能看到,是视觉 RoPE 的通用做法。
顺带说一下,apply_rotary 来自 DeepSeek 的 tile_kernels 库(和 swiglu_forward、dtype_to_str 同源),这是 V4.1 视觉塔复用 DeepSeek 自研 kernel 生态的地方。视觉和文本共用同一批底层算子——这也是为什么本系列第七篇拆 deepinfer/tilelang_ops 时把它们归成”特化算子家族”。
_linear:bias 走 cuBLAS,无 bias 走 DeepGEMM
1def _linear(x: torch.Tensor, w: torch.Tensor, bias: torch.Tensor | None) -> torch.Tensor:2 """bf16 GEMM:bias 走 addmm(cublas);无 bias 走 deep_gemm(输出行 stride 须 16B 对齐)。"""3 if bias is not None:4 return torch.addmm(bias, x, w.T)5 n = w.shape[0]6 if (is_sm_major_10() or accelerator_kind() == "ascend") and x.dtype == torch.bfloat16 and w.dtype == torch.bfloat16 and n > 1:7 alignment = 16 // x.dtype.itemsize8 physical_n = (n + alignment - 1) // alignment * alignment9 out = torch.empty((x.shape[0], physical_n), dtype=x.dtype, device=x.device)10 out.as_strided_((x.shape[0], n), out.stride())11 deep_gemm.bf16_gemm_nt(x, w, out, None)12 else:13 out = torch.empty((x.shape[0], n), dtype=x.dtype, device=x.device)14 deep_gemm.cublaslt_gemm_nt(x, w, out, None)15 return out三个分支的逻辑:
- 有 bias 直接用 cuBLAS 的
addmm。因为 DeepGEMM 不融合 bias,硬要融合就得额外一个加法 kernel。视觉塔里的qkv(nn.Linear(dim, dim*3))和proj(nn.Linear(dim, dim))都带 bias。 - 无 bias 且在 SM100(Blackwell)或 Ascend 上,走 DeepGEMM 的高性能
bf16_gemm_nt。但有个前缀条件:输出行 stride 必须 16 字节对齐。 - 否则回退到
cublaslt_gemm_nt。
第 2 分支里那三行是这个文件里最”工程”的代码:
1alignment = 16 // x.dtype.itemsize # bf16 → 16 // 2 = 82physical_n = (n + alignment - 1) // alignment * alignment # n 向上对齐到 83out = torch.empty((x.shape[0], physical_n), dtype=x.dtype, device=x.device)4out.as_strided_((x.shape[0], n), out.stride())out 实际分配 physical_n 列(向 8 对齐),然后用 as_strided_ 把逻辑形状改成 (rows, n),但保持物理 stride。于是这个张量对外看起来是 n 列,实际底层分配了更多列,行 stride 是对齐后的值。
为什么不让每行紧密排列? 因为如果 n 不是 8 的倍数(bf16 下 8 元素 = 16 字节),紧密排列的行 stride 就不是 16 字节倍数。DeepGEMM 的内核(尤其是 TMA 和 wgmma)要求每个输出行的起始地址 16 字节对齐,否则性能大幅下降或者直接报错。宁可多分配一点显存,也要保住对齐。上取整最多浪费 7 列,相对于 n 是 1024/2816 这种量级,浪费 < 0.7%。
顺带一个有意思的点:这段判断里有 accelerator_kind() == "ascend"。也就是说 V4.1 的视觉塔也在 Ascend NPU 上跑——说明 DeepInfer 的 VE 链路是双平台的。这在本系列第七篇(TileLang 特化算子与双平台)已经拆过,本文在 vit_attention 那里会再见到一次平台分支。
_norm:把残差加融进 norm kernel
1def _norm(x, weight, residual=None) -> tuple[torch.Tensor, torch.Tensor]:2 """RMSNorm + 融合残差加;residual=None 时透传 x 作为下一段的 residual。"""3 if residual is None:4 return norm(x, weight, eps=NORM_EPS), x5 return norm(x, weight, residual=residual, eps=NORM_EPS)norm 来自 deepinfer.model_executor.layers.tilelang_ops.norm,是一个 TileLang 写的 RMSNorm,额外支持”读入 residual 并在归一化前加上”。
这个融合省了什么? 朴素实现里,每个 Transformer block 是:
1y = x + attn(norm1(x))2z = y + mlp(norm2(y))每次加法都要读一遍 hidden 张量、写一遍,占两次全局内存往返。融合后变成:
1r = x # 不物化2y = attn(norm(x, w1, residual=r)) # norm 内部:x = x + r,然后归一化3z = mlp(norm(y, w2, residual=?) # norm 内部再加加法被吸收进 norm kernel 的读入阶段,少了一整轮 hidden 的读写。在 32 层 × 每层两次的规模下,这个节省是很可观的。视觉塔 EMBED_DIM=1024,hidden 不算大,但 patch 数量可能上万(一张图 sum_n_patch 是 n_vit_w * n_vit_h,网格 14×14 时 196,但多图批量后可能几千),此时带宽是实打实的瓶颈。
Block.forward 用了一个显式的开关来控制残差何时被物化:
1class Block(nn.Module):2 def __init__(self, dim, num_heads, mlp_ratio, multiple_of, return_added_residual: bool):3 ...4 # NOTE: True 时显式 y + residual(bf16 加法),False 时把 residual 交给下一块 norm kernel 融合相加5 self.return_added_residual = return_added_residual6
7 def forward(self, x, residual, rope, cu_seqlens, max_seqlen):8 y, residual = _norm(x, self.norm1.weight, residual)9 y = self.attn(y, rope, cu_seqlens, max_seqlen)10 y, residual = _norm(y, self.norm2.weight, residual)11 y = self.mlp(y)12 if self.return_added_residual:13 return y + residual, None14 return y, residual构造时:
1# residual 物化点(bf16 加法顺序影响逐 bit 结果):recompute 段边界与末层物化,2# 其余交给下一块的 norm kernel 融合相加3return_added_residual=(i + 1) % recomputing_every_layer == 0 or i == DEPTH - 1,注释里那句”bf16 加法顺序影响逐 bit 结果”是理解这个开关的钥匙。浮点加法不满足结合律:
(a+b)+c=a+(b+c)在 bf16 下(尾数只有 8 位),这个差异很容易达到 1 ULP。如果想让视觉塔的输出逐 bit 可复现(本系列第六篇讲的 batch-invariant 与确定性推理需要这个前提),那么加法必须按一个固定的、确定的顺序做。
return_added_residual=True 时是 y + residual 显式加法;False 时是 norm(y, w, residual=r) 内部先加再归一化。这两种的加法发生位置不同——一个是”先在 y 上加完再算下一个算子”,另一个是”在 norm kernel 内部作为第一步”。bf16 下结果可能差 1 位。
所以代码选择在固定位置物化:i == DEPTH - 1(最后一层必须物化,因为它要输出给 fc_norm)以及 (i+1) % recomputing_every_layer == 0(重计算段边界)。其余层走融合路径。这样整个 32 层的加法顺序是被代码结构确定下来的常量序列,不受调度或融合启发式影响。
对照一下 llama.cpp、vLLM 这类框架里的同类逻辑:它们通常也提供”是否融合残差”的开关,但往往是为了性能而不是为了确定性。DeepInfer 这里两种动机都有——而且注释把确定性放在了前面。
Attention:patch 级变长 attention
1class Attention(nn.Module):2 def __init__(self, dim, num_heads):3 self.num_heads = num_heads4 self.head_dim = dim // num_heads5 self.qkv_split = [dim, dim, dim]6 self.qkv = nn.Linear(dim, dim * 3)7 self.proj = nn.Linear(dim, dim)8
9 def forward(self, x, rope, cu_seqlens, max_seqlen):10 qkv = _linear(x, self.qkv.weight, self.qkv.bias)11 q, k, v = torch.split(qkv, self.qkv_split, dim=1)12 q = q.view(-1, self.num_heads, self.head_dim)13 k = k.view(-1, self.num_heads, self.head_dim)14 v = v.view(-1, self.num_heads, self.head_dim)15 apply_rotary(q, rope, k, interleaved=False)16 out, *_ = _flash_attn_fwd(17 q, k, v,18 cu_seqlens_q=cu_seqlens, cu_seqlens_k=cu_seqlens,19 max_seqlen_q=max_seqlen, max_seqlen_k=max_seqlen,20 softmax_scale=None, causal=False,21 window_size_left=-1, window_size_right=-1,22 num_splits=1, pack_gqa=None, return_lse=False,23 )24 return _linear(out.reshape(x.shape[0], -1), self.proj.weight, self.proj.bias)这是”变长序列打包”(variable-length packing)的标准写法,和本系列第二篇讲的 SGLang ForwardBatch 扁平化思路完全一致:batch 里所有序列首尾相接成一个扁平的 [total_tokens, dim] 张量,序列边界用 cu_seqlens(cumulative sequence lengths)表达。
逐行要点:
qkv = nn.Linear(dim, dim * 3):一个 GEMM 出 Q/K/V,比三个独立 GEMM 少读两次 x。这是 transformer 实现里的标准融合。
torch.split(qkv, [dim, dim, dim], dim=1) 后 reshape 到 [-1, 16, 64]:-1 就是 total_patches(注意不是 batch size,是全部图的全部 patch 数之和)。
causal=False:视觉塔的 attention 是双向的,不是因果的。一个 patch 可以看同图内任意位置的 patch。
window_size_left=-1, window_size_right=-1:全局注意力,不限制窗口。
num_splits=1:不做 split-K 并行。视觉塔每张图的 patch 数最多几千,单 split 足够。
pack_gqa=None:不打包分组注意力。ViT 的 Q/K/V 头数相同(16 头),不是 GQA——这一条对照本系列 GQA 那篇,是很容易混淆的点:视觉塔用的是标准 MHA,不是 GQA。原因是视觉塔的 K/V 不占 KV 缓存,不需要为推理期内存做 GQA 压缩。这也是个很典型的判断:多模态塔用 MHA,文本塔用 GQA/MLA。
cu_seqlens_q = cu_seqlens_k = cu_seqlens:同一个变量,因为 sum_n_patch 是全部 patch 扁平排列,Q 和 K 的序列边界一致。
那个 out, *_ = 的写法有个注释:
1# flash-attn 各版本/构建的返回元数不一致(2~3 个或 5 个),此处只取第一个输出这是和外部库版本漂移打交道的常见防御。flash_attn.cute.interface._flash_attn_fwd 是 cute 接口(Blackwell 上的新实现),不同构建返回 (out, lse)、(out, lse, rng_state) 或更多。用 *_ 吸收掉多余返回值,只关心 out。
VisionTransformer.forward
1@cpu_timer.record("vit_forward")2def forward(self, images, images_global_attn_pos_id, cu_seqlens_vit, max_seqlen_vit,3 vit_attn_cu_seqlens=None):4 assert images.shape[1:] == (3, self.patch_size, self.patch_size), \5 f"unexpected patch shape {tuple(images.shape)}"6 x = self.patch_embed(images)7
8 rope = self._rope(images_global_attn_pos_id)9 # only temporarily for npu vit attention10 cu_seqlens = cu_seqlens_vit if vit_attn_cu_seqlens is None else vit_attn_cu_seqlens11 residual: torch.Tensor | None = None12 for block in self.blocks:13 x, residual = block(x, residual, rope, cu_seqlens, max_seqlen_vit)14
15 return norm(x, self.fc_norm.weight, eps=NORM_EPS)输入 images 的形状是 [sum_n_patch, 3, 14, 14],不是 [n, 3, H, W]。 也就是说——切 patch 是在 GPU kernel 里做的,不是 CPU 侧的 unfold。这是第二节说的”边界二”的另一半:CPU 只把整图 resize 到 best_width × best_height 的 uint8 像素,GPU 侧的 rgb_pad_to_patches 负责 pad + normalize + 切 patch,一次产出 [sum_n_patch, 3, 14, 14] 的 bf16 张量。
这个分工的理由是:切 patch 的本质是”读连续内存里的非连续块”。CPU 侧做需要三步:先 numpy reshape 成网格、再 transpose、再 contiguous——三次内存拷贝,而且每次都是全量。GPU 侧做则是一次 kernel 读原始连续内存、写目标布局,而且可以和 pad/normalize 融合。在 patch 数上万时,这个差距是几百 MB 的内存流量。
cu_seqlens 的平台分支注释也很有意思:
vit_attn_cu_seqlens: only temporarily for npu vit attention —— NPU 上 attention 只吃 host list(torch_npu 的actual_seq_qlen),此时用cu_seqlens_vit[1:]的 list 形式;GPU 传None,attention 收张量cu_seqlens_vit。
又一次双平台差异:GPU 侧 FlashAttention 吃 device 上的 int32 张量(kernel 里直接索引),NPU 侧 npu_fusion_attention 吃 host 侧的 Python list(框架层参数)。调用方要用同一个接口同时喂两个后端,就得在 metadata 里同时准备两种形式。这个”张量版保持 [num_imgs + 1] 契约留给 kernel”的注释,是在说两边表达的是同一个东西的两种序列化。
assert images.shape[1:] == (3, 14, 14) 这个自检看起来朴素,但它把”CPU 侧算出的几何量”和”GPU 侧实际收到的 patch 形状”绑在一起。如果 CPU 侧用了不同的 patch_size(比如 HF config 里的值和 PATCH_SIZE 常量不一致),这条 assert 会在第一次前向就炸。注意第二节的 load_processor_config 从 HF config.json 读 patch_size,而这里的 PATCH_SIZE = 14 是硬编码常量——两者必须一致,assert 就是这条约束的执行者。

上图截自 DeepSeek-VL2 论文(arXiv:2412.10302)的 Figure 2,它给出了 V4.1-Flash 视觉链路的前身形态:左边是视觉编码器,中间是 VL Adaptor(对应本文第四节的 aligner),右边是 MoE 语言模型。可以对照看”Dynamic Tiling”那个模块——那是 VL2 时代的做法。
两代做法的对照:tiling 与 anyres
DeepSeek-VL2 论文(arXiv:2412.10302)第 2 节把它的做法讲得很清楚:
we define a set of candidate resolutions: CR={(m⋅384,n⋅384)∣m∈N,n∈N,1≤m,n,mn≤9}… We select the resolution (mi⋅384,ni⋅384) that minimizes the padding area. The resized image is then divided into mi×ni local tiles of 384×384 pixels, plus one global thumbnail tile. … For computational efficiency and context length management, we disable the dynamic tiling strategy when processing multiple (> 2) images.
这套 tiling 方案有三个特征值得和 V4.1 对比:
一是离散候选集。 VL2 只在 23 个候选分辨率里挑(processor_config.json 里的 candidate_resolutions 就是这个列表:384×384、384×768、768×384……最大 1152×1152)。V4.1 改成连续解空间——直接解出保持长宽比的最优网格。连续空间的好处是任意长宽比都能得到贴合的分辨率(不会像候选集那样在极端比例下选到严重不合的候选,留下大片 padding);代价是要处理各种边界情况(就是 solve_resize_ratio 里那几个 max_w_float < 1.0 的分支)。
二是 tiling 切分再编码。 VL2 把图切成 m×n 个 384×384 的 tile 加一个缩略图,每个 tile 独立过 SigLIP。这意味着 tile 边界处会出现”切断”——一个横跨两个 tile 的物体在两个 tile 里各被编码一次,而跨 tile 的空间关系丢失(除非在 LLM 侧靠位置编码补回来)。
V4.1 是 anyres:不切 tile,整图缩放到最优网格后整图一次过 ViT。这样 ViT 内部的 attention 覆盖全图,跨区域关系天然保留。代价是单次 attention 的序列长度变大(14×14 网格 = 196 patch,比 384×384 tile 的 27×27=729 patch 小,但如果网格更大,比如 28×28=784,就接近了)。这是一个”用更长的全图 attention 换更好的空间一致性”的取舍。
三是 tiling 的关闭条件。 VL2 在多图(>2)时关闭 tiling。V4.1 没有这个开关——anyres 对多图天然友好,每张图各算各的网格,batch 起来就是 cu_seqlens 的事。这也是 anyres 方案的一个实际优势。
processor_config.json(V4.1)里的几何参数是这样的:
1{2 "patch_size": 14,3 "downsample_ratio": 2,4 "image_mean": [0.5, 0.5, 0.5],5 "image_std": [0.5, 0.5, 0.5],6 "image_token": "<image>",7 "normalize": true,8 "processor_class": "DeepseekVLV2Processor"9}注意 patch_size: 14 和本文第三节的 PATCH_SIZE = 14 对上;downsample_ratio: 2 和第一节里提到的”V4.1 是 3”不一致——那是 V4.1-Flash 自己的 config.json 里 vision_config.downsample_ratio 的值,不同代模型这个数不同。这正好印证了”load_processor_config 从 HF config 读、不硬编码”这个设计的必要性。
PatchEmbedFlatten
1class PatchEmbedFlatten(nn.Module):2 def __init__(self, patch_size, in_chans, embed_dim):3 super().__init__()4 self.proj = nn.Linear(in_chans * patch_size * patch_size, embed_dim)5
6 def forward(self, images: torch.Tensor) -> torch.Tensor:7 """images: [n, 3, p, p] -> [n, embed_dim]。"""8 return F.linear(images.view(images.shape[0], 1, -1), self.proj.weight, self.proj.bias).squeeze(-2)patch embedding 被实现成”一个 3*14*14 = 588 → 1024 的大 Linear”。这不是巧合——patch embed 本质就是每个 patch 一个独立的线性投影,和把图 unfold 成 [n, 588] 再打一个 GEMM 完全等价。代码选择后者是因为 GEMM 比 conv 高效得多,而且不需要 cudnn。
images.view(n, 1, -1) 那个多出来的一维是为了把 F.linear 当成 batched matvec 用,.squeeze(-2) 再压掉。这是个略显绕的写法,等价于 images.reshape(n, -1) @ self.proj.weight.T + self.proj.bias。
注意 PatchEmbedFlatten 名字里的 “Flatten” 就是在说这个——实现上不做卷积,只做展平后的线性层。对照 Qwen2-VL 等实现,它们的 patch embed 通常是 Conv2d(kernel=patch_size, stride=patch_size);两者数学等价,但在 ViT 上下文里用 Linear 更省事(不用管 conv 后端)。
小结这一段
把 GPU 侧 ViT 的关键设计决策列一遍:
| 决策 | 选择 | 理由 |
|---|---|---|
| 位置编码 | 双路(行/列)2D RoPE,各占头维一半 | 让模型理解二维空间关系,OCR/grounding 必需 |
| attention 类型 | MHA、双向、全局 | 视觉塔不占 KV 缓存,无需 GQA;全图 attention 保空间一致性 |
| 序列组织 | 扁平打包 + cu_seqlens | 多图变长批处理的标准做法,和文本侧一致 |
| patch embedding | Linear on flattened patch | 数学等价于 conv,但 GEMM 更高效 |
| 残差物化 | 代码结构确定的固定点 | bf16 加法顺序影响逐 bit 结果,确定性要求 |
| GEMM 后端 | DeepGEMM(SM100/Ascend)+ 行 stride 对齐 | 复用 DeepSeek 自研 kernel 生态 |
| 切 patch | GPU kernel,不在 CPU | 省三次全量内存拷贝 |
第四段:下采样与 aligner,把 patch 降到 LLM 能接受的 token 数
ViT 的输出是 [sum_n_patch, 1024]——每个 patch 一个 1024 维向量。但 LLM 侧只想要 n_llm_w * n_llm_h 个 token。这中间的桥就是 vl_aligner.py。

上两图分别来自 DeepSeek-VL2 论文(arXiv:2412.10302,Figure 2)与 DeepSeek-OCR 论文(arXiv:2510.18234,Figure 3)——这两篇给出了 DeepSeek 视觉侧两代不同的解法:VL2 靠 tile + VL Adaptor,OCR 靠双塔编码 + 卷积压缩器。V4.1-Flash 的 batch_downsample + aligner 是这条路线的继续演进。
VisionLanguageInstructTuner:不复制权重的接管
1class VisionLanguageInstructTuner(nn.Module):2 def __init__(self, cfg: AttrDict, embedding: nn.Module, vision_source: nn.Module):3 """接管 HF 已加载的视觉模块和 image_* 参数,不重新构建/复制权重。"""4 super().__init__()5 self.visual_encoder = vision_source.visual_encoder6 self.aligner = vision_source.aligner这个构造函数做的事非常值得注意——它是”接管”而不是”构建”。HF 加载流程(model_vision_pipeline.aligner)已经把视觉模块的权重读进来了,这里的任务只是把两个子模块挂到自己的命名空间下,让 state_dict 的键路径对得上,然后按推理需要的方式调用它们。
注释里那句”不重新构建/复制权重”——在 671B 级模型上这不是小事。如果 __init__ 里重新 VisionTransformer(...) 再 load,就要多一份权重在显存里来回搬运,而且会破坏 HF 已有的权重共享。
normalize 常量怎么进到模型里
1# forward 在 ViT 前用 rgb_pad_to_patches 把 worker 透传的裸 RGB bytes 补齐成 patch。2# normalize/pad 常量只依赖 processor config(deepseek_vl.load_model 经 derive_image_transform_cfg3# 写入),与图片无关;用缺省值会让 ViT 静默吃到未归一化的 [0,1] 像素,故不设 default,未派生即 KeyError。4self.pixel_mean: float = cfg["image_pixel_mean"]5self.pixel_std: float = cfg["image_pixel_std"]6self.pad_color: float = cfg["image_pad_color"]7self.patch_size: int = self.visual_encoder.patch_size这段注释解释了一个非常隐蔽的失败模式:如果 normalize 常量用默认值,ViT 会静默吃到未归一化的 [0,1] 像素。模型不会报错,只会输出质量下降。所以代码选择”不设 default,未派生即 KeyError”——用启动时崩溃代替运行时静默降质。
“未派生”指的是 derive_image_transform_cfg 没跑。看一下那个函数:
1@staticmethod2def derive_image_transform_cfg(image_processor: Any, gpt_cfg: AttrDict) -> None:3 """从 processor 的 image_transform 取 rgb_pad_to_patches 的 normalize/pad 常量写进 cfg。"""4 transform = image_processor.image_transform5 assert transform.mean[0] == transform.mean[1] == transform.mean[2], (6 f"rgb_pad_to_patches requires same mean for all 3 channels (pad_color 取单值): {transform.mean=}"7 )8 mean = tuple(transform.mean)9 std = tuple(transform.std)10 assert std[0] == std[1] == std[2] != 0, f"rgb_pad_to_patches requires same nonzero std for all 3 channels: {std=}"11 assert image_processor.dtype == torch.bfloat16, f"rgb_pad_to_patches 输出硬编码 bf16,但 processor dtype={image_processor.dtype}"12 gpt_cfg.image_pixel_mean = float(mean[0])13 gpt_cfg.image_pixel_std = float(std[0])14 gpt_cfg.image_pad_color = float(max(0, min(255, int(transform.mean[0] * 255)))) # int() 截断非 round,对齐 processor三个 assert + 三行派生。它们约束的都是 rgb_pad_to_patches 那个 kernel 的接口契约:
mean/std 三通道必须相同,因为 kernel 的 pad_color 是单值(一个标量填满通道维)。如果三个通道 mean 不同,pad 颜色就得是三元组,kernel 签名要改。
std 不能为 0,否则除零。
processor dtype 必须是 bf16,因为 kernel 输出硬编码 bf16。V4.1 的 processor_config.json 里 image_mean/image_std 都是 [0.5, 0.5, 0.5],正好满足前两条。
int(transform.mean[0] * 255) 那行的注释”int() 截断非 round,对齐 processor”很有意思——它说明 CPU 侧 processor 和 GPU 侧 kernel 用了同一个取整方式。0.5 * 255 = 127.5,int() 得到 127,round() 得到 128。如果两边用了不同的函数,pad 像素值会差 1,在边界处造成可见的接缝。这个细节暴露了这类”两套代码算同一件事”的真实痛点。
forward 的完整流程
1def forward(self, input_embeds, images_raw, cu_raw_bytes, image_real_sizes,2 images_valid_spatial_crop, mm_token_type, images_spatial_crop,3 images_global_attn_pos_id, cu_seqlens_vit, max_seqlen_vit,4 num_downsampled_patch, total_patches, vit_attn_cu_seqlens=None):5 """把 worker 透传的裸 RGB bytes 补齐成 image patch(居中 pad + normalize),编码后融合进 input_embeds。6 处于 forward 段,禁止过滤/CUDA 同步。"""7 from deepinfer.model_executor.layers.tilelang_ops.image_embed_ops import rgb_pad_to_patches8
9 images = rgb_pad_to_patches(10 images_raw, cu_raw_bytes, image_real_sizes, images_valid_spatial_crop,11 cu_seqlens_vit, total_patches, self.patch_size,12 self.pixel_mean, self.pixel_std, self.pad_color,13 )14 images_embeds = self._encode_images(15 images, images_spatial_crop=images_spatial_crop,16 images_global_attn_pos_id=images_global_attn_pos_id,17 cu_seqlens_vit=cu_seqlens_vit, max_seqlen_vit=max_seqlen_vit,18 num_downsampled_patch=num_downsampled_patch,19 vit_attn_cu_seqlens=vit_attn_cu_seqlens,20 )21 input_embeds = self._merge_image_embeds(input_embeds, images_embeds, mm_token_type)22 return input_embeds三步:补齐成 patch → 编码 → 回填。注释里”处于 forward 段,禁止过滤/CUDA 同步”是一条硬纪律:
- 禁止过滤:不能在 forward 里用
if tensor is not None之后.item()或bool(tensor)之类的判断,因为那会触发 device→host 同步,打断 GPU 流水线。所有过滤必须在 prepare 阶段(CPU 张量上)做完。 - 禁止 CUDA 同步:同理,不能
.cpu()、不能.item()、不能.tolist()。
这条纪律的直接后果是第二节提到的那些零占位行设计。第三段我们见过一次:
1# valid = 非零 crop 行:零占位行(缓存图/空图)不产 raw、不进 ViT缓存命中的图在 images_spatial_crop 里留一个 (0, 0) 零行,然后靠 seqlens_all != 0 一次性过滤。所有”这张图要不要算”的判断都在 host 侧做完,forward 里拿到的张量已经全是”要算的”。这就是为什么缓存跳过逻辑(第六节)那么绕——它不是能省事,而是必须省在正确的位置。
_encode_images 的三段
1def _encode_images(self, images, images_spatial_crop, images_global_attn_pos_id,2 cu_seqlens_vit, max_seqlen_vit, num_downsampled_patch,3 vit_attn_cu_seqlens=None) -> torch.Tensor:4 """ViT -> downsample -> aligner,输出与 IMAGE token 逐一对应的 embeddings。"""5 images_features = self.visual_encoder(6 images, images_global_attn_pos_id, cu_seqlens_vit, max_seqlen_vit,7 vit_attn_cu_seqlens=vit_attn_cu_seqlens,8 )9 images_embeds = batch_downsample(10 images_feature=images_features,11 images_spatial_crop=images_spatial_crop,12 downsample_ratio=self.aligner.cfg.downsample_ratio,13 mode=self.aligner.cfg.projector_type,14 num_downsampled_patch=num_downsampled_patch,15 )16 images_embeds = self.aligner.layers(images_embeds)17 return images_embeds第一段 visual_encoder 就是第三节的 VisionTransformer,输入 [sum_n_patch, 3, 14, 14] 输出 [sum_n_patch, 1024]。
第二段 batch_downsample 来自 tilelang_ops/image_embed_ops,输入 [sum_n_patch, 1024],靠 images_spatial_crop 知道每张图的网格尺寸、靠 downsample_ratio 知道压缩倍数,输出 [sum_downsampled_patch, 1024],其中 sum_downsampled_patch 就是 prepare_input_tensor_vl 里累加的 num_downsampled_patch。
第三段 self.aligner.layers 是 projector(projector_type 决定结构,两层的 MLP 或线性),把 ViT 的 1024 维投到 LLM 的 hidden 维度。
为什么要 downsample 而不是直接改 patch_size? 这是个值得展开的设计问题。
如果直接把 patch_size 从 14 改成 42(= 14×3),那么:ViT 的每个 patch 是 42×42 的像素块,一次线性投影到 1024 维。看上去一步到位,但问题是——ViT 的 patch 网格会变成 n_vit/3,attention 的序列长度缩短 9 倍,而每个 patch 的信息承载量没变。ViT 在更少的 patch 上做 attention,空间分辨率就降了:一个横跨两个 42×42 patch 的小目标,它的特征在编码前就已经混在一起了。
而 downsample 方案是:ViT 先在 14×14 的精细网格上做全图 attention(保留空间分辨率),然后才把相邻 3×3 的 patch 特征聚合起来降维。ViT 的注意力是在高分辨率下发生的,降维发生在它之后。
这个区别在 OCR、表格、视觉定位这类任务上很关键:精细的空间关系在 attention 阶段就已经被编码进特征了,之后合并是安全和高效的。相反,如果在输入阶段就丢掉分辨率,attention 无法挽回。
代价是 ViT 的计算量按 downsample_ratio² 增加。V4.1 downsample_ratio 如果是 3,ViT 要处理 9 倍于 LLM 的 patch 数。但 ViT 是 32 层、1024 维的小模型(相对 LLM 的几百层、几千维),这个额外开销在可接受范围。这是一个典型的”用便宜模型的高分辨率计算,换取贵模型的低分辨率输入”。
_merge_image_embeds:嵌入回填
1def _merge_image_embeds(self, input_embeds, images_embeds, mm_token_type) -> torch.Tensor:2 """把 IMAGE token 位置替换为 image embeddings、特殊 token 位置替换为对应参数3 (原地,input_embeds [total_q, hidden])。"""4 replace_input_embeds(5 input_embeds,6 mm_token_type,7 images_embeds,8 TokenType.IMAGE,9 self.special_token_type_ids,10 self.special_embeds,11 )12 return input_embeds特殊 token 的真源表:
1# 用于 format image token sequence 的特殊 token2# <|image_start|>, <|image_end|>, <|image_newline|>3self.params = nn.Module() # 这是为了控制 cm 中,参数按照注册顺序排列,否则直接参数一定排在子模块参数之前4self.params.image_start = reconstruct_param(embedding.params.image_start)5self.params.image_end = reconstruct_param(embedding.params.image_end)6self.params.image_newline = reconstruct_param(embedding.params.image_newline)7
8# 特殊 token 单一真源表:(params 属性名, TokenType),下面各查找结构均由此派生9self._special_tokens = [10 ("image_newline", TokenType.IMAGE_NEW_LINE),11 ("image_start", TokenType.IMAGE_START),12 ("image_end", TokenType.IMAGE_END),13]14self.special_token_type_ids = torch.tensor([tt for _, tt in self._special_tokens], dtype=torch.int32)15self.special_embeds = torch.stack([getattr(self.params, name) for name, _ in self._special_tokens], dim=0)三点:
特殊 token 的 embedding 来自 LLM 的 embedding 表(reconstruct_param(embedding.params.image_start)),不是新学的。也就是说 image_start/image_end/image_newline 这三个 embedding 本身是预训练词表里的条目,被”借”来当作结构化标记。注释里的 self.params = nn.Module() 还有一层技术原因——mm(模型合并/转换)工具按注册顺序排列参数,直接挂在 self 上的参数会排到子模块参数之前,破坏顺序。用一个空的 nn.Module 包一层,就能控制顺序。
单一真源表:self._special_tokens 定义了”哪个参数对应哪个 TokenType”,special_token_type_ids 和 special_embeds 都由它派生。加一个新特殊 token 只需改一处。这种”表驱动”的写法在多模态代码里很常见,因为特殊 token 的数量和对应关系容易改。
replace_input_embeds 是原地操作(注释:原地,input_embeds [total_q, hidden])。原地改的动机是省一次 [total_q, hidden] 的分配——在长序列下这个张量可能是 GB 级。
input_embeds 的形状是 [total_q, hidden]——total_q 是这一批的查询 token 总数(prompt 长度之和,不含已缓存的 KV 部分)。replace_input_embeds 遍历 mm_token_type,遇到 IMAGE 就取下一个 images_embeds 行原地写入,遇到三个特殊类型就写入对应的 special_embeds。这就解释了为什么 images_embeds 的行数必须严格等于 mm_token_type 里 IMAGE 的个数——两边错一格,整段图像就串行了。
第五段:嵌入回填之后,chunk prefill 的一致性
嵌入回填完之后,input_embeds 就交给 LLM 正常前向了。但还有一个跨请求的机制要处理:chunk prefill。
长 prompt 在 vLLM/SGLang/DeepInfer 里都会被切成若干个 chunk,分多轮 prefill(本系列第二篇 SGLang PrefillAdder、第三篇 DeepInfer ZOS 都讲过)。一个 chunk 只覆盖一个序列的一段 token 区间。那么问题来了:如果这个区间里只包含某张图的一部分 patch,ViT 该怎么办?
直觉上答案是”跑整张图,只用需要的那部分特征”。这个方案正确但代价大:如果 chunk 只覆盖某张图末尾 3 个 token,却要重跑整张图的 ViT(几百到几千 patch),代价完全不成比例。
DeepInfer 的做法是:按 chunk 的 token 区间精确切出相交的图像元数据,只对相交的图像跑 ViT。核心在 ve/prepare_tensor_input_vl.py。
二分查找相交图像
1@cpu_timer.record("chunk_mm_metadata")2def _chunk_mm_metadata(metadata: dict, start_idx: int, end_idx: int) -> dict:3 """按 token 区间切出 mm 元数据(浅拷贝,不改原 dict)。"""4 metadata = metadata.copy()5
6 for k in MM_TOKEN_KEYS:7 metadata[k] = _slice_or_none(metadata[k], start_idx, end_idx)8
9 image_starts = metadata["image_starts"]10 if image_starts is None:11 return metadata12 image_lens = metadata["image_lens"]13 if end_idx > start_idx:14 starts_list = image_starts.tolist()15 ends_list = (image_starts + image_lens).tolist()16 start_image_idx = bisect.bisect_right(ends_list, start_idx) # 第一张右端 > start_idx 的图17 end_image_idx = bisect.bisect_left(starts_list, end_idx) # 第一张左端 >= end_idx 的图18 else:19 start_image_idx = end_image_idx = 0 # 空 chunk 与任何图都不相交20 if start_image_idx >= end_image_idx:21 metadata = _drop_image_metadata(metadata)22 return metadataimage_starts / image_lens 就是第一节 overwrite_prompt 产出的 image_spans 拆成两个张量。每个图的 token 区间是 [start_i, start_i + len_i)。
bisect_right(ends_list, start_idx):ends_list 是各图右端点的升序列表(因为图按 token 顺序排列、区间不重叠)。bisect_right 找的是”第一个 end > start_idx 的下标”,也就是第一张右端点越过 chunk 起点的图。所有它之前的图,右端点 ≤ chunk 起点,完全落在 chunk 左边。
bisect_left(starts_list, end_idx):starts_list 是各图左端点的升序列表。bisect_left 找的是”第一个 start >= end_idx 的下标”,也就是第一张左端点越过 chunk 终点的图。所有它之后的图,完全落在 chunk 右边。
于是 [start_image_idx, end_image_idx) 就是与 chunk 相交的图像区间。两次二分,O(log n),即使一个 prompt 里有几百张图也几乎不花时间。
if start_image_idx >= end_image_idx 表示没有相交图,直接 _drop_image_metadata:
1def _drop_image_metadata(metadata: dict) -> dict:2 for k in MM_IMAGE_KEYS:3 metadata[k] = None4 return metadata把整组图像键置 None。下游看到 None 就知道”这个 chunk 没有图要算”。
切出相交图像的 per-image 张量
1 # per-image 张量与 image_starts 同索引空间(含缓存图的零占位行)2 for k in ["images_spatial_crop", "images_input_size"]:3 metadata[k] = _slice_or_none(metadata[k], start_image_idx, end_image_idx)4
5 # 这两个键为 None 即本 seq 无 raw 字节(无图 / 图全部 cache-hit),相交的只可能是零占位行6 if metadata["images_input_size"] is None or metadata["cu_raw_bytes"] is None:7 metadata = _drop_image_metadata(metadata)8 return metadata然后按图下标切片 images_spatial_crop 和 images_input_size。那两个 None 判断的注释说明了剩余两种”没有真图”的情况:本 seq 从来没图,或所有图都命中了缓存。两种情况相交的”图”其实都是零占位行,直接 drop。
最后是三组按图下标切、再重算前缀和:
1 cu_raw_bytes = metadata["cu_raw_bytes"]2 raw_start = cu_raw_bytes[start_image_idx]3 raw_end = cu_raw_bytes[end_image_idx]4 metadata["images_raw"] = _slice_or_none(metadata["images_raw"], raw_start, raw_end)5 metadata["cu_raw_bytes"] = cu_raw_bytes[start_image_idx : end_image_idx + 1] - raw_start6
7 cu_image_patch_lens = metadata["cu_image_patch_lens"]8 patch_start_idx = cu_image_patch_lens[start_image_idx]9 patch_end_idx = cu_image_patch_lens[end_image_idx]10 metadata["images_global_attn_pos_id"] = _slice_or_none(11 metadata["images_global_attn_pos_id"], patch_start_idx, patch_end_idx)12 metadata["cu_image_patch_lens"] = cu_image_patch_lens[start_image_idx : end_image_idx + 1] - patch_start_idx这一段处理的是”按图下标切了图,但底层数据是按 patch / 按字节排列的”这个错位。三层索引空间:
| 维度 | 张量 | 索引空间 |
|---|---|---|
| 按图 | images_spatial_crop、images_input_size、cu_image_patch_lens、cu_raw_bytes | 图下标 |
| 按 patch | images_global_attn_pos_id | patch 下标(累计) |
| 按字节 | images_raw | 字节下标(累计) |
切了图之后,patch 和字节这两个空间的起点也要跟着移动。代码用两个前缀和数组做转换:cu_raw_bytes[image_idx] 给出第 image_idx 张图之前累计了多少字节,cu_image_patch_lens[image_idx] 给出累计了多少 patch。切片后再减去起点,得到从 0 开始的新的前缀和。
为什么要减起点重算前缀和? 因为下游的 batch_tensor_dict_list 会把这一批所有 seq 的 cu_raw_bytes 拼接起来。如果每个 seq 的 cu_raw_bytes 都保留原始的大偏移,拼接后的语义就乱了。注释在 prepare_input_metadata_vl 里说得很清楚:
1# cu_raw_bytes 是逐 seq 前缀和,不能直接 concat,下面从 batch 后的 real sizes 全局重建所以这里的本地重算是”先把每个 seq 的局部前缀和对齐到 0”,然后 batch 阶段再重建全局的。
顺带提一下 prepare_tensor_input_vl.py 开头那个 slice 辅助:
1def _slice_or_none(x: torch.Tensor | list | None, start, end):2 return x[start:end] if x is not None else None以及 _chunk_mm_metadata 的第一行 metadata = metadata.copy()——浅拷贝。注释写明”不改原 dict”,因为同一个 seq 可能会被切成多个 chunk,每个 chunk 都要基于原始元数据切一次。如果不 copy 就地改,第二个 chunk 就会拿到被第一个 chunk 改过的数据,切出来的区间全错。
一个隐含的约束
把 mm_image_token 放在图像段首 token 的设计(第一节),在这里终于看到它的用处:如果 chunk 边界恰好落在图像段中间,mm_token_type 切片后,这段的第一个 token 是 pad 类型,而 chunk 的起点在图像中段。下游靠 images_seq_mask = mm_token_type >= TokenType.IMAGE(在 multi_modal_mapper 里派生)来判断”这段是图”,而这个 mask 是按 token 位置切的,与图像段的起点无关。所以真正的正确性保障不是首 token 类型,而是 image_starts/image_lens 这套按图的区间索引 —— 只要 chunk 与图相交,bisect 就能精确找出是哪几张。
那”首 token 必须是 IMAGE 才能支持 chunk prefill”这句注释指的是什么?它指的是另一条路径:当一批请求全部是纯文本或全部无图时,images_seq_mask 缺失,get_input_embeddings 会退化到:
1if input_metadata.images_seq_mask is None:2 input_metadata.images_seq_mask = self._special_image_token_mask(input_ids)3
4def _special_image_token_mask(self, input_ids: torch.Tensor) -> torch.Tensor:5 """标记 [vocab_size, vocab_size + NUM_SPECIAL) 区间内的特殊图像 token。"""6 vocab_size = self.cfg.vocab_size7 return (input_ids >= vocab_size) & (input_ids < vocab_size + ImageTokenOffset.NUM_SPECIAL)这条退化路径直接看 input_ids 的值域。此时 mm_image_token(vocab_size + 2)落在区间内被标为图像类,而 mm_image_pad_token(vocab_size + 1)也在同一区间——所以这条路径上整段都会被正确标记,跟首 token 无关。
注释里的说法其实是在强调一个更弱但更重要的性质:图像段的第一个 token 必须是可以从 input_ids 直接认出”图从这里开始”的。因为 chunk 可能从图像段中间开始(此时不需要重跑 ViT,bisect 会处理),也可能从图像段起点开始(此时需要跑整图)。区分这两种情况的前提是能找到起点——首 token 用 IMAGE 类型(而不是 pad),配合 image_starts 才能定位。这个约束在数据格式层面被固定下来,比在运行时推断来得可靠。
第六段:缓存命中图怎么跳过 ViT
这一节讲本文最绕的一部分:_split_cached_prefix 和 _compute_and_strip_cached_images。
问题定义
场景:同一个用户在同一前缀下重复发同一张图,或者多轮对话里历史轮次带过的图。此时前缀缓存(本系列第七、八篇讲过的 HiCache / RadixCache 类机制)已经把那部分 KV 存下来了。整段图的 token 不需要重算,ViT 也不该跑。
但前缀缓存给出的信息是一个 token 数:num_cached_tokens,意思是”前 N 个 token 的 KV 已命中”。VE 需要把它翻译成”哪些图不用算”。
约束是:缓存是 token 前缀,图是按 token 顺序排列的连续区间。所以一张图要么完整地落在 [0, N) 内(完全命中),要么有一部分在外面(未命中)。部分命中是不可能的——因为命中与否是按 token 位置判定的,一张图的 token 是一段连续区间。
第一步:算出哪些图被完全覆盖
1def _compute_and_strip_cached_images(self, multi_modal_data: dict, num_cached_tokens: int) -> None:2 """计算哪些图片被 KV cache 完全覆盖,剥离其 binary 数据以减少 IPC 开销;3 缓存图片数量存入 `multi_modal_data["_num_cached_images"]`,供下游跳过对应前缀。"""4 if num_cached_tokens <= 0:5 return6 segment_input = multi_modal_data.get("segment_input")7 if segment_input is None:8 return9 image_spans = segment_input.meta.get("image_spans", [])10 if not image_spans:11 return12 image_segments = [seg for seg in segment_input.segments if isinstance(seg, ImageInput)]13 if not image_segments:14 return15
16 # 图片按 token 顺序编号连续递增,缓存是 token 前缀:一张图整段落在 [0, num_cached_tokens)17 # 内(start + count <= num_cached_tokens)才算被完全覆盖,边界落在图中间即视为未缓存。18 num_cached_images = 019 for start, count in image_spans:20 if start + count > num_cached_tokens:21 break22 num_cached_images += 123 if num_cached_images <= 0:24 return25
26 for seg in image_segments[:num_cached_images]:27 seg.binary = None28 multi_modal_data["_num_cached_images"] = num_cached_images注释里那句”一张图整段落在 [0, num_cached_tokens) 内才算被完全覆盖,边界落在图中间即视为未缓存”是这段的规则。由于图按 token 顺序排列,image_spans 是按起点升序的,所以只需顺序扫到第一个不满意的就停——不需要二分,也不需要处理”中间某张没命中、后面某张又命中”的乱序情况(那种情况按定义不可能发生)。
为什么要 seg.binary = None? 因为 segment_input 要经过 mp.Queue 送到 worker 子进程。不剥离的话,整张图的原始 JPEG 字节会走一次 pickle 序列化 + IPC 拷贝。一张 4K 图可能有几 MB,多张图就是几十 MB 的纯浪费——反正这张图不会进 ViT,它的字节根本用不上。
注意这段剥离发生在 add() 里,即主进程把请求放进队列之前:
1def add(self, id, multi_modal_data, num_cached_tokens=0):2 if multi_modal_data is None:3 return4 multi_modal_data["_num_cached_tokens"] = num_cached_tokens5 self._compute_and_strip_cached_images(multi_modal_data, num_cached_tokens)6 ...然后 worker 侧再处理:
1multi_modal_data = self.multi_modal_mapper(multi_modal_data)第二步:worker 侧把缓存前缀换成长度相同的假文本
multi_modal_mapper 在 deepseek_vl.py 里:
1@classmethod2def multi_modal_mapper(cls, mm_processor, multi_modal_data):3 if "segment_input" not in multi_modal_data:4 return multi_modal_data # ve_failed 请求(image_bytes=None)不经 overwrite_prompt,原样透传5
6 segment_input = multi_modal_data["segment_input"]7 image_spans = segment_input.meta.get("image_spans", [])8
9 num_cached_images = multi_modal_data.pop("_num_cached_images", 0)10 num_cached_tokens = multi_modal_data.pop("_num_cached_tokens", 0)11 split = cls._split_cached_prefix(segment_input, num_cached_tokens) if num_cached_images else None_split_cached_prefix 做的事很直白:
1@staticmethod2def _split_cached_prefix(segment_input, num_cached_tokens):3 """把被 KV cache 完全覆盖的前缀 segment 替换为等长 dummy 文本,使下游跳过缓存图片的 tensor 计算;4 返回(替换后的 SegmentInput,被替换的 token 数,被替换的图片数),没有完整缓存的 segment 时返回 None。"""5 image_spans = segment_input.meta.get("image_spans", [])6 cached_token_count = 07 cached_image_count = 08 num_cached_segments = 09 img_idx = 010 for seg in segment_input.segments:11 if isinstance(seg, TextLengthInput):12 seg_tokens = seg.length13 elif isinstance(seg, ImageInput):14 seg_tokens = image_spans[img_idx][1]15 img_idx += 116 else:17 break18 if cached_token_count + seg_tokens > num_cached_tokens:19 break20 cached_token_count += seg_tokens21 num_cached_segments += 122 cached_image_count += isinstance(seg, ImageInput)23
24 if num_cached_segments == 0:25 return None26 fake_text = TextLengthInput(length=cached_token_count)27 new_segments = [fake_text, *segment_input.segments[num_cached_segments:]]28 return SegmentInput(meta=segment_input.meta, segments=new_segments), cached_token_count, cached_image_count关键那两行:
1fake_text = TextLengthInput(length=cached_token_count)2new_segments = [fake_text, *segment_input.segments[num_cached_segments:]]把前缀的所有 segment(文本 + 图)压缩成一个等长的 TextLengthInput,后面接未缓存的 segment。
为什么这样做? 因为 mm_token_type 需要一个和 input_ids 等长的数组。缓存掉的图虽然不用算特征,但它们在 input_ids 里仍然占据 token 位置(这些位置的 KV 已经在缓存里了,但这一轮的前向仍然要拼接完整的 KV)。如果直接把缓存段删掉,mm_token_type 就会比 input_ids 短一截,后面所有位置全部错位。
用 TextLengthInput(length=cached_token_count) 顶替,就得到一个长度正确、类型全是 UNDEFINED 的前缀段。下游 convert_segments_to_tensor_dict 里,TextLengthInput 走的是 TextTokenProcessor,产出的就是 UNDEFINED。而前向时那些位置的 embedding 会被 LLM 正常查表算出来(因为它们不是图像 token),只是 KV 部分命中缓存——这正是我们想要的。
顺带说一句:这个”假文本段”的 token 是真实的文本 token 吗?不是。但它的 embedding 会被算出来、会被写进 KV——这是不是错的?
仔细想:cached_token_count 覆盖的位置本来就是文本 token 和图像 token 混在一起。其中文本 token 的 embedding 是真实算出来的(它们本来就该算),图像 token 的 embedding 是从缓存里来的。现在整段被替换成一个纯文本段,看起来像是把图像位置的 embedding 也算错了。
但这个担心不成立,理由链条如下:
- chunk prefill 是按 token 区间切的。已缓存的 token 区间不会进入这一轮的 prefill chunk(调度器只调度未命中的后缀)。
- 所以那个
fake_text段对应的 token 位置,这一轮根本不会被前向。 - 它存在的唯一意义,是让
mm_token_type的长度和input_ids对齐,从而让未缓存后缀的image_starts偏移量正确。
那么 image_starts 的偏移怎么处理的?看 multi_modal_mapper 的后续:
1 if split is not None:2 convert_input, num_replaced_tokens, num_split_images = split3 assert num_split_images == num_cached_images, \4 f"cached image count mismatch: {num_split_images} != {num_cached_images}"5 else:6 assert num_cached_images == 0, "cached images must belong to a complete cached segment prefix"7 convert_input, num_replaced_tokens = segment_input, 08
9 ret_dict = mm_processor.convert_segments_to_tensor_dict(convert_input)assert num_split_images == num_cached_images 是一个交叉校验——_compute_and_strip_cached_images 和 _split_cached_prefix 用两套独立循环算同一个数(一个遍历 image_spans,一个遍历 segments),必须相等。这类”两条路算一个数再对齐”的断言在多模态代码里几乎是必需品,因为一旦不一致就是静默的图像错位。
然后:
1 images_spatial_crop = ret_dict.get("images_spatial_crop", None)2 if num_cached_images > 0:3 # 缓存图片前面补零 crop:chunk 切片会排除其区间,零值另被4 # ve/prepare_tensor_input_vl 的 seqlens_vit != 0 过滤兜底5 dtype = images_spatial_crop.dtype if images_spatial_crop is not None else torch.int646 cached_crops = torch.zeros((num_cached_images, 2), dtype=dtype)7 images_spatial_crop = cached_crops if images_spatial_crop is None else torch.cat((cached_crops, images_spatial_crop))在 images_spatial_crop 前面补 num_cached_images 行零。
这就是第五段里说的”零占位行”的来源。为什么要补?因为 image_spans(以及由它派生的 image_starts/image_lens)包含了所有图,包括缓存图。为了保持图下标空间一致,images_spatial_crop、images_input_size 这些 per-image 张量也必须是”每个图一行”,缓存图用零填充。
(0, 0) 这个零值的语义是”这张图的 ViT 网格是 0×0”——也就是说 n_vit_w * n_vit_h = 0,不产 patch。第五段里的过滤正好利用这一点:
1seqlens_all = images_spatial_crop[:, 0] * images_spatial_crop[:, 1]2valid = seqlens_all != 03seqlens_vit = seqlens_all[valid]4chunked_multi_modal_data["images_valid_spatial_crop"] = images_spatial_crop[valid].to(torch.int32)一行代码同时过滤掉”缓存图”(零占位)和”空 chunk 相交的图”(也是零)。这就是本节开头说的”所有过滤在 host 侧做完”——valid 是 CPU 张量上的布尔运算,不涉及 GPU 同步。
同理 images_input_size:
1 real_sizes = raw_image_dict.get("images_input_size")2 if real_sizes is not None and real_sizes.numel() > 0:3 if num_cached_images > 0:4 cached_sizes = torch.zeros((num_cached_images, 2), dtype=real_sizes.dtype)5 real_sizes = torch.cat((cached_sizes, real_sizes))6 raw_image_dict["images_input_size"] = real_sizes7 # 在乘法前提升类型;单图/累计 RGB 字节数都不受 LLM token 数的 int32 上限约束。8 sizes_i64 = real_sizes.to(torch.int64)9 per_image_bytes = sizes_i64[:, 0] * sizes_i64[:, 1] * 310 # 从 mapper、batch prepare 到 kernel 均保持 int64,避免累计字节偏移 wrap。11 cu_raw_bytes = torch.zeros((real_sizes.shape[0] + 1,), dtype=torch.int64)12 cu_raw_bytes[1:] = per_image_bytes.cumsum(dim=0)两个 int64 的理由都写在注释里了,都值得单独说:
“在乘法前提升类型”:real_sizes 是 int64,先 .to(torch.int64) 再做乘法。如果不提升,w * h * 3 在 int32 下算:一张 8K 图是 7680 * 4320 * 3 = 99,532,800,还在 int32 范围内(21 亿)。但如果累计,多张大图就会溢出。所以:
“避免累计字节偏移 wrap”:cu_raw_bytes 是累计字节数,必须 int64。注释给了具体量级估计:
字节数走 int64:LLM seqlen 限的是 downsample 后的 token 数,p=14、r=3 时 1M token 对应的 RGB 可超 2 GiB
1M token 的 prompt(长文档扫描)在 patch_size=14、downsample_ratio=3 下,对应 (n_llm × 3)² × 14² × 3 字节的原始 RGB——确实超过 2 GiB,逼近 int32 的 2.1 GB 上限。再多一点就 wrap 成负数,然后所有字节偏移全错。这一类”图像字节数溢出”的坑在长文档场景下很常见,DeepInfer 在 mapper、batch prepare 到 kernel 全程用 int64。
还有那个 images_seq_mask 的派生:
1 metadata = {2 "image_starts": image_starts,3 "image_lens": image_lens,4 "cu_image_patch_lens": torch.LongTensor(cu_image_patch_lens),5 "cu_raw_bytes": cu_raw_bytes,6 "mm_token_type": ret_dict["mm_token_type"],7 # processor 不产 images_seq_mask,从 mm_token_type 推导:IMAGE 及其之后的取值都是图像类 token8 "images_seq_mask": ret_dict["mm_token_type"] >= TokenType.IMAGE,9 "images_spatial_crop": images_spatial_crop,10 "images_global_attn_pos_id": ret_dict.get("images_global_attn_pos_id", None),11 **raw_image_dict,12 }13 assert metadata.keys() == set(MM_METADATA_KEYS), (14 f"multi_modal_mapper 键集合与 ve/schema.py 的 MM_METADATA_KEYS 不同步: 差集 {metadata.keys() ^ set(MM_METADATA_KEYS)}"15 )16 return metadataimages_seq_mask 从 mm_token_type >= TokenType.IMAGE 推导(因为 IMAGE=2,3/4/5/6 都是图像类),ve/schema.py 里集中维护了全部键名常量:
1RAW_IMAGE_KEYS = ("image_sizes", "image_bytes")2MM_TOKEN_KEYS = ("mm_token_type", "images_seq_mask")3MM_IMAGE_SPAN_KEYS = ("image_starts", "image_lens")4MM_IMAGE_KEYS = (5 "images_raw", "images_input_size", "cu_raw_bytes",6 "images_global_attn_pos_id", "images_spatial_crop", "cu_image_patch_lens",7)8MM_METADATA_KEYS = (*MM_IMAGE_SPAN_KEYS, *MM_TOKEN_KEYS, *MM_IMAGE_KEYS)那条 assert metadata.keys() == set(MM_METADATA_KEYS) 用集合对称差报错,能直接告诉你少了哪个键。这类”把键集合契约写成断言”的做法在跨模块的数据字典传递里非常有效——multi_modal_mapper 在 worker 进程里、prepare_tensor_input_vl 在 engine 进程里,中间隔着 IPC,出问题时很难定位,一个启动/首次调用就炸的断言能省掉大量排查时间。

上图是 Qwen2-VL 论文里的原生分辨率处理示意(NAViT 风格),可以横向对照本节的几何规划:同样是把任意长宽比映射到动态的 token 序列。V4.1-Flash 的 compute_image_layout 是这套思路的具体实现,只是把「切 tile」换成了「解最优网格」。
第七段:显存与共享内存——pinned pool、句柄与延迟归还
最后一节讲 VE 的资源管理。这段代码分散在 deepinfer/shared_memory.py 和 ve/schema.py 里,逻辑不多但很讲究。
为什么要共享池
worker 子进程算出的结果(images_raw 裸 RGB 字节、mm_token_type、cu_image_patch_lens 等)要送回 engine 主进程。走 mp.Queue 就是 pickle 序列化 + 内核管道拷贝。一张图的 images_raw 是 best_width × best_height × 3 字节——4K 图(3840×2160,对齐后约 3843×2163)就是 约 25 MB。多图批量时几十上百 MB 走 IPC,延迟和内存占用都不可接受。
解决办法是 SharedTorchMemoryPool:一块 pinned host memory(锁页内存,DMA 可以直读),worker 把结果写进池里,只把 (handle, shape, dtype) 三元组通过队列送过去,主进程用 handle 在同一个池里拿到张量视图。零拷贝。
worker 侧:
1if envs.DEEPINFER_USE_MULTIMODAL_POOL:2 if not isinstance(multi_modal_data, dict):3 raise NotImplementedError(f"multi_modal_data type {type(multi_modal_data)} not supported")4 multi_modal_data["handle"] = handles = {}5 for key, value in multi_modal_data.items():6 if isinstance(value, torch.Tensor) and value.numel() > 0:7 handle, shared_data = multi_modal_pool.alloc_tensor(value.shape, dtype=value.dtype)8 shared_data.copy_(value)9 handles[key] = (handle, value.shape, value.dtype)10 multi_modal_data[key] = None11self.output_queue.put((id, multi_modal_data))注意 multi_modal_data[key] = None——把原张量置空,只留 handle。这样 pickle 时不会拷大张量。主进程侧 fetch() 再把 handle 换回视图:
1if envs.DEEPINFER_USE_MULTIMODAL_POOL:2 # "handle" 保留在 dict 里标记池块所有权3 for key, (handle, shape, dtype) in multi_modal_data["handle"].items():4 multi_modal_data[key] = get_multi_modal_pool().get_tensor(handle, shape, dtype)注释里那句 "handle" 保留在 dict 里标记池块所有权 是这套设计的核心:handle 字典不会被删掉,它是”这块池内存归这个请求所有”的凭证。谁持有 dict,谁负责最终把这些 handle 还回池里。
归还:延迟到 H2D 之后
池块什么时候能还?必须等到池内存被读到 GPU 上、并且那次拷贝已经完成。因为池是 pinned host memory,try_concat 的 H2D 是异步的——如果池块在 DMA 还在飞的时候就还回去并被下一个请求复用,新的写入会污染在途拷贝,得到的是错乱的图像数据。
ve/schema.py 用三行全局状态解决了这个时序问题:
1# prefill 已算完、等着归还的池块(prepare 阶段取走所有权,engine 在同一 step 的 pre_sampler_sync 归还)2_expiring_pool_handles: list[tuple] = []3_mm_h2d_done_evt = torch.cuda.Event()4
5def defer_expiring_pool_handles(seq_metadata_list: list) -> None:6 """取走 prefill 已全部算完(seqlen_k 覆盖全部 mm token)的 seq 的池块所有权并 record 读取完成 event;7 必须在 batch_tensor_dict_list 之后调用(池内存的 H2D 由其 try_concat 下发,之后再无读池的拷贝)。"""8 assert not _expiring_pool_handles, "上一 step 暂存的池块没归还"9 for seq_metadata in seq_metadata_list:10 multi_modal_data = seq_metadata.multi_modal_data11 if not multi_modal_data or not multi_modal_data.get("handle"):12 continue13 # 总 token 数以 mm_token_type 长度为准(worker 产物恒有该键)14 if seq_metadata.seqlen_k >= multi_modal_data["mm_token_type"].shape[0]:15 _expiring_pool_handles.extend(multi_modal_data.pop("handle").values())16 if _expiring_pool_handles:17 _mm_h2d_done_evt.record()18
19def free_deferred_pool_handles() -> None:20 """归还 defer_expiring_pool_handles 取走的池块。池内存是 pinned 的、try_concat 的 H2D 是异步的,21 故先等 event,池块复用才不会覆写在途拷贝。"""22 if not _expiring_pool_handles:23 return24 _mm_h2d_done_evt.synchronize()25 pool = get_multi_modal_pool()26 for handle, _shape, _dtype in _expiring_pool_handles:27 pool.free(handle)28 _expiring_pool_handles.clear()时序是:
prepare_input_metadata_vl里调batch_tensor_dict_list,把所有 seq 的池张量 concat 并下发 H2D(异步);- 紧接着调
defer_expiring_pool_handles。此时读取池内存的拷贝全部下发了(注释:“池内存的 H2D 由其try_concat下发,之后再无读池的拷贝”),所以可以record()一个 event 标记”这些池块已经没人在读了,但拷贝还没完”; - 所有权从
multi_modal_data里pop出来,暂存到全局列表; - engine 在同一 step 的
pre_sampler_sync阶段(GPU 已经推进到采样前)调free_deferred_pool_handles,先synchronize()等 event,再真正还块。
为什么要两阶段而不是直接在 prepare 后还? 因为 try_concat 之后、event 完成之前,DMA 还在读池内存。如果在 defer 里就还,下一个请求的 alloc_tensor 可能拿到同一块并开始写入,污染在途拷贝。用 event + 延后 synchronize 是 CUDA 里处理”异步读 + 内存复用”的标准范式(对照本系列第十一篇讲 CUDA Graph 内存池、第五篇讲 KV 页复用时也见过类似模式)。
判定条件:
1if seq_metadata.seqlen_k >= multi_modal_data["mm_token_type"].shape[0]:seqlen_k 是这个 seq 当前已经算完的 KV 总长度,mm_token_type.shape[0] 是这个 seq 的 mm token 总数。 前者大于等于后者,意味着这个 seq 的所有 mm token 都已经过了 prefill,池里的图像数据再也不需要了。这是”图全部算完”的准确判定——不是”这个 chunk 里没有图”,而是”整个序列的所有 mm token 都算完了”。
assert not _expiring_pool_handles, "上一 step 暂存的池块没归还" 是一个跨 step 的生命周期检查:暂存列表必须每个 step 清空,如果上一步的没还完,说明 free_deferred_pool_handles 漏调了,池会缓慢泄漏。用 assert 在第一次出现时就炸出来。
异常路径上是直接还:
1def free_multi_modal_pool_handles(multi_modal_data, pool=None) -> None:2 """归还 multi_modal_data 仍持有的共享池块(abort/异常路径直接调用;3 正常路径见 defer_expiring_pool_handles)。"""4 handles = multi_modal_data.pop("handle", None)5 if not handles:6 return7 pool = pool if pool is not None else get_multi_modal_pool()8 for handle, _shape, _dtype in handles.values():9 pool.free(handle)在 _processor 的异常分支和 fetch() 的”请求已 abort”分支被调用:
1except Exception:2 logger.exception("Multi-modal request %s failed during multi_modal_mapper", id)3 if isinstance(multi_modal_data, dict):4 free_multi_modal_pool_handles(multi_modal_data, pool=multi_modal_pool)5 self.output_queue.put((id, None))这两条路径都不需要等 H2D——因为压根没有 H2D 发生过(要么 worker 里就失败了,要么结果还没被消费)。
池的容量与可见性
1if rank <= 0 and envs.DEEPINFER_USE_MULTIMODAL_POOL:2 num_ranks = get_actual_num_gpus()3 pool_gb = envs.DEEPINFER_MULTIMODAL_POOL_SIZE_GB4 logger.info(5 f"multimodal 共享池已启用: {pool_gb}GB pinned host memory/rank,"6 f"本节点 {num_ranks} rank 预计常驻 {pool_gb * num_ranks}GB"7 f"(DEEPINFER_USE_MULTIMODAL_POOL=0 关闭,DEEPINFER_MULTIMODAL_POOL_SIZE_GB 调容量)"8 )这段启动日志把”每 rank pool_gb GB,本节点 num_ranks 个 rank,合计 pool_gb * num_ranks GB”算给运维看。pinned host memory 是锁页内存,不可换出,直接计入系统常驻内存——8 卡节点、每卡 4 GB 池就是 32 GB 常驻。这个数必须提前让运维知道,否则会在生产环境上遇到莫名其妙的 OOM。日志里还留了两个调参环境变量的名字,是很直接的运维提示。
池在 stop() 里清理:
1def stop(self) -> None:2 for w in self.workers:3 w.terminate()4 if envs.DEEPINFER_USE_MULTIMODAL_POOL and not self._pool_closed:5 self._pool_closed = True6 for w in self.workers:7 w.join()8 pool = get_multi_modal_pool()9 get_multi_modal_pool.cache_clear()10 pool.close()顺序是:先 terminate 所有 worker → 再 join → 再关池。join 必须在 close 之前——如果 worker 还在跑而池被关了,worker 里的 alloc_tensor 会访问已释放内存。cache_clear() 是清掉 get_multi_modal_pool 的 lru_cache(这是个按参数缓存的单例工厂),避免下个实例拿到同一个已关闭的池。
NPU 的 SIGTERM 补丁
worker 启动时有一段平台分支:
1def _processor(self, multi_modal_pool: SharedTorchMemoryPool) -> None:2 # torch_npu 的 bad-fork 路径把 SIGTERM 从 handler 改成 SIG_IGN,使 terminate() 失效;3 # 此处恢复 SIG_DFL:内核直接终止、不执行继承的 NPU cleanup,父进程 context 不受影响。仅 NPU 需要。4 if PLATFORM.is_npu:5 import signal6 signal.signal(signal.SIGTERM, signal.SIG_DFL)这是一个非常具体的第三方库缺陷绕过。torch_npu 在 fork 路径上会把 SIGTERM 的处理方式改成 SIG_IGN(忽略),于是父进程的 worker.terminate()(本质是 SIGTERM)变成空操作,worker 杀不掉。绕过方式是子进程启动后立刻把 SIGTERM 恢复成 SIG_DFL(内核默认动作:直接终止进程)。
注释还解释了为什么不走正常的 cleanup 路径:走 SIG_DFL 意味着”内核直接终止、不执行继承的 NPU cleanup,父进程 context 不受影响”。如果走 Python 层的正常退出,会执行 torch_npu 注册的清理逻辑,而那份清理逻辑是从父进程 fork 继承来的,它会去动 NPU 的全局状态,从而影响父进程。用 SIG_DFL 把子进程当”纯内存”杀掉,是最干净的。
这段代码的存在本身就是一条信息:DeepInfer 的双平台(NVIDIA + Ascend)支持是深度介入的,不是”用 adapter 层糊一遍”。视觉塔的 _linear、vit_attention 的 cu_seqlens 形式、这里的信号处理,都是平台差异的补丁。本系列第七篇讲 tilelang_ops 双平台时已经看过这个模式,VE 这边是同一策略的又一处体现。
与其他框架的对照
把 DeepInfer 的 VE 链路放到三个同类框架边上,能看出设计取向的差异。
| 维度 | DeepInfer(V4.1-Flash) | vLLM | SGLang | TensorRT-LLM |
|---|---|---|---|---|
| 预处理位置 | fork 子进程 + NUMA 绑核 | 主进程内线程池 | 主进程 / tokenizer manager 侧 | 主进程 + 线程池 |
| 归一化位置 | GPU(TileLang rgb_pad_to_patches) | CPU(transformers processor) | CPU | CPU / GPU 混合 |
| token 展开 | vocab_size + offset 哨兵 + mm_token_type | input_ids 里的占位 id + mm_positions | 占位 token + 前缀哈希 | Placeholder Token + 预算记账 |
| 分辨率策略 | anyres 连续解 + max_n_token 约束 | 由 HF processor 决定 | 由 HF processor 决定 | 由 HF processor 决定 |
| 缓存命中跳过 | 前缀长度 → 图区间二分 | 前缀命中图重算 / 部分复用 | 前缀哈希 + 图像哈希 | 前缀复用树 + 图预算 |
| 跨进程传输 | pinned 共享池 + handle | 序列化 | 共享内存 | 共享内存 |
四处值得展开:
预处理进程化。vLLM 和 TensorRT-LLM 把图像预处理放在主进程的线程池里。线程池方案的好处是省掉 IPC(图像数据不用拷来拷去),坏处是不能绑核(线程的 CPU 亲和性是进程级的),而且 PIL 解码器的各种 C 层状态(Pillow 的全局锁、Image.MAX_IMAGE_PIXELS、字体缓存)会和主进程共享。DeepInfer 选子进程是为了彻底的隔离与绑核——代价是必须配一套共享池(本节讲的那些)。这是两种取向的代表:共享内存省拷贝但隔离弱,进程隔离强但要求一整套句柄管理。
归一化位置。DeepInfer 是唯一把 normalize 放到 GPU 的。理由是 CPU 侧的 resize 已经是一次全量内存写(best_width × best_height × 3 字节),如果紧跟着做 normalize,那又是一次全量读写。挪到 GPU 的好处是 normalize 可以和 pad、切 patch 融进同一个 kernel。代价是模型侧必须知道 mean/std/pad_color(就是 derive_image_transform_cfg 那套),而这些常量在 HF 的 config.json 里没有——所以要用 assert 保证派生发生。
token 展开方案。vocab_size + offset 这个哨兵方案在四个框架里比较少见。vLLM 用 mm_positions 显式记录每个多模态项的 token 区间,不依赖”超出词表的 id”;SGLang 用 PlaceholderRange 记录占位 token 的位置范围。DeepInfer 的方案更底层——它直接在 input_ids 里放越界值,让 embedding gather kernel 自然地把它们置零,再靠 mm_token_type 回填。这个方案的好处是不需要额外的位置数据结构(input_ids 本身携带位置信息),坏处是越界 id 会污染任何直接按 input_ids 做假设的代码(比如任何 embed_tokens 之外的查表、任何按 vocab 做 one-hot 的地方)。
缓存命中跳过。这是四个框架里差异最大的一块。vLLM、SGLang、TensorRT-LLM 都有”多模态项哈希”的概念——它们把图像的特征或哈希纳入前缀键,命中就是”整张图跳过”。DeepInfer 走的是”前缀 token 长度 → 图区间二分”这条路,不维护额外的哈希结构。两种取向的差异在语义粒度上:哈希方案能识别”同一张图”(图像内容相同就算是同一项),而 DeepInfer 的方案只能识别”同一位置的同一 token 前缀”(位置和长度都对上才算命中)。
DeepInfer 的方案更保守,但更简单也更可靠——它复用的是真正的 KV 前缀,不是”内容等价”的推断。哈希方案会有一个真实风险:如果哈希冲突,或者同一张图被放在不同位置,复用的 KV 可能语义上不等价(位置编码不同)。用 token 前缀 + 位置区间二分,等价性是从前缀缓存的语义里直接继承的。
小结
把本文的十个关键设计决策收一遍:
进程与设备边界
- 图像预处理 fork 独立子进程,绑到兄弟超线程——解码延迟与不可信输入都不进 engine 关键路径。
- CPU 只做 contain-resize 输出 uint8 裸像素,normalize/pad/切 patch 全部融进一个 GPU kernel——省掉三次全量内存往返。
- worker 崩溃用
os.abort()整进程放弃而不是重启 worker——带病运行的代价比重启高。
几何与表示
- anyres 连续解空间 +
max_n_token约束 + 保持长宽比,闭式解一元二次方程——取整方向统一为”约束方向 floor、分辨率方向 ceil”。 - 每行插入
IMAGE_NEW_LINE,让 1D RoPE 的 LLM 恢复二维感知——只多 1/w 的 token,不加新机制。 vocab_size + offset哨兵 token +mm_token_type单一真源,首 token 强制IMAGE类型——下游 MoE gate 按>= IMAGE判类。patch_size从 HFconfig.json读、ViT 结构硬编码为常量,靠assert images.shape[1:] == (3, 14, 14)把两侧绑定。
计算
- ViT 在 14×14 精细网格上做双向全局 MHA,双路 (行/列) RoPE——之后才做 3×3 patch 下采样;用便宜模型的高分辨率计算换贵模型的低分辨率输入。
- 残差物化点由代码结构固定,不交给融合启发式——bf16 加法顺序影响逐 bit 结果,确定性推理的前提。
资源与时序
- pinned 共享池 + handle 所有权凭证 + event 延迟归还——异步 H2D 与池块复用之间的时序靠
defer/free_deferred两阶段切开;int64 字节偏移避免长文档场景 wrap。
这十条里有一半以上不是”算法”,而是时序、所有权、取整方向、类型宽度这类工程约束。视觉编码器模块之所以值得单独讲一篇,正是因为这个:它的算法部分(ViT 前向)用几百行就写完了,而让它在生产环境下正确、可复现、不掉帧的部分,分布在处理器、几何规划、进程管理和内存池这四处。理解了这个分布,再去看别的框架的多模态实现,就能很快判断出”它的 VE 复杂度花在哪了”。
参考资料
- DeepInfer 官方仓库(DeepSeek 开源推理引擎)
- deepseek-recipe 官方配方仓库
- DeepSeek-VL2: Mixture-of-Experts Vision-Language Models for Advanced Multimodal Understanding
- DeepSeek-OCR: Contexts Optical Compression
- FlashMLA(DeepSeek 开源 MLA 内核)
- DeepGEMM(DeepSeek 开源 FP8 GEMM 内核库)
- DeepEP(DeepSeek 开源专家并行通信库)
- TileKernels(DeepSeek 开源 tile 级内核库,含 apply_rotary / swiglu_forward)
- FlashAttention(IO 感知的精确注意力实现,ViT 变长前向所用
_flash_attn_fwd的上游) - Qwen2-VL: Enhancing Vision-Language Model’s Perception of the World at Any Resolution
站内延伸阅读:
- MLA 完全拆解:DeepSeek 低秩 KV 压缩注意力的原理与全部实现细节
- FlashAttention 完全拆解(一):IO 感知与分块注意力算法
- GQA 完全拆解:分组查询注意力,用 1/8 的 KV 缓存保住 MHA 的质量
- RoPE 完全拆解:旋转位置编码的原理、实现与长度外推
- DeepSeek-OCR 完全拆解:光学上下文压缩——10 倍压缩下 97% 还原
- DeepInfer 源码完全拆解(一):整体架构与模块全景——DeepSeek 官方推理引擎如何接管 V4.1-Flash
- DeepInfer 源码完全拆解(二):异步调度与零开销调度 ZOS——把 CPU 后处理移出关键路径
- DeepInfer 源码完全拆解(七):TileLang 特化算子与双平台——一套接口如何同时落到 CUDA 与 Ascend
- DeepSeek-V4.1-Flash 推理侧完全拆解:890 字节/token 的 KV Cache 是怎么压出来的
- TensorRT-LLM 源码完全拆解(十九):多模态输入——视觉编码器、Placeholder Token 与嵌入回填
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



