跳转至

8 张 Ascend 910B 跑 DeepSeek-V4-Flash:从 820 tok/s 到 300K 长上下文的调优实录

zzstar101 ・ 2026-09-16 ・ 约 30 分钟读完 ・ 推理服务 / Ascend / DeepSeek / vLLM / 性能调优

一次 Atlas 800 A2 单机推理服务的完整调优记录:TP8 与 DP2×TP4 怎么选、DSA-CP 为什么又快又危险、DSpark 接受率为什么会从 45% 掉到 16%,以及 benchmark 为什么会把人带沟里。

配套仓库

本文提到的全部原始数据、脚本与报告都在传习社 GitLab:

包括 3 份调优报告、生产启动脚本、压测与探针工具、逐点 benchmark JSON、引擎日志与版本溯源,共 539 个文件。想看完整数据而不只是结论,建议配合仓库一起读。

如果只看最后的启动参数,这次部署其实没什么特别:8 张 Ascend 910B4-1,DeepSeek-V4-Flash-0731 W8A8,TP8、EP8、8192 的 max-num-batched-tokens,再加 DSpark speculative decoding。

真正麻烦的是,几乎每一个「看起来显然」的判断,后来都被实测推翻过一次。

我们一开始看到 DP2×TP4 在 C64 下能跑到 820 tok/s,很自然地认为它比 TP8 更适合生产;后来做干净的长上下文测试,TP8 又在 32K~256K Prefill 上稳定快了 20%~30%。再后来,DSA-CP 把 256K TTFT 从 51.98 s 直接压到 19.92 s,看上去几乎没有不开的理由——直到 512K 请求把 KV 顶到 97.5% 后几乎停止 forward progress。

更有意思的是 DSpark。随机 token benchmark 里它能跑出大约 45% 的 draft acceptance,真实 reasoning 流量里却只有 16.6%。也就是说,synthetic benchmark 不只是「和线上有点差异」,而是直接把 speculative decoding 的效率高估了接近一倍。

最后,我们真正需要的并不是「某一个 benchmark 分数最高」的配置,而是一套能满足实际约束的 serving profile:

  • 单请求上下文约 300K;
  • 最大并发 32;
  • 真实 reasoning / 长上下文对话;
  • 单用户 decode 希望稳定在 50 tok/s 左右或以上;
  • 不允许长请求把整个 engine 拖进 stall;
  • KV 必须留出可预测的 headroom。

这篇文章记录的就是这个过程。

开始之前:一张图、三个指标、一份名词速查

如果你刚开始接触 AI Infra,这一节可以让你不用查资料就能读下去;已经做过推理部署的读者可以直接跳到关键数字速览。

一张图:一次请求的一生

flowchart TD
  A["请求到达"] --> B["Prefill:把整段 prompt 从头算一遍"]
  B --> C["吐出第一个 token → 记作 TTFT"]
  C --> D["Decode:一个 token 一个 token 往下吐<br/>相邻两个的间隔即 TPOT"]

图 1:一次请求的一生。长上下文服务的时间几乎都花在前两步,而用户抱怨的「慢」,通常指的是 TTFT。

三个指标:先分清「等得久」和「吐得慢」

指标 含义 生活里的类比 本文用它来判断
TTFT 发出请求到收到第一个 token 的时间 点完菜等第一道菜上桌 Prefill 能力、长上下文体验
TPOT 第一个 token 之后,每个 token 的平均间隔 上菜后每道菜之间的间隔 Decode 速度、单用户体感
吞吐(tok/s) 整个服务每秒产出多少 token 厨房一小时能出多少道菜 集群利用率、成本

为什么一定要把 TTFT 和 TPOT 分开看

一次请求分成两个阶段:Prefill 是一次性把整段 prompt 算完,Decode 是每次只多算一个 token。两者的瓶颈完全不同:前者吃算力与卡间通信,后者吃显存带宽。如果只看「平均速度」,两个问题会互相掩盖——本文后面很多结论,都建立在这个区分上。

名词速查

上面和正文里出现的缩写都在这里,先扫一眼即可,不必记住。

模型与推理

名词 一句话解释
token 模型处理文本的最小单位,可粗略理解成「字/词片段」
Prefill / Decode 见上图:先读完 prompt,再逐 token 生成
reasoning / thinking 模型先输出一段「思考过程」再给答案。thinking ON 时想多久并不固定,这是第 2 节变长输出问题的根源
KV Cache 把已经算过的「历史内容」缓存下来,避免重复计算。上下文越长它越大,是长上下文服务的显存大头
hybrid KV 本文模型混用了多种 KV 组织方式(compress-4 / compress-128 / sliding window / sparse indexer),副作用是「日志里的 KV 数字互相对不上」
Prefix Cache 新请求的前缀如果和老请求相同,就不重算、直接复用。省算力,但也会让 benchmark 谎报速度(见调参数之前,先把尺子修对)
MoE(专家混合) 每层有很多「专家」,每次只激活其中一小部分。本文模型是 256 路由专家 + 1 共享专家、top-6
MLA 多头潜在注意力,用压缩表示来减小 KV 体积;本文 num_kv_heads=1、head_dim=512 就是它的痕迹
W8A8 权重和激活都量化成 8 位,用来省显存
Speculative Decoding 投机解码:先快速「猜」几个 token,再让主模型一次性校验,猜对了就白赚
draft / acceptance draft 是猜出来的候选 token;acceptance 是被主模型确认的比例
acceptance length 平均每轮能真正推进几个 token。它比单纯的接受比例更贴近实际收益
cudagraph 把一串算子调用固化成图重复执行,减少每次启动的开销

