想把 LingBot-Video 接进自建流水线,先别急着换硬件或调并行度。多数“整体太慢”是因为总耗时里模型加载、推理、写文件三段的占比没分开,说不清等待点落在哪。一次生成可以粗分成加载权重、预处理与编码、推理、解码出片写盘四段,只要在这三处打上时间戳,就能把“慢”落成具体的一段,再决定改哪里。
能分开计时,改法才有依据:先在加载、推理、写文件三处各打一个时间戳并落成结构化日志,再重复跑同一输入看每段的 min/max,最后换一组帧数和分辨率确认增长关系。判断标准是波动最大、或随输入规模增长最快的那一段。如果三段差距不大,先别改架构,通常是输入输出或调度在拖后腿。计时只反映你当前的机器、配置和模型版本,换设备或换权重都要重测。
在加载、推理、写文件三处打上时间戳
计时点的位置比计时工具重要。建议至少覆盖这几个位置:进程启动到权重加载完成、输入解码与预处理结束、每次推理调用返回、输出编码结束、写文件 flush 与 close 之后。写盘这一段尤其要在 flush/fsync 之后再记录,否则数据还在页缓存里,日志会给出偏小的假象。
日志格式建议一行一条 JSON,前缀固定,方便 grep 和后续统计。字段用扁平的 key=value 或 JSON 都可以,关键是要带上 run_id 和 stage 这两个可聚合的维度。下面的骨架不依赖任何具体框架的接口名,load_model、build_inputs、run_inference、encode 都换成你自己的实现即可。
import os, json, logging, time, uuid
def now_ms():
return int(time.time() * 1000)
class Stage:
def __init__(self, ctx, name):
self.ctx, self.name = ctx, name
def __enter__(self):
self.t0 = now_ms(); return self
def __exit__(self, et, ev, tb):
rec = dict(run_id=self.ctx['run_id'], stage=self.name,
cost_ms=now_ms() - self.t0, ok=(et is None),
frames=self.ctx['frames'], width=self.ctx['width'],
height=self.ctx['height'], device=self.ctx['device'],
batch=self.ctx['batch'], ckpt=self.ctx['ckpt'])
logging.info('video.gen.timing ' + json.dumps(rec, ensure_ascii=False))
return False
ctx = dict(run_id=uuid.uuid4().hex[:12], frames=48, width=720, height=720,
device='cuda:0', batch=1, ckpt='lingbot-video')
with Stage(ctx, 'load'):
model = load_model(ctx['ckpt']) # 换成你自己的加载函数
with Stage(ctx, 'preprocess'):
inputs = build_inputs(frames)
with Stage(ctx, 'infer'):
outputs = run_inference(model, inputs) # 预热调用单独记 stage='infer_warmup'
with Stage(ctx, 'decode'):
blob = encode(outputs)
with Stage(ctx, 'write_disk'):
with open(out_path, 'wb') as f:
f.write(blob); f.flush(); os.fsync(f.fileno())
单行日志大致长这样,frames、width、height 这几个字段后面换输入规模时要用到:
video.gen.timing {"run_id":"<id>","stage":"load","cost_ms":<int>,"ok":true,"frames":48,"width":720,"height":720,"device":"cuda:0","batch":1,"ckpt":"lingbot-video"}
把三段凑成一张表,先看量级差,不看绝对值:
| 阶段 | 计时点 | 本次耗时 | 先看什么 |
|---|---|---|---|
| 加载 load | 进程启动 → 权重就绪 | 填你的实测值 | 是否每个进程只出现一次 |
| 推理 infer | 入参就绪 → 推理返回 | 填你的实测值 | 是否随帧数近似线性 |
| 写文件 write_disk | 编码结束 → fsync 完成 | 填你的实测值 | 是否随输出体积增长 |
重复跑同一输入看每段耗时的波动
单次计时的说服力有限。建议同一输入、同一参数连续跑 5 到 10 次,第一次单独标成冷启动(含进程启动、权重读盘、解码器初始化),只统计后面几次的 min 和 max。波动最大的一段通常就是随机开销所在:加载段波动大,说明权重每次都在重新读盘;推理段波动大,可能来自显存分配、并发争抢或批次不固定;写盘段波动大,往往是磁盘或下游消费速度在变化。
把计时日志落成一个文件后,用一段几十行的脚本就能出 min/max:
# tools/stat_timing.py
import json, sys, collections
st = collections.defaultdict(list)
for line in sys.stdin:
if 'video.gen.timing ' not in line:
continue
rec = json.loads(line.split('video.gen.timing ', 1)[1])
st[rec['stage']].append(rec['cost_ms'])
for s, v in st.items():
print(s, 'n=', len(v), 'min=', min(v), 'max=', max(v), 'last=', v[-1])
# grep video.gen.timing gen.log | python3 tools/stat_timing.py
| 阶段 | 首次(冷启动,单列) | 稳定后 min | 稳定后 max | 判读 |
|---|---|---|---|---|
| load | 填实测值 | 填实测值 | 填实测值 | 稳定后仍高 → 模型没常驻 |
| infer | 填实测值 | 填实测值 | 填实测值 | max 明显大于 min → 批次/显存不稳 |
| write_disk | 填实测值 | 填实测值 | 填实测值 | max 远大于 min → 磁盘或下游抖动 |
换输入规模再测一次
固定输入测出来的只是本机基线,还要确认耗时随帧数、分辨率怎么长。建议准备两组差距明显的参数,其余条件保持一致,例如组 A 用 16 帧、512×512,组 B 用 64 帧、720×720;这组数值只是示例,按你的业务下限和上限替换即可。每组都跑多次取稳定段的 min,别用单次结果下判断。
| 输入组 | 加载 load | 推理 infer | 写文件 write_disk | 增长最明显的一段 |
|---|---|---|---|---|
| A:16 帧 / 512×512 | 填实测值 | 填实测值 | 填实测值 | 填哪一段 |
| B:64 帧 / 720×720 | 填实测值 | 填实测值 | 填实测值 |
读这张表的方法:加载段应该基本不随输入变化,如果它也跟着涨,说明计时点串了,或者加载函数里混进了预处理;推理段随帧数增长通常最快,这部分属于模型算力约束,只能靠降分辨率、抽帧、分批或换设备来摊;写盘段随输出体积增长,若它成为增长最快的一段,说明瓶颈在编码和落盘,不在模型本身,异步写盘就能直接见效。
把耗时日志接进自建流程
不要让计时只活在临时脚本里。给字段定一套命名,长期沿用,后面接监控、做报表都不用再改解析逻辑:video.gen.run_id、video.gen.stage、video.gen.cost_ms、video.gen.frames、video.gen.width、video.gen.height、video.gen.device、video.gen.batch、video.gen.ok、video.gen.err。stage 的取值固定成 load、preprocess、infer、infer_warmup、decode、write_disk 这几个,不要临时造词。
落盘位置建议两路:一路跟着进程 stdout 走,方便在容器日志里直接翻;一路追加写成本地 JSONL,按天轮转,路径形如 logs/video_gen_timing-YYYYMMDD.jsonl。与既有日志合并的关键是复用上层已经有的关联 ID,把 run_id 直接设成你的任务 ID 或请求 ID,而不是另起一个新的 UUID;这样一次任务的普通日志和计时日志可以用同一个 ID 串起来。
排查顺序建议固定下来,避免每次乱翻:先看 write_disk 是否随输出体积增长,是则往编码和落盘方向查;再看 load 是否只出现在每个进程第一次,若每次请求都有,说明模型没有常驻;接着看 infer 是否随帧数近似线性,是则属于预期范围,靠分批或降参数应对;最后才看 preprocess 和 decode 是否有孤立尖峰,这类尖峰常见于首次解码器初始化或首次读模板文件,重复跑时应当消失。
按瓶颈段给出三种接入改法
常驻加载
适用前提是模型和权重版本在一段时间内固定、请求持续到达、显存和内存能承受常驻占用。做法是把加载放在服务启动阶段,推理进程只保留 infer 及之后的计时。代价是常驻占用一部分显存,切换模型版本需要重启并接受一段不可用窗口,长期运行还要留意显存碎片和缓存的增长,通常配合进程级健康检查来用。
批量推理
适用前提是请求可以在一个小窗口内攒批、上层对延迟不敏感、显存余量足够。做法是让多个 run_id 共用一次推理调用,但每条记录仍按自己的 run_id 上报,否则计时就没法归因。代价是尾延迟抬高、显存峰值上升,同一批里长短样本不一致还会产生补齐浪费;先用小批(如 2 或 4)验证显存和单条延迟,再决定是否扩大。
异步写盘
适用前提是计时确认 write_disk 是主要等待点、下游不要求调用方同步拿到文件。做法是把编码结果交给写盘队列,主流程只记入队耗时,另起一段计时记录真正落盘耗时,并带上同一个 run_id。代价是队列积压、失败重试和磁盘写满都需要单独处理,还要保证进程退出前队列被排空,否则会丢输出;可以先在非关键任务上启用,观察队列深度和失败率再推广。