Prhub

#27431 [diffusion] Run LTX-2 VAE decode in channels_last_3d (faster decode, lower peak memory)

原始 PR 作者 BBuf 合并时间 2026-06-09 23:26 文件变更 4 提交数 3 评论 6 代码增减 +223 / -49

执行摘要

LTX-2 VAE 解码内存布局优化,Conv3d 加速 ~3.7 倍

LTX-2 视频 VAE decode 是 conv3d-bound 的。在 Hopper 上,Conv3d 在 channels_last_3d (NDHWC) 内存格式下速度约快 2.7–3.7 倍,但 LTX-2 之前因两个原因未受益:

1) 因果填充使用 repeat()+concatenate() 产生连续 NCDHW 张量,破坏布局;
2) 加载器 auto 策略仅在 QwenImage 和单 GPU Wan 上启用 channels_last_3d,排除了 LTX-2。

值得精读,展示了内存布局优化驱动性能提升的完整实践:从问题定位(conv3d-bound)、设计权衡(自定义 kernel vs 简单 copy_)、自门控实现到加载器精细控制。_causal_temporal_pad_channels_last 的 allocate+copy_ 设计是亮点,可复用至其他时序卷积优化。

讨论亮点
  1. time_kernel_size=1 优化建议:gemini-code-assist 建议当无 padding 时跳过填充直接转换 layout。作者评估后表示自定义 Triton 核并不更快,且当前逻辑在 left==right==0 时走原路径(无填充),布局由后续 conv 自动处理,故未采纳。
  2. 加载器 isinstance 改造:mickqian 建议用 isinstance 替代字符串匹配,BBuf 采纳并修改最终代码。
  3. _weight_is_channels_last_3d 缓存:mickqian 建议添加 lru_cache,BBuf 回复 done;最终通过 _is_channels_last_3d_stride 函数加 lru_cache 间接实现缓存效果。

实现拆解

  1. 布局保持的时序填充:在 LTX2VideoCausalConv3d 中新增 _weight_is_channels_last_3d 辅助方法检测权重布局,新增 _causal_temporal_pad_channels_last 方法直接以 channels_last_3d 格式分配输出张量,通过 copy_ 完成填充,替代原有 repeat+concat 路径。
  2. 自门控前向路径forward 中根据 use_channels_last_pad 条件(hidden_states.dim()==5 and self._weight_is_channels_last_3d())选择填充分支;权重非 channels_last_3d 时走原路径,零退化风险。
  3. 加载器策略调整:修改 _should_use_channels_last_3d,将 pipeline_name 字符串匹配改为 isinstance 检查,新增 LTX2PipelineConfig + num_gpus==1 返回 True,多 GPU 返回 False(与 Wan 惯例一致)。
  4. 单元测试:新增 test_ltx2_vae_channels_last.py 验证填充和 conv 输出与 Baseline 数值一致(assert_close rtol=1e-4);修改 test_vae_loader.py 增加单/多 GPU LTX-2 的启用测试,并移除内联桩类改用正式的 Config 导入。
文件 模块 状态 重要度
python/sglang/multimodal_gen/runtime/models/vaes/ltx_2_vae.py VAE 解码器 modified 8.59
python/sglang/multimodal_gen/test/unit/test_ltx2_vae_channels_last.py 测试配套 added 7.43
python/sglang/multimodal_gen/test/unit/test_vae_loader.py 测试配套 modified 6.61
python/sglang/multimodal_gen/runtime/loader/component_loaders/vae_loader.py 加载器 modified 6.19

关键符号

_is_channels_last_3d_stride _weight_is_channels_last_3d _causal_temporal_pad_channels_last TestLTX2CausalConvChannelsLast._check test_causal_matches_reference test_non_causal_matches_reference test_temporal_only_kernel_matches_reference test_causal_cache_matches_monolithic_reference test_pad_replicates_edge_frames_exactly

关键源码片段

python/sglang/multimodal_gen/runtime/models/vaes/ltx_2_vae.py data-contract

核心实现文件:添加布局检测函数 `_is_channels_last_3d_stride`、权重布局查询 `_weight_is_channels_last_3d`、布局保持的时序填充 `_causal_temporal_pad_channels_last`,并重构 `forward` 实现自门控路径切换。