并行与服务

名词 一句话解释
TP(张量并行) 把一层网络横向切到多张卡上,卡间要频繁通信。TP8 = 8 张卡拼成一张「大卡」
DP(数据并行) 每张卡放一份完整副本,各跑各的请求,互不干扰
EP(专家并行) MoE 专属:把专家分到不同卡上
SP(序列并行) 把一条序列切开分到多卡上算,长上下文时常用
DSA-CP / FlashComm 把长序列切开分散到多卡、并让通信与计算重叠的一类长上下文优化;CP 通常指 context parallel(上下文并行)
AllGather / AllReduce 卡间通信原语:前者把各卡的数据凑齐,后者做汇总计算
MC2 昇腾上把通信与计算融合起来执行的优化路径
HBM NPU 上的显存。本文单卡 64 GB,8 卡共 512 GB
running slot 一个 engine 同一时刻真正在跑的请求数,由 max-num-seqs 决定
preemption 显存不够时,正在跑的请求被换出去的机制;频繁发生就叫 preemption storm
admission limit 服务愿意收下的长度上限,不等于真的能算完的长度
cliff(悬崖) 性能在某个长度附近突然从「更快」变成「跑不完」,而不是缓慢退化
CANN / HCCL 昇腾的软件栈 / 集合通信库,地位类似 CUDA / NCCL
benchmark harness 压测脚本本身。它的正确性决定了所有结论的正确性

怎么读这篇

关键数字速览

  • 820 tok/s


    DP2×TP4 在 C64 短请求上的峰值。它主要来自「多了一组 running slot」,而不是单 token 执行效率更高。

  • 54.82 s


    TP8 的 256K clean C1 Prefill;同场景下 DP2×TP4 为 72.38 s。32K~256K 区间 TP8 稳定快 20%~30%。

  • 51.98 s → 19.92 s


    DSA-CP 的 256K TTFT 收益(2.61×),但 512K 请求把 KV 顶到 97.5% 后 forward progress 停滞。

  • 45.1% vs 16.6%


    Synthetic 与真实 reasoning 流量的加权接受率差距。随机 benchmark 把 speculative 效率高估了接近一倍。

  • 327680


    max-model-len 的最终取值,围绕约 300K 的业务需求设定,并留出输出余量。

  • 32


    max-num-seqs。它首先是服务容量与尾延迟参数,其次才是 throughput 参数。

结论速览:我们最后做了哪几个取舍

如果不想看完整过程,可以先看这张表。

问题 最终选择 为什么 代价
拓扑 DP1 × TP8 × EP8 ≤32 并发时整体效率更高;长上下文 Prefill 更快;KV 更大 >32 并发时需要更多 slot,否则会排队
最大上下文 max-model-len=327680 覆盖 300K 需求并留出输出余量 不再追求 800K / 1M 宣传级长度
Prefill max-num-batched-tokens=8192 在 64 GB/NPU 下是比较合理的 HBM 交换点 再往上会迅速吃掉 KV headroom
DSA-CP 生产默认不启用 256K 非常快,但更长上下文存在明显 cliff 放弃约 2.6× 的 256K Prefill 加速
Speculative DSpark 保留 对 decode 仍有明显收益 depth 必须按 workload 看,不能只看接受率
并发 max-num-seqs=32 与目标 workload 一致,并控制 KV 压力 C64 offered load 下会出现明显 waiting
HBM gpu-memory-utilization=0.90 留安全余量 牺牲一部分理论 KV 容量

但这张表容易让人产生错觉:好像我们一开始就知道答案。

实际情况正好相反。下面几节基本都在讲「为什么最开始那个看似正确的答案最后不成立」。

flowchart TD
  A["先定义真实 workload<br/>300K · 32 并发 · reasoning"] --> B["再修可信 benchmark<br/>无污染 · engine-side · 固定输出"]
  B --> C["最后才轮到参数与 KV 预算"]
  C --> D["Production profile<br/>TP8 · seq32 · MBT 8192 · util 0.90"]

图 2:这次调优真正走的顺序——先定 workload,再修 benchmark,最后才是参数。

环境与目标:先定义「跑得好」是什么

测试环境

硬件是一台 Atlas 800 A2:

项目 配置
CPU / RAM aarch64,192 核,约 502 GB RAM
NPU 8 × Ascend 910B4-1
单卡 HBM 64 GB
总 HBM 512 GB
模型 DeepSeek-V4-Flash-0731 W8A8
模型大小 约 292 GB,75 个 shard
CANN 9.0.1
Python 3.12.13
torch / torch-npu 2.10.0+cpu / 2.10.0.post2
vLLM 0.25.1
vLLM Ascend 0.19.1rc2.dev1265+g50e0ce608

