跳转至
图像与视频生成推理手册 服务 下载 notebook

生成服务的调度:异构请求、阶段流水与长任务

单次生成优化完了,下一步是把它变成一个服务:几十个用户同时来,分辨率、步数、模型、附件各不相同,有人要 512² 的草稿、有人要 60 秒的视频。LLM 服务那套"攒大 batch 分摊权重"在这里不成立——一步算力受限,batch 不省钱;能省的是三个阶段的错位(文本编码、去噪、VAE 解码)、同形状请求的合并,以及按预计耗时而不是请求数来排队。这一章用一个离散事件模拟把几种调度策略的延迟分布算出来,再讲取消、预览、长任务这些扩散服务特有的问题。

自测:能答上来就可以跳过本章

  1. 为什么扩散服务不像 LLM 服务那样追求大 batch?什么情况下 batch 仍然有用?
  2. 一个请求的三个阶段各适合什么样的批处理和放置?
  3. 按请求数 FCFS 排队会出什么问题?按什么排更好?
  4. 扩散请求的取消和抢占为什么比 LLM 便宜?
  5. 一个 8 卡节点,是"每卡一个请求"还是"8 卡一起算一个请求"?怎么权衡?
自测参考答案(先自己答,再展开对照)
  1. 一步在 batch=1 时就已经算力受限,拼 batch 只让每一步线性变慢,吞吐几乎不涨、延迟线性变差。例外:小模型 + 低分辨率时 kernel 填不满 GPU(batch 提高 MFU),以及"一个提示词出 4 张候选图"这种天然同形状的请求。
  2. 文本编码器:输入短、计算少、显存大,适合攒 batch、放到 CPU 或单独的卡上,并按提示词缓存;去噪:算力受限,按形状分组、通常 batch 很小;VAE 解码:访存和显存峰值大,适合单独的 stream / 卡,和下一个请求的去噪重叠,并分块。
  3. 一条长视频的耗时是一张图的几百倍,FCFS 会让后面的短请求等很久,P99 延迟爆炸。按预计耗时(token 数 × 步数 × 模型系数)排队、短任务优先或分池,并限制长任务的并发。
  4. 去噪每一步之间没有跨步状态(除了调度器对象),取消就是不再发下一步,什么都不用保存或重算;LLM 抢占要么保存 KV Cache、要么丢掉重算 prefill。
  5. 每卡一个请求:吞吐最高(没有通信开销),单请求延迟最差;8 卡一个请求:延迟最低,吞吐因通信和负载不均有损失。按 SLO 决定:交互式的视频生成要延迟(多卡一个请求),批量离线任务要吞吐(每卡一个);常见做法是分池——一部分卡组成序列并行组服务交互请求,另一部分单卡跑离线队列。

请求长什么样

扩散服务的请求是异构的,异构的维度直接决定能不能拼在一起、要多久:

维度 取值 对调度的影响
模型 SDXL / FLUX / Wan…,加 LoRA、ControlNet 不同模型根本不在同一个进程
分辨率、帧数 512²~2048²,5~60 秒 决定 token 数,不同形状不能拼 batch
步数、CFG、采样器 4~50 步 决定前向次数
附件 参考图、控制图、首帧 改变序列长度或增加一个网络
输出数量 1~4 张 天然同形状的 batch

先把"预计耗时"写成一个可计算的函数——它是后面所有调度策略的输入:

def est_seconds(tokens, steps, cfg, model_tflop_per_1k_tokens, gpu_eff_tflops=450):
    """预计耗时 ≈ 前向次数 × 单步 FLOP / 有效算力;单步 FLOP 按 token 数线性估(注意力的平方项在图像尺寸下占比小)"""
    step_tflop = tokens / 1000 * model_tflop_per_1k_tokens
    return steps * cfg * step_tflop / gpu_eff_tflops

REQS = [("SDXL 512² · 20 步", 1024, 20, 2, 2.0), ("SDXL 1024² · 30 步", 4096, 30, 2, 2.9),
        ("FLUX 1024² · 28 步", 4608, 28, 1, 19.5), ("FLUX 2048² · 28 步", 16896, 28, 1, 24.7),
        ("Wan 5 秒 480p · 50 步", 33272, 50, 2, 90.0), ("Wan 5 秒 720p · 50 步", 76112, 50, 2, 175.0)]
print(f"{'请求':<22} {'token':>7} {'前向次数':>7} {'预计耗时(H100)':>13}")
for name, tok, steps, cfg, k in REQS:
    print(f"{name:<22} {tok:>7,} {steps * cfg:>7} {est_seconds(tok, steps, cfg, k):>11.1f} s")
