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)
逐行读:
model_type从deepseek_v4扩到deepseek_v41,但两者走不同的分支hf_config.model_type = "deepseek_mtp"—— 两者都要改(因为都要进 MTP 那套代码路径)architectures只有 V4 设置:V4.1 不设,因为 DSpark 路径会自己重写architectures- 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]
逐行读:
- V4 保持 256 不变,但加了一句注释解释这个 256 的口径
- V4.1 继承
DeepseekV4IndexerBackend(不是重写),只覆盖两个静态方法 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的幂 的除法与乘倒数【精确等价】,不引入新舍入
⭐ 学什么(这三点是精华):
- 跨平台数值差异是真实存在的:NVIDIA 默认不 flush subnormal,AMD 会。同一个 kernel 在两家卡上行为不同。
- "改成乘倒数"不只是性能优化,更是数值修复 —— 而且作者论证了等价性("both forms are exact powers of two"),这是能让人放心合并的关键。
- 这类 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),
)
⭐ 学什么:
- 把一个硬编码的
isinstance检查提取成命名函数 —— 这本身就是好重构。名字is_state_bucket直接表达了意图。 - 概念一般化:原来只有 Mamba 是"state-like",现在三类都是:
MambaSpecCircularBufferSpec← V4.1 新增(SWA Bounded Replay 用)SlidingWindowSpec(且未切分时)
- 三者的共性(从注释推出来):空间占用与序列长度无关 → 所以"可以压缩到每组一个 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。