使用官方 DeepSeek-V4 Flash 镜像。这个模型依赖专用 Ascend custom op,例如 npu_hc_pre_v2,因此不能把环境简单替换成任意通用 vLLM-Ascend wheel 后期待行为完全一致。

镜像版本、启动方式与官方列出的支持特性,以这份教程为准:DeepSeek-V4-Flash · vLLM Ascend 教程。本文的所有数字都建立在这套环境上。

模型本身也不是一个「普通 MLA」。配置里可以看到:

43 layers
256 routed experts + 1 shared expert
top-6
hidden size 4096
num_kv_heads = 1
head_dim = 512
index_topk = 512
sliding_window = 128
compress_ratios 在 4 / 128 之间交替
DSpark target layers = [40, 41, 42]

这些细节不是装饰,后面几节的问题几乎都能从这段配置里找到原因。先花两百字说清楚它们各自意味着什么。

① 它是一个 MoE 模型,而且每次「叫醒」的专家很少。 43 层、每层 256 个路由专家加 1 个共享专家,每个 token 只选 top-6。也就是说,模型文件很大(W8A8 之后约 292 GB),但生成每个 token 时真正参与计算的权重只是其中一小部分——这正是名字里「Flash」所指的方向,也是它能被塞进一台 8 卡机器、还能跑出几百 tok/s 的原因。代价是专家被分到了不同卡上(EP),几乎每一层都要做一次卡间通信,于是「专家怎么放、通信走哪条路径」就成了调参对象(见第 7 节)。

② 它的注意力是压缩过的,KV 还是混合的。 num_kv_heads=1、head_dim=512 说明所有查询头共用一份被压缩过的 KV 潜向量(这就是 MLA 的思路),KV 体积比普通多头注意力小得多。但配置里又出现了 compress_ratios 在 4/128 之间交替、sliding_window=128、index_topk=512:不同层用的 KV 组织方式并不相同。好处是长上下文更省显存,副作用是每种 KV 各记各的账——日志里那几个互相对不上的数字都是真的,只是口径不同(见第 4 节)。

③ 它自带一个投机解码模块。 配置最后一行 DSpark target layers = [40, 41, 42] 是这次调优最关键的一条线索:投机解码能力不在服务端,而是随 checkpoint 一起提供,并且只挂在模型自己的最后三层上(见第 6 节)。

目标不是「最高分」,而是一个具体 workload

调优前最重要的一件事其实不是写启动命令,而是先写验收标准。

我们的目标最后收敛成:

单请求 context   ≈ 300K(thinking ON)
最大并发          32
业务类型          reasoning / 长上下文对话
单用户 decode     目标 ≥ 50 tok/s
长上下文          不允许持续 stall
KV                留合理 headroom,避免 preemption storm

这几个条件很重要,因为后面很多配置在某个单项指标上非常漂亮,但一换 workload 就立即失去优势。

共同基线主要是:

Expert Parallel              ON
max-num-batched-tokens       8192
gpu-memory-utilization       0.90
FULL_DECODE_ONLY             ON
Hybrid KV Cache              ON
DSpark speculative decoding  ON

调参数之前,先把尺子修对

这次调优里,真正最危险的不是某个参数配错,而是 benchmark 在「正常运行」的同时给你错误结论。

我们踩了三个典型坑。

Prefix Cache 会把 Prefill 伪装得非常快

先说清楚它是什么:Prefix Cache 会在新请求的前缀和之前某个请求相同时,直接复用那部分已经算过的结果。它是个好特性,但在压测里会伪装数据。

长上下文测试一开始使用 random dataset。如果 seed 固定,第二次运行可能生成完全相同的 prompt。

这时如果 Prefix Cache 已开启:

128K 第一次:完整 Prefill
128K 第二次:大量 prefix cache hit

第二次 TTFT 会低得离谱,但这不是模型 Prefill 快了,而是根本没重新算那部分 token。

所以后来的 clean Prefill 协议改成:

  • C1;
  • 每个 context length 使用独立 seed;
  • 输出尽量短;
  • 每轮使用独立 prompt;
  • 额外检查 Prefix Cache hit delta。

从这之后,长上下文数字才真正可比较。

Reasoning 模型的客户端 TTFT 很容易算错

更隐蔽的是 streaming。

某些 reasoning parser 路径下,客户端看到的第一个 content chunk 并不等于 engine 已经开始生成。实际测试里曾出现:

客户端 TTFT:8.5~12 s
引擎 TTFT:  1.09~1.89 s

这不是网络波动,而是测量口径错了。

所以后面所有 reasoning latency 对比优先使用 engine-side:

vllm:time_to_first_token_seconds

而不是盲信客户端第一段文本什么时候出现。

变长输出会把 A/B 排序都打乱

根源在模型自己:reasoning 模型会先写一段「思考」再给答案,而想多久并不固定。

第一轮 production-like A/B 使用自然停止的变长输出,结果同一个配置两次运行的 completion token 数可以从:

16,294 → 34,783

漂移超过 100%。

在这种条件下,acceptance length、TPOT、总耗时都会跟着内容路径变化,甚至能让两个配置的排序反转。

后续 DSpark A/B 因此统一改成固定输出长度:

ignore_eos + 固定 max_tokens

组内噪声才从约 10% 降到 2%~3%。