输出
请求                       token    前向次数    预计耗时(H100)
SDXL 512² · 20 步         1,024      40         0.2 s
SDXL 1024² · 30 步        4,096      60         1.6 s
FLUX 1024² · 28 步        4,608      28         5.6 s
FLUX 2048² · 28 步       16,896      28        26.0 s
Wan 5 秒 480p · 50 步     33,272     100       665.4 s
Wan 5 秒 720p · 50 步     76,112     100      2959.9 s

从 0.2 秒到 20 分钟,跨了四个数量级。同一个队列里混着这些请求,排队策略就是一切。

排队:按什么排

用一个最小的离散事件模拟比较三种策略。请求按泊松过程到达,80% 是图像(短)、20% 是视频(长);一张卡一次跑一个请求(算力受限,拼 batch 没有意义):

import heapq
import random

def simulate(policy, n_gpus=4, arrivals=400, rate=0.06, seed=0):
    """policy: 'fcfs' 先到先服务;'sjf' 预计耗时短的优先;'pools' 图像用 1 张卡、视频用 3 张卡(按各自的负载分)。
    到达率 0.06 个/秒、两成是视频,4 张卡的总利用率约 70%。返回图像请求和视频请求各自的 P50 / P99 排队延迟(秒)"""
    rng = random.Random(seed)
    t, reqs = 0.0, []
    for i in range(arrivals):
        t += rng.expovariate(rate)
        if rng.random() < 0.8:
            reqs.append((t, "图像", rng.choice([2.0, 3.0, 6.0])))         # 预计耗时
        else:
            reqs.append((t, "视频", rng.choice([120.0, 300.0])))
    waits = {"图像": [], "视频": []}
    if policy == "pools":
        groups = [("图像", [r for r in reqs if r[1] == "图像"], 1), ("视频", [r for r in reqs if r[1] == "视频"], n_gpus - 1)]
    else:
        groups = [("全部", reqs, n_gpus)]
    for _, rs, gpus_per in groups:
        free = [0.0] * gpus_per                                         # 每张卡什么时候空出来
        pending = []
        i, now = 0, 0.0
        while i < len(rs) or pending:
            # 把已到达的请求放进待办
            while i < len(rs) and rs[i][0] <= now:
                heapq.heappush(pending, ((rs[i][2] if policy == "sjf" else rs[i][0]), rs[i])); i += 1
            g = min(range(gpus_per), key=lambda k: free[k])
            if pending and free[g] <= now:
                _, (arr, kind, dur) = heapq.heappop(pending)
                waits[kind].append(now - arr)
                free[g] = now + dur
            else:                                                       # 推进时间到下一个事件
                nxt = [free[g]] if pending else []
                if i < len(rs): nxt.append(rs[i][0])
                now = max(now, min(nxt)) if nxt else now
    def pct(xs, q):
        xs = sorted(xs); return xs[min(len(xs) - 1, int(q * len(xs)))] if xs else 0.0
    return {k: (pct(v, 0.5), pct(v, 0.99)) for k, v in waits.items()}

print(f"{'策略':<8} {'图像 P50':>8} {'图像 P99':>8} {'视频 P50':>8} {'视频 P99':>8}")
for policy in ("fcfs", "sjf", "pools"):
    r = simulate(policy)
    print(f"{policy:<8} {r['图像'][0]:>7.0f}s {r['图像'][1]:>7.0f}s {r['视频'][0]:>7.0f}s {r['视频'][1]:>7.0f}s")
输出
策略         图像 P50   图像 P99   视频 P50   视频 P99
fcfs          17s     473s      19s     423s
sjf            2s      90s      23s     614s
pools          0s       7s     152s     745s

总利用率只有 70%,FCFS 下图像请求的 P99 排队时间仍然是几分钟——一张 3 秒的图排在一条 5 分钟的视频后面。短任务优先(SJF)把图像的延迟压到几秒,代价是视频在高负载下可能饿死;分池最稳:各自的队列互不影响,代价是资源不能互借(视频池忙的时候图像池可能闲着)。真实系统通常是分池 + 池内按预计耗时排序 + 长任务并发上限,再加一条"过载时先拒绝长任务"的规则。

三个阶段,三种放法

一个请求 = 文本编码(一次)+ 去噪(N 次)+ VAE 解码(一次)。它们的资源特征完全不同,所以不该在一条流水上串行跑:

