news 2026/11/3 11:34:21

SGLang Diffusion BCG 实战:DiT 扩散模型推理加速与显存优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SGLang Diffusion BCG 实战:DiT 扩散模型推理加速与显存优化

1. 从一次显存告急说起:为什么要啃 SGLang Diffusion 的 BCG

第一次在 SGLang 上跑 DiT 类扩散模型做批量推理的时候,我盯着nvidia-smi里那条几乎顶到天花板的显存曲线,心里只有一个念头:这玩意儿要是再往上加 batch,肯定当场 OOM。当时用的是最朴素的逐 step 前向,每个去噪步都老老实实走一遍完整的计算图,Python 侧的调度开销和 kernel launch 开销叠在一起,单张卡的有效吞吐被压得很低。后来翻 SGLang 的代码,注意到它内部有一套叫BCG的机制,全称是Batch CUDA Graph,专门用来把扩散模型这种"固定形状、反复执行"的推理过程打包成 CUDA Graph 来跑。这个思路其实不新鲜,但 SGLang 把它和扩散模型的 batch 调度结合得比较巧,值得单独拿出来记录一下。

先把话说清楚:这篇东西不是官方文档的翻译,也不是什么入门教程。它是我自己在实现和调试 SGLang Diffusion BCG 这条链路上踩过的坑、做过的取舍、验证过的参数,以及最后跑通之后的一些实测数据。SGLang本身是一个面向大模型推理的服务框架,Diffusion指的是扩散模型这条推理路径,BCG是它内部针对扩散模型做的 CUDA Graph 批处理优化,DiT则是当前主流扩散模型(比如 Stable Diffusion 3、Flux 这一类)用的主干网络结构。这几个词凑在一起,核心要解决的问题就一个:怎么让 DiT 类扩散模型在固定 batch 下把推理延迟压到最低,同时不把显存撑爆。

适合谁看?如果你已经在用 SGLang 部署文本模型,想顺手把扩散模型也接进来;或者你自己在写 DiT 推理服务,被 Python 调度开销和 kernel launch 拖累;再或者你只是好奇 CUDA Graph 在扩散模型上到底能带来多少收益——那这篇应该能给你一些直接能抄的东西。我会从整体设计思路讲起,然后拆 BCG 的实现细节,再给完整的实操流程和参数计算,最后把调试过程中遇到的那些"看起来像 bug 其实是配置问题"的坑一个个列出来。

提示:CUDA Graph 对显存的要求比普通推理高,因为它需要把整个计算图的中间状态固定下来。如果你的卡显存本来就紧张,先别急着上 BCG,把 batch 和分辨率降下来再说。

2. BCG 到底在优化什么:整体设计与思路拆解

2.1 扩散模型推理的固有开销在哪里

要理解 BCG 的价值,得先看清楚扩散模型推理的时间都花在哪了。以 DiT 为例,一次完整的文生图推理通常包含几十个去噪步(step),每一步都要把当前 latent 送进 DiT 主干,经过一堆 attention 和 MLP 层,输出噪声预测,然后 scheduler 根据这个预测更新 latent。整个过程里,DiT 主干的形状是完全固定的——输入 latent 的 shape、batch size、序列长度在整条推理链里都不变。变的只有 latent 的数值本身。

这就带来一个很尴尬的局面:每一步的计算图结构一模一样,但 PyTorch 默认的 eager 模式每次都要重新走一遍 Python 解释器、重新做算子分发、重新 launch kernel。对于单步只有几毫秒的 DiT 来说,这些"周边开销"占比相当可观。我实测过,在 A100 上跑一个中等规模的 DiT,单步纯计算大概 3 到 4 毫秒,但加上 Python 调度和 kernel launch,端到端能到 6 到 7 毫秒。也就是说,将近一半的时间没花在真正的矩阵运算上。

CUDA Graph 的思路就是把这些固定形状的算子序列录制成一张图,之后每次执行只需要一次 launch,GPU 侧按图执行,CPU 侧几乎不参与。对于扩散模型这种"同一张图跑几十遍"的场景,简直是量身定做。