最后我们给 benchmark 定了三条规矩

  1. 跨 topology / 参数 A/B 时尽量在同一会话、同一 harness 内完成;
  2. 长上下文测试必须确认 Prefix Cache 没污染;
  3. PASS 不能只看 shell exit code,必须确认 result JSON、成功请求数和 engine 完成状态。

第三条尤其值得单独说。我们自己的 harness 一度存在:

... | tee ... | grep ... || true
RC=${PIPESTATUS[0]}

这种写法会让没有完整结果的实验也可能被 summary 标成 PASS。

Benchmark harness 本身,也需要被 benchmark。

flowchart TD
  A["A/B 实验"] --> B["① Prefix Cache 已隔离"]
  B --> C["② TTFT 取 engine-side"]
  C --> D["③ 输出长度固定"]
  D --> E["④ result JSON · 请求数 · engine 状态"]
  E --> F["结论可用于决策"]
  style F fill:#1b5e20,color:#fff

图 3:四项检查全部通过,结论才算可用。任意一项不满足,数字再漂亮也不能进决策表。

第一个反转:DP2×TP4 的 820 tok/s 没有看起来那么简单

为什么我们一开始认定 DP2×TP4 更适合生产?

直觉很简单:并行度越高,算得越快。而且它确实在 C64 上跑出了 820 tok/s。

我们当时没分清的是一个更基本的问题:这个优势来自「每一步算得更快」,还是仅仅来自「同时能装下更多请求」?这是两种完全不同的原因,前者可以复制到别处,后者只属于高并发场景。

先说明两个缩写(名词速查里有):TP8 指把模型切成 8 份放到 8 张卡上;DP2×TP4 指复制出 2 份、每份占 4 张卡,用同样的 8 张卡换出两组「跑位」。

先看最诱人的结果

短请求 synthetic benchmark:

input  = 1024
output = 512
temperature = 0
ignore_eos

TP8 + DSpark7:

并发 Output throughput mean TTFT mean TPOT
C32 515.76 tok/s 1.57 s 57.1 ms
C64 689.75 tok/s 2.12 s 86.7 ms

换成 DP2×TP4 后,C64 连跑三次:

820.25 tok/s
811.97 tok/s
827.90 tok/s

平均 820.04 tok/s,比 TP8 的 689.75 高大约 19%。

如果只看这张表,很容易直接得出:

高并发生产环境应该用 DP2×TP4。

我们一开始也是这么想的。

但 820 tok/s 的优势,主要来自「多了一组 slot」

进一步做同 workload 的低并发和 C32 对齐后,图景发生变化:

指标 TP8 DP2×TP4
C1 TTFT 1.865 s 3.205 s
C1 Prefill 1.599 s 3.059 s
C1 TPOT 23.0 ms 21.0 ms
C1 单请求 TPS 43.5 47.6
C32 e2e 59.742 s 64.143 s
C32 output throughput 249.2 232.5
每 token 步时成本 0.80 ms 1.84 ms

≤32 并发时,TP8 反而是整体效率更高的一方。

为什么 C64 时 DP2×TP4 突然赢了?答案其实很朴素:

DP2 × max_num_seqs 32 / engine = 64 running slots
TP8 × DP1 × max_num_seqs 32     = 32 running slots

DP2×TP4 的 C64 优势,很大一部分是容量优势,而不是每个 token 的执行效率突然变高。

这件事在 TP8 的并发扫描里表现得非常明显:

并发 Output TPS mean TTFT mean TPOT
C1 66.5 0.45 s 14.2 ms
C8 264.5 0.96 s 27.5 ms
C16 405.4 1.16 s 35.5 ms
C32 583.5 1.45 s 50.5 ms
C64 563.8 28.56 s 54.0 ms

C32 → C64,吞吐几乎不涨,TTFT 却从 1.45 秒跳到 28.56 秒。

不是 kernel 崩了,而是后 32 个请求要等前 32 个请求完整跑完。

因此:

max_num_seqs 首先是服务容量和尾延迟参数,其次才是 throughput 参数。

长上下文进一步把天平压向 TP8

真正让我们最终偏向 TP8 的,是 clean C1 Prefill。

Context DP2×TP4 TP8
8K 2.02 s 1.62 s
32K 7.18 s 5.05 s
64K 14.25 s 10.17 s
128K 30.77 s 22.40 s
256K 72.38 s 54.82 s
512K 未获得稳定完成结果 148.76 s

32K~256K,TP8 基本稳定快 20%~30%。

所以最后的拓扑判断变成了:

短 context + >32 高并发容量需求 → DP2×TP4 有价值
≤32 并发 + 长 context            → TP8 更符合目标

不是「TP8 永远更快」,也不是「TP4 更适合生产」。关键是 workload。

flowchart TD
  Q["目标 workload"] --> C{"并发 ≤ 32"}
  C -->|是| TP["DP1 × TP8 × EP8<br/>Prefill 更快 · KV 更大"]
  C -->|否| DP["DP2 × TP4<br/>running slot 更多"]
  style DP fill:#e65100,color:#fff
  style TP fill:#1b5e20,color:#fff

图 4:拓扑选择决策。这里没有「永远更快」的答案,只有和 workload 是否匹配。

