画面时快时慢,先别急着加显卡或者调并发。把一次请求的总延迟拆成两段看:第一段是从请求进入到最后首帧吐出来的一次性等待,第二段是首帧之后每两帧之间隔多久。前一段突然变长,多半是采样步长或分辨率把单帧计算量抬上去了;后一段忽长忽短、还夹着偶发长尾,通常是显存逼近上限触发了分配回收,或者抽帧、缩放、上传这几步卡在 CPU,让 GPU 有空转。判断顺序建议是:先读日志把两段延迟分开,再用固定参数跑一轮基线,然后拿 nvidia-smi 采样和预处理打点交叉验证,最后按单变量顺序回退。
适用场景:本地或单机部署 Matrix-Game3.5 这类流式出帧服务,画面节奏不稳但进程没崩。操作动作:日志里分别打点首帧延迟与每帧间隔,固定分辨率、步长、并发跑基线,同时用 nvidia-smi 连续采样显存,并在抽帧、缩放、H2D 上传三处加计时。验证方式:看每帧间隔的长尾是否和显存峰值回落、GPU 利用率掉零同时出现。风险边界:显存采样和打点本身有开销,别在高并发线上长开;降低步长或用低分辨率回退会牺牲画面质量与流畅度,属于定位手段,不是最终画质方案。
在输出日志里拆出首帧延迟与每帧间隔
先分清是一次性等待长,还是持续输出不稳。需要四个时间戳字段,命名可以按自己的代码调整,但语义要固定下来:
request_received_ts:请求进入推理入口的时间。preprocess_done_ts:抽帧、缩放、上传全部完成的时间。first_frame_emit_ts:第一帧编码完成、真正写给输出通道的时间。frame_emit_ts(i):第 i 帧写出的时间。
两个指标的计算方式:首帧延迟 = first_frame_emit_ts - request_received_ts,它包含预处理和首帧的全部采样步数;每帧间隔 = frame_emit_ts(i) - frame_emit_ts(i-1),只看稳态出帧。首帧延迟单独看,不要混进间隔里统计,否则中位数会被一次性开销拉偏。
[req 7f3a] T+0 request_received w=1280 h=720 steps=24 fps=16
[req 7f3a] T+38 preprocess_done frames=48 decode=14ms resize=10ms h2d=6ms
[req 7f3a] T+1120 first_frame_emit steps_done=24
[req 7f3a] T+1168 frame_emit idx=1 dt=48
[req 7f3a] T+1219 frame_emit idx=2 dt=51
[req 7f3a] T+1690 frame_emit idx=11 dt=214
[req 7f3a] T+1732 frame_emit idx=12 dt=42
[req 7f3a] T+2601 stream_end frames=30
读法:这一段的首帧延迟约 1120ms,稳态间隔多在 48~51ms 附近,但 idx=11 处出现 214ms 的长尾。整体节拍不算慢,问题在偶发卡顿,属于“持续输出不稳”,不是“首帧特别慢”。如果首帧延迟高、间隔却很稳,优先怀疑步数和分辨率;如果首帧正常、间隔抖动,优先怀疑显存和 CPU 侧供给。建议连续跑几条请求,记录间隔的 P50、P95 和最大值,只看均值会掩盖长尾。
固定分辨率与采样步长再跑一轮
抖动结论必须建立在可对比的基线上,否则上一轮和这一轮的差异说不清是参数造成的还是机器状态造成的。本轮建议固定这些项,并写进配置文件而不是手改命令行:
- 输出分辨率、输入素材尺寸、抽帧间隔与总帧数;
- 采样步数
num_inference_steps、guidance 系数、随机种子、采样器类型; - 输出帧率、批大小、并发请求数固定为 1;
- 数据类型(fp16/bf16)、VAE 解码精度、是否启用图缓存或跨帧复用;
- 进程启动后先跑一轮预热请求,预热结果不进入统计。
与上一轮的差异项只留一个,比如只把步数从 24 降到 16,其余全部对齐。每轮记录三件事:首帧延迟、每帧间隔的 P50/P95/最大值、长尾出现的位置是否规律(固定在第 N 帧,还是随机)。如果间隔长尾每次都在同一帧位置出现,更像单帧计算量或显存分配问题;位置随机,更像 CPU 侧供给和调度。
用 nvidia-smi 连续采样看显存是否触顶
日志只能告诉你慢,显存采样才能告诉你为什么慢。在跑请求的同时另开一个终端采样:
nvidia-smi `--query-gpu`=timestamp,memory.used,memory.total,utilization.gpu,utilization.memory \
`--format`=csv -lms 200 -f gpu_sample.csv
部分驱动版本不支持 -lms,可以退成 -l 1 再用循环拉高频率,或者用 nvidia-smi dmon -s mu -d 1 看显存与利用率曲线。采样间隔建议 100~200ms,太稀会漏掉瞬时峰值。
判读规则:把 memory.used 的峰值和 memory.total 对比,如果峰值长期贴近上限、并且曲线出现“冲高—快速回落—再冲高”的锯齿,同时 utilization.gpu 周期性掉到很低,基本可以认为显存接近触顶,帧间隔的长尾就来自分配与回收,而不是算力不够。
触顶时通常伴随这些现象:日志里出现 CUDA out of memory 或者框架的 allocator 重试提示;相邻两次 frame_emit 之间出现明显空洞;显存采样里能看到 memory.used 骤降后 GPU 利用率短暂归零;如果开了预取队列,还可能看到丢帧或队列水位报警。出现这些信号时,先降分辨率或减少并发,不要先去调步数。
检查预处理与数据搬运是否卡在 CPU
显存没触顶、GPU 利用率却经常不满,就要查显卡之外。在三个位置加计时打点,用 time.perf_counter() 就够,GPU 侧忙碌时间用 CUDA event 记录更准:
import time, torch
t0 = time.perf_counter()
frames = decode_frames(src) # 抽帧
t1 = time.perf_counter()
resized = [resize(f, (720, 1280)) for f in frames] # 缩放
t2 = time.perf_counter()
tensor = torch.from_numpy(batch).to("cuda", non_blocking=True) # 上传
torch.cuda.synchronize()
t3 = time.perf_counter()
start, end = torch.cuda.Event(True), torch.cuda.Event(True)
start.record(); run_model(tensor); end.record()
torch.cuda.synchronize()
print(f"decode={t1-t0:.3f}s resize={t2-t1:.3f}s h2d={t3-t2:.3f}s "
f"gpu_busy={start.elapsed_time(end)/1000:.3f}s")
对比方式:把一次请求的总墙钟时间减去 GPU 忙碌时间,剩下的就是 CPU 预处理加等待的时间。这段占比高,说明出帧节奏被 CPU 拖住,GPU 有相当一段时间在等数据。常见原因是抽帧用了单线程解码、缩放在 Python 层逐帧做、上传没开 non_blocking 且缺少 pinned memory,或者预处理和数据加载共用了同一个进程导致 GIL 争抢。可以先只改一处:把缩放和上传挪到独立线程或子进程,再复跑同一组固定参数,看每帧间隔的 P95 是否收敛。
按单变量顺序回退参数定位抖动来源
回退顺序按“改动成本低、对画质影响可控、最容易证伪”来排:先步长,再分辨率,最后并发数。每一步只改一个变量,其余保持基线,跑完记录延迟变化和抖动是否复现。
| 步骤 | 改动变量 | 保持不动 | 记录项 | 判定 |
|---|---|---|---|---|
| 1 | 采样步数下调一档 | 分辨率、并发、预处理链路 | 首帧延迟、间隔 P50/P95、长尾位置 | 首帧明显下降而长尾仍在,说明算力吃紧但不是抖动来源 |
| 2 | 输出分辨率下调一档 | 步数、并发、预处理链路 | 显存峰值曲线、长尾是否消失 | 长尾消失且显存峰值回落,指向显存压力 |
| 3 | 并发数降到 1 | 步数、分辨率、预处理链路 | GPU 利用率、CPU 计时占比 | 抖动消失,问题在多请求争抢显存或预处理线程 |
三步跑完如果抖动仍复现,把预处理打点结果和显存曲线叠在同一时间轴上对齐看:长尾出现在显存骤降之后,按显存处理;出现在 CPU 计时占比升高之后,按预处理和搬运处理。建议每次只保留一份配置文件和一份采样文件,命名里带上该轮改动的变量,避免几轮数据混在一起无法回溯。回退参数只是定位手段,最终配置要结合目标画质和设备余量确认,不建议长期靠降步长、降分辨率来掩盖显存或 CPU 侧的问题。