跳到正文

THE JOURNAL / FIELD NOTES

vLLM PR #56214:代码差异与兼容性语义

这是技术笔记的公开整理版,原始记录日期为 2026-09-15,本次编辑于 2026-09-21。保留原笔记的公式、代码位置与证据分级;本次仅整理内容,未重新运行 GPU 实验或逐项复核上游。标为【码】【核验】的内容指原记录的核查结果,不代表当前版本仍然如此。

硬件边界:本文涉及 Hopper 与 Blackwell 的不同实现。H100/H20 可用于适配的 Hopper 路径;SM100/SM103 专属实验需单独申请 B200/B300/GB300 等对应设备。没有对应设备时,只能阅读代码或进行不依赖设备的逻辑验证,不能声称复现文中性能。所有跑分沿用原文标注的硬件前提。

每个改动都给出:本地命令 → diff → 逐行解释 → 学什么 关键 SHA:e77daef89e(PR #56214 的 merge commit)


0. 准备:先把 PR 检出到本地看

cd ./vllm  # 先将上游仓库克隆到此目录

# 方式 A:只看 diff(推荐,不改工作区)
git show e77daef89e --stat
git show e77daef89e -- <某个文件>

# 方式 B:真的检出这个 PR 的代码来跑
git worktree add ../vllm-pr56214 e77daef89e
cd ../vllm-pr56214

方式 B 的好处:可以在编辑器里直接跳转、看上下文、跑测试。学代码建议用 B。


① 投机解码:V4.1 没有 classic MTP,只有 DSpark

文件:vllm/config/speculative.py 命令:git show e77daef89e -- vllm/config/speculative.py

改动 1a:配置分发阶段(约 679 行)

-        if hf_config.model_type == "deepseek_v4":
+        if hf_config.model_type in ("deepseek_v4", "deepseek_v41"):
+            # V4.1 has no classic-MTP draft: its checkpoints ship DSpark stages
+            # under ``mtp.*``, so only V4 gets an MTP architecture here. The
+            # DSpark path rewrites ``architectures`` itself and needs only
+            # ``n_predict``; ``method="mtp"`` on V4.1 is rejected below.
+            is_v41 = hf_config.model_type == "deepseek_v41"
             hf_config.model_type = "deepseek_mtp"
             n_predict = getattr(hf_config, "num_nextn_predict_layers", None)
-            hf_config.update(
-                {"n_predict": n_predict, "architectures": ["DeepSeekV4MTPModel"]}
-            )
+            overrides = {"n_predict": n_predict}
+            if not is_v41:
+                overrides["architectures"] = ["DeepSeekV4MTPModel"]
+            hf_config.update(overrides)

逐行读:

  1. model_type 从 deepseek_v4 扩到 deepseek_v41,但两者走不同的分支
  2. hf_config.model_type = "deepseek_mtp" —— 两者都要改(因为都要进 MTP 那套代码路径)
  3. architectures 只有 V4 设置:V4.1 不设,因为 DSpark 路径会自己重写 architectures
  4. V4.1 只需要 n_predict

⭐ 学什么:这是一个**"共享入口、分叉处理"**的经典写法。共同部分(改 model_type、取 n_predict)提到外面,差异部分(architectures)用 if not is_v41 排除。

改动 1b:显式报错(约 1341 行)

                     self.method = "mtp"
+                    if (
+                        self.target_model_config is not None
+                        and self.target_model_config.hf_config.model_type
+                        == "deepseek_v41"
+                    ):
+                        raise ValueError(
+                            "DeepSeek V4.1 has no classic-MTP draft: its "
+                            "checkpoints ship DSpark stages under mtp.* "
+                            "(main_proj/markov_head/confidence_head) and carry "
+                            "no e_proj/h_proj/enorm/hnorm/hc_head weights. Use "
+                            "speculative method 'dspark' instead of 'mtp'."
+                        )

⭐ 这段报错信息本身就是最好的文档,它告诉了你两件事:

V4 的 classic MTP V4.1 的 DSpark
checkpoint 位置 mtp.* 也用 mtp.*(同名不同内容)
权重 e_proj / h_proj / enorm / hnorm / hc_head main_proj / markov_head / confidence_head
方法名 mtp dspark

⭐ 学什么:错误信息应该告诉你"怎么改",不只是"错了"。这条错误信息做到了 —— 它不仅说"V4.1 没有 classic MTP",还列出了缺哪些权重、该用什么方法。对比一下你自己写过的报错,这是可以立刻模仿的。

改动 1c:DSpark 路径的配置(约 1416 行)

-                    # DeepSeek-V4 DSpark reuses the full DeepSeek-V4 config
+                    # DeepSeek-V4(.1) DSpark reuses the full target config
                     # and its weights ship in the target checkpoint.
-                    self.draft_model_config.hf_config.model_type = "deepseek_v4"
-                    self.draft_model_config.hf_config.architectures = [
-                        "DSparkDraftModel"
+                    is_v41 = (
+                        self.target_model_config.hf_config.model_type == "deepseek_v41"
+                    )
+                    draft_hf_config = self.draft_model_config.hf_config
+                    draft_hf_config.model_type = (
+                        "deepseek_v41" if is_v41 else "deepseek_v4"
+                    )
+                    draft_hf_config.architectures = [
+                        "DSparkV41DraftModel" if is_v41 else "DSparkDraftModel"
                     ]
+                    if is_v41:
+                        # hf_config_override set n_predict to the number of
+                        # MTP stages (3), but one DSpark round drafts
+                        # dspark_block_size tokens; num_speculative_tokens
+                        # divisibility is checked against n_predict below.
+                        draft_hf_config.n_predict = getattr(
+                            draft_hf_config, "dspark_block_size", None
+                        ) or getattr(draft_hf_config, "n_predict", None)

⭐ 这里有个关键的数字语义修正(注释说得很清楚):

hf_config_override 把 n_predict 设成了 MTP stages 的数量(3)
但一轮 DSpark 草拟的是 dspark_block_size 个 token
而 num_speculative_tokens 的整除检查是拿 n_predict 做的
→ 所以 V4.1 必须把 n_predict 从 3 改成 dspark_block_size

【推断】 这是一个**"变量名不变但语义变了"的坑**:n_predict 在 V4 里是"MTP 层数"(3),在 V4.1 里应该是"DSpark 一轮草拟多少 token"。复用同一个字段名但含义不同 —— 如果不改,下游的整除性检查会算错。

⭐ 学什么:getattr(x, "dspark_block_size", None) or getattr(x, "n_predict", None) 这个链式回退写法很常见:优先用新字段,没有再退回旧字段。


② Indexer backend:V4 与 V4.1 的 block size 必须分开

文件:vllm/v1/attention/backends/mla/indexer.py 命令:git show e77daef89e -- vllm/v1/attention/backends/mla/indexer.py

@@ -245,9 +245,20 @@ class DeepseekV4IndexerBackend(DeepseekV32IndexerBackend):
     @staticmethod
     def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
+        # Block sizes count uncompressed tokens: C4 indexer pages hold 64 rows.
         return [256]
+
+
+class DeepseekV41IndexerBackend(DeepseekV4IndexerBackend):
+    @staticmethod
+    def get_name() -> str:
+        return "DEEPSEEK_V41_INDEXER"
+
+    @staticmethod
+    def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
+        return [64 if current_platform.is_device_capability_family(90) else 128]

逐行读:

  1. V4 保持 256 不变,但加了一句注释解释这个 256 的口径
  2. V4.1 继承 DeepseekV4IndexerBackend(不是重写),只覆盖两个静态方法
  3. get_supported_kernel_block_sizes() 返回平台相关的值:SM90 → 64,其他 → 128

⭐ 关于那个注释:"Block sizes count uncompressed tokens: C4 indexer pages hold 64 rows"

【推断】 这解释了为什么 V4 是 256 而 C4 的 page 只有 64 行 —— block size 计的是"未压缩 token 数",而 page 行数是压缩后的。CSA2 的压缩率 m=2/4 之类会把两者关联起来。

⭐ 学什么:

  • 子类继承 + 只覆盖必要的方法:DeepseekV41IndexerBackend 只改了名字和 block size,其余全部继承 —— 这是最小改动的正确姿势
  • is_device_capability_family(90):注意它用的是 family(90)而不是精确的 9.0,所以 SM90 家族(H100/H200/H800)都命中

本地关联文件(V4.1 模型侧只有 2 行改动):

git show e77daef89e -- vllm/models/deepseek_v4_1/attention.py
 from vllm.v1.attention.backends.mla.indexer import (
-    DeepseekV4IndexerBackend,
+    DeepseekV41IndexerBackend,
     dsa_indexer_uses_fp4,
     get_max_prefill_buffer_size,
 )
...
     def get_attn_backend(self) -> type[AttentionBackend]:
-        return DeepseekV4IndexerBackend
+        return DeepseekV41IndexerBackend

③ ⭐ MXFP8 的 CDNA subnormal 陷阱(数值修复)

文件:vllm/model_executor/layers/quantization/utils/mxfp8_utils.py 命令:git show e77daef89e -- vllm/model_executor/layers/quantization/utils/mxfp8_utils.py

         amax = tl.maximum(tl.max(tl.abs(x), axis=1), TINY)  # [BLOCK_M]
         sb = tl.ceil(tl.log2(amax / FP8_MAX)) + 127.0
         sb = tl.minimum(tl.maximum(sb, 0.0), 254.0)
-        descale = tl.exp2(sb - 127.0)
-        xq = (x / descale[:, None]).to(xq_ptr.dtype.element_ty)
+        # Scale by the reciprocal rather than dividing by ``exp2(sb - 127)``:
+        # sb == 0 (an all-zero / denormal block) makes that divisor 2**-127,
+        # which is subnormal in fp32 and flushes to zero on CDNA, turning the
+        # block's zeros into 0/0 == NaN. The reciprocal is normal for every
+        # reachable sb, and both forms are exact powers of two, so nothing
+        # else changes.
+        rescale = tl.exp2(127.0 - sb)
+        xq = (x * rescale[:, None]).to(xq_ptr.dtype.element_ty)

⭐ 这个 bug 值得逐步复现一遍:

前提:MXFP8 是 block-wise 量化,每行算一个共享指数 sb(bias = 127)

① sb 被 clamp 到 [0, 254]  →  sb 可以是 0
② 旧代码:descale = exp2(sb - 127) = exp2(-127) = 2^-127
③ 2^-127 在 fp32 里是什么?fp32 的最小规格化数是 2^-126
   → 2^-127 < 2^-126  ⇒  【subnormal(非规格化数)】
④ CDNA(AMD GPU)默认 flush subnormal to zero(FTZ)
   → descale 变成 0
⑤ x / 0 → 对 x=0 的元素就是 0/0 = 【NaN】
⑥ 新代码:rescale = exp2(127 - sb) = exp2(127) = 2^127
   → 2^127 远大于 2^-126  ⇒  【normal,不会被 flush】
⑦ 而且 2的幂 的除法与乘倒数【精确等价】,不引入新舍入

⭐ 学什么(这三点是精华):

  1. 跨平台数值差异是真实存在的:NVIDIA 默认不 flush subnormal,AMD 会。同一个 kernel 在两家卡上行为不同。
  2. "改成乘倒数"不只是性能优化,更是数值修复 —— 而且作者论证了等价性("both forms are exact powers of two"),这是能让人放心合并的关键。
  3. 这类 bug 只在特定输入上触发(全零 / denormal block)—— 典型"测试测不到,生产会炸"。

你可以自己验证:

import numpy as np
print(np.float32(2.0)**-127)          # 0.0(subnormal 被 flush)
print(np.float32(2.0)**-126)          # 1.1754944e-38(最小 normal)
print(np.float32(2.0)**127)           # 1.7014118e+38(normal)

④ KV cache:把"state bucket"的概念一般化

文件:vllm/v1/core/kv_cache_utils.py 命令:git show e77daef89e -- vllm/v1/core/kv_cache_utils.py

改动 4a:导入 + 新增判定函数

 from vllm.v1.kv_cache_interface import (
     AttentionSpec,
     ChunkedLocalAttentionSpec,
+    CircularBufferSpec,
     FullAttentionSpec,
-    # Bytes a block must hold however the mamba buckets end up split: a mamba
-    # bucket can go down to one state per group, every other bucket's split is
-    # already fixed by the repeat pattern.
+    # Bytes a block must hold however the state buckets end up split: mamba,
+    # circular-buffer and unsplit sliding-window buckets can go down to one
+    # state per group, every other bucket's split is fixed by the repeat pattern.
+    def is_state_bucket(spec: UniformTypeKVCacheSpecs) -> bool:
+        if isinstance(spec.first_spec, (MambaSpec, CircularBufferSpec)):
+            return True
+        return repeats_per_group is None and isinstance(
+            spec.first_spec, SlidingWindowSpec
+        )
+
     anchor_bytes = max(
         (
             widest_group_bytes(
                 page_size_layers,
                 len(spec.kv_cache_specs)
-                if isinstance(spec.first_spec, MambaSpec)
+                if is_state_bucket(spec)
                 else num_groups_for(spec, balanced),
             )

⭐ 学什么:

  1. 把一个硬编码的 isinstance 检查提取成命名函数 —— 这本身就是好重构。名字 is_state_bucket 直接表达了意图。
  2. 概念一般化:原来只有 Mamba 是"state-like",现在三类都是:
    • MambaSpec
    • CircularBufferSpec ← V4.1 新增(SWA Bounded Replay 用)
    • SlidingWindowSpec(且未切分时)
  3. 三者的共性(从注释推出来):空间占用与序列长度无关 → 所以"可以压缩到每组一个 state";而 attention KV 的增长与序列长度相关 → 必须按 repeat pattern 切分。

【推断】为什么 V4.1 需要 CircularBufferSpec:V4.1 的 SWA Bounded Replay 用循环缓冲存窗口 KV。这打破了原来"Mamba 是唯一 state-like 类型"的假设。

改动 4b:第二处使用点

-        # `_align_hybrid_block_size` pads a mamba state up to one attention
-        # page, so cap a mamba group at the states a block already fits rather
-        # than let it widen the block.
-        if anchor_bytes and isinstance(spec.first_spec, MambaSpec):
+        # Cap a state group at the states a block already fits rather than let
+        # it widen the block.
+        if anchor_bytes and is_state_bucket(spec):

⭐ 学什么:同一个判定用了两次 —— 这就是提取函数的价值。如果还是两处 isinstance(..., MambaSpec),加第三类时很容易漏改一处。

改动 4c:Eagle/DSpark 的模型类型判定

-    return (
-        model_config is not None and model_config.hf_config.model_type == "deepseek_v4"
+    return model_config is not None and model_config.hf_config.model_type in (
+        "deepseek_v4",
+        "deepseek_v41",
     )

⭐ 学什么:注意这个改动模式和 ① 里那个相反:

  • ① 里 deepseek_v4 和 deepseek_v41 必须分叉(因为行为不同)
  • ④c 里两者可以合并(因为行为相同)

同一个 PR 里出现两种相反的改法,判断依据是"行为是否相同" —— 这正是需要读懂的地方。


⑤ MoE 的 token 分发位置(可选)

文件:vllm/distributed/kv_transfer/...、vllm/v1/simple_kv_offload/manager.py 等 这些是配套适配,改动较小,建议先跳过。

# 想看的时候
git show e77daef89e -- vllm/v1/simple_kv_offload/manager.py
git show e77daef89e -- vllm/v1/worker/gpu_model_runner.py

⑥ 另一处数值处理改动

文件:同样在 vllm/v1/attention/backends/mla/indexer.py,但在另一处(约 1250 行)

-            seq_lens_is_buffer_view = (use_native and next_n > 1) or (
-                not use_native and max_decode_len > 1
-            )
+            # Flattening always returns a buffer view, including single-token
+            # batches. Keep its address stable across varlen graph replays.
+            seq_lens_is_buffer_view = not use_native or next_n > 1

逐行读:

  • 旧逻辑:只有多 token 时才认为是 buffer view
  • 新逻辑:只要 not use_native 就一定是 buffer view,单 token 也算

⭐ 注释说明了动机:"Flattening always returns a buffer view, including single-token batches. Keep its address stable across varlen graph replays."

【推断】 这是一个 CUDA graph 相关的地址稳定性问题:如果判断成"不是 buffer view",代码可能会去拷贝一份,导致地址在 graph replay 之间变化,而 graph 里捕获的是固定地址 → 出错(或结果不对)。

⭐ 学什么:CUDA graph 要求地址稳定。任何"某个张量是不是 view"的判断,都可能影响 graph 的正确性。这类 bug 很难测(只在 varlen + graph replay 下出现)。


⑦ 配套的其它核心改动

7a:模型注册(registry.py)

 _MULTIMODAL_MODELS = {
     ...
+    "DeepseekV41ForCausalLM": (
+        "vllm.models.deepseek_v4_1",
+        "DeepseekV41ForCausalLM",
+    ),
 }

 _SPECULATIVE_DECODING_MODELS = {
     ...
     "DSparkDraftModel": ("vllm.models.deepseek_v4", "DSparkDeepseekV4ForCausalLM"),
+    "DSparkV41DraftModel": (
+        "vllm.models.deepseek_v4_1",
+        "DSparkDeepseekV4ForCausalLM",   # ← 注意:类名和 V4 一样
+    ),
 }

⭐ 学什么:V4.1 的投机模型复用了 V4 的类(DSparkDeepseekV4ForCausalLM),只是注册名不同。所以:

  • 注册名(DSparkV41DraftModel)是给 HF config 匹配用的
  • 实现类指向 v4_1 包,但类名沿用 V4 的

【推断】 这说明 V4.1 的 DSpark 实现与 V4 共享同一份代码,差异只在配置层(回到 ① 的 n_predict 语义修正)。

7b:DSpark 的 attention backend 选择

-    # DeepSeek-V4 draft layers share the target's KV-cache layout. Other
+    # DeepSeek-V4(.1) draft layers share the target's KV-cache layout. Other
     # DSpark architectures may use a different attention kind.
-    if draft_model_config.hf_config.model_type == "deepseek_v4":
+    if draft_model_config.hf_config.model_type in ("deepseek_v4", "deepseek_v41"):

⭐ 学什么:这里可以合并,因为 draft 层复用 target 的 KV-cache 布局这个行为两者一致。

draft 复用 target 的 KV cache 布局 —— 这是个重要的设计事实,也是为什么投机解码能省显存。


8. 三处"合并 vs 分叉"的对照(这个练习最值)

同一个 PR 里,对 deepseek_v4 / deepseek_v41 的处理有三种不同模式:

位置 处理方式 依据
config/speculative.py 分发 分叉(V4.1 不设 architectures) 行为不同:V4.1 没有 classic MTP
config/speculative.py DSpark 段 分叉(n_predict 语义不同) 行为不同:MTP 层数 ≠ DSpark block size
kv_cache_utils.py _is_deepseek_v4_eagle 合并 in (...) 行为相同:都走同一个 fallback
dspark/utils.py backend 选择 合并 in (...) 行为相同:都复用 target 的 KV 布局
registry.py 新增独立注册项 注册名必须区分,但实现类共享

⭐ 练习建议:把这五处找出来,逐一问自己"为什么这里合并、那里分叉"。能答清楚,你就理解了"接线层"工作的本质 —— 判断哪些差异是语义性的(必须分叉),哪些只是命名性的(可以合并)。


9. 可直接运行的本地命令清单

cd ./vllm
PR=e77daef89e

# 全貌
git show $PR --stat
git log --oneline e6821ceac9..$PR           # 8 个 commit

# 五处核心(按信息密度排序)
git show $PR -- vllm/config/speculative.py                              # ★★★
git show $PR -- vllm/model_executor/layers/quantization/utils/mxfp8_utils.py  # ★★★
git show $PR -- vllm/v1/core/kv_cache_utils.py                          # ★★
git show $PR -- vllm/v1/attention/backends/mla/indexer.py               # ★★
git show $PR -- vllm/models/deepseek_v4_1/attention.py                  # ★

# 配套
git show $PR -- vllm/model_executor/models/registry.py
git show $PR -- vllm/v1/worker/gpu/spec_decode/dspark/utils.py
git show $PR -- vllm/compilation/breakable_cudagraph.py
git show $PR -- vllm/config/engram.py

git diff 4b839c3378 $PR -- vllm/

# 想看带上下文的完整文件(而不只是 diff)
git worktree add ../vllm-pr56214 $PR

10. 证据分级

内容 等级
所有 diff 代码 【码】(git show 直接输出)
代码注释里的解释 【码】(作者的注释,属声明,但可直接核对)
我加的"逐行读"与"学什么" 【推断】
block size 为何 SM90=64/其他=128 无法确认(报告与代码均未解释)
n_predict 语义变化的完整影响链 【推断】(基于注释,未追下游调用点)

文献版本说明

本文所引 DeepSeek-V4.1-Flash 技术报告为原笔记记录的 2026-09-10、51 页版本(文件名 DeepSeek_V41_Tech_Report.pdf),页码对应这一版本。本次整理未获得可独立确认的公开下载链接,未将 PDF 打包进本站;涉及该报告的数值沿用原笔记,仍需对照原文复核。

上游入口:vLLM PR #56214。