显存预算和并发上限要一起定:先按单卡可用显存算出缓存能撑多少,再用压测把并发压到这个范围内,最后用超时与会话长度限制住长请求的影响。只改其中一项,另外两项就会以显存不足、排队堆积或大量超时的形式暴露出来:并发调高,缓存超预算就 OOM;并发调低,卡没打满。超时给得太松,长请求长期占着计算位置;给得太紧,正常请求也被中断。
建议按“单卡显存 → 扣权重与框架预留 → 剩余给缓存 → 除以单请求缓存增量”算出并发上界,再逐档加压确认这个并发下既不 OOM 也不长时间排队,最后把请求超时、排队超时、输出上限写成明确值。适用自建单机或单卡多副本推理服务;边界是模型结构、量化方式、上下文长度和框架版本都会改变实际占用,最终并发必须看本机压测与日志。
把显存占用拆成权重、缓存和框架预留三块
拆成三块,是为了让并发上限有一个可计算的上界:权重是常数,框架预留是安全带,真正随并发变化的是缓存。三块分开看,才知道哪部分该在启动时固定、哪部分该留给并发。
- 权重:模型参数本身,加载后基本不变,与并发无关。可动的是精度与量化方式、是否多卡切分、是否同进程加载多个模型。
- 缓存:主要为 KV cache,按请求的 token 数累积,与“并发数 × 序列长度”近似成正比。可动的是缓存精度、分块大小、前缀缓存开关、单请求最大长度。
- 框架预留:CUDA context、算子 workspace、临时激活、通信缓冲和显存碎片,不随并发线性增长,但会在高负载时放大。可动的是显存使用比例上限、workspace 上限、是否预分配。
常用算式是:可用缓存 ≈ 单卡显存 × 使用比例 − 权重 − 框架预留。把右边三项都写成配置而不是口头约定,并发上限才有据可依。CPU offload 或换页只作为临时缓解,不能当成扩容方案。
按单请求输入长度估算缓存增量
缓存增量可以按模型结构手算,单 token 的 KV 缓存字节数约为:2 × 层数 × KV 头数 × head_dim × 单元素字节数(乘 2 是因为 K 和 V 各存一份)。下面的参数只是举例,实际按所部署模型的 config 替换。
假设:32 层、8 个 KV 头、head_dim 128、缓存 fp16(2 字节)
单 token 缓存 = 2 × 32 × 8 × 128 × 2 = 131072 B = 128 KiB
输入 512 token:512 × 128 KiB = 64 MiB
输入 4096 token:4096 × 128 KiB = 512 MiB
若缓存预算为 8 GiB:
按 512 token 估算,上界约 128 个请求
按 4096 token 估算,上界约 16 个请求两组数字说明同一个问题:并发上限对输入长度极其敏感。估算时应按线上较长的分位(如 P95 输入长度)加上预期输出长度,而不是按平均长度,否则压测时容易冒顶。输出长度同样占用缓存,所以单请求上限应取“输入上限 + 输出上限”。
写出显存占比与最大并发写在一起的配置骨架
下面是一份通用骨架,字段名与取值位置按实际部署的配置文件或启动参数替换,不要照抄数字。关键是把显存占比、最大长度和并发上限写在同一处,方便对照改动。
server:
gpu_memory_fraction: 0.90 # 单卡显存使用上限比例,给框架与碎片留余量
max_model_len: 8192 # 单请求最大上下文(输入+输出),决定单请求缓存上限
kv_cache_dtype: fp16 # 缓存精度,降低精度可减小单 token 缓存
max_num_seqs: 5 # 同时在算的请求数,即并发上限,先写由预算推得的初值
max_queue_size: 32 # 排队上限,超出直接拒绝,避免无限堆积
limits:
request_timeout_ms: 30000 # 单请求端到端超时,从接受到返回
queue_timeout_ms: 5000 # 排队等待超时,超过即拒绝,不再继续挂住
max_output_tokens: 1024 # 输出上限,防止长生成长期占槽位
session:
idle_timeout_ms: 60000 # 会话空闲多久释放
max_session_tokens: 16384 # 会话累计上下文上限
# 预算对照示例(数字需按实际替换):
# 24 GiB 卡 × 0.90 ≈ 21.6 GiB
# 权重 14 GiB + 框架预留 2 GiB ≈ 16 GiB
# 剩余约 5 GiB 给缓存;按 128 KiB/token、单请求 8192 token ≈ 1 GiB
# → 并发上界约 5,即上面的 max_num_seqs 初值配置骨架里的每一项都对应一个可观察的量:gpu_memory_fraction 决定显存占用的上限边界,max_model_len 决定单请求缓存单价,max_num_seqs 决定缓存乘数,max_queue_size 决定排队是否可控。改动其中一项时,另外几项要同步复核。
逐档加压验证并发上限是否真的成立
纸面算出的并发只是上界,需要在目标机器上逐档加压,确认每档的实际表现。压测请求应固定输入长度(贴近线上较长分位)和固定输出上限,避免变量互相干扰。
# 并发逐档上升,每档结束后看一次显存与日志
for C in 1 2 3 5 8 12; do
echo "=== concurrency=$C ==="
hey -c $C -n $((C * 20)) -t 60 \
-m POST -H "Content-Type: application/json" \
-d @payload.json \
http://127.0.0.1:8000/v1/completions
nvidia-smi `--query-gpu`=memory.used,memory.total `--format`=csv
sleep 5
donepayload.json 里放固定 prompt 与 max_tokens。每档要同时看三个信号:显存占用是否逼近上限、单请求耗时是否随并发线性恶化、日志里有没有拒绝或排队提示。两种现象在日志里的区别通常是:
- 显存不足:出现 out of memory 一类错误,可能伴随 worker 重启、请求在开始计算前就失败、返回 5xx;nvidia-smi 显示占用贴着上限,进程可能直接退出。
- 排队等待:请求被接受但迟迟不开始,首 token 延迟明显拉长,日志出现队列等待、pending 或排队超时;显存占用平稳不再增长。
如果显存先满,说明并发上限偏高或缓存单价估低;如果显存没满但延迟飙升,说明瓶颈不在显存而在算力或批调度,此时把并发调低并不会提升体验,应优先看调度与长度分布。
用超时与会话长度配置兜住长请求
少数超长请求会以两种方式破坏整体并发:占用计算位置很久,以及累计上下文把缓存单价抬高。前者靠超时限制,后者靠长度限制。
- 请求级:request_timeout_ms 限制端到端时间,queue_timeout_ms 限制排队时间,max_output_tokens 与 max_model_len 限制单个请求的最大代价。
- 会话级:idle_timeout_ms 回收空闲会话,max_session_tokens 限制累计上下文,避免多轮对话越滚越长。
日志里区分超时与被拒绝,主要看请求是否真正进入计算。超时通常出现在生成过程中,日志带有 deadline exceeded、timeout 一类字样,且该请求已有生成 token 数;被拒绝通常出现在入队阶段,日志带 rejected、queue full,或接口直接返回 429/503,请求没有产生任何 token。把这两类分开统计,才能判断该放宽超时还是该提高并发或缩短输出上限。