第二个坑:CLI 上的 KV 参数,不等于运行时真正的容量

背景一句话:KV Cache 决定了这台机器能同时装下多少上下文,所以它是整个容量规划的地基。

DeepSeek-V4 的 hybrid KV 路径很容易让日志看起来互相矛盾。

例如同一个进程里可能同时看到:

GPU KV cache size:      1,078,120 tokens
kv_cache_size_tokens:     244,747
kv_cache_max_concurrency:    0.306

这不是简单的日志 bug。

模型存在不同类型的 KV group:compress-4、compress-128、sliding-window、sparse indexer 等,各自 page size 和容量记账方式不同。

所以做容量规划时,不能随手抓某个 single-group metric 就当总 KV。

我们实际 profile 得到的 baseline 大致是:

项 TP8 DP2×TP4
权重 39.10 GiB 40.47 GiB
激活 1.44 GiB 1.69 GiB
非 torch 0.46 GiB 0.56 GiB
graph 1.07 GiB 1.11 GiB
可用 KV 13.8 GiB 左右 12.1 GiB 左右
等价 KV capacity ≈1.078M tokens ≈941K tokens

这也是 TP8 的另一个优势:KV headroom 更大。

为什么 max-num-batched-tokens=8192 一直没继续往上抬

max-num-batched-tokens(下文简称 MBT)决定「一个 Prefill step 最多处理多少 token」,可以理解成一次搬多少砖。

长上下文 Prefill 很自然会让人想到:

既然 chunk 太小会增加 step 数,那就把 MBT 往 16K、32K 拉。

实际启动直接给出了答案。

mbt=32768 时,activation + KV requirement 已经远超剩余 HBM。

从实验拟合出的近似需求关系是:

needed_KV ≈ 8.15 KB × max_model_len
          + 553 KB × max_num_batched_tokens

以长上下文配置为例:

MBT 大致需求 结果
8192 ≈8.6 GiB ✅ 可用
16384 ≈12.9 GiB ⚠️ 非常吃余量
32768 ≈21.6 GiB ❌ 不可行

所以 8192 最后保留下来,不是因为「官方示例这么写」,而是因为在 64 GB/NPU 上,它确实位于一个很现实的 HBM 交换点。

DSA-CP:这次最强的优化,也是最难舍弃的优化

DSA-CP 属于「把长序列切开分散到多张卡上、并让通信与计算重叠」的一类长上下文优化(CP 通常指 context parallel,即上下文并行)。本文不展开它的实现,只把它当成一个开关来验证。

为什么这类优化会有效:Prefill 的开销随序列长度增长得很快,长度上去之后注意力是主导项,一段 256K 的 prompt 很容易把算力和带宽压在少数几张卡上;把序列切开分给多张卡、并让通信与计算重叠,正好对症。

如果只评「单项性能提升」,DSA-CP 是整轮测试里最惊艳的一项。

TP8 baseline:

Context TTFT
64K 9.54 s
128K 20.99 s
256K 51.98 s

开启 DSA-CP / FlashComm 相关路径后:

Context DSA-CP Baseline Speedup
64K 7.35 s 9.54 s 1.30×
128K 9.47 s 20.99 s 2.22×
256K 19.92 s 51.98 s 2.61×
512K 未完成 144.56 s —

256K 从 51.98 秒降到 19.92 秒,足够让任何人产生「这开关必须进生产」的冲动。

而且短并发也很好看:

TP8 + DSA-CP
C32 ≈ 753 tok/s
C64 ≈ 770 tok/s

问题在于,它不是免费的。

DSA-CP 相对 baseline 的 TTFT 加速比

  64K   ████████████            1.30×
  128K  █████████████████████   2.22×
  256K  ███████████████████████ 2.61×   ← 512K 无法稳定完成

图 5:收益随上下文长度放大(1.30× → 2.22× → 2.61×),但 512K 已经无法稳定完成。

第一层代价:KV headroom 下降

baseline  TP8     ████████████████████████████ 1,078K tokens
DSA-CP    TP8     █████████████████████████ 969K tokens
baseline  DP2×TP4 ████████████████████████ 941K tokens
DSA-CP    DP2×TP4 ████████████████████ 799K tokens

也就是说,DSA-CP 在这套环境里会吃掉大约 10%~15% 的 KV headroom。

第二层代价:长到某个位置会撞 cliff

所谓 cliff(悬崖),是指性能在某个长度附近突然从「更快」变成「跑不完」,而不是缓慢退化。

最典型的是 TP8 + DSA-CP 的 512K 请求。

server-side KV usage 依次走到:

flowchart LR
  A["512K 请求"] --> B["0.6%"] --> C["83.7%"] --> D["90.8%"] --> E["97.5%"] --> F["forward 停滞"]
  style F fill:#b3261e,color:#fff

图 6:512K 请求的 KV 饱和度推进过程。失效点与 KV saturation 强相关。

这里至少能确认一件事:

这次 512K 失败和 KV saturation 强相关,而不只是「客户端等太久超时」。

这也是为什么生产上不能把 --max-model-len 512K 理解成「512K 已经可用」。

它只是 admission limit:服务愿意收下的长度上限。

真正可用长度仍然取决于 KV、activation、Prefill execution、scheduler 和并发状态。