阶段 特征 批处理 放置
文本编码 计算少、显存大(T5 9.5 GB)、输入和噪声无关 攒 batch 划算;按提示词哈希缓存 单独的卡、CPU,或者和去噪共卡但权重按需加载
去噪 算力受限、形状固定、N 次 只拼同形状;小模型低分辨率才值得 主力卡;多卡时组成序列并行组
VAE 解码 访存重、显存峰值高、一次 分块;不拼 单独 stream 或单独卡,和下一个请求的去噪重叠
串行 去噪 VAE 去噪 VAE 每请求 = 文本 + 去噪 + VAE 流水 文本编码器(批处理 + 缓存) 去噪(主力卡) 请求 1 去噪 请求 2 去噪 请求 3 去噪 VAE 解码(另一条 stream / 卡) 1 2 稳态下每个请求只占 max(去噪, VAE) 的时间:SDXL 从 3.25 s 到 2.9 s(省一成),少步模型(去噪 0.15 s、VAE 0.3 s)下 VAE 反而成了瓶颈,要分块或分卡
图:三个阶段三种放法——文本编码批处理,去噪每卡一个请求,VAE 解码与下一个请求重叠

把 VAE 解码挪到另一条 stream(或另一张卡)上和下一个请求的去噪重叠,能省多少?模拟一下:

def pipeline_throughput(denoise, vae, text, overlap_vae):
    """连续 N 个请求的平均每请求耗时:串行 vs VAE 解码与下一个请求的去噪重叠"""
    if not overlap_vae:
        return text + denoise + vae
    return text + max(denoise, vae)                                    # 稳态下 VAE 藏在下一个去噪后面

for name, dn, va, tx in [("SDXL 1024²", 2.9, 0.3, 0.05), ("FLUX 1024²", 4.7, 0.3, 0.1), ("SDXL-Turbo 4 步", 0.15, 0.3, 0.05), ("Wan 5 秒 720p (8 卡)", 150.0, 25.0, 0.8)]:
    a, b = pipeline_throughput(dn, va, tx, False), pipeline_throughput(dn, va, tx, True)
    print(f"{name:<22} 串行 {a:>6.2f} s/请求  VAE 重叠后 {b:>6.2f} s/请求  吞吐 +{a / b - 1:>4.0%}")
输出
SDXL 1024²             串行   3.25 s/请求  VAE 重叠后   2.95 s/请求  吞吐 + 10%
FLUX 1024²             串行   5.10 s/请求  VAE 重叠后   4.80 s/请求  吞吐 +  6%
SDXL-Turbo 4 步         串行   0.50 s/请求  VAE 重叠后   0.35 s/请求  吞吐 + 43%
Wan 5 秒 720p (8 卡)     串行 175.80 s/请求  VAE 重叠后 150.80 s/请求  吞吐 + 17%

大模型上只有几个百分点,少步模型上翻倍——SDXL-Turbo 4 步的去噪只要 0.15 秒,VAE 解码反而成了大头,不重叠就是让 GPU 一半时间在解码上。少步模型的服务,瓶颈从去噪转移到了"零头"(见少步生成)。

取消、预览与长任务

扩散的去噪在步与步之间没有跨步状态(调度器对象是请求私有的),这给了几件 LLM 服务做不到的事:

  • 取消几乎免费:用户关掉页面,下一步不发就行,不用保存任何东西;LLM 抢占要么保存 KV Cache 要么重算 prefill。
  • 预览:每隔几步用 TAESD 这类小解码器出一张低清图推给前端(见 VAE 与潜空间);用户看到前几步就不满意,直接取消,后面的步全省下来。预览解码的成本大约是一步去噪的几个百分点。
  • 步数可以中途变:不满意的请求可以提前停、满意的可以追加精修步(图生图式续跑)。
  • 长任务:一条 60 秒的视频是几分钟的任务,要按段流式返回、支持取消和断点(见长视频与一致性),调度器按预计时长而不是请求数计数,过载时先拒绝它们。

多卡:每卡一个请求,还是一组卡一个请求

8 张卡可以是 8 个独立的 worker(吞吐最高),也可以是 1 个序列并行组(延迟最低),或者中间的组合。用一条 Wan 5 秒 720p 视频算一下:

SINGLE = 25 * 60.0                                                     # 单卡 25 分钟
for group in (1, 2, 4, 8):
    eff = {1: 1.0, 2: 0.95, 4: 0.88, 8: 0.80}[group]                  # 序列并行的效率(通信、负载不均)
    latency = SINGLE / (group * eff)
    throughput = 8 / group / latency * 3600                            # 每小时能出几条视频
    print(f"每组 {group} 卡(8 卡 {8 // group} 组):单条延迟 {latency / 60:>5.1f} 分钟,吞吐 {throughput:>5.1f} 条/小时")