2.2 为什么是 Batch CUDA Graph 而不是单样本 Graph

这里有个关键选择:SGLang 的 BCG 做的是batch 级别的 CUDA Graph,而不是给单个样本录一张图然后循环。原因有两个。

第一,GPU 利用率。单样本推理时,DiT 的很多算子(尤其是 attention)在 batch=1 的情况下根本喂不饱 SM,算力浪费严重。把 batch 凑大之后,同样的 kernel launch 开销摊到更多样本上,单位样本的成本就下来了。BCG 的设计目标就是让一个 graph 覆盖整个 batch,而不是每个样本一张图。

第二,显存和 graph 数量的平衡。CUDA Graph 本身是要占显存的,每张图都要保留自己的中间 buffer。如果给每个样本单独录图,graph 数量随 batch 线性增长,显存直接爆炸。而一张 batch 图只占一份中间 buffer,显存开销是固定的。当然代价是 batch size 必须固定——你不能今天跑 batch=4 明天跑 batch=8 还用同一张图,得重新录。

SGLang 在这里做了一个折中:它维护一个graph pool,针对几种常见的 batch size(比如 1、2、4、8)各录一张图,请求来了之后按实际 batch 去匹配最接近的图。匹配不上就回退到 eager 模式。这个设计在真实服务里很实用,因为线上请求的 batch 分布通常集中在几个值附近。

2.3 BCG 和普通 CUDA Graph 的差异点

普通的 CUDA Graph 捕获流程大概是:warmup 几次让显存分配器稳定,然后torch.cuda.graph上下文里跑一遍前向,把图录下来,之后 replay。BCG 在这之上多了几层处理。

一是输入输出的静态化。扩散模型的输入是 latent,每一步都在变,所以 BCG 必须把输入 buffer 固定下来,每次 replay 前把新的 latent 拷贝进去。这个拷贝本身也有开销,但比重新 launch 一堆 kernel 便宜得多。SGLang 用的是静态输入 tensor,配合copy_做原地更新。

二是和 scheduler 的解耦。去噪步之间的 scheduler 更新(比如 DDIM、Euler 的更新公式)是纯 CPU 侧的轻量计算,没必要塞进 graph。BCG 只把 DiT 主干的前向录进图里,scheduler 在外面跑。这样图的粒度更细,复用性也更好。

三是显存池的复用。录图的时候 PyTorch 会为中间激活分配显存,这些显存录完图之后不能随便释放,否则 replay 会出错。BCG 用了一个专门的显存池来管理这部分,避免和主推理流程的显存分配打架。

注意:录 CUDA Graph 的时候一定要确保显存分配器处于稳定状态。如果你在录图过程中触发了新的显存分配,PyTorch 会直接报错。标准做法是先跑几次 warmup,把该分配的显存都分配好,再开始录。

3. 核心细节解析:BCG 实现里的几个关键环节

3.1 静态输入 buffer 的设计与拷贝开销

BCG 的第一个核心细节是输入 buffer 的静态化。DiT 主干的输入通常是一个 shape 为[B, C, H, W]或者[B, N, D]的 tensor(取决于你是用卷积还是 patch embedding)。在录图之前,需要先创建一个固定 shape 的静态 tensor,比如static_input = torch.empty(B, C, H, W, device='cuda'),然后在录图时把这个 tensor 作为输入。

每次 replay 之前,把当前 step 的 latent 拷贝进去:static_input.copy_(current_latent)。这个copy_是 device-to-device 的拷贝,带宽很高,对于典型的 latent 尺寸(比如 64x64x4 或者 128x128x16),拷贝时间在微秒级别,完全可以忽略。

但这里有个容易踩的坑:latent 的 dtype 和 layout 必须和静态 buffer 完全一致。我一开始用 float32 录的图,后来为了省显存把 latent 换成 float16,结果 replay 的时候直接报 shape mismatch。原因是静态 buffer 的 dtype 在录图时就固定了,后面改不了。所以录图之前一定要把 dtype 定死,别中途换。