为什么最后没有默认开 DSA-CP

我们的目标不是「256K TTFT 最低」,而是大约 300K 的可靠 serving。

在已经确认:

256K  → 很快
512K  → 出现明显 cliff

但又没有足够证据证明 300K 在长期生产 workload 下绝对稳定时,最保守的选择就是把 DSA-CP 从默认 production profile 拿掉。

代价非常明确:

256K Prefill
19.92 s → 51.98 s

但换回了更大的 KV headroom 和更简单的 failure mode。

这不是「最快配置」的选择,而是「哪个失败模式更能接受」的选择。

取舍原则

当一条优化路径在两个相邻的长度上分别是「2.6× 加速」和「forward progress 停滞」,而业务需求正好落在两者之间时,默认关闭它,并把边界写进文档。

DSpark:真正该优化的不是接受率

这是这次最容易被指标误导的一部分。

先解释两个词:draft 是主模型之前先猜出来的候选 token,acceptance rate 是它们被主模型确认的比例。看起来,猜得越准就赚得越多——但这是本文最大的认知陷阱之一。

DSpark 是怎么「猜」的

先说清楚机制,后面几节的结论才站得住。

投机解码的思路很朴素:既然逐 token 生成时每一步都要等一次完整的前向,那不如先一口气猜出几个候选,再让主模型一次性把它们校验掉。

普通解码:每一步只产出 1 个 token,走一次前向
投机解码:先猜 7 个候选(draft)
          → 主模型一次前向,并行校验这 7 个位置
          → 猜对的直接采用,第一个猜错的位置重新算

关键在于「校验」这一步几乎不额外花钱:主模型做一次前向,本来就能同时给出这几个位置上的分布。所以收益的来源是用一次前向的时间,换到多个 token。

flowchart TD
  A["上一轮接受的 token"] --> B["DSpark 一次猜 7 个 draft"]
  B --> C["主模型一次前向,并行校验这 7 个位置"]
  C --> D["接受前 k 个 + 1 个新 token<br/>本轮实际推进 1 + k 个 token"]

图 7:一轮投机解码。每轮真正推进的 token 数是 1 + k,而 k 的平均值就是 acceptance length——它比 acceptance rate 更接近真实收益。

本文用的 DSpark 随 checkpoint 一起提供,配置里只标了它挂在第 40~42 层。从这些信息看,它没有另起一个独立的小模型,而是在模型自己的后半段上做预测。这带来两个很实际的后果:

  • 不需要额外的权重文件,显存开销小,比「外挂一个 draft 模型」更省事;
  • 但它和主模型共用同一套执行图,shape 必须和并行方式对齐——本节最后一个子节要讲的 width 约束就是这么来的。

Synthetic benchmark 先给了一个反直觉结果

同一批 workload,只改 speculative depth:

num_spec Output TPS Draft acceptance Acceptance length mean TPOT
7 689.75 45.11% 4.158 86.7 ms
6 676.03 52.00% 4.120 86.5 ms
5 668.79 56.11% 3.805 87.0 ms

如果只盯 acceptance percentage,会得到完全相反的答案:DSpark5 最好。

但最终吞吐最高的是 DSpark7。

原因很简单:

吞吐 ≈ 每轮实际推进 token 数 / speculative step time

DSpark5 虽然「猜得更准」,但一次提出的 draft 更少,最终每轮推进 token 也更少。

所以:

Speculative decoding 的目标函数不是 acceptance rate,而是 accepted advancement / step latency。

真正麻烦的是:真实 reasoning 流量和 synthetic 完全不是一个世界

随机 token benchmark:

acceptance ≈ 45.1%
acceptance length ≈ 4.16

真实 production reasoning 流量,约 205 个有效 metrics window:

mean acceptance length = 2.214
median                 = 2.04
weighted acceptance    ≈ 16.62%
min / max              = 1.49 / 5.88

换句话说,随机 benchmark 对 speculative efficiency 的描述明显过于乐观。

如果想知道同一套 speculative 配置在别的硬件上是什么表现,可以对照延伸阅读里 GPUStack 那篇 H20 / 910B 双平台实测。

更直观的是 per-position acceptance:

Draft position Acceptance
#1 53.2%
#2 29.1%
#3 16.4%
#4 9.8%
#5 6.3%
#6 4.1%
#7 2.4%
Draft position acceptance(真实 reasoning 流量)

  #1  ███████████████████████████ 53.2%
  #2  ███████████████             29.1%
  #3  ████████                    16.4%
  #4  █████                        9.8%
  #5  ███                          6.3%
  #6  ██                           4.1%
  #7  █                            2.4%

图 8:接受率随 draft position 快速衰减,最后两个位置几乎没有贡献。

接受率一路下滑并不奇怪:第一个 draft 只要接住最自然的续写就行,越往后越等于在猜「猜对了第一步之后会怎样」。

DSpark7 每轮的期望推进大约是:

1 + 0.532 + 0.291 + 0.164 + 0.098 + 0.063 + 0.041 + 0.024
≈ 2.21 tokens

如果只保留前五个 draft:

≈ 2.15 tokens

期望推进只少约 3%,但少掉了两个利用率极低的位置。

