LingBot-Video 接入自建流水线 / 从模型加载到出片逐段计时

文章导读
想把 LingBot-Video 接进自建流水线,先别急着换硬件或调并行度。多数“整体太慢”是因为总耗时里模型加载、推理、写文件三段的占比没分开,说不清等待点落在哪。一次生成可以粗分成加载权重、预处理与编码、推理、解码出片写盘四段,只要在这三处打上时间戳,就能把“慢”落成具体的一段,再决定改哪里。
📋 目录
  1. 一 在加载、推理、写文件三处打上时间戳
  2. 二 重复跑同一输入看每段耗时的波动
  3. 三 换输入规模再测一次
  4. 四 把耗时日志接进自建流程
  5. 五 按瓶颈段给出三种接入改法
A A

想把 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,别用单次结果下判断。

LingBot-Video 接入自建流水线 / 从模型加载到出片逐段计时
输入组加载 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。代价是队列积压、失败重试和磁盘写满都需要单独处理,还要保证进程退出前队列被排空,否则会丢输出;可以先在非关键任务上启用,观察队列深度和失败率再推广。