另一个坑是contiguous。如果 current_latent 不是连续内存(比如经过某些 transpose 操作之后),copy_会先做一次隐式的 contiguous 转换,这个转换会触发新的显存分配,进而导致 replay 失败。稳妥的做法是在拷贝前显式调用.contiguous()。

3.2 CUDA Graph 捕获时的 warmup 策略

warmup 是录图成败的关键。PyTorch 的 CUDA Graph 捕获要求捕获期间不能有新的显存分配,而第一次跑前向的时候,cuDNN、cuBLAS 这些库会做 autotuning,分配一堆 workspace。所以必须在正式录图之前先跑几次,把这些一次性开销都消化掉。

我的做法是 warmup 跑 3 次,用和正式推理完全一样的输入 shape 和 dtype。跑完之后调用torch.cuda.synchronize()确保所有 kernel 都执行完,再开始录图。warmup 次数太少(比如只跑 1 次)有时候不够,因为某些库的 autotuning 是分阶段的;跑太多次又浪费时间。3 次是我实测下来比较稳的值。

还有一个细节:warmup 时用的 batch size 必须和录图时一致。因为不同 batch size 下 cuBLAS 选的 kernel 可能不一样,workspace 大小也不同。如果你用 batch=1 warmup 然后用 batch=8 录图,大概率会在录图时报显存分配错误。

3.3 graph pool 的匹配逻辑与回退机制

SGLang 的 graph pool 维护了一个从 batch size 到 graph 的映射。请求进来之后,框架会先看当前 batch 能不能精确匹配到某个已录的图。匹配上了就直接 replay,匹配不上就走回退逻辑。

回退逻辑有两种策略。一种是向上取整:比如请求 batch=6,已录的图有 batch=4 和 batch=8,那就用 batch=8 的图,多出来的两个位置填 padding。这种策略的好处是延迟稳定,坏处是浪费算力。另一种是直接回退到 eager:batch=6 就老老实实跑 eager,不蹭图。SGLang 默认用的是第一种,因为线上服务更看重延迟稳定性。

这里有个参数值得调:graph pool 里录哪些 batch size。录太多会占显存,录太少又经常匹配不上。我的经验是覆盖 1、2、4、8 这四个值,基本能覆盖 90% 以上的线上请求。如果你的业务 batch 分布比较特殊,比如集中在 16 和 32,那就针对性录这两个。

batch size是否录图显存占用(相对值)典型延迟收益
1是1.0x约 30%
2是1.3x约 35%
4是1.8x约 40%
8是2.6x约 42%
16视情况4.5x约 43%

这张表是我在 A100 80G 上跑一个中等 DiT 的实测结果。可以看到 batch 越大,相对收益越高,但边际收益在递减,而显存占用是线性增长的。所以 batch=8 通常是个甜点。

3.4 和 DiT 主干结构的适配问题

DiT 主干里有些结构对 CUDA Graph 不太友好,需要特别处理。最典型的是动态 shape 的 attention。标准的多头注意力在序列长度固定时没问题,但如果你的 DiT 用了可变长度的 patch 或者动态分辨率,那 attention 的 shape 就会变,录图直接失败。

解决办法是把序列长度也固定下来。对于文生图任务,输出分辨率通常是固定的(512x512、1024x1024 这些),patch size 也固定,所以序列长度天然就是固定的。真正麻烦的是那些支持任意分辨率的模型,这时候要么放弃 BCG,要么在服务层做分辨率对齐,把请求都 pad 到同一个分辨率。

另一个坑是RoPE 或者位置编码。如果位置编码是在 forward 里动态计算的,那它也会引入动态 shape。稳妥的做法是把位置编码预计算好,作为静态 buffer 传进去。

4. 实操过程:从零跑通 SGLang Diffusion BCG

4.1 环境准备与依赖版本确认

