Prhub

#27420 Diffusion: add JoyEcho multi-shot A/V generation support

原始 PR 作者 niehen6174 合并时间 2026-06-26 15:46 文件变更 20 提交数 23 评论 15 代码增减 +2265 / -24

执行摘要

新增 JoyEcho 多镜头音视频生成支持

JoyEcho(jdopensource/JoyAI-Echo)是一个基于 LTX-2 的长格式音视频生成模型,核心思想是使用成对音视频内存银行:每个镜头将解码后的帧和音频潜变量存入滚动银行,后续镜头基于该内存前缀进行条件生成,从而支持多镜头、分钟级视频生成。该 PR 旨在让 SGLang Diffusion 能够加载 JoyEcho 并执行多镜头生成,同时保持与现有 LTX-2 基础设施的兼容。

值得精读。JoyEcho 的内存银行设计和 DMD 去噪流程是典型的视频生成模型实现示例,对于需要实现类似多镜头机制的开发者有参考价值。SP 处理中对复制前缀与分片后缀的 attention 处理方式(避免 all_gather)也值得关注。建议后续跟踪该 PR 引入的新模型路径的稳定性,以及社区对多镜头生成的支持反馈。

讨论亮点
  1. 文件结构重组:reviewer mickqian 建议将 JoyEcho 相关 stage 文件放到 model_specific_stages/joy_echo/ 目录下,author 同意并后续重构。
  2. SP 支持讨论:mickqian 建议添加序列并行(SP)支持及多 GPU 一致性测试,author 最初解释实现复杂度高,但在后续提交中实际实现了 SP,并添加了 2 GPU 测试用例。
  3. 非 diffusers 注册:mickqian 询问 utils.py 中添加 joy-echo 非 diffusers 模式注册的必要性,author 确认冗余并计划清理。
  4. USPAttention 接口:mickqian 建议使用 USPAttention.forwardnum_replicated_prefix 参数,author 解释了 JoyEcho 的内存布局(复制前缀 + 分片后缀)与现有 API 不兼容,涉及 3D 掩码、交叉注意力等差异。
  5. 测试治理:mickqian 要求上传一致性 ground truth 并启用检查,author 已创建 CI data PR,待合并。

实现拆解

步骤 1:扩展 LTX-2 VAE 与 Transformer 以支持 JoyEcho 特性

ltx_2_vae.py 中新增 video_encoder_variant="ltx_2_3_condition" 分支,使用 LTX23VideoConditionEncoder 编码内存帧。在 ltx_2.py 中增加逐块(per-block)音频自注意力掩码覆盖机制,用于 JoyEcho 后层掩码控制。

步骤 2:实现 JoyEcho 专用管道阶段

  • JoyEchoMultishotSetupStagesetup.py):管理多镜头会话重置与每次生成的种子增量(seed + shot_idx)。
  • JoyEchoSigmaPreparationStagesetup.py):安装 JoyEcho DMD sigma 调度(8 步,无 LTX-2 流偏移重映射)。
  • JoyEchoMemoryBankFetchStagememory.py):从 PairedAudioVideoMemoryBank 获取之前镜头的解码帧与音频潜变量,并重新编码为内存前缀。
  • JoyEchoDMDDenoisingStagedenoising.py):DMD Euler 去噪逻辑,支持可选内存前缀、自定义音视频交叉/自注意力掩码、后层音频掩码覆盖,以及序列并行(SP)下的视频时间分片与音频全复制。
  • JoyEchoAVDecodingStagememory.py):标准 LTX-2 音视频解码,并在启用内存银行时将新解码结果提交到内存槽。

步骤 3:实现成对音视频内存银行

PairedAudioVideoMemoryBank 类(memory.py)管理最多 7 个内存槽。辅助函数包括音频峰值窗口选择(select_max_response_audio_window_with_bounds)、视频帧索引选择(select_video_frame_indices_from_time_range)、内存槽范围计算(memory_slot_ranges)及成对内存跨注意力掩码构建(build_paired_memory_cross_mask)。

步骤 4:注册模型与管道

