AI漫剧的批量分镜生成,卡脖子问题基本不在生成质量,而在跑图速度。我用SDXL在单张4090上出一张1024x1024的分镜底图,20步采样通常要七八秒,看起来单张不算慢,但一个剧本拆出几百个分镜、每个分镜还要出好几张候选图,时间账一下子就难看了。后来我把CUDA Graph的捕获与重放机制用到SDXL的UNet采样循环里,单张耗时可观下降,而且不用改模型结构、不动权重,风险很小。这篇文章把我的实践过程、踩过的坑和收益数据都整理出来,给同样做批量出图、卡在推理延迟上的朋友做个参考。
1. 项目背景:AI漫剧为什么会被SDXL的推理速度卡住
1.1 漫剧生产链条里的“分镜流水线”
漫剧的生产逻辑和传统漫画不一样,它更接近“动画分镜”的流水线:拿到文字脚本后,先拆成一场场分镜,每个分镜再转成具体的人物、动作、背景描述,最后批量生成底图。这一套流程里,SDXL承担的是最重的那一块:生成构图合理、细节丰富的底图。LoRA负责角色一致性,ControlNet负责卡住构图结构,SDXL负责把画面质量拉起来。问题在于,这套组合会成倍放大推理耗时。
我自己的项目里,一个中期的漫剧剧本大约会拆出300到500个分镜。每个分镜为了质量筛选,至少生成2到4张候选图。这意味着一个项目就要跑上千次SDXL推理。如果单张卡的出图速度是7秒一张,不考虑排队和错误重试,光底图生成就是2个小时起步的纯GPU时间。在多人协作、频繁改稿的节奏下,这个成本很难接受。所以推理加速不是“锦上添花”,而是能不能批量交付的关键。
1.2 SDXL推理耗时拆解:UNet采样循环是绝对大头
要把加速做明白,先得知道时间都花在哪。SDXL一次完整推理可以切成三段:两个文本编码器各跑一次、UNet扩散采样循环迭代20到50步、VAE解码器最后把latent还原成像素图。我实测中,文本编码加VAE通常只占0.6到0.9秒,剩下几乎全部时间都压在UNet的采样循环里。
以RTX 4090、fp16、1024x1024分辨率、20步采样为例,原始diffusers管线单张总耗时在8秒左右,UNet采样循环至少占7秒。换算下来,每一步UNet forward平均要跑350到400毫秒。而这一步的前向过程,在fp16下会启动大概几百个CUDA kernel。每个kernel本身很快,有的只有几十微秒,但几百个kernel一个接一个从CPU提交到GPU,调度和等待开销就攒成了几十毫秒甚至上百毫秒的“隐形浪费”。
这也是为什么单纯看计算量会觉得SDXL不应该这么慢,但实际跑起来就是慢。GPU卡在CPU逐个下发kernel的节奏上,计算单元一直处于“吃不满”的状态。想解决,无非两条路:把kernel合并掉,或者把kernel的下发成本压下去。CUDA Graph走的是后面这条路。
1.3 对比一圈后,为什么选CUDA Graph
在决定用CUDA Graph之前,我把常见的加速手段都过了一遍。TensorRT的加速效果确实最强,但需要导出ONNX、构建engine,还要盯着算子兼容性,SDXL的UNet结构又复杂,每次diffusers版本一升级,engine基本要重新折腾一轮。torch.compile在部分模型上能拿到不错的收益,但它和PyTorch的版本、模式耦合很深,遇到自定义attention反而容易踩坑。xFormers能省显存也稍快一些,但主要优化的是attention算子,解决不了CPU调度开销的问题。
CUDA Graph的思路完全不同:它不碰你的模型权重,也不改任何算子实现,只把“CPU逐个启动kernel”的模式改成“一次启动整张kernel图”。SDXL这种固定分辨率、固定batch、循环几十步执行同一个UNet的场景,正好是CUDA Graph最能发挥价值的地方。低侵入、可回滚、对版本不敏感,这是我选它的核心理由。
2. CUDA Graph捕获与重放,到底加速在哪里
2.1 被忽视的GPU空转:CPU逐个启动kernel的开销
要理解CUDA Graph,得先清楚GPU kernel的启动链路。CPU端调用一个CUDA kernel时,不是直接把函数扔给GPU执行,而是把kernel参数、启动配置填进命令缓冲区,通过驱动提交到GPU前端。这个过程本身有固定开销,大概几微秒到几十微秒,具体取决于驱动状态和硬件平台。单个kernel看起来不多,但模型一复杂就完全不一样了。
我拿UNet一个step的kernel数量估算过,在fp16推理、无attention特殊优化的情况下,一次forward会产生几百个kernel。如果每个kernel的CPU启动开销平均按10到20微秒算,一个step仅调度开销就可能累积到几毫秒到十几毫秒。更麻烦的是,CPU和GPU之间天然存在异步执行,CPU提交完一个kernel后可能已经跑到下一个kernel的提交逻辑里,但GPU必须等前面的kernel执行完才能开始下一个,这中间如果CPU提交速度跟不上,GPU就出现空闲气泡。
打个比方,这就像点外卖:CPU是那个接单的人,GPU是送餐员。正常模式是一次只接一单,送完再回来接下一单,路上来回折腾,送餐员大部分时间都在空跑。CUDA Graph相当于提前把一整天的配送路线全部规划好,接单员一次性把几百单的路线交给送餐员,他照着路线跑就行。省掉的不是送餐时间,而是接单、分单、来回沟通的中间损耗。
2.2 捕获、实例化、重放三步走,图和重放各负责什么
CUDA Graph的生命周期可以分成三个阶段。第一个阶段是捕获,也就是在启动“记录”的CUDA stream上正常执行计算任务,驱动会把所有kernel的启动顺序、参数、依赖关系原样记录下来。这个阶段不能有CPU和GPU的同步操作,比如item()、synchronize(),否则捕获会直接在运行时抛错。第二个阶段是实例化,驱动把捕获到的图结构编译成一份可执行的cudaGraphExec_t,并且在这个阶段完成内存地址的静态绑定和依赖优化。第三个阶段是重放,调用一次cudaGraphLaunch,GPU按图里固化好的顺序批量执行所有kernel。
PyTorch对这套流程做了封装。torch.cuda.CUDAGraph()负责创建一个图对象,torch.cuda.graph(graph)上下文管理器负责控制捕获范围,之后每次调用graph.replay()就会重放一次。重放时你不需要再逐个跑UNet里的kernel,CPU只要发起一次图启动,剩下的全交给GPU。
需要特别强调的是,“图”记录的不是PyTorch的autograd计算图,而是GPU kernel级的有向无环图。它不关心你们的张量从哪里来,也不关心反向传播,只关心kernel怎么按顺序跑。所以在推理场景用CUDA Graph,和训练时的动态图完全是两回事。
2.3 为什么固定shape是硬约束,而SDXL采样恰好满足
捕获的时候,每个kernel的grid尺寸、block尺寸、输入输出张量的显存地址都会被固化到图里。这意味着重放时,输入数据的shape和dtype必须和捕获时完全一致。如果下一次输入的latent从1张变成2张,kernel启动配置里的block数就对不上了,轻则结果错误,重则显存越界直接崩掉。这是CUDA Graph最核心的约束。
SDXL的扩散采样循环为什么非常适合?因为去噪的每一步都在做同一件事:把当前latent、timestep、文本条件输入UNet,输出预测的噪声残差。只要分辨率固定,latent的shape永远是(batch, 4, H/8, W/8),文本embedding的seq_len也固定,整个计算图的形状不会变。
有人会问:timestep不是从1000一路变到0吗?注意,timestep虽然数值变了,但它的shape是固定的,比如(1,)或者(batch,)。CUDA Graph固化的只是kernel结构和显存地址,并不要求输入数值不能变。你在重放前往静态缓冲区里copy一个新的timestep值,图里的kernel会读到新数值,行为完全正常。这就是“动态值”和“动态shape”的区别,也是CUDA Graph能在diffusion模型上落地的前提。
3. SDXL推理接入CUDA Graph的实操方案
3.1 先手动接管采样循环,别硬套pipeline
直接用diffusers的StableDiffusionXLPipeline.__call__去接CUDA Graph是不现实的,因为pipeline内部有不少Python逻辑:tokenize、文本长度截断、CFG分支、scheduler.step、后处理,这些都会破坏捕获过程。正确做法是把采样循环拆出来,自己控制每一步的UNet调用。
我习惯只用diffusers做模型加载,采样循环自己写。这样做的好处是控制力强,既能方便插入捕获逻辑,也能在调度器、CFG策略上灵活调整。下面是我平时搭的骨架:
import torch from diffusers import DiffusionPipeline pipe = DiffusionPipeline.from_pretrained( "stabilityai/stable-diffusion-xl-base-1.0", torch_dtype=torch.float16, variant="fp16", use_safetensors=True ).to("cuda") unet = pipe.unet unet.eval() class UNetWrapper(torch.nn.Module): def __init__(self, unet): super().__init__() self.unet = unet def forward( self, latent, timestep, encoder_hidden_states, text_embeds, time_ids, ): out = self.unet( latent, timestep, encoder_hidden_states=encoder_hidden_states, added_cond_kwargs={ "text_embeds": text_embeds, "time_ids": time_ids, }, ) return out.sample unet_wrapper = UNetWrapper(unet)这层wrapper的目的不是加功能,而是把UNet forward的入参收敛成固定元组,方便后面统一往静态缓冲区里copy。实际使用中,文本编码和VAE解码放在图外,只有UNet采样循环进入CUDA Graph。
3.2 静态输入输出缓冲区怎么设计
捕获前必须给所有输入输出分配好静态缓冲区。这些张量一旦分配,在整个生命周期内不能释放、不能改变shape,重放时所有数据都通过copy_操作进入这些缓冲区。SDXL的UNet主要输入我列成了下面这张表,实际项目里按自己的模型配置调整即可:
| 张量 | Shape | dtype | 说明 |
|---|---|---|---|
| latent | (1, 4, 128, 128) | fp16 | 当前去噪latent,1024x1024分辨率对应128x128 |
| timestep | (1,) | fp32 | 当前时间步,数值会变但shape不变 |
| encoder_hidden_states | (1, 77, 2048) | fp16 | 两个文本编码器拼接后的条件embedding |
| text_embeds | (1, 1280) | fp16 | SDXL的pooled文本embedding,走added_cond_kwargs |
| time_ids | (1, 6) | fp32 | 原始尺寸、裁剪坐标、目标尺寸等6个数值 |
| output | (1, 4, 128, 128) | fp16 | UNet输出噪声预测 |
需要提醒的是,diffusers内部对timestep的处理有时会传torch.long,但UNet的时间编码层会做float转换。我自己在捕获时统一用fp32的timestep,避免dtype不一致导致重放结果异常。text_embeds和encoder_hidden_states在SDXL里必须是fp16,这个由模型权重dtype决定,别用fp32去copy。
静态缓冲区按照上面的表统一创建,用torch.empty就行,不用初始化成零。捕获开始前,往这些缓冲区里塞一批真实shape的数据跑几次forward,让cuBLAS和cuDNN完成autotune并分配好workspace,否则捕获出来的图可能落回保守算法,加速效果打折扣。
3.3 完整捕获与重放代码,以及每行的意图
捕获的核心流程是warmup、进入torch.cuda.graph、执行一次完整UNet forward。下面这段代码是我在项目里跑通的版本,做了注释,方便对照:
# 预热,这里跑3次,触发底层库的算法选择 for _ in range(3): _ = unet_wrapper( s_latent, s_timestep, s_hidden, s_text_embeds, s_time_ids, ) torch.cuda.synchronize() # 捕获 graph = torch.cuda.CUDAGraph() with torch.cuda.graph(graph): s_output.copy_( unet_wrapper( s_latent, s_timestep, s_hidden, s_text_embeds, s_time_ids, ) ) torch.cuda.synchronize()预热为什么要跑3次?底层库会在第一次调用时做autotune,第二次、第三次基本会命中缓存里的最优配置。如果跳过预热直接捕获,捕获时偶尔会触发一些一次性分配或慢路径选择,导致图里的kernel不是最优状态。
捕获时我在图内做了一次copy_,把UNet的输出写入预先分配的s_output缓冲区。这样做的原因是输出张量必须有一个固定地址,后续每次重放都会往这个地址写结果。捕获完成后,每次采样step就只需要做两件事:把当前输入copy进静态缓冲区,然后调用graph.replay()。
def run_unet_step(latent, timestep, hidden, text_embeds, time_ids): s_latent.copy_(latent, non_blocking=True) s_timestep.copy_(timestep, non_blocking=True) s_hidden.copy_(hidden, non_blocking=True) s_text_embeds.copy_(text_embeds, non_blocking=True) s_time_ids.copy_(time_ids, non_blocking=True) graph.replay() return s_output.clone()copy_和replay()都在当前CUDA stream上执行,顺序是有保障的。源张量和静态缓冲区都在显存里,non_blocking=True可以避免不必要的同步等待。返回时用clone()复制一份,是为了防止下一次step重放把这块显存里的结果覆盖掉。如果你不clone,后面scheduler拿到的是同一个地址,下一轮会被改写,结果乱掉。
3.4 配合xFormers、ControlNet和CFG批量的一揽子策略
CUDA Graph不是孤立用的,实际工程里往往叠加其他优化。我先说xFormers和SDPA。现在diffusers新版本里attention默认走torch.nn.functional.scaled_dot_product_attention,在Ampere以上架构本身就有加速效果。再套CUDA Graph时,SDPA的kernel同样会被捕获进图里,二者叠加没有冲突。如果你的环境用了xFormers的MemoryEfficientAttention,也是一样的,只要注意力输入shape固定,捕获照常进行。
ControlNet是漫剧工作流里的常客。ControlNet不会改变UNet的计算图,只是额外增加一组条件和中间特征。实际操作时,我建议把ControlNet的forward也放进同一个捕获范围内,让它的kernel同样被图固化。这样可以避免ControlNet带来的额外kernel启动次数抵消UNet本身的优化收益。
CFG批量值得专门提一句。做Classifier-Free Guidance时,每个step要同时跑条件分支和无条件分支,常规做法是把两个输入拼成一个batch,让UNet一次推理处理两份数据。捕获时直接把batch维度设为2,静态缓冲区的latent shape定为(2, 4, 128, 128),这样一次replay就完成了两个分支的计算。CUDA Graph对batch维度是敏感的,捕获时是2,重放时就不能换回1,所以如果需要灵活切换,就为batch=1和batch=2各捕获一张图,运行时按需选择。
4. 实测加速比、显存开销与参数调优
4.1 单张4090上的实测数据
我在自己的测试环境里跑过几组对比,硬件是RTX 4090 24GB,PyTorch 2.1,diffusers 0.2x,模型是SDXL base 1.0,分辨率1024x1024,采样器Euler,20步。每张图测5次取平均,单张推理包括文本编码、UNet采样循环和VAE解码全流程,结果如下表:
| 配置 | 单张耗时 | 相对原版提升 |
|---|---|---|
| 原版diffusers fp16 | 8.2秒 | 基线 |
| 开启xFormers/SDPA | 7.3秒 | 约11% |
| 只加CUDA Graph | 6.1秒 | 约26% |
| SDPA + CUDA Graph | 5.4秒 | 约34% |
可以看到,CUDA Graph单独带来的收益在20%以上,和attention优化叠加后能到三成多。换算到UNet单步耗时,从约370毫秒降到了约245毫秒。这一步省下来的时间主要就是CPU调度和GPU空转的损耗。
不同显卡上收益会有差异。显卡越弱、kernel越短,CUDA Graph的收益越明显,因为调度开销在单步耗时里的占比更大。反过来,如果用H100这种计算极强的卡,kernel本身更快,启动开销占比反而可能更高,收益也不小。总之,这种加速手段不吃模型、不吃显卡架构,只要CUDA版本支持,基本都能拿到稳定回报。
4.2 CUDA Graph的显存代价,以及内存池复用
捕获会带来一个容易被忽略的问题:显存占用上升。普通推理时,中间激活张量用完就释放,同一块显存可以反复被不同tensor使用。但CUDA Graph捕获时,图里所有中间tensor都必须固定在特定显存地址上,不能再被复用。SDXL的UNet一次forward会产生不少大激活张量,全部固化下来可能额外占掉1到2GB显存,这个开销在24GB卡上还能接受,在8GB、12GB卡上就很紧张了。
解决思路是复用内存池。torch.cuda.CUDAGraph在捕获时会绑定一个memory pool,后续捕获如果重复申请新pool,显存碎片会非常难看。我在代码里是这样处理的:第一次捕获后保存pool = graph.pool(),第二次捕获时把pool传进去,with torch.cuda.graph(graph2, pool=pool):,这样两个图共享同一块池子,显存不会翻倍增长。
如果你的显存实在紧张,还可以把VAE解码和文本编码都留在图外,只捕获UNet采样循环,因为UNet才是激活值的大户。我在漫剧流水线上就是这么干的,既能保住加速收益,又能控制显存水位。
4.3 batchsize和分辨率变化时的收益曲线
我还专门测过batchsize对收益的影响。batch=1时,CPU调度开销在单步耗时里的占比最高,CUDA Graph收益最明显,能到25%以上。随着batch变大,比如batch=4,kernel每个都更“胖”,GPU单次kernel运行时间变长,CPU启动开销占比自然下降,CUDA Graph的收益会回落到10%到15%左右。漫剧分镜场景绝大多数时候是batch=1或batch=2,正好落在收益最高的区间里。
分辨率的影响也类似。1024x1024换成768x768时,latent缩小到96x96,kernel运行时间变短,调度开销占比变大,CUDA Graph的加速比会更高。反过来换成1536x1536,kernel运行时间拉长,收益比例会稍微下降,但由于整体耗时变长,省下的绝对时间依然可观。
这里要提醒的硬约束是,CUDA Graph和分辨率强绑定。你捕获了一个1024x1024的图,就不能用在768x768的输入上,因为latent的shape变了,grid配置完全对不上。漫剧项目如果同时存在竖版、横版、方图等多种规格,我的做法是给每种常用规格单独捕获一张图,运行时按输入shape分发。
5. 实测中遇到的坑与排查思路汇总
5.1 捕获失败的常见原因与处理
捕获阶段最容易碰到的是cudaStreamCapture相关报错。我踩过最多的坑是没预热就捕获,cuBLAS的workspace或算法选择还没稳定,捕获过程中触发了某些不允许的行为。解决办法就是老老实实在捕获前跑2到3次warmup forward,并且这几次warmup的shape和后续完全一致。
第二个常见坑是捕获里面混入了CPU同步操作。有一次我在代码里为了调试打印了一个中间张量的shape,print(s_latent.shape)其实不会同步,但如果我写成print(s_latent.sum().item()),就会触发GPU到CPU的同步,捕获直接失败。排查技巧是把捕获段里的代码收敛到最干净,任何Python侧判断、同步、item()都移到捕获外面。
第三个坑是作用域内变量被重新赋值。捕获图记录的是显存地址,不是变量名。如果你在with torch.cuda.graph(g)块里写了output = unet(...),块外面又把output指向另一个张量,图本身不会有问题,但后续你可能误读了错误的指针,结果一跑就错。统一用预先分配的buffer,不用临时变量名,能规避掉这类问题。
5.2 重放结果不对的罪魁祸首
重放执行完,图是“成功”运行了,但出图结果明显不对,比如画面有噪块、颜色整体偏掉,或者同一张图两次重放结果不同。这类问题大多数出在输入没有完整更新上。我遇到过一次比较隐蔽的情况:所有输入都copy了,唯独time_ids漏了,但SDXL对time_ids的敏感度没有latent那么高,短步数下肉眼不太容易看出来,长步数一跑就露馅。排查时把五个静态输入做一个版本校验,每个step记录一次是否更新,能在几分钟内定位。
还有一个更阴间的坑:copy_是异步操作,如果源张量来自另一个CUDA stream,且那个stream没有和当前stream做同步,重放时静态缓冲区可能只copy了一半,图里读到的是脏数据。做多stream并行时,必须在copy之前调用src_stream.wait_stream(dst_stream)之类的事件同步,确保数据落地再replay。
输出侧的问题也有。我早期图省事不clone输出,直接返回s_output,结果scheduler里对输出做原地更新,把静态缓冲区污染了,下一轮重放输入都被改成上一次的输出。教训是:图内使用静态buffer,图外必须把结果当成“一次性读取”的数据处理,要么clone,要么尽快消费。
5.3 与其他加速手段共存时的冲突排查
torch.compile和CUDA Graph放一起用,是我踩过最久的坑。PyTorch的torch.compile在某些后端里自己就会生成CUDA Graph,你再去手动捕获,等于把一个图启动的kernel序列又包了一层图,轻则收益不明显,重则捕获失败。经验是二选一,优先保留手动CUDA Graph,因为它的执行路径更可控,排查起来也更简单。
TensorRT engine和CUDA Graph能不能共存?能,但要小心显存争抢。TensorRT的engine会保存自己的workspace,CUDA Graph的pool也会占一块显存,两者加起来可能把显存压到危险水位。我的处理方式是把CUDA Graph的pool显存上限控制住,尽量避免同时把多个分辨率的图全部装进显存,用的时候再切。
多卡环境还有个小陷阱:CUDA Graph是和设备绑定的。你在0号卡上捕获的图,不能直接拿到1号卡上去replay,即使两台卡型号完全一样也不行。多卡推理要么每张卡各自捕获,要么用同样的初始化流程每进程捕获一次,千万不要想“copy一下图对象就完事”。
最后说一个和采样器兼容性相关的问题。DDIM、Euler、DPM++这类调度器,它们的scheduler.step()里有大量Python侧的计算,不适合放进CUDA Graph。一定要坚持只捕获UNet forward,让调度器在图外运行。这个边界理清楚之后,换采样器、换步数都只是改调度器参数的事,CUDA Graph部分完全不用动。
我个人在漫剧批量出图流水线上把CUDA Graph加进去之后,最大的感受不是单张图快了多少,而是GPU的利用率肉眼可见地上去了。以前每张图的间隔里总有那么几十毫秒在空等CPU调度,批量任务一多,这种空等会叠成几分钟的额外耗时。CUDA Graph相当于把“每顿外卖只送一单”改成“提前把一天的路线规划好,一次跑完”,听起来玄,实际操作起来就是个两三天能完成的小工程。如果你也在用SDXL批量出图,并且卡在推理延迟上,我的建议是先把这个做完,再考虑换卡、加机器这些花大钱的路子。