先量一遍每千 token 的实际耗时——再定 Kimi K3 批量推理的并发上限

文章导读
Kimi K3 批量推理的并发上限,通常不该由显存容量或标称参数直接推导,而要先用一组压测数据找到“吞吐还在上升、单请求延迟开始明显变差”的那个拐点。做法是先固定输入与输出 token 数,测出没有并发干扰时的端到端耗时(换算成每千 token 秒数),再按并发 1、2、4、8 各自跑一轮,同时记录吞吐、平均延迟、P95 延迟和显存峰值;用显存占用与 GPU 利用率两条曲线排除换页和调度干扰,最后
📋 目录
  1. Ⅰ 固定输入输出长度,先测单请求端到端耗时
  2. Ⅱ 按并发梯度加压,记录吞吐和延迟两条曲线
  3. Ⅲ 读显存占用与利用率,排除硬件层干扰
  4. Ⅳ 对比扩大批大小与扩大并发两种做法
  5. Ⅴ 把拐点值写进服务配置的并发上限
A A

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 数收集起来统一统计。

先量一遍每千 token 的实际耗时——再定 Kimi K3 批量推理的并发上限
# 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/sQPS平均延迟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 的实际耗时——再定 Kimi K3 批量推理的并发上限

对比扩大批大小与扩大并发两种做法

这两个参数并不完全等价。批大小影响一次前向计算里塞进多少 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 必须覆盖最慢那条路径,也就是最大输出长度对应的耗时,设小了会把正常长请求掐断,设大了会让异常请求长期占住并发名额。改完配置后重跑一次同样的梯度,确认实际行为与压测表一致,再考虑放量。