registry.py 中添加 jdopensource/JoyAI-Echo 到 overlay 映射,指向 Niehen6174/JoyAI-Echo-overlay。新建 JoyEchoPipelinejoy_echo_pipeline.py)继承 _BaseLTX2Pipeline,并在管道工厂中通过 EntryClass 导出。对应的管道配置 JoyEchoPipelineConfig 和采样参数 JoyEchoSamplingParams 分别定义在 configs/pipeline_configs/joy_echo.pyconfigs/sample/joy_echo.py

步骤 5:添加验证与使用文档

添加单 GPU 烟雾测试用例(test/server/gpu_cases.py)和多 GPU SP 一致性测试(含一致性检查)。新增 JoyEcho Cookbook 文档(docs_new/cookbook/diffusion/JoyEcho/JoyEcho.mdx),介绍模型概述、部署方式、单镜头/多镜头用法及内存银行控制参数。

文件 模块 状态 重要度
python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/joy_echo/memory.py 内存银行 added 9.36
python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/joy_echo/denoising.py 去噪流程 added 9.36
python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/joy_echo/setup.py 管道设置 added 8.95
python/sglang/multimodal_gen/runtime/pipelines/joy_echo_pipeline.py 管道组装 added 8.57
python/sglang/multimodal_gen/configs/pipeline_configs/joy_echo.py 配置定义 added 7.66
python/sglang/multimodal_gen/configs/models/dits/joy_echo.py 模型配置 added 7.53
python/sglang/multimodal_gen/configs/sample/joy_echo.py 采样参数 added 6.95
python/sglang/multimodal_gen/runtime/models/vaes/ltx_2_vae.py VAE 适配 modified 7.21
python/sglang/multimodal_gen/runtime/models/dits/ltx_2.py Transformer 适配 modified 6.98
python/sglang/multimodal_gen/registry.py 模型注册 modified 6.04
docs_new/cookbook/diffusion/JoyEcho/JoyEcho.mdx 文档 added 5.33

关键符号

JoyEchoPipeline PairedAudioVideoMemoryBank JoyEchoDMDDenoisingStage JoyEchoMultishotSetupStage JoyEchoSigmaPreparationStage JoyEchoMemoryBankFetchStage JoyEchoAVDecodingStage JoyEchoPipelineConfig JoyEchoSamplingParams select_max_response_audio_window_with_bounds build_paired_memory_cross_mask memory_slot_ranges

关键源码片段

python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/joy_echo/memory.py data-contract

核心内存银行实现,包含 PairedAudioVideoMemoryBank 类及音视频窗口选择、掩码构建等辅助函数;新增 1025 行,是 PR 中最大的单一文件。