from functools import lru_cache
from typing import Optional, Tuple, Unionimport torch
import torch.nn as nn
from diffusers.models.autoencoders.vae import DecoderOutput, DiagonalGaussianDistribution
from sglang.multimodal_gen.runtime.models.vaes.common import ParallelTiledVAE
​
​
@lru_cache(maxsize=128)
def _is_channels_last_3d_stride(size: tuple[int, ...], stride: tuple[int, ...]) -> bool:
    """检查给定 size/stride 是否匹配 channels_last_3d (NDHWC) 格式。
    维度顺序:N, C, D, H, W;channels_last_3d 的 stride 顺序应为 C, 1, H*W, W, H*W*D。
    这里按 (C, W, H, D, N) 迭代验证,跳过大小为 1 的维度。”""
    if len(size) != 5:
        return False
    expected_stride = 1
    for dim in (1, 4, 3, 2, 0):  # C, W, H, D, N
        if size[dim] == 0:
            return True
        if size[dim] == 1:
            continue
        if stride[dim] != expected_stride:
            return False
        expected_stride *= size[dim]
    return True
​
​
class LTX2VideoCausalConv3d(nn.Module):
    # ... (init 略)
    def _weight_is_channels_last_3d(self) -> bool:
        """判断卷积权重是否为 channels_last_3d 布局"""
        w = self.conv.weight
        return hasattr(torch, "channels_last_3d") and _is_channels_last_3d_stride(
            tuple(w.size()), tuple(w.stride())
        )    def _causal_temporal_pad_channels_last(
        self,
        x: torch.Tensor,
        left: int,
        right: int,
        left_pad: Optional[torch.Tensor] = None,
        right_pad: Optional[torch.Tensor] = None,
    ) -> torch.Tensor:
        # 直接分配 channels_last_3d 的空张量,一次 allocate+copy_ 完成填充和布局保持
        b, c, t, h, w = x.shape
        out = torch.empty(
            (b, c, t + left + right, h, w),
            dtype=x.dtype,
            device=x.device,
            memory_format=torch.channels_last_3d,
        )
        out[:, :, left : left + t].copy_(x)
        if left:
            out[:, :, :left].copy_(x[:, :, :1] if left_pad is None else left_pad)
        if right:
            out[:, :, left + t :].copy_(x[:, :, -1:] if right_pad is None else right_pad)
        return out    def forward(self, hidden_states, causal=True, conv_cache=None, cache_key=None):
        time_kernel_size = self.kernel_size[0]
        # 仅在 5D 输入且权重为 channels_last_3d 时启用新路径
        use_channels_last_pad = (
            hidden_states.dim() == 5 and self._weight_is_channels_last_3d()
        )
        if causal:
            left, right = time_kernel_size - 1, 0
            if conv_cache is not None and cache_key is not None and left:
                # 处理流式缓存(略)
            else:
                if use_channels_last_pad:
                    hidden_states = self._causal_temporal_pad_channels_last(
                        hidden_states, left, right
                    )
                else:
                    # 原始 repeat+concatenate 路径
                    pad_left = hidden_states[:, :, :1, :, :].repeat(
                        (1, 1, time_kernel_size - 1, 1, 1)
                    )
                    hidden_states = torch.concatenate([pad_left, hidden_states], dim=2)
        else:
            # 非因果填充类似,也分支到新路径
            ...
        return self.conv(hidden_states)
python/sglang/multimodal_gen/test/unit/test_ltx2_vae_channels_last.py test-coverage

新增测试文件,覆盖因果 / 非因果 / 时序 -only kernel/ 流式缓存 / 边缘帧精确性,验证新路径与 Baseline 数值一致。

import unittest
import torch
from sglang.multimodal_gen.runtime.models.vaes.ltx_2_vae import LTX2VideoCausalConv3d@unittest.skipUnless(hasattr(torch, "channels_last_3d"), "channels_last_3d 不可用")
class TestLTX2CausalConvChannelsLast(unittest.TestCase):
    def _check(self, causal: bool, kernel_size):
        # 构建 float32 卷积,比较默认权重(contiguous)和 channels_last_3d 权重的输出
        device = "cuda" if torch.cuda.is_available() else "cpu"
        torch.manual_seed(0)
        conv = LTX2VideoCausalConv3d(8, 8, kernel_size).to(device, torch.float32).eval()
        x = torch.randn(1, 8, 5, 6, 7, dtype=torch.float32, device=device)
​
        # 默认权重走原有 repeat+concat 路径
        self.assertFalse(conv._weight_is_channels_last_3d())
        with torch.no_grad():
            y_ref = conv(x.clone(), causal=causal)
​
        # 转换权重为 channels_last_3d,触发新路径
        conv.conv.weight.data = conv.conv.weight.data.to(memory_format=torch.channels_last_3d)
        self.assertTrue(conv._weight_is_channels_last_3d())
        with torch.no_grad():
            y_cl = conv(x.clone(), causal=causal)
​
        self.assertEqual(y_ref.shape, y_cl.shape)
        if device == "cuda":
            self.assertTrue(y_cl.is_contiguous(memory_format=torch.channels_last_3d))
        # 数值公差 rtol=1e-4, atol=1e-4 ( 仅浮点累积顺序差异 )
        torch.testing.assert_close(y_ref, y_cl, rtol=1e-4, atol=1e-4)
​
    def test_causal_matches_reference(self):
        self._check(causal=True, kernel_size=3)
    def test_non_causal_matches_reference(self):
        self._check(causal=False, kernel_size=3)
    def test_temporal_only_kernel_matches_reference(self):
        self._check(causal=True, kernel_size=(3, 1, 1))
    def test_causal_cache_matches_monolithic_reference(self):
        # 验证流式缓存路径分段输出与整体输出一致
        ...
    def test_pad_replicates_edge_frames_exactly(self):
        # 验证填充帧与 raw repeat+concat 完全一致 (rtol=0, atol=0)
        ...

评论区精华

time_kernel_size==1 时跳过填充的优化建议 性能

gemini-code-assist 建议当左右填充均为 0 时,若权重为 channels_last_3d 则仅转换输入布局,跳过填充逻辑。作者回复自定义 Triton 核并不更快,且当前逻辑在 time_kernel_size==1 时走原路径(无 padding),布局由后续 conv 自动处理,故未采纳。

结论:放弃优化,保持现有自门控路径。 · 已解决

加载器使用 isinstance 代替字符串匹配 设计

mickqian 评论建议 `better base on pipelineconfig class?`,BBuf 回复 `done`。最终代码将 `pipeline_name.startswith("QwenImage")` 改为 `isinstance(...)`,并统一使用正式 Config 类。

结论:采纳建议,增强类型安全和可维护性。 · 已解决

_weight_is_channels_last_3d 添加缓存 性能

mickqian 建议 `make it lru cached?`,BBuf 回复 `done`。最终实现通过 `_is_channels_last_3d_stride` 函数加 `lru_cache` 间接提供缓存。

结论:间接实现缓存,减少重复 stride 检查。 · 已解决

风险与影响

  1. 数值精度差异channels_last_3d 布局下 cuDNN Conv3d 的浮点累积顺序不同,fp32 差值约 1e-4,但 fp16/bf16 可能更大,需关注模型输出质量(测试已覆盖 fp32 且视觉等效)。
  2. 多 GPU 兼容性:多 GPU 默认关闭 channels_last_3d,保持与之前行为一致,无回归风险。
  3. 性能边界:新增 allocate+copy_ 操作在最大解码形状下约 0.5ms,已接近带宽极限,不会成为新瓶颈。

对用户:单 GPU LTX-2 视频生成解码速度提升约 1.41×,峰值内存下降约 13.5%,输出质量不变。对系统:仅影响 LTX-2 VAE 模块和加载器逻辑,无跨模块波及。对团队:该布局感知填充模式(allocate+copy_)可作为模板推广至其他 Conv3d 优化的模型。

数值精度差异(浮点累积顺序) 多 GPU 默认关闭 benchmark 仅限 H100 缺少 fp16/bf16 精度验证

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论