AkiYang推理工程手记

Decode 已经很快了,吞吐为什么上不去?

版本与源码依据
首次提交 最近更新 文章修改记录 ↗
系列阅读 · 让AI调优大模型推理展开篇目
本页目录

同一轮压测里,平均 TPOT 已经降到 8.7ms,整套 PD 的输出吞吐却只有 166.24 token/s/卡,离 230 的目标还有距离。

最后带来最大收益的改动,没有继续降低 TPOT。我们把 P 端 --prefill-max-requests 从 1 改到 2:相同 640 条请求,whole 吞吐提高 30.26%,AISBench native 区间提高 32.27%。平均 TPOT 反而略高了。

这件事改变了后续排查的顺序。此前一直盯着草稿长度、接受长度和 Decode 的执行,真正值得先处理的,却是请求怎样从 Prefill 端交付出来。顺着调度过程看下去,一个只有 4 个 token 的尾块,成了这次实验最有用的线索。

本文展开的是 2026 年 10 月 21 日保存的 DeepSeek-V4-Flash / Ascend 910C 实验,使用当时冻结的本地 SGLang 适配。数据、调度验证与结果整理在证据摘录中。相关优化的横向对照见调优总复盘,这里跟着这一个问题走到底。

如果正在排查类似现象,先读第一节的 TTFT、TPOT 与系统吞吐关系,再看 P 端排队和尾块的证据。whole 与 native 的统计窗口贯穿后面的比较,不熟悉时可对照指标口径说明;这里的收益数字仅对应本文保存的模型、负载和配置。

一、先看清这 8.7ms 包含了什么

部署采用真实的 Prefill / Decode 分离:P、D 各 8 张 Ascend 910C,共 16 张物理卡。本文所有单卡吞吐都用系统输出吞吐除以这 16 张卡。模型使用同一份 W8A8 权重,负载是容易预测的原文复制展示集,正文 16384 token,经本次聊天编码后为 16388 token,实际输出固定为 1024 token。

两组对照均发送物理 640 条请求,并发上限 C128,配置到达速率 6 req/s;正式请求全部成功,审计缓存命中为 0。D 端的草稿 5 / verify 6、Compact 自动 SPS、IndexCache freq2,以及 P/D 场景静态专家布局保持一致。变化只有 P 的单次 Prefill 接纳请求上限。

先看 cap1 的 whole 结果:平均 TTFT 为 34.0264 秒,平均 E2E 为 42.9285 秒,而 TPOT 是 8.7ms。请求的大部分生命周期,发生在首 token 到达之前。

TPOT 反映生成过程摊到输出 token 上的时间。它很低,说明这条请求开始持续输出后推进得快;首 token 之前的等待、输入处理和 PD 交接,并不会因此消失。这轮请求输出 1024 token,从首 token 到完成的客户端时间大约是 9 秒,前面却已经等了约 34 秒。

这 34 秒还没有拆成独立的设备阶段:TTFT 同时包含排队、Prefill、交接以及首 token 的相关执行。单看它还不知道哪段最慢,但已经足以提出一个更有用的问题:为什么请求那么久才开始输出?

如果只按 1 / 0.0087 估算速度,得到的是约 115 token/s 的单请求生成速度量级。整套系统却要不断完成新请求,处理它们的 16K 输入,并让 P、D 之间的供给接续起来。系统吞吐最终按整个窗口的输出 token 总量计数,二者用的时间范围不同。

二、C128 已经打满,为什么还要看 P 端

当时最容易想到的是再加并发或速率。请求多一些,D 端也许就能组成更大的批次。

但 C 是客户端在途请求上限:正在 P 前面排队的请求、正在做 Prefill 的请求、已经进入 D 的请求,都可能占着这个上限。客户端显示有 128 条在途,并没有告诉我们其中多少条正在 Decode。

cap1 的 native 区间,AISBench 统计的平均系统并发就是 128。继续往系统里堆请求之前,先看这些请求卡在哪里更划算。已有采样里,P 侧持续排队,D 等待队列为零。这让 Prefill 的供给和批次组织成为优先调查方向;P 内部到底是计算、通信还是调度在限制交付,还要继续往下看。

可以把系统简化成下面这条链路。图里的每条边都可能积累等待,D 上输出流畅的请求,只是已经走过前面阶段的那一部分。

flowchart LR
    A[客户端在途请求 C128] --> B[P 等待队列]
    B --> C[分块 Prefill 与合批]
    C --> D[PD 状态交接]
    D --> E[Decode 持续输出]
    E --> F[完成并释放客户端名额]

P 交付一个新请求,D 才多一条可继续生成的序列。即使 D 每条序列生成得很快,前面交付慢,输出流也会受影响。反过来,D 已经饱和时,继续加速 P 又可能只会把等待移到另一处。

这也是为什么低 running 数值本身很难指导调参。请求槽之外,还有 token 预算、KV 资源和各 DP 实例的分布。要知道这次为什么没接更多请求,得走到 Prefill 的接纳逻辑里。

三、16388 个 token,为什么会留下一个单独的 4