先把环境说清楚,因为 CUDA Graph 这东西对版本很敏感。我用的组合是:PyTorch 2.3.1 + CUDA 12.1 + SGLang 最新主分支。PyTorch 2.2 之前的版本在 CUDA Graph 的显存管理上有一些已知问题,建议至少 2.3。

安装 SGLang 的时候注意,扩散模型相关的依赖是单独的一组,需要额外装:

pip install "sglang[diffusion]"

如果你是从源码装,记得把python/sglang/srt/diffusion这个目录下的东西都编译进去。我第一次装的时候漏了 diffusion 的 extra,结果 import 的时候一直报找不到 BCG 相关的模块,排查了半天才发现是依赖没装全。

版本确认清单:

  • PyTorch >= 2.3.0
  • CUDA >= 12.1
  • SGLang 主分支(diffusion 支持还在快速迭代,别用太老的 release)
  • transformers >= 4.40(DiT 的权重加载依赖)

4.2 录制第一张 BCG 图的完整代码

下面是我实际用的录图代码,简化了一下,去掉了业务相关的部分。核心逻辑就是 warmup、录图、replay 三步。

import torch from sglang.srt.diffusion.bcg import BCGWrapper # 假设 model 是已经加载好的 DiT 主干 model = model.eval().cuda() batch_size = 4 latent_shape = (batch_size, 4, 64, 64) dtype = torch.float16 # 静态输入 buffer static_input = torch.empty(latent_shape, device='cuda', dtype=dtype) static_timestep = torch.empty(batch_size, device='cuda', dtype=torch.long) # warmup:跑 3 次,消化 autotuning 和一次性显存分配 with torch.no_grad(): for _ in range(3): _ = model(static_input, static_timestep) torch.cuda.synchronize() # 录图 graph = torch.cuda.CUDAGraph() with torch.cuda.graph(graph): static_output = model(static_input, static_timestep) # replay:每次更新输入,然后 replay def run_step(new_latent, new_timestep): static_input.copy_(new_latent.contiguous()) static_timestep.copy_(new_timestep) graph.replay() return static_output.clone()

这段代码里有两个地方值得展开说。一是static_output.clone(),因为 replay 之后 static_output 会被下一次 replay 覆盖,如果你要保留结果必须 clone。二是new_latent.contiguous(),前面提过,不 contiguous 会触发隐式转换导致 replay 失败。

4.3 参数计算:batch size 和显存怎么配

显存是 BCG 最紧的约束。录一张图需要的显存大致等于:模型权重 + 静态输入输出 buffer + 中间激活 buffer + graph 本身的元数据。其中中间激活 buffer 是大头,它和 batch size、序列长度成正比。

我一般用这个公式估算:

graph_mem ≈ model_mem + batch_size * per_sample_activation + overhead

per_sample_activation对于典型的 DiT(hidden dim 1152,24 层)大概在 200MB 到 400MB 之间,取决于你有没有用 flash attention。overhead是 graph 元数据和显存池的固定开销,大概 500MB 到 1GB。

举个例子,A100 80G 上,模型权重占 20G,per_sample_activation 按 300MB 算,overhead 按 1G 算,那么:

  • batch=4:20 + 4*0.3 + 1 = 22.2G,很宽裕
  • batch=8:20 + 8*0.3 + 1 = 23.4G,还是宽裕
  • batch=16:20 + 16*0.3 + 1 = 25.8G,开始有压力

但这是单张图的显存。如果你要录多张图(batch=1、2、4、8 各一张),显存要累加。所以实际部署的时候,graph pool 的大小要控制好,别贪多。

提示:录图之前先用torch.cuda.memory_summary()看一下当前显存占用,心里有个底。录完图之后再打一次,两次的差值就是这张图的实际开销。

4.4 实测数据:BCG 到底快了多少

我在 A100 上跑了一组对比,模型是一个 24 层、hidden dim 1152 的 DiT,输出 512x512,去噪步数 28。对比 eager 模式和 BCG 模式的端到端延迟。

batch sizeeager 延迟 (ms)BCG 延迟 (ms)加速比
12101481.42x
23802451.55x
47204301.67x
814208101.75x