这就是为什么到了真实 workload,我们又重新开始认真考虑 DSpark5。

但 DSpark5 不能简单替换 DSpark7

在当前 revision 的 Sequence Parallel 路径里,cudagraph shape 要同时满足 speculative width 和 TP size 的倍数约束。(cudagraph 会把一串算子调用固化成图重复执行,所以它的 shape 必须在编译期就固定下来。)

TP=8 时:

num_speculative_tokens + 1

需要和 TP8 shape 对齐。

因此 DSpark7 天然是 width 8,而 DSpark5 会落到 width 6;在 SP 路径中会直接遇到 shape 约束。

所以历史实验里经常出现:

DSpark7 + DSA-CP / FlashComm

和:

DSpark5 + no-SP / no-DSA

成套出现。

这也意味着一件很重要的事:

我们现在看到的「DSpark5 package decode 更快」,不能全部归因于 speculative depth 本身,因为执行路径也一起变了。

严格判断仍然需要:

DSpark7 / no DSA
vs
DSpark5 / no DSA

只改变 num_speculative_tokens。

有些「优化开关」,最好先读源码再决定要不要测

这轮调优里最省时间的一次操作,不是跑 benchmark,而是读源码。

DeepSeek-V4 有 256 个 routed experts,EP8:

256 / 8 = 32 experts per device

而当时 A2 MC2 路径的选择条件大致要求:

experts/device <= 24
EP world size >= 16

我们的机器两个条件都不满足。

因此这套拓扑下:

enable_prefill_mc2
enable_fused_mc2

不会让 MoE 通信切到 MC2,实际仍走 ALLGATHER。(AllGather 是集合通信原语:每张卡把自己那份拼给所有人;MC2 是昇腾上把通信与计算融合执行的路径。)

这种参数最好的 benchmark 方法其实是:

不 benchmark,先确认代码路径根本走不走得到。

另外几项看起来很诱人的旋钮,也没有证明自己:

配置 结果
--async-scheduling TP8 C64 689.75 → 687.31,基本无收益
shared expert multistream DP2×TP4 约 820 → 806 tok/s,无提升
多项 additional config 叠加 DP2×TP4 下降到约 718 tok/s
enable_reduce_sample 与当前 DSpark 路径不兼容,启动/捕获失败
rejection sampler 调整 acceptance 略变,但没有可靠 decode 收益,且内容路径发生变化

这部分最大的经验是:

不要把「性能优化开关」理解成可累加的 buff。

尤其在 NPU serving stack 里,每个开关都可能改变 graph、communication、memory layout 或 scheduler path。

最终 production profile

在这一轮测试结束时,我们把 production profile 收敛到一个相对简单的版本:

export OMP_NUM_THREADS=10
export PYTORCH_NPU_ALLOC_CONF=expandable_segments:True
export LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libjemalloc.so.2
export HCCL_BUFFSIZE=1024
export TASK_QUEUE_ENABLE=1
export HCCL_OP_EXPANSION_MODE=AIV

vllm serve /data/models/DeepSeek-V4-Flash-0731-w8a8 \
  --host 0.0.0.0 \
  --port 8000 \
  --served-model-name deepseek-v4-flash \
  --max-model-len 327680 \
  --max-num-batched-tokens 8192 \
  --gpu-memory-utilization 0.90 \
  --max-num-seqs 32 \
  --data-parallel-size 1 \
  --tensor-parallel-size 8 \
  --enable-expert-parallel \
  --tokenizer-mode deepseek_v4 \
  --tool-call-parser deepseek_v4 \
  --enable-auto-tool-choice \
  --reasoning-parser deepseek_v4 \
  --no-disable-hybrid-kv-cache-manager \
  --enable-prompt-tokens-details \
  --model-loader-extra-config='{"enable_multithread_load":true,"num_threads":128}' \
  --quantization ascend \
  --block-size 128 \
  --speculative-config '{"method":"dspark","num_speculative_tokens":7,"enforce_eager":true}' \
  --compilation-config '{"cudagraph_mode":"FULL_DECODE_ONLY"}'

没有额外启用 DSA-CP / SP / shared expert DP,也没有默认加 async scheduling。

这里有几个需要特别说明的边界:

  • max-model-len=327680 是围绕约 300K 的业务需求设置,不代表模型理论极限;
  • 300K Prefill probe 已经通过,但「300K context 下单用户 decode 持续 ≥50 tok/s」仍需要单独严格验收;
  • max_num_seqs=32 表示最多 32 条 running sequence,并不代表 32 条都能同时是 300K;
  • 超长请求仍需要额外的 active-token admission control。

「32 并发 + 300K」合理的理解更接近:

1 × 300K
+
若干短请求

而不是:

32 × 300K

后者光 active token 就是 9.6M,远超这台机器百万 token 量级的 KV pool。

上线后才会遇到的问题

Benchmark 跑完,并不等于服务已经能长期运行。

这次真正上线以后还踩到几类完全不出现在性能表里的问题。

长时间运行后,同配置吞吐会漂

历史上曾出现同一套 DP2×TP4 C64 配置:

早期:约 820 tok/s
长时间运行后:约 695 tok/s

下降接近 18%。