输出
每组 1 卡(8 卡 8 组):单条延迟  25.0 分钟,吞吐  19.2 条/小时
每组 2 卡(8 卡 4 组):单条延迟  13.2 分钟,吞吐  18.2 条/小时
每组 4 卡(8 卡 2 组):单条延迟   7.1 分钟,吞吐  16.9 条/小时
每组 8 卡(8 卡 1 组):单条延迟   3.9 分钟,吞吐  15.4 条/小时

8 卡一组延迟最低但吞吐只有每卡独立的 80%。所以要看 SLO:交互式产品(用户在等)用大组;批量任务(离线生成素材)用小组甚至单卡;实际常常分池——两组 4 卡服务交互请求,剩下的单卡跑离线队列,再按队列深度动态调整。

显存:多模型怎么放

一张 80 GB 的卡放一个 FLUX(24 GB)+ T5(9.5 GB)之后还有五十 GB,能不能再放一个 SDXL?能,但两个模型交替跑时各自的激活和 VAE 峰值要分别预留,而且 CUDA Graph / 编译的缓存各占一份。常见的做法:

  • 按模型分实例:每个进程只服务一个模型(加它的 LoRA 池),路由层按模型分发——最简单、最稳;
  • 热的常驻、冷的卸载:LoRA 几十 MB 全部常驻,ControlNet 几 GB 放锁页内存按需搬(见多 LoRA / ControlNet 的服务);
  • 文本编码器共享:几个模型用同一个 T5 的,只放一份,甚至放在单独的编码服务上。

怎么讲清楚

讲"文生图 / 视频服务怎么调度",先说和 LLM 的根本差别:一步算力受限,batch 不省钱,所以不追求大 batch,追求把卡填满、把请求排开。再给三件事:按预计耗时排队并分池(一张图 3 秒、一条视频 20 分钟混在 FCFS 里 P99 必爆);三个阶段分开放(文本编码攒 batch 并缓存、去噪按形状分组、VAE 解码单独 stream 和下一个请求重叠,少步模型上这一项翻倍);取消几乎免费、预览让用户早停。最后给多卡的权衡:一组卡一个请求延迟最低、每卡一个吞吐最高,按 SLO 分池。

练习

  1. 把模拟里的到达率 rate 从 0.06 提到 0.08(总利用率接近 95%),三种策略的 P99 各变成多少?哪种策略退化得最厉害?为什么?
参考答案

接近满载时所有策略的排队时间都急剧上升(见排队论),FCFS 退化最厉害,因为长任务堵住队列的概率随负载上升;分池里视频池先饱和、图像池受影响小。这也是为什么生成服务要留余量、过载时先拒绝长任务。

  1. 给 simulate 加一条规则:视频请求最多同时占 2 张卡(并发上限)。对图像和视频的延迟各有什么影响?
参考答案

图像的 P99 明显下降(至少有 2 张卡随时能服务短请求),视频的排队时间上升。这是"用长任务的延迟换短任务的延迟"的典型取舍,大多数产品会这么选——视频用户本来就预期要等几分钟。

  1. SDXL-Turbo 4 步的服务,去噪 0.15 秒、VAE 0.3 秒、文本编码 0.05 秒。除了把 VAE 重叠,还有什么办法把每请求耗时压到 0.2 秒以内?
参考答案

VAE 用 TAESD 这类小解码器(质量略降、几十毫秒)、或者把 VAE 解码放到另一张卡上形成两级流水;文本编码按提示词缓存;去噪开 CUDA Graph(小模型上启动开销占比大);相同分辨率的请求拼 batch(Turbo 的小算力需求让 batch 真的能提高 MFU)。少步模型的服务是"零头工程"。

小结

  • 扩散请求跨四个数量级的耗时,调度的第一件事是按预计耗时排队并分池,而不是 FCFS;过载时先拒绝长任务。
  • 一步算力受限,batch 不省钱;能省的是三个阶段的错位:文本编码攒 batch 并缓存,去噪按形状分组,VAE 解码单独 stream 和下一个请求重叠——少步模型上这一项翻倍。
  • 没有跨步状态:取消免费、预览早停、步数可中途变;长视频是长任务,按时长计数、流式返回、可取消。
  • 多卡:一组一个请求延迟最低、每卡一个吞吐最高,按 SLO 分池;显存上按模型分实例、热的常驻冷的卸载、文本编码器共享。

发现错误或有更好的写法?在 GitHub 上编辑此页提交问题