可以看到 batch 越大加速比越高,因为 kernel launch 开销被摊薄了。batch=8 的时候接近 1.75x,这个收益在线上服务里相当可观。不过要注意,这是纯推理延迟,不含网络传输和前后处理。实际端到端收益会小一些,大概在 1.3x 到 1.5x 之间。

另外我测了一下单步延迟的分布。eager 模式下单步延迟抖动比较大,标准差能到 0.8ms;BCG 模式下标准差降到 0.2ms 左右。这个稳定性对线上服务很重要,因为尾延迟(P99)往往比平均延迟更影响用户体验。

5. 调试实录:那些看起来像 bug 的配置问题

5.1 replay 报 "operation not permitted" 怎么排查

这个错误我遇到不下五次,每次原因都不一样。最常见的是录图期间发生了新的显存分配。PyTorch 的 CUDA Graph 捕获对显存分配极其敏感,只要捕获期间有cudaMalloc,就会直接报这个错。

排查思路是:在录图前后各打一次显存快照,看有没有新增分配。如果有,定位到是哪一步触发的。常见的触发点包括:attention 里的 mask 生成、位置编码的动态计算、某些库的 lazy initialization。

解决办法就是把这些一次性操作挪到 warmup 阶段。比如 mask 可以在 warmup 时生成好,存成静态 buffer,录图时直接引用。

5.2 输出结果不对:静态 buffer 没更新

有一次 replay 跑通了,但输出全是噪声。查了半天发现是static_input.copy_()没生效。原因是 current_latent 是一个 view(经过 slice 或者 reshape 得到的),copy_对 view 的行为和普通 tensor 不一样,有时候会静默失败。

解决办法很简单:拷贝前先.contiguous(),确保是一个独立的 tensor。或者更稳妥一点,用static_input.copy_(current_latent, non_blocking=False),显式关掉异步拷贝。

5.3 多卡场景下的 graph 同步问题

如果你用多卡跑 BCG,会碰到一个额外的问题:每张卡都要录自己的图,而且录图的时机要同步。如果卡 0 录完了卡 1 还在录,replay 的时候就会 hang 住。

SGLang 的处理方式是让所有 rank 在录图前做一次 barrier,确保大家同时开始录。这个 barrier 是必须的,别想着省。我一开始为了图快把 barrier 去掉了,结果跑了几十次之后偶发 hang,排查了两天才定位到。

5.4 常见问题速查表

现象可能原因解决办法
replay 报 operation not permitted录图期间有新显存分配把一次性操作挪到 warmup
输出全噪声静态输入没更新拷贝前加 contiguous
replay 后结果被覆盖没 clone 输出返回前 clone
多卡 hang录图时机不同步加 barrier
显存 OOMgraph pool 太大减少录图的 batch size 数量
延迟没降batch 太小增大 batch 或检查是否真的走了 graph

5.5 几个我踩过的独家坑

第一个坑:别在录图之后改模型的 dtype。我试过录完图之后把模型从 fp16 转成 bf16,结果 replay 直接崩。原因是 graph 里记录的 kernel 是针对特定 dtype 编译的,改了 dtype 就对不上了。要改 dtype 必须重新录图。

第二个坑:graph pool 的清理要小心。如果你动态增删 graph,一定要确保没有正在 replay 的图被释放。SGLang 内部有引用计数,但如果你自己写扩展,记得手动管理。

第三个坑:warmup 用的输入别用全零。全零输入有时候会触发某些库的 fast path,导致录图时选的 kernel 和实际推理时不一样。用随机数 warmup 更稳妥。

第四个坑:注意 CUDA Graph 和 profiler 的兼容性。用 nsight 或者 torch profiler 抓 BCG 的时候,有些 profiler 会干扰 graph 的执行,导致数据不准。建议先关掉 profiler 跑一遍基线,再开 profiler 单独抓。

6. 后续可以继续挖的方向

BCG 这套东西跑通之后,我顺手试了几个扩展方向,有些效果不错,有些还在折腾。

