要给 HyOCR-1.5 定并发数,顺序建议是:先量单路推理的显存占用和单张耗时,用这两个数算一个理论并发上限;再按梯度加压做延迟压测,看延迟从哪一档开始明显变差。显存估算只回答“放不放得下”,回答不了“排队要等多久”,所以两者不一致时以压测结果为准。估算的作用是给你一个可验证的起点,让你不必从并发 1 一路盲试。
先用单路数据估上限,再用压测定上限。单路显存增量决定显存维度能开多少并发,单张耗时决定排队维度的忍耐度;估算里显存线性增长、输入尺寸一致这两个假设,在预处理和 IO 环节常先失效。做法是把并发按 1、2、4、8、16 逐档加压,每档记录 P50/P95 延迟、显存峰值、错误数,找到延迟陡升且吞吐不再增长的那一档,取它的前一档写进服务配置。适用场景是单机单卡、批量图片识别服务;多卡或动态批处理需要重新取增量。
记录单路推理的显存占用与单张耗时
先让服务在空载下稳定跑起来,只发一路请求,拿到两个基础数字:空闲常驻显存和单路运行峰值显存。差值就是“单路增量”,后面所有估算都基于这个数。
显存观测用 nvidia-smi 轮询,比一次性快照可靠,因为推理峰值往往只维持很短时间:
# 每秒采样一次整卡显存,压测期间全程开着
nvidia-smi `--query-gpu`=memory.used,memory.total,utilization.gpu `--format`=csv -l 1
# 确认是本进程占的显存,避免和其他进程混淆
nvidia-smi `--query-compute-apps`=pid,process_name,used_memory `--format`=csv
如果服务是 PyTorch 实现,可以在请求结束处打印 torch.cuda.max_memory_allocated(),这是框架层视角的峰值,和 nvidia-smi 的口径不同,两个都记,不要混用。
耗时统计口径要事先写死,否则跨天对比没有意义:固定同一张图、同一分辨率、batch=1 连续请求;预热若干次后开始计时;明确记录的是服务端内部耗时还是客户端往返耗时(客户端含网络和序列化,通常偏大)。取样至少三轮,每轮不少于二三十次,取中位数作为单张耗时,同时记下 P95,因为排队策略要按 P95 而不是均值来定超时。
用单路数据估一个并发上限,并写明估算假设
估算公式建议直接写成一行,放在压力测试脚本同目录的 capacity.md 里,方便后面被压测结果修正:
单路增量显存 = 单路运行峰值显存 − 空闲常驻显存
可用显存 = 显存总量 × 留白比例 − 空闲常驻显存
估算并发上限 = floor(可用显存 ÷ 单路增量显存)
必须同时写清假设:显存随并发线性增长;没有跨请求共享缓存或动态批处理;所有输入图片尺寸一致;显存碎片和激活峰值已经包含在“留白比例”里。如果服务里开了动态 batch 或 CUDA Graph,单路增量不成立,要改成“单 batch 增量”重新算。
留白比例先按可用显存的七成左右算第一版,其余留给激活峰值、碎片和偶发的大图。估算结果记在 capacity.md 里,至少包含:模型版本、显卡型号、图片尺寸、单路增量、留白比例、估算上限、估算日期。这份记录是后面判断“瓶颈在显存还是别处”的对照基准。
按梯度加压做延迟压测,记录每档延迟与显存
梯度不必从 1 开始一路加,可以围绕估算上限取点:估算上限的四分之一、一半、四分之三、等于上限、略超上限。每档之间留间隔让显存回落。下面是一份可替换的骨架,主要替换 URL、图片路径和 LEVELS:
# pressure_test.py —— 在能访问推理服务的机器上执行
import concurrent.futures as cf, statistics, subprocess, time, json, requests
URL = "http://127.0.0.1:8000/ocr" # 换成实际服务地址
IMG = "sample.jpg" # 固定一张图,保证各档可比
LEVELS = [1, 2, 4, 8, 16] # 按第 2 节的估算上限调整
ROUNDS = 20 # 每档正式请求数
def one_call(_):
t0 = time.perf_counter()
with open(IMG, "rb") as f:
r = requests.post(URL, files={"file": f}, timeout=60)
return time.perf_counter() - t0, r.status_code
def gpu_mem_mb():
out = subprocess.run(
["nvidia-smi", "`--query-gpu`=memory.used",
"`--format`=csv,noheader,nounits"],
capture_output=True, text=True).stdout
return max(int(x) for x in out.strip().splitlines())
for n in LEVELS:
for _ in range(3):
one_call(0) # 预热,不计入统计
lat, err = [], 0
with cf.ThreadPoolExecutor(max_workers=n) as ex:
for d, code in ex.map(one_call, range(ROUNDS)):
lat.append(d)
err += int(code >= 400)
lat.sort()
print(json.dumps({
"concurrency": n,
"p50": round(statistics.median(lat), 3),
"p95": round(lat[int(len(lat) * 0.95) - 1], 3),
"max": round(lat[-1], 3),
"errors": err,
"gpu_mem_mb": gpu_mem_mb(), # 峰值建议另开线程持续采样
}, ensure_ascii=False))
time.sleep(30) # 档间间隔,等显存回落
每档需要记录的指标:并发数、P50/P95/最大延迟、错误数与状态码分布、显存峰值、GPU 利用率、CPU 使用率。显存峰值最好另起一个采样线程持续读,请求结束时读一次容易漏掉峰值。
比较估算与实测差异,判断瓶颈在显存还是其他环节
把估算和实测填进同一张表,差异就变成排查线索:
- 实测显存明显高于估算:可能存在未释放的缓存、碎片或额外的中间缓冲;先查是否有请求级对象未回收。
- 实测显存远低于估算但延迟已经陡升:瓶颈不在显存,往 CPU 预处理、IO 或调度去看。
- 每档显存都线性增长且延迟平稳:估算假设基本成立,可以按估算上限附近取值。
差异出现时按这个顺序查:先看显存曲线是否随并发线性,再看 CPU 预处理(图片解码、缩放、色彩转换常是隐藏瓶颈,表现为 GPU 利用率上不去而延迟上涨),再看 IO(读图走网络存储或大文件时,P99 拉长而 GPU 空闲),最后看服务端并发模型本身,比如单 worker 串行处理、后处理里有全局锁、客户端压测机自己的线程池或连接数不够。
需要补测的场景:多分辨率混合输入、长图或大图、长时间稳态跑(观察显存是否缓慢爬升)、以及服务端返回错误或超时后的重试行为。
按拐点定并发与排队策略,写进服务配置
拐点的判断标准是:某一档并发下 P95 相比上一档明显上升,而整体吞吐没有继续增长。运行并发取拐点的前一档,留出一档的余量应对突发。下面是一份可替换的配置片段,字段名按你实际的服务框架改:
server:
max_concurrency: 4 # 取压测拐点的前一档
request_timeout_s: 30 # 按单张 P95 的若干倍设定,不是按均值
queue:
max_size: 32 # 队列上限,超了直接拒绝,不要无限排队
wait_timeout_s: 10 # 排队超过该时长返回 503
reject_status: 503
retry_after_s: 2
队列和超时要一起看:max_concurrency 决定同时处理多少,队列决定允许多少请求等待,超时决定等待多久放弃。队列无限增长时,延迟会先失控再引发雪崩式超时,所以宁可早一点返回 503,让调用方退避重试。上线后持续关注的指标:显存峰值与 OOM 次数、P95/P99 延迟、队列深度、拒绝与超时计数、GPU 利用率、CPU 使用率。任何一项偏离压测时的水位,都回到第三节重新跑一遍梯度,不要凭感觉调并发。