本次 P 的有效 chunk 是 8192 token。启动配置里的全局 chunked-prefill-size 为 131072,DP16 路径将其除以 DP 数,得到每个 DP 实例使用的 8192。这里的 DP16 是软件并行参数;资源统计仍按前面的 P8+D8 物理卡口径。

所以,这条实际输入为 16388 token 的请求会分成:

16388 = 8192 + 8192 + 4

前两个 chunk 正好填满预算,最后只剩 4 个 token。对这条请求而言,它们仍然要完成 Prefill,之后才能交付后续生成。尾块需要走模型前向和分布式执行,只是新输入 token 数非常少。

接下来要分清两个上限。AISBench 的 C128 控制客户端最多多少请求在途;P 的 prefill-max-requests=1 控制一次 Prefill 批次能接纳多少条请求。后者为 1 时,即使 token 预算还剩很多,当前批次已经放入一条请求,就没有第二条请求的位置了。

在当时的实际 PrefillAdder 主机侧验证里,cap1 的前几个批次是:

请求 A: [8192] → [8192] → [4]
请求 B: [8192] → [8192] → [4]

这里的方括号表示一个批次内各请求贡献的新输入 token 数。[4] 就是一轮只有这个尾块的批次。虽然 chunk 预算设成了 8192,本轮却只带入 4 个新 token。

这时先增大客户端并发,并不会改变批次最多接纳一条请求的事实。队列里可以再多等几条,尾块批次仍然是尾块批次。

四、cap2 改变了批次的接缝

把 prefill-max-requests 改为 2 后,A 的尾块放入批次,接纳器还可以继续考虑 B。token 预算没变,第二条请求只取当前能放下的部分。

实际验证的序列是这样的:

第 1 批:A 8192
第 2 批:A 8192
第 3 批:A    4 + B 8064
第 4 批:B 8192
第 5 批:B  132 + C 7936
第 6 批:C 8192
第 7 批:C  260 + 下一请求 7808

B 在第 3 批先处理 8064,下一批再处理 8192,于是还剩 16388 - 8064 - 8192 = 132。这个尾块又与 C 的第一部分合批,后面的请求沿着同样方式接续。

为什么 A 只有 4 个 token,B 却放入 8064,而非看起来能装下的 8188?因为这条路径的预算按 128-token 页对齐计费。A 的 4 个 token 占用一页预算,余下是 8192 - 128 = 8064。B 剩余的 132 又按两页计费,给 C 留下 8192 - 256 = 7936。

因此 cap2 增加的是请求槽,实际新 token 总量仍受原 chunk 和页对齐约束。它让一个请求的短尾块,接上另一个请求的输入;没有把每批 token 上限翻倍。

在单个接纳流程里依次送入同一组 320 条、每条 16388 token 的输入,实际方法的 CPU 验证给出:

项目 cap1 cap2
完成请求数 320 320
输入 token 总量 5244160 5244160
调度批次数 960 645
单批最大新输入 token 数 8192 8192

cap1 每条三批,320 条正好是 960 批。cap2 变成 645 批,总 token 没少,减少的是这些工作被拆成多少次交给执行端。

CPU 验证时提供了宽松的可用内存,目的是观察接纳与预算行为。真实运行还会受队列到达、KV 资源和 DP 分布影响,所以它先回答“这个参数有没有改变我们想改的东西”,实际能快多少留给下一轮 NPU 压测。

随后短筛保留下来的一段 P 日志有 458 条 Prefill 报告,其中 217 条是两请求批次,单请求 4-token 批次计数为零。这段日志覆盖的是短筛中的一个窗口,与主机验证指向同一种调度变化。

五、先得到一个信号,再做同负载确认

第一次 320 请求短筛,whole 单卡输出从同 N 的 158.8958 提高到 199.4288 token/s,约 25.51%。native 看起来更高,达到了 239.9814,但区间只有约 50.67 秒。

这个信号值得继续。它还没达到当时约定的 60 秒门槛,所以接下来发了一次物理 640 请求确认,服务沿用已经验证的 cap2 配置,没有重新启动模型、重做 MF 或路由验证。cap2 短筛之前的服务切换与配套准备,已经花了约 716.50 秒;确认轮的正式窗口为 189.15 秒,控制器为 292.58 秒。性能表用正式测量窗口,前面的准备成本另留记录。

下面才是本文的主对照:cap1、cap2 各一轮 640 请求,C128、6 req/s,相同输入、输出、源码、静态布局与投机配置。两轮正式请求均为 640 成功、0 失败,whole 缓存审计为 0%。列名中的 native 指原 AISBench 算法选出的区间。

指标 cap1 whole cap2 whole cap1 native cap2 native
纳入请求数 640 640 496 493
区间时长(s) 246.3904 189.1495 177.2971 133.2282
平均系统并发 111.5070 110.8591 128.0000 127.0185
TTFT 平均 / P90(s) 34.0264 / 39.0140 23.1727 / 25.0642 37.3785 / 39.2014 24.7007 / 25.1003
TPOT 平均 / P90(ms) 8.7 / 9.3 9.4 / 9.6 8.8 / 9.4 9.4 / 9.6
ITL 平均(ms) 48.9 52.7 49.2 52.9
E2E 平均(s) 42.9285 32.7640 46.3392 34.3254
实际完成速率(req/s) 2.5975 3.3836 2.7976 3.7004
输出总吞吐(token/s) 2659.8434 3464.7731 2864.7054 3789.2292
输出 / PD16 物理卡(token/s) 166.2402 216.5483 179.0441 236.8268

