单条请求正常、并发一上来就排队,光看端到端延迟分不出是解码算得慢、还是显存拷贝拖的、还是请求压根没进模型就在排队。通常的切入办法是先把单次请求拆成音频预处理、模型前向、后处理三段分别计时,再按并发梯度加压,看延迟拐点出现时是哪一段先变长。解码(自回归逐 token 生成)在并发上升时往往表现为前向段随 batch 变长而变长;显存拷贝(主机到设备、设备到主机的数据搬运、KV cache 增长与批内拼接)则常在并发和批大小同时上升时占比抬头。两者都不必然是主因,还要看排队策略:如果最大并发数设得偏小,请求会在队列里干等,前向段耗时反而平得很。这三个原因用配置、日志和计时点都能验证,不需要猜测。
先给单请求加三段计时,找出耗时占比最大的一段;再加设备同步点,单独记录一次数据搬运的耗时,判断时间花在传输还是计算上;然后按并发梯度(例如 1、2、4、8、16)压一轮,记录并发数、成功数、平均延迟、最大延迟,找延迟开始明显上升的拐点。最后固定总请求量,分别只调批处理大小和只调并发上限各跑一轮。若前向段随批大小增长但不失控、而队列等待占比高,多半是并发上限与排队策略问题;若前向段和搬运段同步抬升,才优先怀疑解码算力与显存带宽。具体阈值需结合自身硬件与模型版本确认。
给单次请求的三段各加计时并跑一次
三段边界要先定清楚,不然后面所有对照都会串味。音频预处理通常包括音频文件解码、重采样、分帧、特征提取;模型前向指真正送进 Ming-3.0 的那次调用(编码、prefill 与逐 token 解码都算在内);后处理指结果反序列化、标点与格式整理、响应拼装。计时骨架可以用上下文管理器写,不绑定任何框架:
import time, contextlib
@contextlib.contextmanager
def stage(name, sink):
t0 = time.perf_counter()
try:
yield
finally:
sink[name] = round((time.perf_counter() - t0) * 1000, 1) # ms
sink = {}
with stage('preprocess', sink):
feats = load_and_extract(path) # 换成你的音频预处理入口
with stage('forward', sink):
out = model.generate(feats) # 换成你的推理调用
with stage('postprocess', sink):
text = decode_tokens(out)
log.info('stages_ms=%s', sink)跑一次单请求,看输出里哪一段占大头。下面只是格式示意,数值不代表任何环境实测结果,请以自己日志里的真实值为准:
stages_ms={'preprocess': 0.0, 'forward': 0.0, 'postprocess': 0.0}如果 forward 段已经占到绝大部分,问题优先往解码侧找;如果 preprocess 或 postprocess 意外地高,先排除文件解码、特征库版本、日志与序列化开销,再谈并发。建议每个阶段连续跑若干次取中位数,单次抖动很大时不要急于下判断。
观察数据在设备之间搬运的耗时
只在前向外面套一层计时是不够的,因为很多设备端的调用是异步入队的,计时器可能在数据还没搬完时就停了。常见做法是在开始和结束处插入设备同步点,让计时覆盖真实的数据搬运与计算完成时间。下面用通用的写法,把同步做成可替换的函数,没有 CUDA 的环境换成对应设备的同步或事件记录即可:
import time
def sync_device():
# 有 GPU 时通常会调用当前流的 synchronize;无 GPU 环境留空
try:
import torch
if torch.cuda.is_available():
torch.cuda.current_stream().synchronize()
except Exception:
pass
def timed(fn, *args, **kw):
sync_device()
t0 = time.perf_counter()
result = fn(*args, **kw)
sync_device() # 少了这一步,测到的只是“调用返回”而不是“搬运完成”
return result, round((time.perf_counter() - t0) * 1000, 1)把特征张量上传、KV cache 扩张、结果拷回主机这几个动作分别用 timed() 包一层,同时记录本次搬运的字节数。有了耗时和字节数,就能算出当次搬运的大致速率,用来判断是带宽吃满还是单次小包过多导致的调度开销;小包频繁搬运通常比大包一次搬运更容易在并发下放大。日志里建议同时带上 batch 大小和序列长度,否则不同请求之间的搬运耗时没有可比性。
按并发数梯度加压,记录吞吐与延迟
从低并发往上加,例如 1、2、4、8、16,每一档发相同总量的请求,观察平均延迟从哪一档开始明显抬升,那个拐点就是当前容量的边界。线程版骨架适合压 HTTP 或 RPC 客户端,注意客户端对象要可并发复用:
import concurrent.futures as cf, time, statistics
def one_request(i):
t0 = time.perf_counter()
ok = call_service(i) # 换成你的客户端调用,返回 True/False
return ok, (time.perf_counter() - t0) * 1000
def run(concurrency, total):
lat, ok_n = [], 0
with cf.ThreadPoolExecutor(max_workers=concurrency) as ex:
futs = [ex.submit(one_request, i) for i in range(total)]
for f in cf.as_completed(futs):
good, ms = f.result()
ok_n += 1 if good else 0
lat.append(ms)
return {'并发数': concurrency, '成功数': ok_n,
'平均延迟ms': round(statistics.mean(lat), 1),
'最大延迟ms': round(max(lat), 1)}如果客户端是同步阻塞且 GIL 或连接池会成为干扰,改用进程版更干净,每个子进程自己建客户端、自己计时:
import multiprocessing as mp
def worker(args):
i, = args
return one_request(i)
if __name__ == '__main__':
ctx = mp.get_context('spawn')
with ctx.Pool(processes=concurrency) as pool:
results = pool.map(worker, [(i,) for i in range(total)])结果按下面这几列落到一张表里,逐档累加,拐点会很直观;建议再加一列 P95 延迟,避免最大值被个别慢请求带偏:
| 并发数 | 成功数 | 平均延迟(ms) | 最大延迟(ms) |
|---|---|---|---|
| 1 | |||
| 2 | |||
| 4 | |||
| 8 | |||
| 16 |
压测期间同时看服务端日志里的队列等待时间和设备利用率,否则客户端延迟升高时无法区分是服务端排不上还是客户端自己排不上。
分别调整批处理大小与并发上限各跑一轮
这一步是为了把“模型本身慢”和“请求排队策略不匹配”分开。配置通常长这样,字段名按你的服务框架替换即可:
server:
max_batch_size: 8 # 单次前向最多合并多少条请求
max_concurrency: 16 # 同时在处理的请求上限,超出即进入队列
queue_timeout_ms: 3000 # 排队超时,用于观察拒绝行为
max_queue_size: 64两轮对照要固定其他变量:第一轮只改 max_batch_size(例如 1 与 8 各跑一次),观察单条请求的 forward 段耗时随批大小怎么变;第二轮把批大小固定在同一值,只改 max_concurrency(例如 4 与 16),观察平均延迟和队列等待时间怎么变。每轮记录成一行,字段包括轮次、max_batch_size、max_concurrency、平均延迟、最大延迟、前向段耗时、搬运段耗时、队列等待耗时。
判断主要看两个指标:一是前向段耗时随批大小的增长斜率,增长很快往往说明解码侧算力或显存带宽已经接近饱和,继续加并发只会让排队更长;二是队列等待耗时占端到端延迟的比例,这个比例高而前向段几乎不变,通常指向并发上限偏低或调度批次凑不齐,属于排队策略问题而不是模型算不动。搬运段耗时随并发明显抬头时,再回头检查是否存在小包频繁传输、重复上传特征、KV cache 反复扩容这类可以合并的动作。上述判断依赖具体硬件型号、模型精度和序列长度分布,同一组配置换环境后结论可能不同,建议在准备扩容或改并发参数前重新跑一遍这两轮对照,再决定是先调排队参数还是先做计算与传输侧的合并优化。