同时容器内累计了大量未被 PID 1 回收的 VLLM::Worker zombie process(僵尸进程:已经退出、但父进程还没回收状态的进程),容器重启后这些状态被清空,性能也恢复。

这里需要谨慎区分相关性和因果性:zombie 本身通常不会持续占用实际 NPU/HBM,因此不能简单写成「僵尸进程直接吃掉了 18% TPS」。更合理的工程判断是:

长生命周期容器存在某种 runtime state degradation;容器重启会同时清理 worker、allocator、CANN/HCCL 等状态,因此定期做容器级 recycle 是有效的运维手段。

生产上,这比寻找一个「神奇的启动参数」更实际。

HTTP 200 不代表参数真的生效

还有一个非常隐蔽的 shell 坑:

exec vllm serve ... \
  # comment
  --speculative-config '...'

把注释放进续行参数列表后,后面的参数可能被 shell 静默截断。

结果是:

服务正常启动
/v1/models 返回 200
但 speculative_config=None

因此服务启动后必须做 post-start assertion,而不是只检查端口:

speculative_config.method == dspark
cudagraph_mode == FULL_DECODE_ONLY
关键 additional config 与预期一致

/health 也不够

长上下文 stall 时,HTTP server 仍然可能活着。

所以 production health check 应该包含一个真正的 generation canary:

输入几个 token
→ 请求输出 1~8 token
→ 设置严格超时

只有真实 forward 成功,才能说明 engine 真正健康。

建议持续监控的指标

running / waiting requests
KV cache usage
TTFT / TPOT
generation throughput
prefill latency
decode step interval
prefix cache hit rate
speculative acceptance length
per-position acceptance
preemption

对于 speculative decoding,尤其不要只盯 acceptance percentage。

Mean acceptance length + step interval 更接近真正的效率。

哪些结论还没有被证明

这篇文章记录了很多数据,但还有几个边界必须明确写出来。

300K 下的 decode ≥50 tok/s 还没有严格验收

当前中短上下文 C1 reasoning workload 下,engine TPOT 做到过约 20 ms,已经接近 50 tok/s。

但不能直接外推到 300K。

真正还需要补:

300K context
thinking ON
output 1024~2048
重复至少 3 次

观察 TTFT、TPOT、decode TPS、KV peak、preemption 和 acceptance length。

1 × 300K + 31 × short 还缺完整 mixed workload 验收

这是比 32 × 300K 更有现实意义的 production test。

重点应该看:

  • 300K request 是否持续 forward;
  • 短请求是否被饿死;
  • TTFT tail;
  • KV peak;
  • aggregate throughput;
  • preemption。

DSpark5 vs DSpark7 还缺真正的单变量实验

必须固定:

TP8
no DSA-CP
相同 prompt
相同 output
相同 concurrency

只改:

num_speculative_tokens = 5 / 7

否则不能把 package 差异全部归因于 speculative depth。

DSA-CP 的 cliff 位置还值得精确扫描

目前真正可靠的边界是:

256K PASS
512K FAIL / stall

如果未来要重新考虑 DSA-CP,最值得补的不是更多短 benchmark,而是:

288K / 320K / 384K / 448K

找到真正的 failure boundary。

这些实验完成后,才有资格把「当前 production profile」写成「最终 production profile」。

最后:这次真正学到的不是一条启动命令

如果只把这次调优总结成:

TP8
seq32
MBT8192
util0.90
DSpark7

那其实错过了最有价值的部分。真正重要的是几次认知反转。

第一,最高吞吐的 topology 不一定是最有效率的 topology。

DP2×TP4 的 820 tok/s 很漂亮,但主要出现在 C64 这种超过单 TP8 engine slot 数的 workload。≤32 并发时,TP8 的整体效率和长上下文能力更符合我们的目标。

第二,最强的性能开关可能也是最危险的开关。

DSA-CP 把 256K Prefill 从 51.98 秒压到 19.92 秒,但 production 更关心的是 300K 是否能稳定完成,而不是 256K benchmark 能不能刷新纪录。

第三,Speculative Decoding 的 acceptance rate 不是 KPI。

Synthetic 45% 和真实 reasoning 16.6% 可以同时成立。真正要优化的是每单位 wall time 推进了多少有效 token。

第四,长上下文 serving 本质上首先是资源管理。

模型「支持」多长 context,与生产环境「应该允许」多长 context,不是一回事。max_model_len 更像 resource isolation boundary,而不是模型能力勋章。

第五,benchmark 方法错了,所有小数点后两位都没有意义。

Prefix Cache、reasoning stream、变长输出、shell exit code、长生命周期环境漂移,都足以让一张看起来很专业的性能表变成错误结论。

如果要用一句话总结:

先定义真实 workload,再建立可信的 benchmark、KV 预算和 failure boundary;最后才轮到调参数。

对我们这台 8×Ascend 910B 的 Atlas A2 来说,性能最大的杠杆最终不是「多开几个优化开关」,而是选对拓扑、理解 KV、控制并发,并接受一个事实:最快的配置,并不一定是最适合长期服务的配置。

延伸阅读

本文的环境与特性清单以官方文档为准,配套的仓库与部署记录如下:

修订记录

版本 日期 说明
v1.0 2026-09-16 首次发布