一个是动态 batch 的图选择策略。现在的实现是按 batch size 精确匹配,匹配不上就向上取整。我试过改成按请求的延迟敏感度来选图,对延迟敏感的请求用大图(多 padding),对吞吐敏感的用小图(回退 eager),效果还行,但调度逻辑复杂了不少。

另一个是和量化结合。DiT 量化之后显存占用大幅下降,理论上可以录更大的 batch。但量化 kernel 对 CUDA Graph 的支持参差不齐,有些量化库在录图时会报错。我试过 fp8 量化 + BCG,能跑通,但收益没有想象中那么大,因为量化本身已经把计算时间压下去了,kernel launch 开销的占比反而上升了。

还有一个是跨 step 的图复用。扩散模型的每个 step 计算图结构一样,但输入不同。理论上可以录一张图,然后所有 step 都 replay 这张图,只更新输入。这个其实就是 BCG 现在做的事,但我想的是能不能把 scheduler 也塞进去,做成一张覆盖整个去噪过程的超大图。试了一下,显存扛不住,而且 scheduler 里有条件分支,录图会失败。所以还是维持现在的粒度比较合理。

最后分享一个小技巧:如果你在调试 BCG 的时候不确定图有没有真的生效,可以在 replay 前后打时间戳,对比一下单步延迟。如果延迟从 6ms 降到 3ms 左右,那基本就是走 graph 了。如果还是 6ms,那大概率是回退到 eager 了,去检查 batch size 匹配逻辑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 23:28:59

OpenClaw 98个真实用例解析:AI代理本地部署与技能系统实战

1. 从"98个用例"这个数字说起:OpenClaw到底在解决什么问题第一次看到"OpenClaw 的98个真实世界精彩用例"这个标题,我的反应是:又一个堆数量的清单文。但真正把 OpenClaw 跑起来、接上本地模型、让它替我操作浏览器和文件…

作者头像 李华
网站建设 2026/10/30 10:13:28

LeetCode经典哈希题:字母异位词分组全解析

研一那段时间,我把 LeetCode 当成每天睡前的固定节目。3 月 20 号的打卡题是道哈希专题的经典题:字母异位词分组。题目本身不难,但很多同学第一次见到时都会愣住——明明是一堆看起来毫无规律的单词,怎么才能高效地把“字母相同但…

作者头像 李华
网站建设 2026/10/30 11:59:01

多Agent编排实战:从串行到事件驱动的七种模式与落地避坑指南

1. 为什么偏偏是编排:Agent 数量上来之后的那道坎1.1 从单 Agent 到多 Agent,复杂度不是线性增长我第一次真正意识到编排问题,是在做一个内部知识库问答系统的时候。最初只有一个 Agent,用户提问,Agent 检索&#xff0…

作者头像 李华
网站建设 2026/10/31 16:02:48

Markdown入门指南:高效写作与文档管理

1. 为什么选择Markdown作为第一个标记语言刚接触文本标记语言时,我面对HTML、LaTeX和Markdown等多种选择犹豫不决。最终选择Markdown作为起点,主要基于三个实际考量:首先它的语法简单到能在10分钟内掌握基础,用几个#号就能创建标题…

作者头像 李华
网站建设 2026/10/30 5:58:02

Android TCP网络通信前置配置:权限、明文流量与线程模型全解析

做Android开发久了就会知道,TCP网络通信本身并不难,难的其实是把它跑通之前那一大堆前置配置。很多新手第一次写Socket连接,代码抄得一字不差,编译也通过了,结果一装到真机上就报错——不是权限没声明,就是…

作者头像 李华
网站建设 2026/11/2 6:25:33

独角戏破圈实战拆解:从选题到舞台表演的完整方法论

第三届国际独角戏剧节颁奖那晚,最佳女演员的结果一出,台下没什么争议,朋友圈倒是先炸开了。《北去南归》的女主演捧着奖杯,另一只手还握着那根贯穿全剧的行李箱拉杆,这一幕被摄影师抓下来,成了那晚传播最广…

作者头像 李华