Kimi K3 批量推理的并发上限,通常不该由显存容量或标称参数直接推导,而要先用一组压测数据找到“吞吐还在上升、单请求延迟开始明显变差”的那个拐点。做法是先固定输入与输出 token 数,测出没有并发干扰时的端到端耗时(换算成每千 token 秒数),再按并发 1、2、4、8 各自跑一轮,同时记录吞吐、平均延迟、P95 延迟和显存峰值;用显存占用与 GPU 利用率两条曲线排除换页和调度干扰,最后把拐点前一档写进服务配置。这套方法适用于单机、单模型版本、配置固定的场景,换模型版本、改 batch 策略或换硬件后需要重测。
判断并发上限的顺序是:先量单请求每千 token 耗时作为基线,再按并发梯度加压看 P95 何时脱离基线,用显存与利用率曲线确认瓶颈在算力、排队还是显存换页。并发上限建议取拐点档位的前一档,而不是最高吞吐那一档;单请求延迟要求宽松的离线批处理可以贴着拐点走,交互式任务应留出余量。前提是压测时输入输出长度、采样参数与线上一致,否则拐点位置不可复用。
固定输入输出长度,先测单请求端到端耗时
基线数据的价值在于它是后面所有对比的参照,所以第一轮必须没有并发干扰:只发一个请求,等它完整返回再发下一个。输入长度固定可以用一段说明性文本按需重复拼接到目标长度,然后在响应里读 usage.prompt_tokens 校验每轮是否一致;不一致的轮次直接丢弃重跑。输出长度靠 max_tokens 固定,temperature 设为 0,关闭流式,这样测到的是完整端到端时间而不是首 token 时间。
统计方式建议是:先跑一轮预热并丢弃结果,再连续跑 20 次以上,取中位数和 P95。中位数代表常态,P95 代表尾部抖动,两者差距过大说明有外部干扰(其他进程占卡、网络抖动、日志落盘),需要先清干净再继续。下面的骨架用标准库即可运行,把地址和模型名换成实际服务的即可。
# measure_single.py —— 单请求基线,无并发
import time, json, statistics, urllib.request
URL = "http://127.0.0.1:8000/v1/chat/completions" # 换成实际服务地址
MODEL = "kimi-k3"
PROMPT = FIXED_PROMPT # 固定长度的输入文本
MAX_TOK = 512 # 固定输出上限
def run_once():
body = json.dumps({
"model": MODEL,
"messages": [{"role": "user", "content": PROMPT}],
"max_tokens": MAX_TOK,
"temperature": 0,
"stream": False,
}).encode()
req = urllib.request.Request(URL, data=body,
headers={"Content-Type": "application/json"})
t0 = time.perf_counter()
with urllib.request.urlopen(req, timeout=600) as r:
resp = json.loads(r.read())
dt = time.perf_counter() - t0
tok = resp["usage"]["total_tokens"]
return dt, tok, dt / tok * 1000 # 每千 token 端到端耗时(秒)
def pct(xs, p):
xs = sorted(xs)
return xs[min(len(xs) - 1, int(len(xs) * p))]
if __name__ == "__main__":
run_once() # 预热,结果丢弃
rows = [run_once() for _ in range(20)]
lat = [x[0] for x in rows]
per1k = [x[2] for x in rows]
print("n =", len(rows))
print("单请求延迟 中位数/P95:",
round(statistics.median(lat), 3), round(pct(lat, 0.95), 3))
print("每千 token 耗时 中位数/P95:",
round(statistics.median(per1k), 4), round(pct(per1k, 0.95), 4))
把这一轮的每千 token 中位数记下来,它就是判断延迟有没有恶化的尺子。注意 total_tokens 包含 prompt,如果你更关心纯解码速度,可以改用 completion_tokens 单独算一份,两份都留着后面好对照。
按并发梯度加压,记录吞吐和延迟两条曲线
梯度不建议一次拉太多档,1、2、4、8 四档足够看清趋势,每档发固定数量的请求(比如 40 个),跑完再进下一档,中间留几秒让显存和队列回落。脚本用信号量控制同时在途的请求数,把每次的耗时和 token 数收集起来统一统计。
# sweep.py —— 并发梯度压测
import asyncio, time, statistics
from measure_single import run_once, pct
LEVELS = [1, 2, 4, 8]
N_PER_LEVEL = 40
async def worker(sem, out):
async with sem:
loop = asyncio.get_running_loop()
dt, tok, _ = await loop.run_in_executor(None, run_once)
out.append((dt, tok))
async def one_level(conc):
sem = asyncio.Semaphore(conc)
out = []
t0 = time.perf_counter()
await asyncio.gather(*[worker(sem, out) for _ in range(N_PER_LEVEL)])
wall = time.perf_counter() - t0
lat = [x[0] for x in out]
toks = sum(x[1] for x in out)
print(f"conc={conc} QPS={len(out)/wall:.2f} "
f"tokens/s={toks/wall:.1f} "
f"mean={statistics.mean(lat):.3f} "
f"P95={pct(lat, 0.95):.3f}")
async def main():
for c in LEVELS:
await one_level(c)
await asyncio.sleep(5) # 等队列与显存回落
asyncio.run(main())
每档跑完,把结果填进同一张表,显存峰值另外用采样命令取,不要靠肉眼估。
| 并发 | 请求数 | 吞吐 tokens/s | QPS | 平均延迟 | P95 延迟 | 显存峰值 |
|---|---|---|---|---|---|---|
| 1 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 2 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 4 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 8 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
拐点的判定建议用相对关系而不是绝对值:当某一档的吞吐增幅相对上一档明显收窄,同时 P95 明显高于并发 1 时的基线,这一档就是拐点。只看平均延迟容易被少数快请求拉平,P95 更能暴露排队开始变长。
读显存占用与利用率,排除硬件层干扰
延迟恶化有三种常见解释:算力吃满、调度排队、显存不够触发换页。三者用两条曲线就能大致区分,采样频率建议 500ms 一次,和压测同时进行。
nvidia-smi `--query-gpu`=timestamp,memory.used,memory.total,utilization.gpu \
`--format`=csv,noheader,nounits -lms 500 > gpu.csv
结果按时间对齐压测日志。如果 GPU 利用率长时间贴着高位、显存稳定,说明瓶颈在算力,加大并发只会让请求排队;如果利用率周期性掉零而请求仍在途,通常意味着在等 CPU 侧预处理、日志落盘或网络收发,这类瓶颈加并发反而放大排队;如果显存占用持续逼近上限并伴随利用率掉零,需要先怀疑显存回收或换页,而不是继续压并发。显存逼近上限时还有一个容易忽略的现象:单请求延迟的 P95 先恶化、平均延迟变化不大,因为只有部分请求踩到了回收。
这些现象都需要结合你自己的服务实现确认,不同框架的显存池行为差别不小,建议至少在一次压测里开着采样,把结论和曲线一起看。
对比扩大批大小与扩大并发两种做法
这两个参数并不完全等价。批大小影响一次前向计算里塞进多少 token,主要吃显存并提高算力利用率;并发数影响同时有多少请求在途,主要吃显存并抬高排队概率。同一个目标吞吐,用批大小可能得到更低的单请求延迟,用并发数可能更快撞到排队墙。建议在同一基线上各跑一组,填同一张表再决定。
| 比较维度 | 扩大批大小 | 扩大并发 |
|---|---|---|
| 吞吐变化 | 待填 | 待填 |
| 平均延迟 | 待填 | 待填 |
| P95 延迟 | 待填 | 待填 |
| 显存峰值 | 待填 | 待填 |
| 显存增长来源 | 单次计算的激活与缓存随批线性增长 | 每个在途请求各占一份 KV 缓存 |
| 主要失败形态 | OOM 或单次计算时间过长 | 排队变长、尾部延迟拉高 |
经验上,离线批量任务优先调批大小,把单卡算力用满;在线有延迟承诺的场景优先调并发,并把并发压在拐点以下。两个参数同时拉高时最难判断瓶颈归属,建议一次只动一个。
把拐点值写进服务配置的并发上限
压测结论要落到三个配置项上:最大并发数、排队超时、单请求超时。名字在不同框架里可能不同,按实际服务的参数名替换,但语义和取值依据是一致的。
# 示例骨架:按实际服务参数名替换
server:
max_concurrency: 4 # 取拐点档位的前一档,而不是吞吐最高档
queue_timeout_ms: 30000 # 排队等待上限,参考基线 P95 的若干倍
request_timeout_ms: 180000 # 单请求超时,需覆盖最长输出长度的耗时
取值依据:max_concurrency 取“P95 还没明显脱离基线”的最高一档,如果拐点在 8,就写 4;queue_timeout_ms 用来挡住无谓等待,取值要让正常请求不会因为短暂排队被拒,可以先用基线 P95 乘以一个余量,再根据放量后的排队分布调整;request_timeout_ms 必须覆盖最慢那条路径,也就是最大输出长度对应的耗时,设小了会把正常长请求掐断,设大了会让异常请求长期占住并发名额。改完配置后重跑一次同样的梯度,确认实际行为与压测表一致,再考虑放量。