生成服务的调度:异构请求、阶段流水与长任务¶
单次生成优化完了,下一步是把它变成一个服务:几十个用户同时来,分辨率、步数、模型、附件各不相同,有人要 512² 的草稿、有人要 60 秒的视频。LLM 服务那套"攒大 batch 分摊权重"在这里不成立——一步算力受限,batch 不省钱;能省的是三个阶段的错位(文本编码、去噪、VAE 解码)、同形状请求的合并,以及按预计耗时而不是请求数来排队。这一章用一个离散事件模拟把几种调度策略的延迟分布算出来,再讲取消、预览、长任务这些扩散服务特有的问题。
自测:能答上来就可以跳过本章
- 为什么扩散服务不像 LLM 服务那样追求大 batch?什么情况下 batch 仍然有用?
- 一个请求的三个阶段各适合什么样的批处理和放置?
- 按请求数 FCFS 排队会出什么问题?按什么排更好?
- 扩散请求的取消和抢占为什么比 LLM 便宜?
- 一个 8 卡节点,是"每卡一个请求"还是"8 卡一起算一个请求"?怎么权衡?
自测参考答案(先自己答,再展开对照)
- 一步在 batch=1 时就已经算力受限,拼 batch 只让每一步线性变慢,吞吐几乎不涨、延迟线性变差。例外:小模型 + 低分辨率时 kernel 填不满 GPU(batch 提高 MFU),以及"一个提示词出 4 张候选图"这种天然同形状的请求。
- 文本编码器:输入短、计算少、显存大,适合攒 batch、放到 CPU 或单独的卡上,并按提示词缓存;去噪:算力受限,按形状分组、通常 batch 很小;VAE 解码:访存和显存峰值大,适合单独的 stream / 卡,和下一个请求的去噪重叠,并分块。
- 一条长视频的耗时是一张图的几百倍,FCFS 会让后面的短请求等很久,P99 延迟爆炸。按预计耗时(token 数 × 步数 × 模型系数)排队、短任务优先或分池,并限制长任务的并发。
- 去噪每一步之间没有跨步状态(除了调度器对象),取消就是不再发下一步,什么都不用保存或重算;LLM 抢占要么保存 KV Cache、要么丢掉重算 prefill。
- 每卡一个请求:吞吐最高(没有通信开销),单请求延迟最差;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")
总利用率只有 70%,FCFS 下图像请求的 P99 排队时间仍然是几分钟——一张 3 秒的图排在一条 5 分钟的视频后面。短任务优先(SJF)把图像的延迟压到几秒,代价是视频在高负载下可能饿死;分池最稳:各自的队列互不影响,代价是资源不能互借(视频池忙的时候图像池可能闲着)。真实系统通常是分池 + 池内按预计耗时排序 + 长任务并发上限,再加一条"过载时先拒绝长任务"的规则。
三个阶段,三种放法¶
一个请求 = 文本编码(一次)+ 去噪(N 次)+ VAE 解码(一次)。它们的资源特征完全不同,所以不该在一条流水上串行跑:
| 阶段 | 特征 | 批处理 | 放置 |
|---|---|---|---|
| 文本编码 | 计算少、显存大(T5 9.5 GB)、输入和噪声无关 | 攒 batch 划算;按提示词哈希缓存 | 单独的卡、CPU,或者和去噪共卡但权重按需加载 |
| 去噪 | 算力受限、形状固定、N 次 | 只拼同形状;小模型低分辨率才值得 | 主力卡;多卡时组成序列并行组 |
| VAE 解码 | 访存重、显存峰值高、一次 | 分块;不拼 | 单独 stream 或单独卡,和下一个请求的去噪重叠 |
把 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 分池。
练习¶
- 把模拟里的到达率
rate从 0.06 提到 0.08(总利用率接近 95%),三种策略的 P99 各变成多少?哪种策略退化得最厉害?为什么?
参考答案
接近满载时所有策略的排队时间都急剧上升(见排队论),FCFS 退化最厉害,因为长任务堵住队列的概率随负载上升;分池里视频池先饱和、图像池受影响小。这也是为什么生成服务要留余量、过载时先拒绝长任务。
- 给
simulate加一条规则:视频请求最多同时占 2 张卡(并发上限)。对图像和视频的延迟各有什么影响?
参考答案
图像的 P99 明显下降(至少有 2 张卡随时能服务短请求),视频的排队时间上升。这是"用长任务的延迟换短任务的延迟"的典型取舍,大多数产品会这么选——视频用户本来就预期要等几分钟。
- 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 上编辑此页提交问题