def select_max_response_audio_window_with_bounds(
    segment: Tensor,
    window_size: int,
) -> tuple[Tensor, Tensor, Tensor]:
    """在音频段 [B, C, T, F] 中,扫描候选窗口并返回 exp 响应最大的窗口及起止索引。"""
    if segment.dim() != 4:
        raise ValueError(
            f"Expected segment shape [B, C, T, F], got {tuple(segment.shape)}"
        )
    if window_size <= 0:
        raise ValueError(f"window_size must be positive, got {window_size}")
    num_time_steps = segment.shape[2]
    if num_time_steps <= 0:
        raise ValueError("Cannot select from an empty audio segment")
    # 扫描步长为 `window_size` // 4,确保覆盖最大范围
    scan_stride = max(1, window_size // 4)
    offsets = torch.arange(window_size, device=segment.device)
    max_start_idx = (
        num_time_steps - window_size
        if num_time_steps >= window_size
        else num_time_steps - 1
    )
    candidate_start_indices = list(range(0, max_start_idx + 1, scan_stride))
    if candidate_start_indices[-1] != max_start_idx:
        candidate_start_indices.append(max_start_idx)
​
    candidate_windows = []
    candidate_scores = []
    candidate_start_indices_tensor = torch.tensor(
        candidate_start_indices, device=segment.device, dtype=torch.long
    )
    for start_idx in candidate_start_indices:
        gather_indices = (start_idx + offsets).clamp(0, num_time_steps - 1).long()
        window = segment.index_select(dim=2, index=gather_indices)
        candidate_windows.append(window)
        # exp 求和作为响应分数
        candidate_scores.append(window.float().exp().sum(dim=(1, 2, 3)))
​
    scores = torch.stack(candidate_scores, dim=1)
    best_window_indices = scores.argmax(dim=1)
    best_start_indices = candidate_start_indices_tensor[best_window_indices]
    best_end_indices = torch.clamp(
        best_start_indices + window_size - 1, max=num_time_steps - 1
    )
    selected_windows = torch.cat(
        [
            candidate_windows[int(best_window_indices[batch_index])][
                batch_index : batch_index + 1
            ]
            for batch_index in range(segment.shape[0])
        ],
        dim=0,
    )
    return selected_windows, best_start_indices, best_end_indices
python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/joy_echo/denoising.py data-contract

JoyEcho DMD 去噪阶段,处理内存前缀注入、自定义注意力掩码、序列并行分片逻辑;新增 575 行。

class JoyEchoDMDDenoisingStage(LTX2AVDenoisingStage):
    """JoyEcho DMD denoising with optional memory prefix and late-layer masks."""
​
    def _prepare_denoising_loop(
        self,
        batch: Req,
        server_args: ServerArgs,
    ) -> LTX2DenoisingContext:
        ctx = super()._prepare_denoising_loop(batch, server_args)
        if get_sp_world_size() <= 1:
            return ctx
        # JoyEcho DMD: 视频按时间分片,音频在每个 rank 上全复制
        # 若音频和视频同时分片,则每层需要大量 all_gather,开销过大
        ctx.replicate_audio_for_sp = True
        batch.ltx23_audio_replicated_for_sp = True
        batch.did_sp_shard_audio_latents = False
        return ctx
​
    def _prepare_ltx2_model_inputs(
        self,
        ctx: LTX2DenoisingContext,
        step: DenoisingStepState,
        batch: Req,
        server_args: ServerArgs,
        sigma: torch.Tensor,
    ) -> LTX2ModelInputs:
        model_inputs = super()._prepare_ltx2_model_inputs(
            ctx, step, batch, server_args, sigma
        )
        if not batch.did_sp_shard_latents:
            return model_inputs # 未使用 SP 时直接返回父类结果
​
        batch_size = int(model_inputs.latent_model_input.shape[0])
        seq_v = int(model_inputs.latent_model_input.shape[1])
        video_valid = batch.sp_video_valid_token_count
        # 构建视频自注意力掩码(padding mask)
        video_self_attention_mask = self._build_ltx2_sp_padding_mask(
            batch,
            seq_len=seq_v,
            batch_size=batch_size,
            key="sp_video_valid_token_count",
            device=model_inputs.latent_model_input.device,
        )
        # 后续处理 ...
        return model_inputs
python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/joy_echo/setup.py data-contract

多镜头会话管理和 DMD sigma 调度准备,控制 memory bank 重置和种子增量。

class JoyEchoMultishotSetupStage(PipelineStage):
    """Apply official per-shot seeding before input validation and latent noise."""
​
    def __init__(self, pipeline: JoyEchoPipeline) -> None:
        super().__init__()
        self.pipeline = pipeline
​
    def _maybe_reset_multishot_session(self, batch: Req) -> None:
        """在 `generate()` 新会话开始时复位 shot index 和 memory bank。"""
        if not batch.reset_memory_bank:
            return
        session_id = batch.request_id
        if session_id is not None:
            if session_id == self.pipeline._multishot_session_id:
                return # 同一会话内不重复复位
            self.pipeline._multishot_session_id = session_id
            self.pipeline.multishot_index = 0
            self.pipeline.reset_memory_bank()
            logger.info(
                "JoyEcho memory bank reset for new multi-shot session (request_id=%s)",
                session_id,
            )
            return
        # 无 request_id 时,若 shot index 为 0 则复位
        if self.pipeline.multishot_index == 0:
            self.pipeline.reset_memory_bank()
            logger.info("JoyEcho memory bank reset for new multi-shot session")
​
    def forward(self, batch: Req, server_args: ServerArgs) -> Req:
        if not batch.enable_memory_bank:
            return batch
        self._maybe_reset_multishot_session(batch)
        shot_idx = self.pipeline.multishot_index
        self.pipeline.multishot_index += 1
        base_seed = batch.seed
        if isinstance(base_seed, list):
            if not base_seed:
                raise ValueError("seed list must not be empty for JoyEcho multi-shot")
            base_seed = base_seed[0]
        # 官方逻辑:prompt_seed = int(cfg.seed) + shot_idx
        batch.seed = int(base_seed) + shot_idx
        logger.info(
            "JoyEcho multi-shot setup: shot_idx=%d seed=%d",
            shot_idx,
            batch.seed,
        )
        return batch

评论区精华

文件结构重组 设计

mickqian 建议将 JoyEcho stage 文件移到 `model_specific_stages/joy_echo/` 目录下,以保持组织结构清晰。

结论:author 同意并在后续提交中完成了重组。 · 已解决

序列并行支持 性能

mickqian 要求添加 Ulysses SP 支持并设置多 GPU 一致性测试。author 最初解释复杂性(3D 掩码、交叉注意力),但随后实现了 SP。

结论:PR 中实际实现了 SP 支持(视频分片、音频全复制),并添加了 2 GPU 测试用例。 · 已解决

非 diffusers 模型注册必要性 question

mickqian 询问 `utils.py` 中添加 `joy-echo` 到 `KNOWN_NON_DIFFUSERS_DIFFUSION_MODEL_PATTERNS` 是否必要,因为权重格式已转为 diffusers。

结论:author 确认冗余,计划清理。 · 已解决

USPAttention 接口兼容性 设计

mickqian 建议使用 `USPAttention.forward` 的 `num_replicated_prefix` 参数简化 SP 处理。author 解释了 JoyEcho 的内存布局(复制前缀 + 分片后缀)与现有 API 不匹配(Q 全复制、3D 掩码等)。

结论:维持自定义 SP 路径,未使用通用接口,但讨论澄清了设计约束。 · 已解决

测试治理与一致性检查 测试

mickqian 建议将测试用例改为多 GPU 并上传一致性 ground truth。author 创建了 CI data PR,并启用了一致性检查标志。

结论:CI data PR 待合并;测试用例已更新。 · pending

风险与影响

  1. 新模型路径回归:首次引入 JoyEcho 模型,推理路径可能存在未发现的边缘情况,特别是多镜头交互和内存银行状态转换。
  2. 序列并行新增路径:PR 中新增了 SP 支持,涉及视频分片、音频复制和特殊掩码构建,若未充分测试可能在其他 SP 配置下出现错误。
  3. LTX-2 基座改动影响:对 ltx_2_vae.pyltx_2.py 的修改(条件编码器、后层掩码)虽为条件性,但可能影响其他基于 LTX-2 的模型(如 LTX-2.3 HQ)。
  4. 测试覆盖不足:烟雾测试仅覆盖单镜头、单 GPU 场景;多 GPU 一致性测试依赖 CI data PR 合并,当前尚未生效。
  5. 资源配置JoyEchoPipelineConfig 中引入了多个新参数(如 memory_max_size, audio_window_size),若默认值不合理可能导致生成失败或性能问题。

用户:可直接通过加载 jdopensource/JoyAI-Echo 使用 JoyEcho 生成多镜头音视频,支持 enable_memory_bankreset_memory_bank 等参数控制多镜头行为。
系统:新增约 2200 行代码,包含独立的 joy_echo/ 模块、配置注册和 overlay 映射。对现有 LTX-2 VAE 和 Transformer 的添加是可选分支,不影响原有模型路径。
团队:模块化设计良好,JoyEcho 特定代码集中在 model_specific_stages/joy_echo/ 下,便于后续维护和扩展。

新模型推理路径 内存银行状态机 序列并行新增路径 LTX-2 基础改动影响 测试覆盖待完善

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论