8 张 Ascend 910B 跑 DeepSeek-V4-Flash:从 820 tok/s 到 300K 长上下文的调优实录¶
一次 Atlas 800 A2 单机推理服务的完整调优记录:TP8 与 DP2×TP4 怎么选、DSA-CP 为什么又快又危险、DSpark 接受率为什么会从 45% 掉到 16%,以及 benchmark 为什么会把人带沟里。
配套仓库
本文提到的全部原始数据、脚本与报告都在传习社 GitLab:
- 仓库:https://git.cx-studio.tech/cx-studio/DeepSeek-V4-Flash-Atlas-A2-Tuning(需登录)
- 本站项目页:DeepSeek-V4-Flash-Atlas-A2-Tuning
包括 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 | 压测脚本本身。它的正确性决定了所有结论的正确性 |
怎么读这篇
- 只想要结论:看关键数字速览和结论速览,十分钟够用。
- 准备自己复现:重点看测试环境、最终 production profile和哪些结论还没有被证明。
- 想学调优方法:调参数之前,先把尺子修对,以及从第一个反转到 DSpark这几节,才是这篇真正想讲的东西。
关键数字速览¶
-
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 已开启:
第二次 TTFT 会低得离谱,但这不是模型 Prefill 快了,而是根本没重新算那部分 token。
所以后来的 clean Prefill 协议改成:
- C1;
- 每个 context length 使用独立 seed;
- 输出尽量短;
- 每轮使用独立 prompt;
- 额外检查 Prefix Cache hit delta。
从这之后,长上下文数字才真正可比较。
Reasoning 模型的客户端 TTFT 很容易算错¶
更隐蔽的是 streaming。
某些 reasoning parser 路径下,客户端看到的第一个 content chunk 并不等于 engine 已经开始生成。实际测试里曾出现:
这不是网络波动,而是测量口径错了。
所以后面所有 reasoning latency 对比优先使用 engine-side:
而不是盲信客户端第一段文本什么时候出现。
变长输出会把 A/B 排序都打乱¶
根源在模型自己:reasoning 模型会先写一段「思考」再给答案,而想多久并不固定。
第一轮 production-like A/B 使用自然停止的变长输出,结果同一个配置两次运行的 completion token 数可以从:
漂移超过 100%。
在这种条件下,acceptance length、TPOT、总耗时都会跟着内容路径变化,甚至能让两个配置的排序反转。
后续 DSpark A/B 因此统一改成固定输出长度:
组内噪声才从约 10% 降到 2%~3%。
最后我们给 benchmark 定了三条规矩
- 跨 topology / 参数 A/B 时尽量在同一会话、同一 harness 内完成;
- 长上下文测试必须确认 Prefix Cache 没污染;
PASS不能只看 shell exit code,必须确认 result JSON、成功请求数和 engine 完成状态。
第三条尤其值得单独说。我们自己的 harness 一度存在:
这种写法会让没有完整结果的实验也可能被 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:
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.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×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%。
所以最后的拓扑判断变成了:
不是「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 路径很容易让日志看起来互相矛盾。
例如同一个进程里可能同时看到:
这不是简单的日志 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。
从实验拟合出的近似需求关系是:
以长上下文配置为例:
| 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 秒,足够让任何人产生「这开关必须进生产」的冲动。
而且短并发也很好看:
问题在于,它不是免费的。
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。
在已经确认:
但又没有足够证据证明 300K 在长期生产 workload 下绝对稳定时,最保守的选择就是把 DSA-CP 从默认 production profile 拿掉。
代价非常明确:
但换回了更大的 KV headroom 和更简单的 failure mode。
这不是「最快配置」的选择,而是「哪个失败模式更能接受」的选择。
取舍原则
当一条优化路径在两个相邻的长度上分别是「2.6× 加速」和「forward progress 停滞」,而业务需求正好落在两者之间时,默认关闭它,并把边界写进文档。
DSpark:真正该优化的不是接受率¶
这是这次最容易被指标误导的一部分。
先解释两个词:draft 是主模型之前先猜出来的候选 token,acceptance rate 是它们被主模型确认的比例。看起来,猜得越准就赚得越多——但这是本文最大的认知陷阱之一。
DSpark 是怎么「猜」的¶
先说清楚机制,后面几节的结论才站得住。
投机解码的思路很朴素:既然逐 token 生成时每一步都要等一次完整的前向,那不如先一口气猜出几个候选,再让主模型一次性把它们校验掉。
关键在于「校验」这一步几乎不额外花钱:主模型做一次前向,本来就能同时给出这几个位置上的分布。所以收益的来源是用一次前向的时间,换到多个 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。
原因很简单:
DSpark5 虽然「猜得更准」,但一次提出的 draft 更少,最终每轮推进 token 也更少。
所以:
Speculative decoding 的目标函数不是 acceptance rate,而是 accepted advancement / step latency。
真正麻烦的是:真实 reasoning 流量和 synthetic 完全不是一个世界¶
随机 token benchmark:
真实 production reasoning 流量,约 205 个有效 metrics window:
换句话说,随机 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 每轮的期望推进大约是:
如果只保留前五个 draft:
期望推进只少约 3%,但少掉了两个利用率极低的位置。
这就是为什么到了真实 workload,我们又重新开始认真考虑 DSpark5。
但 DSpark5 不能简单替换 DSpark7¶
在当前 revision 的 Sequence Parallel 路径里,cudagraph shape 要同时满足 speculative width 和 TP size 的倍数约束。(cudagraph 会把一串算子调用固化成图重复执行,所以它的 shape 必须在编译期就固定下来。)
TP=8 时:
需要和 TP8 shape 对齐。
因此 DSpark7 天然是 width 8,而 DSpark5 会落到 width 6;在 SP 路径中会直接遇到 shape 约束。
所以历史实验里经常出现:
和:
成套出现。
这也意味着一件很重要的事:
我们现在看到的「DSpark5 package decode 更快」,不能全部归因于 speculative depth 本身,因为执行路径也一起变了。
严格判断仍然需要:
只改变 num_speculative_tokens。
有些「优化开关」,最好先读源码再决定要不要测¶
这轮调优里最省时间的一次操作,不是跑 benchmark,而是读源码。
DeepSeek-V4 有 256 个 routed experts,EP8:
而当时 A2 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」合理的理解更接近:
而不是:
后者光 active token 就是 9.6M,远超这台机器百万 token 量级的 KV pool。
上线后才会遇到的问题¶
Benchmark 跑完,并不等于服务已经能长期运行。
这次真正上线以后还踩到几类完全不出现在性能表里的问题。
长时间运行后,同配置吞吐会漂¶
历史上曾出现同一套 DP2×TP4 C64 配置:
下降接近 18%。
同时容器内累计了大量未被 PID 1 回收的 VLLM::Worker zombie process(僵尸进程:已经退出、但父进程还没回收状态的进程),容器重启后这些状态被清空,性能也恢复。
这里需要谨慎区分相关性和因果性:zombie 本身通常不会持续占用实际 NPU/HBM,因此不能简单写成「僵尸进程直接吃掉了 18% TPS」。更合理的工程判断是:
长生命周期容器存在某种 runtime state degradation;容器重启会同时清理 worker、allocator、CANN/HCCL 等状态,因此定期做容器级 recycle 是有效的运维手段。
生产上,这比寻找一个「神奇的启动参数」更实际。
HTTP 200 不代表参数真的生效¶
还有一个非常隐蔽的 shell 坑:
把注释放进续行参数列表后,后面的参数可能被 shell 静默截断。
结果是:
因此服务启动后必须做 post-start assertion,而不是只检查端口:
/health 也不够¶
长上下文 stall 时,HTTP server 仍然可能活着。
所以 production health check 应该包含一个真正的 generation canary:
只有真实 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。
真正还需要补:
观察 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 还缺真正的单变量实验¶
必须固定:
只改:
否则不能把 package 差异全部归因于 speculative depth。
DSA-CP 的 cliff 位置还值得精确扫描¶
目前真正可靠的边界是:
如果未来要重新考虑 DSA-CP,最值得补的不是更多短 benchmark,而是:
找到真正的 failure boundary。
这些实验完成后,才有资格把「当前 production profile」写成「最终 production profile」。
最后:这次真正学到的不是一条启动命令¶
如果只把这次调优总结成:
那其实错过了最有价值的部分。真正重要的是几次认知反转。
第一,最高吞吐的 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、控制并发,并接受一个事实:最快的配置,并不一定是最适合长期服务的配置。
延伸阅读¶
本文的环境与特性清单以官方文档为准,配套的仓库与部署记录如下:
- 配套仓库:DeepSeek-V4-Flash-Atlas-A2-Tuning(传习社 GitLab,需登录)—— 本文全部原始数据、脚本与报告:3 份调优报告、生产启动脚本、压测与探针工具、逐点 benchmark JSON、引擎日志与溯源信息。仓库导航见本站项目页。https://git.cx-studio.tech/cx-studio/DeepSeek-V4-Flash-Atlas-A2-Tuning
- vLLM Ascend 官方教程:DeepSeek-V4-Flash —— 镜像、启动方式与 Supported Features 清单,本文的环境基线都跟它对齐。https://docs.vllm.ai/projects/ascend/en/main/tutorials/models/DeepSeek-V4-Flash.html#2-supported-features
- Atlas 800 A2 单机部署 DeepSeek-V4-Flash:从上线到全维度压测(含完整配置)(2026-09-07)—— 同一套硬件上的部署与压测记录,想看完整配置可以从这篇入手。https://mp.weixin.qq.com/s/jE3doza1Ol8o2YL8QfvJqw
- GPUStack 部署 DeepSeek-V4-Flash-0731 双平台实测:H20 单并发 600+ TPS,昇腾 910B 表现如何?(GPUStack,2026-08-06)—— 同一个模型在 8×NVIDIA H20-3e 与 8×昇腾 910B 上的对照实测,包含 DSpark 投机解码、长上下文性能与生产配置建议,很适合和本文的数字互相参照。https://mp.weixin.qq.com/s/fhRg7iG0OcPZY1zC3phBiQ
修订记录¶
| 版本 | 日期 | 说明 |
|---|---|---|
| v1.0 | 2026-09-16 | 首次发布 |