whole 提高 30.26%,native 提高 32.27%。两种窗口都指向同一方向,native 的两个区间也都超过 60 秒、纳入超过 128 条请求。320 短筛用于决定继续做确认;640 对照用于比较 cap1 和 cap2,前后两个 N 的高低没有混在一起算收益。

cap2 的平均 TPOT 和 ITL 都略高,但 TTFT、E2E 大幅下降,完成速率上升。对照这些指标,收益应从请求等待与供给去解释,不能归为 Decode 每个 token 算得更快。

接受长度也几乎没动。whole AL 按整轮输出 token 数除以 verify 调用数计算,cap1 为 655360 / 114009 = 5.7483,cap2 为 655360 / 113988 = 5.7494。草稿长度、verify 宽度与 D 配置都没变化。这次 Compact 的实际 proposal 分母未采全,严格接受率在证据里保留为未观测;AL 则按上述定义比较。

完整原统计、分位数、配置与计数范围都保留在结果文件里。ITL 使用原 AISBench 口径,投机输出存在多 token 流式事件,因此与 TPOT 分开报告。

六、吞吐提高时,请求究竟少等了什么

先用 whole 窗口看一个简单关系:平均系统并发大约等于完成速率乘以平均 E2E。cap1 是 2.5975 × 42.9285 ≈ 111.507,cap2 是 3.3836 × 32.7640 ≈ 110.859。这只是把同一窗口的统计量连起来理解,没有增加一份独立的测量。

系统里同时在途的请求量相近,每条请求的生命周期却缩短了约 10 秒,于是同样的客户端名额能更快完成一批、再接下一批。固定输出 1024 token 时,完成速率提高,输出吞吐就随之提高。

TTFT 从约 34 秒降到约 23 秒,变化主要发生在首 token 之前;后面的 TPOT 没有改善。结合接纳方法的 token 守恒、批次数减少,以及真实 P 日志里的合批,我们把这轮收益归到 P 端批次组织改善和交付加快上。

更细的时间分解还没有采全:少掉的调度轮次各省多少 host 时间、模型前向与通信怎样变化、交接等待又减少多少,现有数据只解释到这一级。645 与 960 是 CPU 验证中的批次数,30.26% 与 32.27% 是真实系统吞吐变化,两组结果分别回答机制和性能。

这里也没有固定并测量 D 的活跃 batch 大小。P 更快交付后,D 处理的并发组合可能发生变化;生成阶段的 TPOT 略高,正说明端到端变快时,局部指标可以向另一个方向移动。继续追原因,需要对应的 D 批次和阶段计时。

TTFT 降下来之后仍有二十多秒。这次目标是平均 TPOT 小于 20ms 时尽量提高吞吐,TTFT 作为参考保留。如果业务更在意首 token 等待,下一轮的选择还会变化。吞吐达标和在线体验之间,还隔着具体的时延要求。

七、下一次再看到“低 TPOT、低吞吐”

我会先把请求生命周期展开:首 token 前花多久,输出阶段花多久,在途请求分别等在哪里。只拿 TPOT 推算整机容量,容易漏掉输入处理与交接;只看 C 是否打满,又容易把排队误认为 Decode 已经饱和。

然后查实际生效的预算和请求槽。全局 CLI、每 DP 实例的有效 chunk、页大小、一次能接几条请求,几项放到一起,才看得出当前 batch 为什么结束。像这次的 4-token 尾块,如果只看 16K 这个整数标签,很容易被略过去。

机制有线索以后,再做一个只改变相关变量的对照。cap1→2 的关键条件,是当前确实有短尾块、队列里有后续请求,而且 token 与 KV 预算允许继续接纳。换成另一种长度、缓存状态或分布,接缝会变化;我们更早在普通负载上就测过 Pcap2 的负向结果。

因此后续保留的是这一组场景和配置:16K 正文、实际 Chat16388、零缓存、高 AL 复制负载,以及对应的 P/D 源码与布局。对这个组合,native 达到 236.8268 token/s/PD16 卡、平均 TPOT 为 9.4ms;whole 是 216.5483。性能差异指向哪里,下一轮就从哪里继续,而不是把最大吞吐写在配置名旁边就结束。

这次最有用的一步,是从“怎样再省一点 Decode 时间”,转而追问“这些请求什么时候才有机会开始 Decode”。沿着这个问题往前走,才看见 Prefill 批次里那段很短的尾巴。

返回阅读地图 →
读到这里

从一条请求,看见整个系统。

返回文章目录

本页目录

搜索文章与 Skills

↑ ↓ 选择 · Enter 打开 · Esc 关闭 · Ctrl / ⌘ K 搜索

工程图