Automated PR - 2026-07-07
This commit is contained in:
@@ -11,17 +11,38 @@ This package provides ready-to-use pipelines for video generation:
|
||||
- RetakePipeline: Regenerate a time region (retake) of an existing video
|
||||
For more detailed components and utilities, import from specific submodules
|
||||
like `ltx_pipelines.utils.media_io` or `ltx_pipelines.utils.constants`.
|
||||
Pipeline classes are imported lazily (PEP 562). Importing this package therefore
|
||||
does not eagerly pull in every pipeline module, which keeps `import ltx_pipelines`
|
||||
light and avoids the runpy double-import warning when a pipeline is run as a module
|
||||
(e.g. `python -m ltx_pipelines.distilled`).
|
||||
"""
|
||||
|
||||
from ltx_pipelines.a2vid_two_stage import A2VidPipelineTwoStage
|
||||
from ltx_pipelines.distilled import DistilledPipeline
|
||||
from ltx_pipelines.ic_lora import ICLoraPipeline
|
||||
from ltx_pipelines.keyframe_interpolation import KeyframeInterpolationPipeline
|
||||
from ltx_pipelines.lipdub import LipDubPipeline
|
||||
from ltx_pipelines.retake import RetakePipeline
|
||||
from ltx_pipelines.t2a_one_stage import T2AOneStagePipeline
|
||||
from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline
|
||||
from ltx_pipelines.ti2vid_two_stages import TI2VidTwoStagesPipeline
|
||||
import importlib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ltx_pipelines.a2vid_two_stage import A2VidPipelineTwoStage
|
||||
from ltx_pipelines.distilled import DistilledPipeline
|
||||
from ltx_pipelines.ic_lora import ICLoraPipeline
|
||||
from ltx_pipelines.keyframe_interpolation import KeyframeInterpolationPipeline
|
||||
from ltx_pipelines.lipdub import LipDubPipeline
|
||||
from ltx_pipelines.retake import RetakePipeline
|
||||
from ltx_pipelines.t2a_one_stage import T2AOneStagePipeline
|
||||
from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline
|
||||
from ltx_pipelines.ti2vid_two_stages import TI2VidTwoStagesPipeline
|
||||
|
||||
# Public name -> module that defines it. Used for lazy resolution in __getattr__.
|
||||
_EXPORTS = {
|
||||
"A2VidPipelineTwoStage": "ltx_pipelines.a2vid_two_stage",
|
||||
"DistilledPipeline": "ltx_pipelines.distilled",
|
||||
"ICLoraPipeline": "ltx_pipelines.ic_lora",
|
||||
"KeyframeInterpolationPipeline": "ltx_pipelines.keyframe_interpolation",
|
||||
"LipDubPipeline": "ltx_pipelines.lipdub",
|
||||
"RetakePipeline": "ltx_pipelines.retake",
|
||||
"T2AOneStagePipeline": "ltx_pipelines.t2a_one_stage",
|
||||
"TI2VidOneStagePipeline": "ltx_pipelines.ti2vid_one_stage",
|
||||
"TI2VidTwoStagesPipeline": "ltx_pipelines.ti2vid_two_stages",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"A2VidPipelineTwoStage",
|
||||
@@ -34,3 +55,16 @@ __all__ = [
|
||||
"TI2VidOneStagePipeline",
|
||||
"TI2VidTwoStagesPipeline",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
module_path = _EXPORTS.get(name)
|
||||
if module_path is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
value = getattr(importlib.import_module(module_path), name)
|
||||
globals()[name] = value # cache so later lookups skip __getattr__
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted({*globals(), *_EXPORTS})
|
||||
|
||||
@@ -13,6 +13,7 @@ from ltx_core.model.transformer.compiling import CompilationConfig
|
||||
from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.types import Audio, AudioLatentShape, VideoPixelShape
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import default_2_stage_arg_parser
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioConditioner,
|
||||
@@ -43,7 +44,7 @@ class A2VidPipelineTwoStage:
|
||||
both video and audio using a distilled LoRA for higher quality output.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
checkpoint_path: str,
|
||||
distilled_lora: list[LoraPathStrengthAndSDOps],
|
||||
@@ -55,17 +56,28 @@ class A2VidPipelineTwoStage:
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
self.device = device or get_device()
|
||||
self.dtype = torch.bfloat16
|
||||
self._scheduler = LTX2Scheduler()
|
||||
|
||||
self.prompt_encoder = PromptEncoder(
|
||||
checkpoint_path, gemma_root, self.dtype, self.device, registry=registry, offload_mode=offload_mode
|
||||
checkpoint_path,
|
||||
gemma_root,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_conditioner = AudioConditioner(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.stage_1 = DiffusionStage(
|
||||
self.image_conditioner = ImageConditioner(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.audio_conditioner = AudioConditioner(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.stage_1 = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -74,9 +86,10 @@ class A2VidPipelineTwoStage:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
stage_2_loras = (*tuple(loras), *tuple(distilled_lora))
|
||||
self.stage_2 = DiffusionStage(
|
||||
self.stage_2 = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -85,11 +98,19 @@ class A2VidPipelineTwoStage:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.upsampler = VideoUpsampler(
|
||||
checkpoint_path, spatial_upsampler_path, self.dtype, self.device, registry=registry
|
||||
checkpoint_path,
|
||||
spatial_upsampler_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.video_decoder = VideoDecoder(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
|
||||
def __call__( # noqa: PLR0913
|
||||
self,
|
||||
|
||||
@@ -10,10 +10,11 @@ from ltx_core.model.transformer.compiling import CompilationConfig
|
||||
from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.types import Audio
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import (
|
||||
ImageConditioningInput,
|
||||
default_2_stage_distilled_arg_parser,
|
||||
detect_checkpoint_path,
|
||||
resolve_cli_params,
|
||||
)
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioDecoder,
|
||||
@@ -26,7 +27,6 @@ from ltx_pipelines.utils.blocks import (
|
||||
from ltx_pipelines.utils.constants import (
|
||||
DISTILLED_SIGMAS,
|
||||
STAGE_2_DISTILLED_SIGMAS,
|
||||
detect_params,
|
||||
)
|
||||
from ltx_pipelines.utils.denoisers import SimpleDenoiser
|
||||
from ltx_pipelines.utils.helpers import (
|
||||
@@ -56,6 +56,7 @@ class DistilledPipeline:
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
self.device = device or get_device()
|
||||
self.dtype = torch.bfloat16
|
||||
@@ -67,9 +68,16 @@ class DistilledPipeline:
|
||||
self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.stage = DiffusionStage(
|
||||
self.image_conditioner = ImageConditioner(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage = DiffusionStage.from_checkpoint(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -78,12 +86,30 @@ class DistilledPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.upsampler = VideoUpsampler(
|
||||
distilled_checkpoint_path, spatial_upsampler_path, self.dtype, self.device, registry=registry
|
||||
distilled_checkpoint_path,
|
||||
spatial_upsampler_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.audio_decoder = AudioDecoder(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_decoder = AudioDecoder(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
|
||||
def __call__( # noqa: PLR0913
|
||||
self,
|
||||
@@ -182,8 +208,7 @@ class DistilledPipeline:
|
||||
@torch.inference_mode()
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
checkpoint_path = detect_checkpoint_path(distilled=True)
|
||||
params = detect_params(checkpoint_path)
|
||||
params = resolve_cli_params(distilled=True)
|
||||
parser = default_2_stage_distilled_arg_parser(params=params)
|
||||
args = parser.parse_args()
|
||||
pipeline = DistilledPipeline(
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Multi-GPU distilled video runner.
|
||||
Runs :class:`DistilledPipeline` across multiple GPUs with:
|
||||
- **Shared stage** -- sequence parallelism (SP); the same DiffusionStage is
|
||||
reused for both stage 1 (half-res) and stage 2 (full-res), so a single
|
||||
SP wrapping covers both invocations.
|
||||
- **Gemma** -- Accelerate-based parallelization
|
||||
- **VAE** -- distributed decoding
|
||||
Requires ``ltx-kernels`` to be installed (transitive via SP builder).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
from multiprocessing import SimpleQueue
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from ltx_core.loader.registry import StateDictRegistry
|
||||
from ltx_core.model.transformer.compiling import CompilationConfig
|
||||
from ltx_core.model.video_vae import get_video_chunks_number
|
||||
from ltx_core.model.video_vae.tiling import TilingConfig
|
||||
from ltx_core.multigpu.transformer.attention import AttentionManager
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.quantization.fp8_cast import build_policy as _build_fp8_cast_policy
|
||||
from ltx_core.tiling import DimensionTilingConfig, TileCountConfig, balanced_tile_split
|
||||
from ltx_pipelines.distilled import DistilledPipeline
|
||||
from ltx_pipelines.multigpu.controller import MGPUController
|
||||
from ltx_pipelines.multigpu.gemma_builders import AccelerateGemmaBuilder
|
||||
from ltx_pipelines.multigpu.runner import MGPURunner
|
||||
from ltx_pipelines.multigpu.sp_builder import SequenceParallelBuilder
|
||||
from ltx_pipelines.multigpu.vae_builders import DistributedDecoderBuilder
|
||||
from ltx_pipelines.multigpu.weight_tracker import TransformerWeightTracker
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.media_io import encode_video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stage 2 (full-res) dominates: 1024x1536, 121 frames ~= 24576 video tokens + audio tokens.
|
||||
_DEFAULT_SP_MAX_TOKENS = 32768
|
||||
# Rank that collects distributed-VAE tiles and encodes the assembled video.
|
||||
_DRIVER_RANK = 0
|
||||
|
||||
|
||||
class DistilledRunner(MGPURunner):
|
||||
"""Distributed :class:`DistilledPipeline`: SP shared stage + Accelerate Gemma + distributed VAE."""
|
||||
|
||||
@torch.inference_mode()
|
||||
def setup(
|
||||
self,
|
||||
*,
|
||||
distilled_checkpoint_path: str,
|
||||
gemma_root: str,
|
||||
spatial_upsampler_path: str,
|
||||
vae_queue: SimpleQueue,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
sp_max_tokens: int = _DEFAULT_SP_MAX_TOKENS,
|
||||
quantization: Callable[[], QuantizationPolicy] | None = None,
|
||||
) -> None:
|
||||
# quantization is a picklable zero-arg builder (built per worker, post-spawn); default fp8-cast.
|
||||
quantization_policy = (
|
||||
quantization() if quantization is not None else _build_fp8_cast_policy(distilled_checkpoint_path)
|
||||
)
|
||||
registry = StateDictRegistry()
|
||||
pipeline = DistilledPipeline(
|
||||
distilled_checkpoint_path=distilled_checkpoint_path,
|
||||
gemma_root=gemma_root,
|
||||
spatial_upsampler_path=spatial_upsampler_path,
|
||||
loras=[],
|
||||
registry=registry,
|
||||
quantization=quantization_policy,
|
||||
compilation_config=compilation_config,
|
||||
alloc_trim_strategy=AllocatorTrimStrategy.DEFER,
|
||||
)
|
||||
tracker = TransformerWeightTracker(group=self.groups.transformer_group)
|
||||
|
||||
# Shared stage: sequence parallelism (covers both stage 1 and stage 2 invocations).
|
||||
model_cfg = pipeline.stage._transformer_builder.model_config().get("transformer", {})
|
||||
attn_mgr = AttentionManager(
|
||||
max_tokens=sp_max_tokens,
|
||||
num_heads=model_cfg["num_attention_heads"],
|
||||
head_dim=model_cfg["attention_head_dim"],
|
||||
tensor_dtype=pipeline.dtype,
|
||||
group=self.groups.transformer_group,
|
||||
)
|
||||
pipeline.stage._transformer_builder = SequenceParallelBuilder(
|
||||
inner=pipeline.stage._transformer_builder,
|
||||
attn_mgr=attn_mgr,
|
||||
registry=registry,
|
||||
tracker=tracker,
|
||||
)
|
||||
|
||||
# Accelerate Gemma parallelization.
|
||||
pipeline.prompt_encoder._text_encoder_builder = AccelerateGemmaBuilder(
|
||||
gemma_root_path=gemma_root,
|
||||
gemma_group=self.groups.gemma_group,
|
||||
broadcast_group=self.groups.transformer_group,
|
||||
registry=registry,
|
||||
src_rank=_DRIVER_RANK,
|
||||
dtype=pipeline.dtype,
|
||||
)
|
||||
|
||||
# Distributed VAE decoding: balanced 2D spatial grid over the group (one tile/rank).
|
||||
# height takes the smaller factor of world_size, width the larger; size-aware split is a follow-up.
|
||||
vae_height_tiles, vae_width_tiles = balanced_tile_split(dist.get_world_size(self.groups.vae_group))
|
||||
vae_tiling = TileCountConfig(
|
||||
height=DimensionTilingConfig(num_tiles=vae_height_tiles, overlap=4),
|
||||
width=DimensionTilingConfig(num_tiles=vae_width_tiles, overlap=4),
|
||||
)
|
||||
pipeline.video_decoder._decoder_builder = DistributedDecoderBuilder( # type: ignore[assignment]
|
||||
inner=pipeline.video_decoder._decoder_builder,
|
||||
queue=vae_queue,
|
||||
vae_group=self.groups.vae_group,
|
||||
vae_tiling=vae_tiling,
|
||||
driver_rank=_DRIVER_RANK,
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
self._pipeline = pipeline
|
||||
|
||||
@torch.inference_mode()
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
output_path: str,
|
||||
prompt: str,
|
||||
seed: int,
|
||||
height: int,
|
||||
width: int,
|
||||
num_frames: int,
|
||||
frame_rate: float,
|
||||
images: list[Any] | None = None,
|
||||
) -> Iterator[str | None]:
|
||||
# The pipeline raises ValueError on invalid input (symmetric across ranks); the controller
|
||||
# catches that and turns it into a recoverable RunnerError. Anything else is fatal.
|
||||
video, audio = self._pipeline(
|
||||
prompt=prompt,
|
||||
seed=seed,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
frame_rate=frame_rate,
|
||||
images=images or [],
|
||||
tiling_config=None,
|
||||
)
|
||||
if dist.get_rank() != _DRIVER_RANK:
|
||||
yield None # workers: nothing to encode
|
||||
return
|
||||
encode_video(
|
||||
video=video,
|
||||
fps=frame_rate,
|
||||
audio=audio,
|
||||
output_path=output_path,
|
||||
video_chunks_number=get_video_chunks_number(num_frames, TilingConfig.default()),
|
||||
)
|
||||
yield output_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from ltx_pipelines.utils.args import (
|
||||
default_2_stage_distilled_arg_parser,
|
||||
resolve_cli_params,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||
params = resolve_cli_params(distilled=True)
|
||||
args = default_2_stage_distilled_arg_parser(params=params).parse_args()
|
||||
|
||||
vae_queue = torch.multiprocessing.get_context("spawn").SimpleQueue()
|
||||
controller = MGPUController(DistilledRunner)
|
||||
controller.start(
|
||||
distilled_checkpoint_path=args.distilled_checkpoint_path,
|
||||
gemma_root=args.gemma_root,
|
||||
spatial_upsampler_path=args.spatial_upsampler_path,
|
||||
vae_queue=vae_queue,
|
||||
compilation_config=args.compile,
|
||||
)
|
||||
try:
|
||||
for _ in controller.stream(
|
||||
output_path=args.output_path,
|
||||
prompt=args.prompt,
|
||||
seed=args.seed,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
frame_rate=args.frame_rate,
|
||||
images=args.images,
|
||||
):
|
||||
pass # drive the job to completion; the runner writes the file as a side effect
|
||||
finally:
|
||||
controller.shutdown()
|
||||
@@ -39,6 +39,7 @@ from ltx_core.conditioning import (
|
||||
ConditioningItem,
|
||||
VideoConditionByReferenceLatent,
|
||||
)
|
||||
from ltx_core.devices import empty_device_cache
|
||||
from ltx_core.hdr import apply_hdr_decode_postprocess
|
||||
from ltx_core.loader import LoraPathStrengthAndSDOps
|
||||
from ltx_core.loader.registry import Registry
|
||||
@@ -49,6 +50,7 @@ from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.tiling import DimensionTilingConfig, TileCountConfig
|
||||
from ltx_core.tools import VideoLatentTools
|
||||
from ltx_core.types import VideoLatentShape, VideoPixelShape
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
DiffusionStage,
|
||||
ImageConditioner,
|
||||
@@ -199,7 +201,7 @@ class HDRICLoraPipeline:
|
||||
Tonemapping and EXR saving are the caller's responsibility.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
distilled_checkpoint_path: str,
|
||||
spatial_upsampler_path: str,
|
||||
@@ -211,6 +213,7 @@ class HDRICLoraPipeline:
|
||||
hdr_lora_config: HdrLoraConfig | None = None,
|
||||
tiled_vae_encode_pixel_threshold: int = TILED_VAE_ENCODE_PIXEL_THRESHOLD,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@@ -252,17 +255,14 @@ class HDRICLoraPipeline:
|
||||
f.get_tensor("audio_context"),
|
||||
)
|
||||
|
||||
self.image_conditioner = ImageConditioner(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.stage_1 = DiffusionStage(
|
||||
self.image_conditioner = ImageConditioner(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
loras=loras,
|
||||
quantization=quantization,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage_2 = DiffusionStage(
|
||||
self.stage_1 = DiffusionStage.from_checkpoint(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -270,11 +270,33 @@ class HDRICLoraPipeline:
|
||||
quantization=quantization,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage_2 = DiffusionStage.from_checkpoint(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
loras=loras,
|
||||
quantization=quantization,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.upsampler = VideoUpsampler(
|
||||
distilled_checkpoint_path, spatial_upsampler_path, self.dtype, self.device, registry=registry
|
||||
distilled_checkpoint_path,
|
||||
spatial_upsampler_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
|
||||
# HDR config: explicit override, or auto-detect from LoRA metadata.
|
||||
if hdr_lora_config is not None:
|
||||
@@ -721,7 +743,7 @@ def _process_single_video( # noqa: PLR0913
|
||||
|
||||
del hdr_video
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
empty_device_cache()
|
||||
|
||||
if not skip_mp4:
|
||||
# Wait for EXR saves to finish before encoding.
|
||||
|
||||
@@ -16,12 +16,13 @@ from ltx_pipelines.iclora_utils import (
|
||||
read_lora_reference_downscale_factor,
|
||||
read_lora_reference_temporal_scale_factor,
|
||||
)
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import (
|
||||
ImageConditioningInput,
|
||||
VideoConditioningAction,
|
||||
VideoMaskConditioningAction,
|
||||
default_2_stage_distilled_arg_parser,
|
||||
detect_checkpoint_path,
|
||||
resolve_cli_params,
|
||||
)
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioDecoder,
|
||||
@@ -34,7 +35,6 @@ from ltx_pipelines.utils.blocks import (
|
||||
from ltx_pipelines.utils.constants import (
|
||||
DISTILLED_SIGMAS,
|
||||
STAGE_2_DISTILLED_SIGMAS,
|
||||
detect_params,
|
||||
)
|
||||
from ltx_pipelines.utils.denoisers import SimpleDenoiser
|
||||
from ltx_pipelines.utils.helpers import assert_resolution, combined_image_conditionings, get_device
|
||||
@@ -64,6 +64,7 @@ class ICLoraPipeline:
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
self.device = device or get_device()
|
||||
self.dtype = torch.bfloat16
|
||||
@@ -75,9 +76,16 @@ class ICLoraPipeline:
|
||||
self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.stage_1 = DiffusionStage(
|
||||
self.image_conditioner = ImageConditioner(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage_1 = DiffusionStage.from_checkpoint(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -86,8 +94,9 @@ class ICLoraPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage_2 = DiffusionStage(
|
||||
self.stage_2 = DiffusionStage.from_checkpoint(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -96,12 +105,30 @@ class ICLoraPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.upsampler = VideoUpsampler(
|
||||
distilled_checkpoint_path, spatial_upsampler_path, self.dtype, self.device, registry=registry
|
||||
distilled_checkpoint_path,
|
||||
spatial_upsampler_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.audio_decoder = AudioDecoder(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_decoder = AudioDecoder(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
|
||||
# Read reference scale factors from LoRA metadata.
|
||||
# IC-LoRAs trained with scaled reference videos store these factors
|
||||
@@ -344,8 +371,7 @@ class ICLoraPipeline:
|
||||
@torch.inference_mode()
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
checkpoint_path = detect_checkpoint_path(distilled=True)
|
||||
params = detect_params(checkpoint_path)
|
||||
params = resolve_cli_params(distilled=True)
|
||||
parser = default_2_stage_distilled_arg_parser(params=params)
|
||||
parser.add_argument(
|
||||
"--video-conditioning",
|
||||
|
||||
@@ -16,10 +16,11 @@ from ltx_core.model.transformer.compiling import CompilationConfig
|
||||
from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.types import Audio, VideoPixelShape
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import (
|
||||
ImageConditioningInput,
|
||||
default_2_stage_arg_parser,
|
||||
detect_checkpoint_path,
|
||||
resolve_cli_params,
|
||||
)
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioDecoder,
|
||||
@@ -31,7 +32,6 @@ from ltx_pipelines.utils.blocks import (
|
||||
)
|
||||
from ltx_pipelines.utils.constants import (
|
||||
STAGE_2_DISTILLED_SIGMAS,
|
||||
detect_params,
|
||||
)
|
||||
from ltx_pipelines.utils.denoisers import FactoryGuidedDenoiser, SimpleDenoiser
|
||||
from ltx_pipelines.utils.helpers import (
|
||||
@@ -53,7 +53,7 @@ class KeyframeInterpolationPipeline:
|
||||
as the upsampled video already has good quality and just needs refinement.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
checkpoint_path: str,
|
||||
distilled_lora: list[LoraPathStrengthAndSDOps],
|
||||
@@ -65,16 +65,25 @@ class KeyframeInterpolationPipeline:
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
self.device = device or get_device()
|
||||
self.dtype = torch.bfloat16
|
||||
self._scheduler = LTX2Scheduler()
|
||||
|
||||
self.prompt_encoder = PromptEncoder(
|
||||
checkpoint_path, gemma_root, self.dtype, self.device, registry=registry, offload_mode=offload_mode
|
||||
checkpoint_path,
|
||||
gemma_root,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.stage_1 = DiffusionStage(
|
||||
self.image_conditioner = ImageConditioner(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.stage_1 = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -83,9 +92,10 @@ class KeyframeInterpolationPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
stage_2_loras = (*tuple(loras), *tuple(distilled_lora))
|
||||
self.stage_2 = DiffusionStage(
|
||||
self.stage_2 = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -94,12 +104,22 @@ class KeyframeInterpolationPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.upsampler = VideoUpsampler(
|
||||
checkpoint_path, spatial_upsampler_path, self.dtype, self.device, registry=registry
|
||||
checkpoint_path,
|
||||
spatial_upsampler_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.audio_decoder = AudioDecoder(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.video_decoder = VideoDecoder(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_decoder = AudioDecoder(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
|
||||
def __call__( # noqa: PLR0913
|
||||
self,
|
||||
@@ -235,8 +255,7 @@ class KeyframeInterpolationPipeline:
|
||||
@torch.inference_mode()
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
checkpoint_path = detect_checkpoint_path()
|
||||
params = detect_params(checkpoint_path)
|
||||
params = resolve_cli_params()
|
||||
parser = default_2_stage_arg_parser(params=params)
|
||||
args = parser.parse_args()
|
||||
pipeline = KeyframeInterpolationPipeline(
|
||||
|
||||
@@ -21,10 +21,11 @@ from ltx_pipelines.iclora_utils import (
|
||||
append_ic_lora_reference_video_conditionings,
|
||||
read_lora_reference_downscale_factor,
|
||||
)
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import (
|
||||
ImageConditioningInput,
|
||||
detect_checkpoint_path,
|
||||
lipdub_arg_parser,
|
||||
resolve_cli_params,
|
||||
)
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioConditioner,
|
||||
@@ -35,7 +36,7 @@ from ltx_pipelines.utils.blocks import (
|
||||
VideoDecoder,
|
||||
VideoUpsampler,
|
||||
)
|
||||
from ltx_pipelines.utils.constants import DISTILLED_SIGMAS, STAGE_2_DISTILLED_SIGMAS, detect_params
|
||||
from ltx_pipelines.utils.constants import DISTILLED_SIGMAS, STAGE_2_DISTILLED_SIGMAS
|
||||
from ltx_pipelines.utils.denoisers import SimpleDenoiser
|
||||
from ltx_pipelines.utils.helpers import assert_resolution, combined_image_conditionings, get_device
|
||||
from ltx_pipelines.utils.media_io import decode_audio_from_file, encode_video, get_videostream_metadata
|
||||
@@ -62,6 +63,7 @@ class LipDubPipeline:
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> None:
|
||||
self.device = device or get_device()
|
||||
self.dtype = torch.bfloat16
|
||||
@@ -75,15 +77,23 @@ class LipDubPipeline:
|
||||
self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_conditioner = AudioConditioner(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage = DiffusionStage(
|
||||
self.stage = DiffusionStage.from_checkpoint(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -92,12 +102,30 @@ class LipDubPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.upsampler = VideoUpsampler(
|
||||
distilled_checkpoint_path, spatial_upsampler_path, self.dtype, self.device, registry=registry
|
||||
distilled_checkpoint_path,
|
||||
spatial_upsampler_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.audio_decoder = AudioDecoder(
|
||||
distilled_checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_decoder = AudioDecoder(distilled_checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.reference_downscale_factor = read_lora_reference_downscale_factor(ic_lora.path)
|
||||
|
||||
def _create_stage_conditionings(
|
||||
@@ -291,8 +319,7 @@ def patchify_lipdub_audio_reference_latent(
|
||||
@torch.inference_mode()
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
checkpoint_path = detect_checkpoint_path(distilled=True)
|
||||
params = detect_params(checkpoint_path)
|
||||
params = resolve_cli_params(distilled=True)
|
||||
parser = lipdub_arg_parser(params=params)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Multi-GPU inference controller.
|
||||
Public API:
|
||||
- ``MGPUController``: controller-driven persistent multi-GPU fleet (start / stream / drain / shutdown)
|
||||
- ``MGPURunner``: base class implemented per pipeline (setup + __call__; __call__ is a generator)
|
||||
- ``Stream``: handle returned by ``controller.stream(...)``; iterate it for each rank's yields as they arrive
|
||||
- ``RunnerError``: raised by a runner for a recoverable, symmetric failure
|
||||
- ``SymmetricRunnerError`` / ``AsymmetricRunnerError``: caller-side exceptions raised from a job's result
|
||||
- ``ControllerBusyError``: ``stream()`` called while a previous job is still uncollected
|
||||
- ``NCCLGroups``: per-component NCCL process groups passed to a runner's ``setup``
|
||||
"""
|
||||
|
||||
from ltx_pipelines.multigpu.controller import (
|
||||
AsymmetricRunnerError,
|
||||
ControllerBusyError,
|
||||
MGPUController,
|
||||
Stream,
|
||||
SymmetricRunnerError,
|
||||
)
|
||||
from ltx_pipelines.multigpu.nccl_groups import NCCLGroups
|
||||
from ltx_pipelines.multigpu.runner import MGPURunner, RunnerError
|
||||
|
||||
__all__ = [
|
||||
"AsymmetricRunnerError",
|
||||
"ControllerBusyError",
|
||||
"MGPUController",
|
||||
"MGPURunner",
|
||||
"NCCLGroups",
|
||||
"RunnerError",
|
||||
"Stream",
|
||||
"SymmetricRunnerError",
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Store-based broadcast signaling between the relay rank and workers.
|
||||
Workers poll an incrementing counter in the process group's store instead of
|
||||
blocking directly on a collective, so an idle fleet doesn't trip the NCCL
|
||||
watchdog timeout while waiting for the next job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import torch.distributed as dist
|
||||
|
||||
|
||||
class BroadcastCoordinator:
|
||||
"""Coordinates broadcast signaling between the relay and workers via a distributed store.
|
||||
Uses an incrementing counter to signal when a broadcast is ready. Workers
|
||||
poll for counter changes to avoid NCCL timeout issues during idle periods.
|
||||
The counter wraps at a large value to prevent overflow.
|
||||
"""
|
||||
|
||||
def __init__(self, store: dist.Store, is_driver: bool) -> None:
|
||||
"""Initialize the broadcast coordinator.
|
||||
Args:
|
||||
store: Distributed store (TCPStore, PrefixStore, etc.) for coordination across ranks.
|
||||
is_driver: Whether this rank is the relay (signals broadcasts).
|
||||
"""
|
||||
self.store = store
|
||||
self.is_driver = is_driver
|
||||
self.broadcast_key = "dist_pipeline_broadcast_ready"
|
||||
self.signal = 0
|
||||
self.signal_max = 2**31 - 1 # ~2.1 billion calls before wraparound
|
||||
|
||||
if is_driver:
|
||||
self.store.set(self.broadcast_key, str(self.signal))
|
||||
|
||||
self.last_seen_signal = self.get_current_signal() if not is_driver else 0
|
||||
|
||||
@contextmanager
|
||||
def broadcast_context(self) -> Iterator[None]:
|
||||
"""Signal a broadcast (driver only).
|
||||
Increments the signal counter on entry to notify workers that a
|
||||
broadcast is ready. The counter stays incremented for change detection.
|
||||
Raises:
|
||||
RuntimeError: If called on a non-driver rank.
|
||||
"""
|
||||
if not self.is_driver:
|
||||
raise RuntimeError("broadcast_context can only be called on driver rank")
|
||||
self.signal = (self.signal + 1) % self.signal_max
|
||||
self.store.set(self.broadcast_key, str(self.signal))
|
||||
yield
|
||||
|
||||
def wait_for_signal_change(self, poll_interval: float = 0.01) -> None:
|
||||
"""Wait until the signal changes from the last seen value (worker only).
|
||||
Args:
|
||||
poll_interval: Seconds to sleep between polls (default: 0.01).
|
||||
"""
|
||||
while True:
|
||||
current = self.get_current_signal()
|
||||
if current != self.last_seen_signal:
|
||||
self.last_seen_signal = current
|
||||
return
|
||||
time.sleep(poll_interval)
|
||||
|
||||
def get_current_signal(self) -> int:
|
||||
"""Get the current signal value from the store."""
|
||||
return int(self.store.get(self.broadcast_key).decode("utf-8"))
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Batch-parallel Gemma text encoder builder.
|
||||
Each rank materialises a full :class:`GemmaTextEncoder` on its own local
|
||||
CUDA device via the standard :class:`SingleGPUModelBuilder` pipeline (the
|
||||
same code path used by the non-MGPU pipelines). No Accelerate, no
|
||||
``device_map``, no per-layer dispatch hooks.
|
||||
Every ``build()`` reconstructs the encoder through
|
||||
:class:`SingleGPUModelBuilder`: a fresh meta module is created, the
|
||||
``GEMMA_MODEL_OPS`` chain re-runs (recomputing the rotary / position
|
||||
buffers that live outside the safetensors file), and the trained weights
|
||||
are bound from the provided :class:`Registry`. The registry caches the
|
||||
loaded state dict so subsequent calls skip disk I/O while still rebuilding
|
||||
the module tree -- mirroring the rebuild logic of
|
||||
:class:`AccelerateGemmaBuilder` on this branch. Encoder-instance caching is
|
||||
intentionally left out; it will arrive later as a global builder refactor.
|
||||
The result is wrapped in :class:`BatchParallelGemmaWrapper`, which
|
||||
partitions prompt lists across ranks in ``encode`` and routes
|
||||
non-deterministic sampling (``enhance_t2v`` / ``enhance_i2v``) through a
|
||||
single ``src_rank``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from ltx_core.loader.primitives import BuilderProtocol
|
||||
from ltx_core.loader.registry import Registry
|
||||
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
|
||||
from ltx_core.multigpu.gemma.batch_parallel_wrapper import BatchParallelGemmaWrapper
|
||||
from ltx_core.text_encoders.gemma import (
|
||||
GEMMA_LLM_KEY_OPS,
|
||||
GEMMA_MODEL_OPS,
|
||||
GemmaTextEncoderConfigurator,
|
||||
module_ops_from_gemma_root,
|
||||
)
|
||||
from ltx_core.utils import find_matching_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BatchParallelGemmaBuilder(BuilderProtocol):
|
||||
"""Per-rank Gemma replica builder for the batch-parallel encode path.
|
||||
Mirrors the inline Gemma builder construction inside
|
||||
:class:`PromptEncoder` (single-GPU path) and adds the MGPU wiring --
|
||||
broadcast group + source rank for non-deterministic methods. Each
|
||||
``build()`` reconstructs the encoder via :class:`SingleGPUModelBuilder`;
|
||||
the registry caches the state dict so only disk I/O is skipped across
|
||||
calls.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gemma_root_path: str,
|
||||
broadcast_group: dist.ProcessGroup | None,
|
||||
registry: Registry,
|
||||
*,
|
||||
src_rank: int,
|
||||
dtype: torch.dtype = torch.bfloat16,
|
||||
) -> None:
|
||||
model_folder = find_matching_file(gemma_root_path, "model*.safetensors").parent
|
||||
weight_paths = tuple(str(p) for p in model_folder.rglob("*.safetensors"))
|
||||
self._inner = Builder(
|
||||
model_path=weight_paths,
|
||||
model_class_configurator=GemmaTextEncoderConfigurator,
|
||||
model_sd_ops=GEMMA_LLM_KEY_OPS,
|
||||
module_ops=(GEMMA_MODEL_OPS, *module_ops_from_gemma_root(gemma_root_path)),
|
||||
registry=registry,
|
||||
)
|
||||
self._broadcast_group = broadcast_group
|
||||
self._src_rank = src_rank
|
||||
self._dtype = dtype
|
||||
|
||||
def model_config(self) -> dict:
|
||||
return {}
|
||||
|
||||
def build(
|
||||
self,
|
||||
device: torch.device | None = None,
|
||||
dtype: torch.dtype | None = None,
|
||||
) -> BatchParallelGemmaWrapper:
|
||||
dtype = dtype or self._dtype
|
||||
encoder = self._inner.build(device=device, dtype=dtype).eval()
|
||||
return BatchParallelGemmaWrapper(
|
||||
encoder=encoder,
|
||||
broadcast_group=self._broadcast_group,
|
||||
src_rank=dist.get_group_rank(self._broadcast_group, self._src_rank),
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Synchronous multi-GPU controller.
|
||||
Spawns one worker process per GPU. Rank 0 is the *relay*: the controller hands it a job over a
|
||||
queue and it NCCL-broadcasts the job to every rank. All ranks run the user's runner -- a
|
||||
*generator* -- in SPMD lockstep. `stream(**kwargs)` dispatches and returns a `Stream` you iterate;
|
||||
each yielded value is forwarded straight to you, one element per yield, as it comes off the result
|
||||
queue (no gathering across ranks). One job at a time -- no job queue, no pipelining.
|
||||
Constraints and contract:
|
||||
- Single machine only: MASTER_ADDR is localhost, RANK == LOCAL_RANK, one rank per GPU. By default
|
||||
rank r runs on cuda:r; pass `devices=[...]` to place the fleet on a specific physical GPU subset
|
||||
(rank r -> cuda:devices[r]), e.g. to run two controllers side by side on disjoint GPUs.
|
||||
- The runner is a generator and runs in SPMD LOCKSTEP. Yields are forwarded individually, not
|
||||
gathered, so each rank's yields appear as their own stream elements (in result-queue order). Only
|
||||
the ranks' terminals are collected: once all have ended, the controller classifies and ends iteration.
|
||||
- Job kwargs (non-tensor parts) cross the queue and are pickled again through the NCCL broadcast;
|
||||
yielded values ride only the result queue (pickled once). All must be picklable and small.
|
||||
Tensors are the exception: pass them as top-level kwargs and the relay broadcasts them over NCCL
|
||||
instead of pickling (see `stream`); tensors inside a yielded value ride the result queue by
|
||||
shared memory / CUDA IPC.
|
||||
- Each job belongs to the thread that dispatched it: only that thread may iterate it, enforced in
|
||||
`Stream`.
|
||||
- One job in flight at a time: consume the `Stream` to the end before dispatching the next.
|
||||
Abandoning it is NOT cleaned up -- the next `stream()` raises `ControllerBusyError` until you
|
||||
`stream.drain()` or `shutdown()`.
|
||||
- A runner that *raises* an unexpected exception kills the controller (a desynced NCCL collective
|
||||
cannot be unwound; make a new one). For a recoverable failure the runner raises a `RunnerError`
|
||||
(or a `ValueError`, which the controller turns into one) identically on every rank: the worker loop
|
||||
catches it, the fleet survives, and iterating the `Stream` re-raises it as `SymmetricRunnerError`
|
||||
-- or `AsymmetricRunnerError` if only some ranks raised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator, Sequence
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.multiprocessing as torch_mp
|
||||
from torch.distributed.elastic.multiprocessing.api import DefaultLogsSpecs, LogsSpecs, Std
|
||||
|
||||
from ltx_pipelines.multigpu.fleet import _POLL_S, _Channels, _Job, _RunnersFleet
|
||||
from ltx_pipelines.multigpu.runner import MGPURunner, RunnerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_INIT_TIMEOUT = timedelta(minutes=30)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Errors raised to the caller: a busy dispatch, or a runner failure classified across ranks.
|
||||
# =============================================================================
|
||||
class SymmetricRunnerError(Exception):
|
||||
"""Every rank raised a RunnerError -- the contract was honored. Recoverable: fix the
|
||||
input and retry; the controller is still alive."""
|
||||
|
||||
def __init__(self, errors: list[RunnerError]) -> None:
|
||||
self.errors = errors
|
||||
super().__init__(errors[0].message)
|
||||
|
||||
|
||||
class AsymmetricRunnerError(Exception):
|
||||
"""Some ranks raised a RunnerError and some finished cleanly. The fleet is provably healthy
|
||||
(a mix of terminals means every rank ran to completion and reported), but the runner's error
|
||||
path is non-deterministic across ranks -- a latent hang risk. Loud by design; does NOT kill
|
||||
the fleet."""
|
||||
|
||||
def __init__(self, terminals: list[Any]) -> None:
|
||||
self.terminals = terminals # the per-rank terminals (RunnerErrors mixed with clean StopIterations)
|
||||
super().__init__("runner raised RunnerError on some ranks but not all")
|
||||
|
||||
|
||||
class ControllerBusyError(RuntimeError):
|
||||
"""`stream()` was called while a job is still in flight (uncollected).
|
||||
`stream` returns IMMEDIATELY, so it raises rather than silently draining the in-flight job
|
||||
(which would block for the full job, possibly one owned by another thread). The in-flight job belongs
|
||||
to the thread that dispatched it: consume it (`try: ... finally: stream.drain()`) so you never
|
||||
wedge yourself, and let a concurrent caller that loses the dispatch race simply bounce.
|
||||
"""
|
||||
|
||||
def __init__(self, job_id: int) -> None:
|
||||
self.job_id = job_id # the in-flight job's id, for catch-site logging
|
||||
super().__init__(f"MGPU job {job_id} is still in flight; consume it (stream.drain()) first.")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The caller's streaming handle: a thin wrapper over the collector generator.
|
||||
# Iterating it drives the job and forwards each rank's yields as they arrive.
|
||||
# =============================================================================
|
||||
class Stream:
|
||||
"""The iterable returned by `stream()` and the handle to one in-flight job. Each element is one
|
||||
rank's yielded value, forwarded as it comes off the result queue -- yields are NOT gathered
|
||||
across ranks. When every rank's generator has finished, iteration ends: all clean -> the
|
||||
`StopIteration` value is the per-rank list of returns (normally Nones, since results are yielded
|
||||
-- `result = yield from stream` to read it); all `RunnerError` -> `SymmetricRunnerError`; a mix
|
||||
-> `AsymmetricRunnerError`.
|
||||
The job belongs to the thread that dispatched it: only that thread may iterate (or `drain`) this
|
||||
Stream -- a second thread advancing the same ordered collection is the one hazard forbidden here.
|
||||
Consume the Stream to completion before the next `stream()`; abandoning it leaves the job in
|
||||
flight (the next `stream()` raises `ControllerBusyError` until it is drained or shut down). The
|
||||
controller does not clean up after you -- the recommended pattern is `try: ... finally:
|
||||
stream.drain()`.
|
||||
"""
|
||||
|
||||
def __init__(self, pump: Iterator[Any], job_thread: int, job_id: int) -> None:
|
||||
self._pump = pump # generator: yields each rank's values, returns the per-rank returns
|
||||
self._job_thread = job_thread # the thread that dispatched this job; only it may iterate/drain
|
||||
self.job_id = job_id # this job's id (for messages/debugging)
|
||||
|
||||
def __iter__(self) -> Stream:
|
||||
return self
|
||||
|
||||
def __next__(self) -> Any: # noqa: ANN401
|
||||
if threading.get_ident() != self._job_thread:
|
||||
raise RuntimeError(
|
||||
f"Stream for job {self.job_id} is single-threaded: dispatched on thread "
|
||||
f"{self._job_thread}, iterated from thread {threading.get_ident()} -- a job belongs "
|
||||
f"to its dispatching thread."
|
||||
)
|
||||
# The pump yields each rank's value directly; once all ranks have ended it raises
|
||||
# StopIteration(per-rank returns), or Symmetric/Asymmetric if any rank raised.
|
||||
return next(self._pump)
|
||||
|
||||
def drain(self) -> None:
|
||||
"""Exhaust the Stream and free the controller, discarding any unconsumed yields. drain() must
|
||||
be called from the same thread that called stream() (it iterates the Stream, so the owner
|
||||
check applies); the recommended pattern is `try: ... finally: stream.drain()`. A recoverable
|
||||
Symmetric/AsymmetricRunnerError is swallowed (cleanup); a dead/timed-out/desynced fleet still
|
||||
surfaces -- you need to know.
|
||||
"""
|
||||
try:
|
||||
for _ in self: # iterate via __next__, so the dispatching-thread check applies
|
||||
pass
|
||||
except (SymmetricRunnerError, AsymmetricRunnerError):
|
||||
pass # cleanup: a recoverable runner error isn't worth surfacing when draining
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The controller.
|
||||
# =============================================================================
|
||||
class MGPUController:
|
||||
"""Persistent one-job-at-a-time GPU fleet.
|
||||
controller = MGPUController(MyRunner, num_gpus=8)
|
||||
controller.start(**setup_kwargs)
|
||||
stream = controller.stream(prompt="...")
|
||||
try:
|
||||
for item in stream: # one element per yield, as it arrives (not gathered)
|
||||
show(item)
|
||||
finally:
|
||||
stream.drain() # free the controller even on early exit -- abandoning wedges it
|
||||
controller.shutdown()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runner_cls: type[MGPURunner],
|
||||
*,
|
||||
num_gpus: int | None = None,
|
||||
devices: Sequence[int] | None = None,
|
||||
logs_specs: LogsSpecs | None = None,
|
||||
) -> None:
|
||||
"""`num_gpus` uses GPUs 0..num_gpus-1 (default: all). `devices` places the fleet on specific
|
||||
physical GPUs (e.g. `[2, 3]` -> rank r on cuda:devices[r]), so several controllers can share a
|
||||
box on disjoint GPU sets; the two are mutually exclusive. Indices are as the controller sees
|
||||
them; every GPU stays visible to each worker, which simply binds to its assigned one.
|
||||
"""
|
||||
if devices is not None:
|
||||
if num_gpus is not None:
|
||||
raise ValueError("Pass either num_gpus or devices, not both.")
|
||||
if len(devices) == 0 or len(set(devices)) != len(devices):
|
||||
raise ValueError(f"devices must be non-empty and unique: {list(devices)}.")
|
||||
self._runner_cls = runner_cls
|
||||
self._devices = list(devices) if devices is not None else None
|
||||
self._num_gpus = num_gpus
|
||||
self._logs_specs = logs_specs
|
||||
self._spawn_ctx = torch_mp.get_context("spawn")
|
||||
|
||||
self._fleet: _RunnersFleet | None = None
|
||||
self._channels: _Channels | None = None
|
||||
self._next_job_id = 0 # monotonic job-id generator; persists across jobs (powers desync detection)
|
||||
self._inflight: Stream | None = None # the one in-flight job, as its handle; None between jobs
|
||||
|
||||
# baton-lock: guards ONLY the _inflight check-and-set; never held across dispatch / iteration / _collect.
|
||||
self._lock = threading.Lock()
|
||||
self._fatal_error: BaseException | None = None # one-way: set once, then every call raises
|
||||
self._started = False
|
||||
|
||||
@property
|
||||
def is_alive(self) -> bool:
|
||||
"""True while the fleet is up and unpoisoned -- a health check that needs no try/except."""
|
||||
return self._started and self._fatal_error is None
|
||||
|
||||
# ---------------------------------------------------------------- lifecycle
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
timeout: timedelta = _DEFAULT_INIT_TIMEOUT,
|
||||
**setup_kwargs: Any, # noqa: ANN401
|
||||
) -> None:
|
||||
"""Spawn the fleet, run setup() on every rank, block until all report ready.
|
||||
`timeout` bounds both the NCCL `init_process_group` and the controller's wait for
|
||||
every rank to finish CUDA init, `create_local_nccl_groups`, and `setup()`, so a
|
||||
worker wedged ALIVE turns into a clear error instead of an infinite hang (poll()
|
||||
only sees a process *exit*, never a wedge). It must comfortably exceed your slowest
|
||||
model load; pass a large value to effectively wait forever.
|
||||
"""
|
||||
if self._started:
|
||||
raise RuntimeError("MGPUController.start() called twice")
|
||||
if self._devices is not None:
|
||||
num_gpus = len(self._devices)
|
||||
device_ids: list[int] | None = list(self._devices)
|
||||
else:
|
||||
num_gpus = torch.cuda.device_count() if self._num_gpus is None else self._num_gpus
|
||||
device_ids = None
|
||||
if num_gpus <= 0:
|
||||
raise ValueError(f"No GPUs available: num_gpus={num_gpus}.")
|
||||
self._num_gpus = num_gpus # resolve "all GPUs" to the actual count == world size (one rank per GPU)
|
||||
|
||||
self._channels = _Channels(
|
||||
jobs=self._spawn_ctx.Queue(),
|
||||
results=self._spawn_ctx.Queue(),
|
||||
ready=self._spawn_ctx.Queue(),
|
||||
)
|
||||
self._fleet = _RunnersFleet.spawn(
|
||||
runner_cls=self._runner_cls,
|
||||
setup_kwargs=setup_kwargs,
|
||||
init_timeout=timeout,
|
||||
channels=self._channels,
|
||||
num_gpus=num_gpus,
|
||||
logs_specs=self._logs_specs or DefaultLogsSpecs(tee=Std.ALL),
|
||||
device_ids=device_ids,
|
||||
)
|
||||
try:
|
||||
self._await_ready(num_gpus, timeout.total_seconds())
|
||||
except BaseException:
|
||||
self.shutdown() # tear the half-up fleet down so the caller need not
|
||||
raise
|
||||
self._started = True
|
||||
logger.info("MGPU fleet ready (%d workers).", num_gpus)
|
||||
|
||||
def _await_ready(self, num_gpus: int, timeout: float) -> None:
|
||||
assert self._channels is not None
|
||||
assert self._fleet is not None
|
||||
deadline = time.monotonic() + timeout
|
||||
seen: set[int] = set() # which ranks have checked in (ready.put sends the rank)
|
||||
while len(seen) < num_gpus:
|
||||
died = self._fleet.poll()
|
||||
if died is not None: # a worker exited -- catches crashes, not wedges
|
||||
raise died
|
||||
if time.monotonic() > deadline: # catches the wedges poll() can't see
|
||||
missing = sorted(set(range(num_gpus)) - seen)
|
||||
raise TimeoutError(
|
||||
f"MGPU startup: {len(seen)}/{num_gpus} workers ready after {timeout:.0f}s; "
|
||||
f"ranks {missing} never checked in -- stuck in init_process_group / "
|
||||
f"create_local_nccl_groups / setup()? Check the worker logs."
|
||||
)
|
||||
try:
|
||||
seen.add(self._channels.ready.get(timeout=_POLL_S))
|
||||
except Exception:
|
||||
continue # queue.Empty: re-check death + deadline, then retry
|
||||
|
||||
def shutdown(self, *, graceful_timeout: float = 60.0) -> None:
|
||||
"""Tell the fleet to exit, give it a moment, then make sure it is gone.
|
||||
Both teardown and kill switch -- safe to call from another thread while a job is in flight: it
|
||||
force-kills the fleet, so a thread wedged on the Stream surfaces an error and unwedges (this is
|
||||
how you recover a job you can't drain). A mid-job shutdown waits out `graceful_timeout` before
|
||||
forcing; pass 0 to skip it.
|
||||
"""
|
||||
if self._fleet is None:
|
||||
return
|
||||
if self._fatal_error is None and self._channels is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
self._channels.jobs.put(None) # relay broadcasts the sentinel to all ranks
|
||||
self._fleet.drain(graceful_timeout)
|
||||
self._fleet.terminate()
|
||||
self._fleet = None
|
||||
self._started = False
|
||||
|
||||
# ---------------------------------------------------------------- dispatch
|
||||
def stream(self, *, timeout: float | None = None, **kwargs: Any) -> Stream: # noqa: ANN401
|
||||
"""Dispatch one job and return IMMEDIATELY; collect later by iterating the returned Stream.
|
||||
The workers run the job on their own while the controller does nothing; iterate the Stream
|
||||
for the runner's yields -- each rank's yield is forwarded as its own element, as it arrives
|
||||
(not gathered across ranks). Worker death or a blown timeout (measured from dispatch)
|
||||
surfaces when you come back to iterate, not before.
|
||||
TENSORS ARE TRANSPARENT. Pass them as top-level kwargs (`stream(latent=t, steps=30)`): the
|
||||
tensor rides the queue to the relay by shared memory / CUDA IPC and the relay broadcasts it
|
||||
to every rank over NCCL, so `__call__` receives it on the local GPU. (Only top-level kwargs
|
||||
are broadcast this way; tensors nested in a list/dict ride the pickle path.) To send one
|
||||
back, `yield` it from every rank; the yield rides the result queue, so its tensors come back
|
||||
without pickling.
|
||||
ONE JOB AT A TIME. Consume the Stream to the end before dispatching the next. Abandoning a
|
||||
Stream is NOT cleaned up: the job stays in flight and the next `stream()` raises
|
||||
`ControllerBusyError` until you `stream.drain()` or `shutdown()`. Use `try: ... finally:
|
||||
stream.drain()`.
|
||||
"""
|
||||
if not self._started:
|
||||
raise RuntimeError("MGPUController not started; call start() first")
|
||||
if self._fatal_error is not None:
|
||||
raise RuntimeError("MGPUController is dead; create a new one.") from self._fatal_error
|
||||
assert self._channels is not None
|
||||
job_thread = threading.get_ident() # this job belongs to the dispatching thread (checked in Stream)
|
||||
|
||||
# The baton-lock guards exactly this check-and-set -- nothing else (see the lock's comment).
|
||||
with self._lock:
|
||||
if self._inflight is not None:
|
||||
raise ControllerBusyError(self._inflight.job_id)
|
||||
job_id = self._next_job_id
|
||||
self._next_job_id += 1
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
stream = Stream(self._collect(job_id, deadline, timeout), job_thread, job_id)
|
||||
self._inflight = stream # the single source of "a job is in flight"
|
||||
|
||||
self._channels.jobs.put(_Job(job_id=job_id, kwargs=kwargs)) # dispatch OUTSIDE the lock; fleet starts NOW
|
||||
return stream
|
||||
|
||||
@staticmethod
|
||||
def _is_terminal(value: object) -> bool:
|
||||
"""True if `value` is a rank's end marker on the wire: a clean StopIteration or a RunnerError."""
|
||||
return isinstance(value, (StopIteration, RunnerError))
|
||||
|
||||
@staticmethod
|
||||
def _classify_terminal(values: list[Any]) -> list[Any]:
|
||||
"""Classify every rank's terminal (a StopIteration or a RunnerError), ordered by rank. All
|
||||
RunnerError -> SymmetricRunnerError; a mix of RunnerError and clean StopIteration ->
|
||||
AsymmetricRunnerError; all clean -> the per-rank return values (the caller's StopIteration.value).
|
||||
Neither typed error kills the fleet."""
|
||||
errs = [v for v in values if isinstance(v, RunnerError)]
|
||||
if errs and len(errs) == len(values):
|
||||
raise SymmetricRunnerError(errs)
|
||||
if errs:
|
||||
logger.error("asymmetric RunnerError across ranks: %r", values)
|
||||
raise AsymmetricRunnerError(values)
|
||||
return [v.value for v in values] # all StopIteration -> their return values
|
||||
|
||||
def _collect(self, job_id: int, deadline: float | None, timeout: float | None) -> Iterator[Any]:
|
||||
"""Drain the result queue for the in-flight job: forward each yielded value to the caller as
|
||||
soon as it arrives (no gathering), collecting each rank's terminal as it ends. Once every
|
||||
rank has ended, classify them -- all clean -> return the per-rank returns (the caller's
|
||||
StopIteration.value); all/some RunnerError -> Symmetric/Asymmetric. Watches for a dead worker
|
||||
/ blown timeout meanwhile. Runs on the caller's thread when they come back to the Stream.
|
||||
"""
|
||||
assert self._channels is not None
|
||||
assert self._fleet is not None
|
||||
assert self._num_gpus is not None
|
||||
channels, fleet = self._channels, self._fleet
|
||||
n = self._num_gpus
|
||||
terminals: dict[int, Any] = {} # rank -> its end marker (StopIteration | RunnerError)
|
||||
while True:
|
||||
try:
|
||||
msg = channels.results.get(timeout=_POLL_S)
|
||||
except Exception:
|
||||
# Queue empty: nothing ready, so NOW (and only now) check for death / timeout.
|
||||
died = fleet.poll()
|
||||
if died is not None:
|
||||
self._fatal_error = died
|
||||
fleet.terminate()
|
||||
raise died from None
|
||||
if deadline is not None and time.monotonic() > deadline:
|
||||
self._fatal_error = TimeoutError(f"MGPU job {job_id} exceeded {timeout}s")
|
||||
fleet.terminate()
|
||||
raise self._fatal_error from None
|
||||
continue
|
||||
|
||||
if msg.job_id != job_id: # a rank ran a different job than we dispatched -> SPMD desync
|
||||
self._fatal_error = RuntimeError(
|
||||
f"MGPU fleet desync: rank {msg.rank} sent job {msg.job_id}, expected {job_id}."
|
||||
)
|
||||
fleet.terminate()
|
||||
raise self._fatal_error from None
|
||||
|
||||
if not self._is_terminal(msg.value):
|
||||
yield msg.value # forward this rank's yield straight to the caller -- no gathering
|
||||
continue
|
||||
|
||||
terminals[msg.rank] = msg.value # a rank ended; hold its terminal for classification
|
||||
if len(terminals) == n: # every rank has ended -> fleet free; classify and finish
|
||||
self._inflight = None
|
||||
return self._classify_terminal([terminals[r] for r in range(n)])
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Base class for multigpu transformer builders that delegate to an inner builder.
|
||||
Shared boilerplate for SP and TDP builders — both wrap a
|
||||
:class:`SingleGPUModelBuilder` and forward the ``ModelBuilderProtocol`` surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||
|
||||
import torch
|
||||
|
||||
from ltx_core.loader.fuse_loras import FuseRule
|
||||
from ltx_core.loader.module_ops import ModuleOps
|
||||
from ltx_core.loader.primitives import LoraPathStrengthAndSDOps
|
||||
from ltx_core.loader.registry import Registry
|
||||
from ltx_core.loader.sd_ops import SDOps
|
||||
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Self
|
||||
|
||||
InnerModelT = TypeVar("InnerModelT", bound=torch.nn.Module)
|
||||
|
||||
|
||||
class DelegatingBuilder(Generic[InnerModelT]):
|
||||
"""Thin wrapper that delegates all ``ModelBuilderProtocol`` accessors to *inner*.
|
||||
``InnerModelT`` is the type produced by the inner builder. Subclasses only
|
||||
need to implement ``__init__`` and ``build`` (whose return type may differ).
|
||||
"""
|
||||
|
||||
_inner: Builder[InnerModelT]
|
||||
|
||||
# -- delegated properties / with_* methods --------------------------------
|
||||
|
||||
@property
|
||||
def checkpoint(self) -> str | tuple[str, ...]:
|
||||
return self._inner.checkpoint
|
||||
|
||||
@property
|
||||
def model_sd_ops(self) -> SDOps | None:
|
||||
return self._inner.model_sd_ops
|
||||
|
||||
def with_sd_ops(self, sd_ops: SDOps | None) -> Self:
|
||||
clone = copy.copy(self)
|
||||
clone._inner = self._inner.with_sd_ops(sd_ops)
|
||||
return clone
|
||||
|
||||
@property
|
||||
def module_ops(self) -> tuple[ModuleOps, ...]:
|
||||
return self._inner.module_ops
|
||||
|
||||
def with_module_ops(self, module_ops: tuple[ModuleOps, ...]) -> Self:
|
||||
clone = copy.copy(self)
|
||||
clone._inner = self._inner.with_module_ops(module_ops)
|
||||
return clone
|
||||
|
||||
@property
|
||||
def loras(self) -> tuple[LoraPathStrengthAndSDOps, ...]:
|
||||
return self._inner.loras
|
||||
|
||||
def with_loras(self, loras: tuple[LoraPathStrengthAndSDOps, ...]) -> Self:
|
||||
clone = copy.copy(self)
|
||||
clone._inner = self._inner.with_loras(loras)
|
||||
return clone
|
||||
|
||||
@property
|
||||
def registry(self) -> Registry:
|
||||
return self._inner.registry
|
||||
|
||||
def with_registry(self, registry: Registry) -> Self:
|
||||
clone = copy.copy(self)
|
||||
clone._inner = self._inner.with_registry(registry)
|
||||
return clone
|
||||
|
||||
def with_lora_load_device(self, device: torch.device) -> Self:
|
||||
clone = copy.copy(self)
|
||||
clone._inner = self._inner.with_lora_load_device(device)
|
||||
return clone
|
||||
|
||||
def with_fuse_rule(self, fuse_rule: FuseRule) -> Self:
|
||||
clone = copy.copy(self)
|
||||
clone._inner = self._inner.with_fuse_rule(fuse_rule)
|
||||
return clone
|
||||
|
||||
def model_config(self) -> dict:
|
||||
return self._inner.model_config()
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Worker fleet, wire protocol, and SPMD job execution driven by the MGPU controller.
|
||||
Everything the controller (``MGPUController`` in ``controller.py``) drives lives here: the on-the-wire
|
||||
payloads, the per-job NCCL input broadcast (``_RankLink`` / ``_run_job``), the worker entrypoint +
|
||||
loops, the persistent worker fleet (``_RunnersFleet``), and ``_RunnerShipper`` (ships the runner
|
||||
class to workers by value). The runner contract it executes (``MGPURunner`` / ``RunnerError``)
|
||||
lives in ``runner.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from multiprocessing import Queue
|
||||
from typing import Any
|
||||
|
||||
import cloudpickle
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch.distributed.elastic.multiprocessing import start_processes
|
||||
from torch.distributed.elastic.multiprocessing.api import LogsSpecs
|
||||
from torch.distributed.elastic.multiprocessing.errors import ProcessFailure, record
|
||||
|
||||
from ltx_pipelines.multigpu._broadcast import BroadcastCoordinator
|
||||
from ltx_pipelines.multigpu.nccl_groups import create_local_nccl_groups
|
||||
from ltx_pipelines.multigpu.runner import MGPURunner, RunnerError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RELAY_RANK = 0
|
||||
_POLL_S = 0.2 # how often the controller re-checks "did a worker die?" while waiting for a result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The wire: what crosses the queues between controller and workers.
|
||||
# =============================================================================
|
||||
@dataclass
|
||||
class _TensorPlaceholder:
|
||||
"""Marker left in a kwargs/return dict where a tensor was lifted out for separate
|
||||
transport. Carries the shape/dtype so receivers can preallocate before NCCL fills it.
|
||||
Picklable and tiny -- this is what rides the object collective in the tensor's place.
|
||||
"""
|
||||
|
||||
idx: int # position in the lifted-out tensor list
|
||||
shape: tuple[int, ...]
|
||||
dtype: Any # torch.dtype
|
||||
|
||||
|
||||
def _replace_tensors_by_placeholders(d: dict[str, Any]) -> tuple[dict[str, Any], list[torch.Tensor]]:
|
||||
"""Split a dict into (skeleton, tensors): top-level Tensor values become _TensorPlaceholders.
|
||||
Top level only -- a tensor buried inside a list or nested dict is left alone and
|
||||
will ride the pickle path. Keep tensors as direct kwargs/return values.
|
||||
"""
|
||||
skeleton: dict[str, Any] = {}
|
||||
tensors: list[torch.Tensor] = []
|
||||
for k, v in d.items():
|
||||
if isinstance(v, torch.Tensor):
|
||||
skeleton[k] = _TensorPlaceholder(len(tensors), tuple(v.shape), v.dtype)
|
||||
tensors.append(v)
|
||||
else:
|
||||
skeleton[k] = v
|
||||
return skeleton, tensors
|
||||
|
||||
|
||||
def _fill_tensors_into_placeholders(skeleton: dict[str, Any], tensors: list[torch.Tensor]) -> dict[str, Any]:
|
||||
"""Inverse of _replace_tensors_by_placeholders: put the tensors back where the _TensorPlaceholders are."""
|
||||
return {k: (tensors[v.idx] if isinstance(v, _TensorPlaceholder) else v) for k, v in skeleton.items()}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Job:
|
||||
"""One dispatched job: the controller queues it, the relay (rank 0) broadcasts it to every
|
||||
rank, and all ranks run it. `kwargs` may hold top-level tensors -- broadcast over NCCL rather
|
||||
than pickled (see `_RankLink`). A `None` on the job queue is the shutdown sentinel.
|
||||
"""
|
||||
|
||||
job_id: int # every rank echoes this back; a mismatch means the fleet desynced
|
||||
kwargs: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _JobResult:
|
||||
"""worker -> controller: one item from one rank -- a yielded value, a RunnerError, or the
|
||||
terminating StopIteration itself (carrying `.value`, the generator's `return`). A yielded value
|
||||
is forwarded to the caller as soon as it arrives. Terminals are collected per rank: once all
|
||||
`world_size` ranks have ended, the controller classifies them -- all StopIteration -> end
|
||||
iteration with `StopIteration([returns...])`; all/some RunnerError -> Symmetric/Asymmetric.
|
||||
Tensors in `value` ride the queue by shared memory / CUDA IPC, the same for every rank.
|
||||
"""
|
||||
|
||||
job_id: int # the job this is for; the controller rejects a mismatch as a desync
|
||||
rank: int # which rank produced this -- used to collect one terminal per rank (and order returns)
|
||||
value: Any # a yielded value, a RunnerError, or the StopIteration that ended this rank
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Channels:
|
||||
"""The queues bridging the controller and the workers."""
|
||||
|
||||
jobs: Queue # type: ignore[type-arg] # controller -> relay: _Job or None
|
||||
results: Queue # type: ignore[type-arg] # all ranks -> controller: chunk items + each rank's _JobResult
|
||||
ready: Queue # type: ignore[type-arg] # workers -> controller: rank, on setup-complete
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The NCCL core: the per-job input broadcast (relay -> all ranks).
|
||||
# =============================================================================
|
||||
class _RankLink:
|
||||
"""One rank's handle to a job's *input* broadcast (relay -> all ranks, over NCCL). A job's data
|
||||
crosses in two directions and this covers only the inbound one: inputs arrive here, while each
|
||||
rank sends its results back out-of-band on the result queue -- never through a collective -- so
|
||||
the controller assembles them without a second NCCL gather.
|
||||
Between jobs the coordinator parks idle non-relay ranks on a cheap store signal instead of
|
||||
leaving them blocked inside a pending broadcast (which would trip the NCCL watchdog); a rank
|
||||
enters the collective only once the relay signals it has a job to send.
|
||||
"""
|
||||
|
||||
def __init__(self, coordinator: BroadcastCoordinator, device: torch.device) -> None:
|
||||
self._coordinator = coordinator
|
||||
self._device = device # this rank's GPU; where received tensors land
|
||||
|
||||
# ---- relay side
|
||||
def announce_job(self, job: _Job | None) -> _Job | None:
|
||||
"""Broadcast the job to every rank, sending kwargs tensors over NCCL instead of
|
||||
pickling them. Returns the job with its kwargs tensors now on THIS rank's GPU,
|
||||
ready for the relay to run; None for the shutdown sentinel.
|
||||
"""
|
||||
with self._coordinator.broadcast_context():
|
||||
if job is None:
|
||||
dist.broadcast_object_list([None], src=_RELAY_RANK)
|
||||
return None
|
||||
skeleton, tensors = _replace_tensors_by_placeholders(job.kwargs)
|
||||
gpu = [t.to(self._device).contiguous() for t in tensors] # NCCL needs CUDA + contiguous
|
||||
dist.broadcast_object_list([_Job(job.job_id, skeleton)], src=_RELAY_RANK)
|
||||
for t in gpu: # same order every rank, driven by the skeleton broadcast above
|
||||
dist.broadcast(t, src=_RELAY_RANK)
|
||||
job.kwargs = _fill_tensors_into_placeholders(skeleton, gpu)
|
||||
return job
|
||||
|
||||
# ---- non-relay side
|
||||
def await_job(self) -> _Job | None:
|
||||
self._coordinator.wait_for_signal_change()
|
||||
payload: list[Any] = [None]
|
||||
dist.broadcast_object_list(payload, src=_RELAY_RANK)
|
||||
shell: _Job | None = payload[0]
|
||||
if shell is None:
|
||||
return None
|
||||
holes = sorted((v for v in shell.kwargs.values() if isinstance(v, _TensorPlaceholder)), key=lambda h: h.idx)
|
||||
tensors: list[Any] = []
|
||||
for h in holes: # receive in idx order -- matches the relay's send order
|
||||
buf = torch.empty(h.shape, dtype=h.dtype, device=self._device)
|
||||
dist.broadcast(buf, src=_RELAY_RANK)
|
||||
tensors.append(buf)
|
||||
shell.kwargs = _fill_tensors_into_placeholders(shell.kwargs, tensors)
|
||||
return shell
|
||||
|
||||
|
||||
def _run_job(runner: MGPURunner, kwargs: dict[str, Any], job_id: int, rank: int, result: Queue[_JobResult]) -> None:
|
||||
"""Run one job on this rank and put each item on the result queue: every yield is a `_JobResult`
|
||||
(tagged by rank), forwarded straight to the caller; the rank's end -- a clean StopIteration or a
|
||||
raised RunnerError (recoverable) -- is itself a `_JobResult`, which the controller collects and
|
||||
classifies. Runners must be generators; a non-generator return is not iterable, so `next` raises
|
||||
(fatal).
|
||||
"""
|
||||
out = runner(**kwargs)
|
||||
while True:
|
||||
try:
|
||||
value = next(out)
|
||||
except StopIteration as stop: # clean finish: carries the (normally None) return value
|
||||
result.put(_JobResult(job_id, rank, stop))
|
||||
return
|
||||
except RunnerError as err: # runner raised it explicitly -> recoverable; collect and classify
|
||||
result.put(_JobResult(job_id, rank, err))
|
||||
return
|
||||
except ValueError as err: # input validation (symmetric) -> synthesize a recoverable RunnerError
|
||||
result.put(_JobResult(job_id, rank, RunnerError(str(err))))
|
||||
return
|
||||
result.put(_JobResult(job_id, rank, value))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The worker process: entrypoint + the two loops.
|
||||
# =============================================================================
|
||||
def _relay_loop(runner: MGPURunner, link: _RankLink, channels: _Channels) -> None:
|
||||
"""Rank 0: take a job, broadcast it (tensors over NCCL), run it, stream every item on the queue."""
|
||||
while True:
|
||||
job: _Job | None = channels.jobs.get()
|
||||
job = link.announce_job(job) # returns the job with kwargs tensors on this GPU
|
||||
if job is None: # shutdown sentinel (already broadcast to the others)
|
||||
return
|
||||
_run_job(runner, job.kwargs, job.job_id, _RELAY_RANK, channels.results)
|
||||
|
||||
|
||||
def _worker_loop(runner: MGPURunner, link: _RankLink, channels: _Channels, rank: int) -> None:
|
||||
"""Non-relay ranks: wait for the broadcast, run in SPMD, stream every item on the queue."""
|
||||
while True:
|
||||
job = link.await_job()
|
||||
if job is None: # shutdown sentinel
|
||||
return
|
||||
_run_job(runner, job.kwargs, job.job_id, rank, channels.results)
|
||||
|
||||
|
||||
def _shutdown_distributed() -> None:
|
||||
# Drop torch.compile state before tearing down NCCL: compiled artifacts hold CUDA
|
||||
# pool/stream refs that ncclCommDestroy waits on, and destroy_process_group can
|
||||
# deadlock without this.
|
||||
torch._dynamo.reset()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.synchronize()
|
||||
if dist.is_initialized():
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
@record
|
||||
def _worker_entrypoint(
|
||||
runner_cls: type[MGPURunner],
|
||||
setup_kwargs: dict[str, Any],
|
||||
init_timeout: timedelta,
|
||||
channels: _Channels,
|
||||
device_ids: list[int] | None = None,
|
||||
) -> None:
|
||||
"""Per-worker entry. @record turns a crash into a readable failure on the controller."""
|
||||
local_rank = int(os.environ["LOCAL_RANK"])
|
||||
# All GPUs stay visible; we bind this rank to its physical GPU by index. (Setting
|
||||
# CUDA_VISIBLE_DEVICES here is unreliable -- the spawn bootstrap can touch CUDA, freezing
|
||||
# the device list, before a late env override would apply.)
|
||||
device_index = local_rank if device_ids is None else device_ids[local_rank]
|
||||
torch.cuda.set_device(device_index)
|
||||
# Build the runner before NCCL init so its __init__ runs as the per-worker pre-init hook, for setup
|
||||
# that must precede init_process_group (e.g. tests set torch.use_deterministic_algorithms there).
|
||||
runner = runner_cls()
|
||||
if not dist.is_initialized():
|
||||
dist.init_process_group(
|
||||
backend="nccl",
|
||||
device_id=torch.device("cuda", device_index),
|
||||
timeout=init_timeout,
|
||||
)
|
||||
|
||||
is_relay = dist.get_rank() == _RELAY_RANK
|
||||
device = torch.device("cuda", device_index)
|
||||
groups = create_local_nccl_groups()
|
||||
store = dist.distributed_c10d.PrefixStore("ltx_pipeline_broadcast/", dist.distributed_c10d._get_default_store())
|
||||
link = _RankLink(BroadcastCoordinator(store=store, is_driver=is_relay), device)
|
||||
|
||||
runner._groups = groups
|
||||
runner.setup(**setup_kwargs)
|
||||
channels.ready.put(dist.get_rank())
|
||||
|
||||
try:
|
||||
if is_relay:
|
||||
_relay_loop(runner, link, channels)
|
||||
else:
|
||||
_worker_loop(runner, link, channels, dist.get_rank())
|
||||
finally:
|
||||
_shutdown_distributed()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The fleet: spawn, poll for death, terminate. No job knowledge.
|
||||
# =============================================================================
|
||||
class _RunnerShipper:
|
||||
"""Ships a runner CLASS to spawned workers by value (cloudpickle), not by reference.
|
||||
A runner defined in ``__main__`` (a plain script or ``python -m``) or in a test module is
|
||||
not importable under that name in a freshly spawned worker, so the stock by-reference pickle
|
||||
the elastic launcher uses would raise ModuleNotFoundError on every rank. This proxy serializes
|
||||
the class by value; its ``__reduce__`` targets ``cloudpickle.loads`` (importable everywhere),
|
||||
so only the runner crosses by value -- the controller's own channel payloads stay on the stock
|
||||
pickler. The worker unpickles it straight back to the runner class.
|
||||
"""
|
||||
|
||||
def __init__(self, runner_cls: type[MGPURunner]) -> None:
|
||||
module = sys.modules.get(runner_cls.__module__)
|
||||
if module is None:
|
||||
self._payload = cloudpickle.dumps(runner_cls)
|
||||
return
|
||||
# cloudpickle pickles a class by reference when its module looks importable; force
|
||||
# by-value so a __main__/test-module runner survives the spawn.
|
||||
cloudpickle.register_pickle_by_value(module)
|
||||
try:
|
||||
self._payload = cloudpickle.dumps(runner_cls)
|
||||
finally:
|
||||
cloudpickle.unregister_pickle_by_value(module)
|
||||
|
||||
def __reduce__(self) -> tuple[object, tuple[bytes]]:
|
||||
return (cloudpickle.loads, (self._payload,))
|
||||
|
||||
|
||||
def _format_failures(failures: dict[int, ProcessFailure]) -> RuntimeError:
|
||||
lines = [f" rank {r} (pid {f.pid}) exit {f.exitcode}:\n{f.message}" for r, f in failures.items()]
|
||||
return RuntimeError("MGPU worker(s) failed:\n" + "\n".join(lines))
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class _RunnersFleet:
|
||||
"""The worker processes. All three methods call torch.elastic from the controller
|
||||
thread only -- that is the whole reason there is no lock anywhere.
|
||||
"""
|
||||
|
||||
def __init__(self, pcontext: Any) -> None: # noqa: ANN401
|
||||
self._pcontext = pcontext
|
||||
|
||||
@classmethod
|
||||
def spawn(
|
||||
cls,
|
||||
*,
|
||||
runner_cls: type[MGPURunner],
|
||||
setup_kwargs: dict[str, Any],
|
||||
init_timeout: timedelta,
|
||||
channels: _Channels,
|
||||
num_gpus: int,
|
||||
logs_specs: LogsSpecs,
|
||||
device_ids: list[int] | None = None,
|
||||
) -> _RunnersFleet:
|
||||
port = _find_free_port()
|
||||
envs = {
|
||||
r: {
|
||||
"RANK": str(r),
|
||||
"LOCAL_RANK": str(r),
|
||||
"WORLD_SIZE": str(num_gpus),
|
||||
"MASTER_ADDR": "127.0.0.1",
|
||||
"MASTER_PORT": str(port),
|
||||
}
|
||||
for r in range(num_gpus)
|
||||
}
|
||||
# device_ids maps rank -> physical GPU (None = identity); the worker binds to it. The runner
|
||||
# class ships by value so a __main__/test-module runner survives the spawn (see _RunnerShipper).
|
||||
packed = (_RunnerShipper(runner_cls), setup_kwargs, init_timeout, channels, device_ids)
|
||||
args = dict.fromkeys(range(num_gpus), packed)
|
||||
logger.info("Spawning %d MGPU workers...", num_gpus)
|
||||
return cls(
|
||||
start_processes(
|
||||
name="ltx_mgpu_worker",
|
||||
entrypoint=_worker_entrypoint,
|
||||
args=args,
|
||||
envs=envs,
|
||||
logs_specs=logs_specs,
|
||||
start_method="spawn",
|
||||
)
|
||||
)
|
||||
|
||||
def poll(self) -> RuntimeError | None:
|
||||
"""None while all workers are alive. Once any has exited, an error describing it.
|
||||
Used only mid-job, where ANY exit is unexpected (workers only exit on the
|
||||
shutdown sentinel), so a clean exit is reported as an error too.
|
||||
"""
|
||||
result = self._pcontext.wait(timeout=0)
|
||||
if result is None:
|
||||
return None
|
||||
if result.failures:
|
||||
return _format_failures(result.failures)
|
||||
return RuntimeError("MGPU workers exited unexpectedly")
|
||||
|
||||
def drain(self, timeout: float) -> bool:
|
||||
"""Wait up to `timeout` for all workers to exit on their own. True if they did."""
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if self._pcontext.wait(timeout=0) is not None:
|
||||
return True
|
||||
time.sleep(_POLL_S)
|
||||
return False
|
||||
|
||||
def terminate(self) -> None:
|
||||
"""SIGTERM -> SIGKILL. Never raises; safe to call more than once."""
|
||||
with contextlib.suppress(Exception):
|
||||
self._pcontext.close()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Multi-GPU Gemma text encoder builder.
|
||||
Replaces the text encoder builder on the ``PromptEncoder`` block with an
|
||||
:class:`AccelerateGemmaBuilder` that uses ``device_map="auto"`` on the
|
||||
source rank and a broadcast stub elsewhere.
|
||||
On the source rank the first ``build()`` loads via HuggingFace
|
||||
``from_pretrained`` and caches the full state dict (including non-persistent
|
||||
buffers) in the provided :class:`Registry`. Subsequent calls recreate the
|
||||
model from cache and reinstall accelerate dispatch hooks — no disk I/O.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from accelerate import dispatch_model
|
||||
from transformers import Gemma3ForConditionalGeneration
|
||||
|
||||
from ltx_core.loader.primitives import BuilderProtocol, StateDict
|
||||
from ltx_core.loader.registry import Registry
|
||||
from ltx_core.multigpu.gemma.accelerate_wrapper import AccelerateGemmaWrapper
|
||||
from ltx_core.multigpu.gemma.loader import load_gemma_with_device_map
|
||||
from ltx_core.text_encoders.gemma.encoders.base_encoder import GemmaTextEncoder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccelerateGemmaBuilder(BuilderProtocol):
|
||||
"""Builder that loads Gemma with ``device_map="auto"`` on the source rank.
|
||||
Conforms to the builder interface expected by ``PromptEncoder``:
|
||||
``build(device, dtype) -> model``. Non-source ranks get a lightweight
|
||||
:class:`AccelerateGemmaWrapper` that receives embeddings via broadcast.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gemma_root_path: str,
|
||||
gemma_group: dist.ProcessGroup | None,
|
||||
broadcast_group: dist.ProcessGroup | None,
|
||||
registry: Registry,
|
||||
*,
|
||||
src_rank: int,
|
||||
dtype: torch.dtype = torch.bfloat16,
|
||||
) -> None:
|
||||
self._gemma_root_path = gemma_root_path
|
||||
self._gemma_group = gemma_group
|
||||
self._broadcast_group = broadcast_group
|
||||
self._registry = registry
|
||||
self._src_rank = src_rank
|
||||
self._is_src = dist.get_rank() == src_rank
|
||||
self._dtype = dtype
|
||||
# Cached on the src rank after first build (non-tensor objects).
|
||||
self._config: object | None = None
|
||||
self._hf_device_map: dict[str, int | str] | None = None
|
||||
self._tokenizer: object | None = None
|
||||
self._processor: object | None = None
|
||||
|
||||
@property
|
||||
def registry(self) -> Registry:
|
||||
return self._registry
|
||||
|
||||
def with_registry(self, registry: Registry) -> Self:
|
||||
clone = copy.copy(self)
|
||||
clone._registry = registry
|
||||
return clone
|
||||
|
||||
def model_config(self) -> dict:
|
||||
return {}
|
||||
|
||||
def build(
|
||||
self,
|
||||
device: torch.device | None = None,
|
||||
dtype: torch.dtype | None = None,
|
||||
**_kwargs: Any, # noqa: ANN401
|
||||
) -> AccelerateGemmaWrapper:
|
||||
dtype = dtype or self._dtype
|
||||
|
||||
encoder = self._build_encoder(dtype) if self._is_src else None
|
||||
|
||||
return AccelerateGemmaWrapper(
|
||||
encoder=encoder,
|
||||
broadcast_group=self._broadcast_group,
|
||||
src_rank=dist.get_group_rank(self._broadcast_group, self._src_rank),
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
|
||||
# -- src-rank helpers ---------------------------------------------------
|
||||
|
||||
def _build_encoder(self, dtype: torch.dtype) -> GemmaTextEncoder:
|
||||
cached = self._registry.get([self._gemma_root_path], None)
|
||||
if cached is not None:
|
||||
logger.info("Rebuilding Gemma from cached state dict (no disk I/O).")
|
||||
return self._rebuild_from_cache(cached, dtype)
|
||||
|
||||
encoder = load_gemma_with_device_map(self._gemma_root_path, dtype)
|
||||
|
||||
# Cache non-tensor objects on the builder instance.
|
||||
self._config = encoder.model.config
|
||||
self._hf_device_map = encoder.model.hf_device_map
|
||||
self._tokenizer = encoder.tokenizer
|
||||
self._processor = encoder.processor
|
||||
|
||||
# Cache full state dict including non-persistent buffers.
|
||||
sd = encoder.model.state_dict()
|
||||
for name, buf in encoder.model.named_buffers():
|
||||
if name not in sd:
|
||||
sd[name] = buf
|
||||
total_size = sum(t.nelement() * t.element_size() for t in sd.values())
|
||||
dtypes = {t.dtype for t in sd.values()}
|
||||
self._registry.add(
|
||||
[self._gemma_root_path],
|
||||
None,
|
||||
StateDict(sd=sd, device=torch.device("meta"), size=total_size, dtype=dtypes),
|
||||
)
|
||||
logger.info("Cached Gemma state dict in registry (%d entries).", len(sd))
|
||||
|
||||
return encoder
|
||||
|
||||
def _rebuild_from_cache(self, cached: StateDict, dtype: torch.dtype) -> GemmaTextEncoder:
|
||||
with torch.device("meta"):
|
||||
model = Gemma3ForConditionalGeneration(self._config)
|
||||
|
||||
# Split into persistent (load_state_dict) and non-persistent (manual assign).
|
||||
expected_keys = set(model.state_dict().keys())
|
||||
persistent_sd = {k: v for k, v in cached.sd.items() if k in expected_keys}
|
||||
non_persistent_sd = {k: v for k, v in cached.sd.items() if k not in expected_keys}
|
||||
|
||||
model.load_state_dict(persistent_sd, strict=True, assign=True)
|
||||
for name, tensor in non_persistent_sd.items():
|
||||
parent_path, attr = name.rsplit(".", 1)
|
||||
module = model
|
||||
for part in parent_path.split("."):
|
||||
module = getattr(module, part)
|
||||
setattr(module, attr, tensor)
|
||||
|
||||
dispatch_model(model, self._hf_device_map)
|
||||
|
||||
return GemmaTextEncoder(
|
||||
model=model,
|
||||
tokenizer=self._tokenizer,
|
||||
processor=self._processor,
|
||||
dtype=dtype,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""NCCL process group management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch.distributed as dist
|
||||
|
||||
|
||||
@dataclass
|
||||
class NCCLGroups:
|
||||
"""Container for the NCCL process groups used by each pipeline component."""
|
||||
|
||||
gemma_group: dist.ProcessGroup
|
||||
transformer_group: dist.ProcessGroup
|
||||
vae_group: dist.ProcessGroup
|
||||
|
||||
|
||||
def create_local_nccl_groups() -> NCCLGroups:
|
||||
"""Create NCCL process groups for each pipeline component.
|
||||
All ranks must call this collectively because ``dist.new_group`` is a
|
||||
collective operation. All ranks participate in every group.
|
||||
Returns:
|
||||
NCCLGroups with one process group per component.
|
||||
"""
|
||||
all_ranks = list(range(dist.get_world_size()))
|
||||
return NCCLGroups(
|
||||
gemma_group=dist.new_group(ranks=all_ranks),
|
||||
transformer_group=dist.new_group(ranks=all_ranks),
|
||||
vae_group=dist.new_group(ranks=all_ranks),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The runner contract: the base class a pipeline subclasses and the error it raises.
|
||||
``MGPURunner`` is what a pipeline subclasses (``setup`` + a generator ``__call__``); ``RunnerError``
|
||||
is the recoverable, symmetric failure a runner raises. The fleet (``fleet.py``) runs runners and
|
||||
ships them to the spawned workers; the controller (``controller.py``) classifies their results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from ltx_pipelines.multigpu.nccl_groups import NCCLGroups
|
||||
|
||||
|
||||
class RunnerError(Exception):
|
||||
"""Raised by a runner (or synthesized by the controller from a ValueError) for a recoverable,
|
||||
SYMMETRIC failure. The worker loop catches it and puts it on the result queue as that rank's
|
||||
end; the controller collects it with the other terminals and classifies them -- every rank ->
|
||||
SymmetricRunnerError, a mix with clean finishes -> AsymmetricRunnerError. Raise it IDENTICALLY on
|
||||
every rank, outside any collective (e.g. validating the broadcast kwargs before the first one);
|
||||
a RunnerError on only some ranks is the contract violation AsymmetricRunnerError flags.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MGPURunner(ABC):
|
||||
"""Subclass this. The controller builds one per worker, injects groups,
|
||||
calls setup() once, then calls the instance per job. Define it anywhere -- the controller
|
||||
ships the class to workers by value, so a runner in ``__main__`` or a test module works.
|
||||
setup() and __call__() run on EVERY rank. __call__ MUST be a generator (use `yield`, even
|
||||
once): each yield is forwarded to the Stream on its own, as it arrives -- yields are NOT gathered
|
||||
across ranks. Results are *yielded*; a `return` value, if any, rides the terminal
|
||||
StopIteration.value (the rare path), collected per rank once every rank has ended. The framework
|
||||
does not wrap them in inference mode -- annotate your setup()/__call__ with @torch.inference_mode()
|
||||
if you want it.
|
||||
Tensors are transparent: pass them as kwargs (e.g. `stream(latent=t)`) and the relay
|
||||
broadcasts them to every rank over NCCL, so `__call__` receives them already on the local GPU.
|
||||
Yield tensors back the same way -- as values in a yielded dict -- and they come back to the
|
||||
controller without being pickled. See the module docstring.
|
||||
Raising an unexpected exception from __call__ is FATAL: it kills the worker, poisons the
|
||||
controller, and needs a new one. For a RECOVERABLE failure raise a `RunnerError` (or a `ValueError`,
|
||||
which the controller converts) -- identically on every rank, outside any collective. The worker
|
||||
loop catches it and the fleet stays alive; iterating the Stream re-raises it as
|
||||
SymmetricRunnerError.
|
||||
"""
|
||||
|
||||
_groups: NCCLGroups
|
||||
|
||||
@property
|
||||
def groups(self) -> NCCLGroups:
|
||||
return self._groups
|
||||
|
||||
@abstractmethod
|
||||
def setup(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
||||
...
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Sequence parallel transformer builder.
|
||||
Wrapping builder that produces a transformer model with sequence parallelism applied.
|
||||
Requires ``ltx-kernels`` to be installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic
|
||||
|
||||
import torch
|
||||
|
||||
from ltx_core.loader.primitives import ModelBuilderProtocol
|
||||
from ltx_core.loader.registry import Registry
|
||||
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
|
||||
from ltx_core.model.model_protocol import LTXModelProtocol
|
||||
from ltx_core.multigpu.transformer.attention import AttentionManager
|
||||
from ltx_core.multigpu.transformer.sequence_parallel import (
|
||||
SequenceParallelModelWrapper,
|
||||
create_video_self_attention_module_ops,
|
||||
)
|
||||
from ltx_pipelines.multigpu.delegating_builder import DelegatingBuilder, InnerModelT
|
||||
from ltx_pipelines.multigpu.weight_tracker import TransformerWeightTracker
|
||||
|
||||
|
||||
class SequenceParallelBuilder(DelegatingBuilder[InnerModelT], Generic[InnerModelT]):
|
||||
"""Builder that injects SP module ops and wraps with :class:`SequenceParallelModelWrapper`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner: ModelBuilderProtocol[LTXModelProtocol],
|
||||
attn_mgr: AttentionManager,
|
||||
registry: Registry,
|
||||
tracker: TransformerWeightTracker,
|
||||
) -> None:
|
||||
if not isinstance(inner, Builder):
|
||||
raise TypeError(f"SequenceParallelBuilder wraps a SingleGPUModelBuilder, got {type(inner).__name__}")
|
||||
cuda_device = torch.device(f"cuda:{torch.cuda.current_device()}")
|
||||
inner = inner.with_registry(registry).with_lora_load_device(cuda_device)
|
||||
sp_ops = create_video_self_attention_module_ops(attn_mgr)
|
||||
self._inner = inner.with_module_ops((*inner.module_ops, sp_ops))
|
||||
self._tracker = tracker
|
||||
self._attn_mgr = attn_mgr
|
||||
|
||||
@property
|
||||
def all2all_timeout_seconds(self) -> float:
|
||||
"""The SP all2all barrier timeout (seconds); forwards to the AttentionManager that owns the refs."""
|
||||
return self._attn_mgr.all2all_timeout_seconds
|
||||
|
||||
@all2all_timeout_seconds.setter
|
||||
def all2all_timeout_seconds(self, seconds: float) -> None:
|
||||
self._attn_mgr.all2all_timeout_seconds = seconds
|
||||
|
||||
def build(
|
||||
self, device: torch.device | None = None, dtype: torch.dtype | None = None, **kwargs: object
|
||||
) -> SequenceParallelModelWrapper:
|
||||
model = self._tracker.build(self._inner, device=device, dtype=dtype, **kwargs)
|
||||
return SequenceParallelModelWrapper(model, self._attn_mgr)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tiled data parallel transformer builder.
|
||||
Wrapping builder that produces a transformer model with tiled data parallelism applied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from ltx_core.loader.primitives import ModelBuilderProtocol
|
||||
from ltx_core.loader.registry import Registry
|
||||
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
|
||||
from ltx_core.model.model_protocol import LTXModelProtocol
|
||||
from ltx_core.multigpu.transformer.tiled_data_parallel import (
|
||||
TiledDataParallelModelWrapper,
|
||||
)
|
||||
from ltx_core.tiling import TileCountConfig
|
||||
from ltx_core.tools import VideoLatentTools
|
||||
from ltx_pipelines.multigpu.delegating_builder import DelegatingBuilder, InnerModelT
|
||||
from ltx_pipelines.multigpu.weight_tracker import TransformerWeightTracker
|
||||
|
||||
|
||||
class TiledDataParallelBuilder(DelegatingBuilder[InnerModelT], Generic[InnerModelT]):
|
||||
"""Builder conforming to :class:`ModelBuilderProtocol` that wraps with
|
||||
:class:`TiledDataParallelModelWrapper`.
|
||||
Requires ``video_tools`` as a keyword argument to :meth:`build` so the
|
||||
wrapper can compute the tile for this rank.
|
||||
The underlying model must accept ``(video, audio, perturbations)`` and return
|
||||
``(denoised_video, denoised_audio)`` — i.e. conform to the ``X0Model`` forward
|
||||
signature used by the LTX transformer.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner: ModelBuilderProtocol[LTXModelProtocol],
|
||||
group: dist.ProcessGroup,
|
||||
tiling: TileCountConfig,
|
||||
registry: Registry,
|
||||
tracker: TransformerWeightTracker,
|
||||
normalize_positions: bool = True,
|
||||
) -> None:
|
||||
if not isinstance(inner, Builder):
|
||||
raise TypeError(f"TiledDataParallelBuilder wraps a SingleGPUModelBuilder, got {type(inner).__name__}")
|
||||
cuda_device = torch.device(f"cuda:{torch.cuda.current_device()}")
|
||||
self._inner = inner.with_registry(registry).with_lora_load_device(cuda_device)
|
||||
self._tracker = tracker
|
||||
self._group = group
|
||||
self._tiling = tiling
|
||||
self._normalize_positions = normalize_positions
|
||||
|
||||
def build(
|
||||
self,
|
||||
device: torch.device | None = None,
|
||||
dtype: torch.dtype | None = None,
|
||||
*,
|
||||
video_tools: VideoLatentTools | None = None,
|
||||
**_kwargs: object,
|
||||
) -> TiledDataParallelModelWrapper:
|
||||
if video_tools is None:
|
||||
raise ValueError("TiledDataParallelBuilder.build() requires video_tools")
|
||||
model = self._tracker.build(self._inner, device=device, dtype=dtype, **_kwargs)
|
||||
return TiledDataParallelModelWrapper(
|
||||
model,
|
||||
video_tools=video_tools,
|
||||
tiling=self._tiling,
|
||||
group=self._group,
|
||||
normalize_positions=self._normalize_positions,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Multi-GPU VAE decoder builder.
|
||||
Wrapping builder that produces a :class:`DistributedVideoDecoder`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
from torch.multiprocessing import Queue
|
||||
|
||||
from ltx_core.loader.primitives import BuilderProtocol
|
||||
from ltx_core.loader.registry import Registry
|
||||
from ltx_core.multigpu.vae.distributed_decoder import DistributedVideoDecoder
|
||||
from ltx_core.tiling import TileCountConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class DistributedDecoderBuilder(BuilderProtocol):
|
||||
"""Builder that wraps a base decoder builder with distributed logic."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner: BuilderProtocol,
|
||||
queue: Queue, # type: ignore[type-arg]
|
||||
vae_group: dist.ProcessGroup,
|
||||
vae_tiling: TileCountConfig,
|
||||
driver_rank: int,
|
||||
registry: Registry,
|
||||
) -> None:
|
||||
self._inner = inner.with_registry(registry)
|
||||
self._queue = queue
|
||||
self._vae_group = vae_group
|
||||
self._vae_tiling = vae_tiling
|
||||
self._driver_rank = driver_rank
|
||||
|
||||
@property
|
||||
def registry(self) -> Registry:
|
||||
return self._inner.registry
|
||||
|
||||
def with_registry(self, registry: Registry) -> Self:
|
||||
clone = copy.copy(self)
|
||||
clone._inner = self._inner.with_registry(registry)
|
||||
return clone
|
||||
|
||||
def build(
|
||||
self,
|
||||
device: torch.device | None = None,
|
||||
dtype: torch.dtype | None = None,
|
||||
**kwargs: Any, # noqa: ANN401
|
||||
) -> DistributedVideoDecoder:
|
||||
base_decoder = self._inner.build(device=device, dtype=dtype, **kwargs)
|
||||
return DistributedVideoDecoder(
|
||||
base_decoder,
|
||||
queue=self._queue,
|
||||
vae_group=self._vae_group,
|
||||
vae_tiling=self._vae_tiling,
|
||||
driver_rank=dist.get_group_rank(self._vae_group, self._driver_rank),
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Distributed transformer weight tracker with LoRA hot-swap.
|
||||
Shared infrastructure used by both TDP and SP builders.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from ltx_core.loader.fuse_loras import fuse_lora_weights
|
||||
from ltx_core.loader.primitives import LoraPathStrengthAndSDOps, LoraStateDictWithStrength, StateDict
|
||||
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
|
||||
from ltx_core.model.model_protocol import ModelType
|
||||
from ltx_core.multigpu.sharded_sd import ShardedSD
|
||||
|
||||
|
||||
def _apply_loras_inplace(
|
||||
source: dict[str, torch.Tensor],
|
||||
target: dict[str, torch.Tensor],
|
||||
loras: tuple[LoraPathStrengthAndSDOps, ...],
|
||||
builder: Builder, # type: ignore[type-arg]
|
||||
lora_keys: frozenset[str],
|
||||
) -> None:
|
||||
"""Reset *target* to clean weights from *source*, then fuse all LoRAs in one pass."""
|
||||
for key, clean_weight in source.items():
|
||||
target[key].copy_(clean_weight)
|
||||
|
||||
lora_sds = [
|
||||
LoraStateDictWithStrength(
|
||||
builder.load_sd(
|
||||
[lora.path],
|
||||
sd_ops=lora.sd_ops.with_additional_allowed_keys(lora_keys),
|
||||
registry=builder.registry,
|
||||
device=builder.lora_load_device,
|
||||
),
|
||||
lora.strength,
|
||||
)
|
||||
for lora in loras
|
||||
if lora.strength != 0
|
||||
]
|
||||
target_sd = StateDict(
|
||||
sd=target, device=next(iter(target.values())).device, size=0, dtype={next(iter(target.values())).dtype}
|
||||
)
|
||||
for key, fused in fuse_lora_weights(target_sd, lora_sds, fuse_rule=builder.fuse_rule):
|
||||
target[key].copy_(fused)
|
||||
|
||||
|
||||
class TransformerWeightTracker:
|
||||
"""Tracks cached transformer weights with distributed LoRA hot-swap.
|
||||
Shared across stage builders that operate on the same checkpoint.
|
||||
Does **not** own the model weights — it references tensors stored in a
|
||||
:class:`Registry` and receives a builder at :meth:`build` time.
|
||||
Uses two :class:`ShardedSD` instances (created on first :meth:`build` call):
|
||||
- ``stored_sd`` — cloned backup of the original (pre-LoRA) weights.
|
||||
Used to restore registry tensors before applying a different LoRA set.
|
||||
- ``broadcast_sd`` — zero-copy view into the registry tensors.
|
||||
After in-place LoRA fusion on the owning rank, this broadcasts the
|
||||
fused results to all other ranks so every rank sees the same weights.
|
||||
Both are created together and are always either both ``None`` or both set.
|
||||
With ``no_lora_swap``, the LoRA set is assumed fixed (none, or one set):
|
||||
the backup clone is skipped and any swap or reset raises.
|
||||
"""
|
||||
|
||||
def __init__(self, group: dist.ProcessGroup, bucket_mb: int = 256, no_lora_swap: bool = False) -> None:
|
||||
if bucket_mb <= 0:
|
||||
raise ValueError("bucket_mb must be > 0")
|
||||
self._group = group
|
||||
self._bucket_mb = bucket_mb
|
||||
self._no_lora_swap = no_lora_swap
|
||||
self._staging: torch.Tensor | None = None
|
||||
self.stored_sd: ShardedSD | None = None
|
||||
self.broadcast_sd: ShardedSD | None = None
|
||||
self.loras: list[tuple[str, float, str]] = []
|
||||
|
||||
@property
|
||||
def staging(self) -> torch.Tensor:
|
||||
"""The single broadcast scratch buffer, shared by both SDs, allocated on first use."""
|
||||
if self._staging is None:
|
||||
device = torch.device(f"cuda:{torch.cuda.current_device()}")
|
||||
self._staging = torch.empty(self._bucket_mb * 1024 * 1024, dtype=torch.uint8, device=device)
|
||||
return self._staging
|
||||
|
||||
def loras_match(self, lora_list: list[tuple[str, float, str]]) -> bool:
|
||||
if len(lora_list) != len(self.loras):
|
||||
return False
|
||||
return sorted(lora_list) == sorted(self.loras)
|
||||
|
||||
def reset_loras(self, target_sd: dict[str, torch.Tensor]) -> None:
|
||||
"""Restore *target_sd* to original (pre-LoRA) weights.
|
||||
No-op if no LoRAs are currently applied. This is a cooperative
|
||||
operation — all ranks must call it simultaneously.
|
||||
"""
|
||||
if not self.loras:
|
||||
return
|
||||
if self._no_lora_swap:
|
||||
raise RuntimeError("no_lora_swap tracker has no backup to reset from")
|
||||
if self.stored_sd is None:
|
||||
raise RuntimeError("stored_sd must be initialised before reset_loras")
|
||||
self.loras = []
|
||||
self.stored_sd.broadcast_shards_into(target_sd, self.staging)
|
||||
|
||||
def _local_lora_keys(self) -> frozenset[str]:
|
||||
"""Derive LoRA key names from the locally owned model keys."""
|
||||
if self.stored_sd is None:
|
||||
return frozenset()
|
||||
|
||||
keys: set[str] = set()
|
||||
for k in self.stored_sd.local_shard:
|
||||
if k.endswith(".weight"):
|
||||
prefix = k[: -len(".weight")]
|
||||
keys.add(f"{prefix}.lora_A.weight")
|
||||
keys.add(f"{prefix}.lora_B.weight")
|
||||
return frozenset(keys)
|
||||
|
||||
def apply_loras_(
|
||||
self,
|
||||
target_sd: dict[str, torch.Tensor],
|
||||
loras: tuple[LoraPathStrengthAndSDOps, ...],
|
||||
builder: Builder, # type: ignore[type-arg]
|
||||
) -> None:
|
||||
"""Fuse *loras* into *target_sd* in-place (trailing ``_`` denotes in-place).
|
||||
Skips work when the requested LoRAs already match. Restores stored
|
||||
weights before applying new LoRAs. This is a cooperative operation —
|
||||
all ranks must call it simultaneously.
|
||||
"""
|
||||
new_loras = [(lora.path, lora.strength, lora.sd_ops.name) for lora in loras]
|
||||
|
||||
if self.loras_match(new_loras):
|
||||
return
|
||||
|
||||
if self._no_lora_swap and self.loras:
|
||||
raise RuntimeError(f"no_lora_swap tracker cannot change LoRAs: have {self.loras}, requested {new_loras}")
|
||||
|
||||
if all(lora.strength == 0 for lora in loras):
|
||||
self.reset_loras(target_sd)
|
||||
return
|
||||
|
||||
if self.stored_sd is None or self.broadcast_sd is None:
|
||||
raise RuntimeError("ShardedSDs must be initialised before apply_loras_ (call build first)")
|
||||
|
||||
source = self.stored_sd.local_shard
|
||||
target = {k: v for k, v in target_sd.items() if k in source}
|
||||
lora_keys = self._local_lora_keys()
|
||||
_apply_loras_inplace(source, target, loras, builder, lora_keys)
|
||||
|
||||
self.broadcast_sd.broadcast_shards_into(target_sd, self.staging)
|
||||
self.loras = new_loras
|
||||
|
||||
def build(
|
||||
self,
|
||||
builder: Builder[ModelType],
|
||||
device: torch.device | None = None,
|
||||
dtype: torch.dtype | None = None,
|
||||
**kwargs: object,
|
||||
) -> ModelType:
|
||||
"""Build the transformer model with distributed LoRA hot-swap.
|
||||
Populates the registry with clean weights on first call, then applies
|
||||
LoRAs in-place and broadcasts to all ranks. Assumes the builder carries
|
||||
a non-dummy :class:`Registry` so that weights can be cached and reused
|
||||
across calls.
|
||||
"""
|
||||
loras = builder.loras
|
||||
clean_builder = builder.with_loras(())
|
||||
|
||||
model_paths = list(builder.model_path) if isinstance(builder.model_path, tuple) else [builder.model_path]
|
||||
|
||||
# First call: populate the registry with clean weights.
|
||||
if clean_builder.registry.get(model_paths, clean_builder.model_sd_ops) is None:
|
||||
clean_builder.build(device=device, dtype=dtype, **kwargs)
|
||||
|
||||
cached_sd = clean_builder.registry.get(model_paths, clean_builder.model_sd_ops)
|
||||
if cached_sd is None:
|
||||
raise RuntimeError("Expected model state dict in registry but found None")
|
||||
|
||||
if self.stored_sd is None:
|
||||
self.stored_sd = ShardedSD.from_state_dict(cached_sd.sd, self._group, clone=not self._no_lora_swap)
|
||||
self.broadcast_sd = ShardedSD.from_state_dict(cached_sd.sd, self._group, clone=False)
|
||||
|
||||
if loras:
|
||||
self.apply_loras_(cached_sd.sd, loras, builder)
|
||||
else:
|
||||
self.reset_loras(cached_sd.sd)
|
||||
|
||||
return clean_builder.build(device=device, dtype=dtype, **kwargs)
|
||||
@@ -17,6 +17,7 @@ from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.types import (
|
||||
SpatioTemporalScaleFactors,
|
||||
)
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import video_editing_arg_parser
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioConditioner,
|
||||
@@ -76,6 +77,7 @@ class RetakePipeline:
|
||||
distilled: bool = True,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
self.device = device or get_device()
|
||||
self.dtype = torch.bfloat16
|
||||
@@ -89,20 +91,23 @@ class RetakePipeline:
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.audio_conditioner = AudioConditioner(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage = DiffusionStage(
|
||||
self.stage = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
@@ -111,18 +116,21 @@ class RetakePipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.audio_decoder = AudioDecoder(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
@@ -16,16 +16,16 @@ from ltx_core.model.transformer.compiling import CompilationConfig
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.types import Audio
|
||||
from ltx_pipelines.utils import get_device
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import (
|
||||
default_1_stage_t2a_arg_parser,
|
||||
detect_checkpoint_path,
|
||||
resolve_cli_params,
|
||||
)
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioDecoder,
|
||||
DiffusionStage,
|
||||
PromptEncoder,
|
||||
)
|
||||
from ltx_pipelines.utils.constants import detect_params
|
||||
from ltx_pipelines.utils.denoisers import FactoryGuidedDenoiser
|
||||
from ltx_pipelines.utils.media_io import encode_audio
|
||||
from ltx_pipelines.utils.types import ModalitySpec, OffloadMode
|
||||
@@ -56,6 +56,7 @@ class T2AOneStagePipeline:
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
self.dtype = torch.bfloat16
|
||||
self.device = device or get_device()
|
||||
@@ -67,12 +68,13 @@ class T2AOneStagePipeline:
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
# Audio-only: build an audio-only transformer (model_configurator) so the video
|
||||
# weights are never instantiated, plus a use-case-specific SDOps that restricts
|
||||
# checkpoint reads to the audio model's keys, so the video weights are never even
|
||||
# read from disk (the loader skips any key the SDOps maps to None).
|
||||
self.stage = DiffusionStage(
|
||||
self.stage = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
@@ -83,12 +85,14 @@ class T2AOneStagePipeline:
|
||||
offload_mode=offload_mode,
|
||||
model_configurator=LTXAudioOnlyModelConfigurator,
|
||||
model_sd_ops=LTXV_AUDIO_ONLY_MODEL_COMFY_RENAMING_MAP,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.audio_decoder = AudioDecoder(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
|
||||
def __call__(
|
||||
@@ -153,8 +157,7 @@ class T2AOneStagePipeline:
|
||||
@torch.inference_mode()
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
checkpoint_path = detect_checkpoint_path()
|
||||
params = detect_params(checkpoint_path)
|
||||
params = resolve_cli_params()
|
||||
parser = default_1_stage_t2a_arg_parser(params=params)
|
||||
args = parser.parse_args()
|
||||
pipeline = T2AOneStagePipeline(
|
||||
|
||||
@@ -21,10 +21,11 @@ from ltx_pipelines.utils import (
|
||||
combined_image_conditionings,
|
||||
get_device,
|
||||
)
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import (
|
||||
ImageConditioningInput,
|
||||
default_1_stage_arg_parser,
|
||||
detect_checkpoint_path,
|
||||
resolve_cli_params,
|
||||
)
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioDecoder,
|
||||
@@ -33,7 +34,6 @@ from ltx_pipelines.utils.blocks import (
|
||||
PromptEncoder,
|
||||
VideoDecoder,
|
||||
)
|
||||
from ltx_pipelines.utils.constants import detect_params
|
||||
from ltx_pipelines.utils.denoisers import FactoryGuidedDenoiser
|
||||
from ltx_pipelines.utils.media_io import encode_video
|
||||
from ltx_pipelines.utils.types import ModalitySpec, OffloadMode
|
||||
@@ -58,6 +58,7 @@ class TI2VidOneStagePipeline:
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
self.dtype = torch.bfloat16
|
||||
self.device = device or get_device()
|
||||
@@ -69,14 +70,16 @@ class TI2VidOneStagePipeline:
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage = DiffusionStage(
|
||||
self.stage = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
@@ -85,18 +88,21 @@ class TI2VidOneStagePipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.audio_decoder = AudioDecoder(
|
||||
checkpoint_path=checkpoint_path,
|
||||
dtype=self.dtype,
|
||||
device=self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
|
||||
def __call__( # noqa: PLR0913
|
||||
@@ -187,8 +193,7 @@ class TI2VidOneStagePipeline:
|
||||
@torch.inference_mode()
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
checkpoint_path = detect_checkpoint_path()
|
||||
params = detect_params(checkpoint_path)
|
||||
params = resolve_cli_params()
|
||||
parser = default_1_stage_arg_parser(params=params)
|
||||
args = parser.parse_args()
|
||||
pipeline = TI2VidOneStagePipeline(
|
||||
|
||||
@@ -16,10 +16,11 @@ from ltx_core.model.transformer.compiling import CompilationConfig
|
||||
from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.types import Audio, VideoPixelShape
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import (
|
||||
ImageConditioningInput,
|
||||
default_2_stage_arg_parser,
|
||||
detect_checkpoint_path,
|
||||
resolve_cli_params,
|
||||
)
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioDecoder,
|
||||
@@ -31,7 +32,6 @@ from ltx_pipelines.utils.blocks import (
|
||||
)
|
||||
from ltx_pipelines.utils.constants import (
|
||||
STAGE_2_DISTILLED_SIGMAS,
|
||||
detect_params,
|
||||
)
|
||||
from ltx_pipelines.utils.denoisers import FactoryGuidedDenoiser, SimpleDenoiser
|
||||
from ltx_pipelines.utils.helpers import (
|
||||
@@ -52,7 +52,7 @@ class TI2VidTwoStagesPipeline:
|
||||
images parameter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
checkpoint_path: str,
|
||||
distilled_lora: list[LoraPathStrengthAndSDOps],
|
||||
@@ -64,22 +64,40 @@ class TI2VidTwoStagesPipeline:
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
self.device = device or get_device()
|
||||
self.dtype = torch.bfloat16
|
||||
self._scheduler = LTX2Scheduler()
|
||||
|
||||
self.prompt_encoder = PromptEncoder(
|
||||
checkpoint_path, gemma_root, self.dtype, self.device, registry=registry, offload_mode=offload_mode
|
||||
checkpoint_path,
|
||||
gemma_root,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.upsampler = VideoUpsampler(
|
||||
checkpoint_path, spatial_upsampler_path, self.dtype, self.device, registry=registry
|
||||
checkpoint_path,
|
||||
spatial_upsampler_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.audio_decoder = AudioDecoder(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.video_decoder = VideoDecoder(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_decoder = AudioDecoder(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
|
||||
self.stage_1 = DiffusionStage(
|
||||
self.stage_1 = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -88,8 +106,9 @@ class TI2VidTwoStagesPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage_2 = DiffusionStage(
|
||||
self.stage_2 = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -98,6 +117,7 @@ class TI2VidTwoStagesPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
|
||||
def __call__( # noqa: PLR0913
|
||||
@@ -196,7 +216,9 @@ class TI2VidTwoStagesPipeline:
|
||||
)
|
||||
)
|
||||
|
||||
video_state, audio_state = self.stage_2(
|
||||
# Stage 2 refines video only; discard its audio. On the multi-GPU path stage-2 audio
|
||||
# runs under partial tiled/TDP video context, so the full-context stage-1 audio is kept.
|
||||
video_state, _ = self.stage_2(
|
||||
denoiser=SimpleDenoiser(v_context=v_context_p, a_context=a_context_p),
|
||||
sigmas=stage_2_sigmas,
|
||||
noiser=noiser,
|
||||
@@ -225,8 +247,7 @@ class TI2VidTwoStagesPipeline:
|
||||
@torch.inference_mode()
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
checkpoint_path = detect_checkpoint_path()
|
||||
params = detect_params(checkpoint_path)
|
||||
params = resolve_cli_params()
|
||||
parser = default_2_stage_arg_parser(params=params)
|
||||
args = parser.parse_args()
|
||||
pipeline = TI2VidTwoStagesPipeline(
|
||||
|
||||
@@ -13,6 +13,7 @@ from ltx_core.model.transformer.compiling import CompilationConfig
|
||||
from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.types import Audio, VideoLatentShape, VideoPixelShape
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.args import ImageConditioningInput, hq_2_stage_arg_parser
|
||||
from ltx_pipelines.utils.blocks import (
|
||||
AudioDecoder,
|
||||
@@ -63,6 +64,7 @@ class TI2VidTwoStagesHQPipeline:
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
):
|
||||
self.device = device or get_device()
|
||||
self.dtype = torch.bfloat16
|
||||
@@ -80,16 +82,33 @@ class TI2VidTwoStagesHQPipeline:
|
||||
)
|
||||
|
||||
self.prompt_encoder = PromptEncoder(
|
||||
checkpoint_path, gemma_root, self.dtype, self.device, registry=registry, offload_mode=offload_mode
|
||||
checkpoint_path,
|
||||
gemma_root,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.image_conditioner = ImageConditioner(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.upsampler = VideoUpsampler(
|
||||
checkpoint_path, spatial_upsampler_path, self.dtype, self.device, registry=registry
|
||||
checkpoint_path,
|
||||
spatial_upsampler_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
registry=registry,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.video_decoder = VideoDecoder(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.audio_decoder = AudioDecoder(
|
||||
checkpoint_path, self.dtype, self.device, registry=registry, alloc_trim_strategy=alloc_trim_strategy
|
||||
)
|
||||
self.video_decoder = VideoDecoder(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
self.audio_decoder = AudioDecoder(checkpoint_path, self.dtype, self.device, registry=registry)
|
||||
|
||||
self.stage_1 = DiffusionStage(
|
||||
self.stage_1 = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -98,8 +117,9 @@ class TI2VidTwoStagesHQPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
self.stage_2 = DiffusionStage(
|
||||
self.stage_2 = DiffusionStage.from_checkpoint(
|
||||
checkpoint_path,
|
||||
self.dtype,
|
||||
self.device,
|
||||
@@ -108,6 +128,7 @@ class TI2VidTwoStagesHQPipeline:
|
||||
registry=registry,
|
||||
compilation_config=compilation_config,
|
||||
offload_mode=offload_mode,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
|
||||
@torch.inference_mode()
|
||||
@@ -213,7 +234,9 @@ class TI2VidTwoStagesHQPipeline:
|
||||
)
|
||||
)
|
||||
|
||||
video_state, audio_state = self.stage_2(
|
||||
# Stage 2 refines video only; discard its audio. On the multi-GPU path stage-2 audio
|
||||
# runs under partial tiled/TDP video context, so the full-context stage-1 audio is kept.
|
||||
video_state, _ = self.stage_2(
|
||||
denoiser=SimpleDenoiser(v_context=v_context_p, a_context=a_context_p),
|
||||
sigmas=stage_2_sigmas,
|
||||
noiser=noiser,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Multi-GPU two-stage HQ text/image-to-video runner.
|
||||
Runs :class:`TI2VidTwoStagesHQPipeline` across multiple GPUs with:
|
||||
- **Stage 1** -- sequence parallelism (SP) at half resolution
|
||||
- **Stage 2** -- tiled data parallelism (TDP) on height + width with overlap,
|
||||
at full resolution
|
||||
- **Gemma** -- Accelerate-based parallelization
|
||||
- **VAE** -- distributed decoding
|
||||
The HQ pipeline applies the distilled LoRA in both stages with separate
|
||||
strengths and uses the res_2s second-order sampler.
|
||||
Requires ``ltx-kernels`` to be installed (transitive via SP builder).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from multiprocessing import SimpleQueue
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from ltx_core.components.guiders import MultiModalGuiderParams
|
||||
from ltx_core.loader import LTXV_LORA_COMFY_RENAMING_MAP, LoraPathStrengthAndSDOps
|
||||
from ltx_core.loader.registry import StateDictRegistry
|
||||
from ltx_core.model.transformer.compiling import CompilationConfig
|
||||
from ltx_core.model.video_vae import get_video_chunks_number
|
||||
from ltx_core.model.video_vae.tiling import TilingConfig
|
||||
from ltx_core.multigpu.transformer.attention import AttentionManager
|
||||
from ltx_core.quantization.fp8_cast import build_policy as _build_fp8_cast_policy
|
||||
from ltx_core.tiling import DimensionTilingConfig, TileCountConfig, balanced_tile_split
|
||||
from ltx_pipelines.multigpu.controller import MGPUController
|
||||
from ltx_pipelines.multigpu.gemma_builders import AccelerateGemmaBuilder
|
||||
from ltx_pipelines.multigpu.runner import MGPURunner
|
||||
from ltx_pipelines.multigpu.sp_builder import SequenceParallelBuilder
|
||||
from ltx_pipelines.multigpu.tdp_builder import TiledDataParallelBuilder
|
||||
from ltx_pipelines.multigpu.vae_builders import DistributedDecoderBuilder
|
||||
from ltx_pipelines.multigpu.weight_tracker import TransformerWeightTracker
|
||||
from ltx_pipelines.ti2vid_two_stages_hq import TI2VidTwoStagesHQPipeline
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.constants import TDP_DISTILLED_SIGMAS
|
||||
from ltx_pipelines.utils.media_io import encode_video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stage 1 at half-res, 121 frames = 6144 video tokens + audio tokens.
|
||||
_DEFAULT_SP_MAX_TOKENS = 32768
|
||||
# Rank that collects distributed-VAE tiles and encodes the assembled video.
|
||||
_DRIVER_RANK = 0
|
||||
|
||||
|
||||
class TI2VidTwoStagesHQRunner(MGPURunner):
|
||||
"""Distributed HQ pipeline: SP stage 1 (half-res) + TDP stage 2 (full-res) + Gemma + distributed VAE."""
|
||||
|
||||
@torch.inference_mode()
|
||||
def setup(
|
||||
self,
|
||||
*,
|
||||
checkpoint_path: str,
|
||||
gemma_root: str,
|
||||
spatial_upsampler_path: str,
|
||||
vae_queue: SimpleQueue,
|
||||
distilled_lora_path: str,
|
||||
distilled_lora_strength_stage_1: float,
|
||||
distilled_lora_strength_stage_2: float,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
sp_max_tokens: int = _DEFAULT_SP_MAX_TOKENS,
|
||||
) -> None:
|
||||
distilled_lora = [LoraPathStrengthAndSDOps(distilled_lora_path, 1.0, LTXV_LORA_COMFY_RENAMING_MAP)]
|
||||
registry = StateDictRegistry()
|
||||
pipeline = TI2VidTwoStagesHQPipeline(
|
||||
checkpoint_path=checkpoint_path,
|
||||
distilled_lora=distilled_lora,
|
||||
distilled_lora_strength_stage_1=distilled_lora_strength_stage_1,
|
||||
distilled_lora_strength_stage_2=distilled_lora_strength_stage_2,
|
||||
spatial_upsampler_path=spatial_upsampler_path,
|
||||
gemma_root=gemma_root,
|
||||
loras=(),
|
||||
registry=registry,
|
||||
quantization=_build_fp8_cast_policy(checkpoint_path),
|
||||
compilation_config=compilation_config,
|
||||
alloc_trim_strategy=AllocatorTrimStrategy.DEFER,
|
||||
)
|
||||
tracker = TransformerWeightTracker(group=self.groups.transformer_group)
|
||||
|
||||
# Stage 1: sequence parallelism.
|
||||
model_cfg = pipeline.stage_1._transformer_builder.model_config().get("transformer", {})
|
||||
attn_mgr = AttentionManager(
|
||||
max_tokens=sp_max_tokens,
|
||||
num_heads=model_cfg["num_attention_heads"],
|
||||
head_dim=model_cfg["attention_head_dim"],
|
||||
tensor_dtype=pipeline.dtype,
|
||||
group=self.groups.transformer_group,
|
||||
)
|
||||
pipeline.stage_1._transformer_builder = SequenceParallelBuilder(
|
||||
inner=pipeline.stage_1._transformer_builder,
|
||||
attn_mgr=attn_mgr,
|
||||
registry=registry,
|
||||
tracker=tracker,
|
||||
)
|
||||
|
||||
# Stage 2: tiled data parallelism -- balanced 2D spatial grid over the group (one tile/rank).
|
||||
# height takes the smaller factor of world_size, width the larger; size-aware split is a follow-up.
|
||||
tdp_height_tiles, tdp_width_tiles = balanced_tile_split(dist.get_world_size(self.groups.transformer_group))
|
||||
tdp_tiling = TileCountConfig(
|
||||
height=DimensionTilingConfig(num_tiles=tdp_height_tiles, overlap=5),
|
||||
width=DimensionTilingConfig(num_tiles=tdp_width_tiles, overlap=5),
|
||||
)
|
||||
pipeline.stage_2._transformer_builder = TiledDataParallelBuilder(
|
||||
inner=pipeline.stage_2._transformer_builder,
|
||||
group=self.groups.transformer_group,
|
||||
tiling=tdp_tiling,
|
||||
registry=registry,
|
||||
tracker=tracker,
|
||||
)
|
||||
|
||||
# Accelerate Gemma parallelization.
|
||||
pipeline.prompt_encoder._text_encoder_builder = AccelerateGemmaBuilder(
|
||||
gemma_root_path=gemma_root,
|
||||
gemma_group=self.groups.gemma_group,
|
||||
broadcast_group=self.groups.transformer_group,
|
||||
registry=registry,
|
||||
src_rank=_DRIVER_RANK,
|
||||
dtype=pipeline.dtype,
|
||||
)
|
||||
|
||||
# Distributed VAE decoding: balanced 2D spatial grid over the group (one tile/rank).
|
||||
# height takes the smaller factor of world_size, width the larger; size-aware split is a follow-up.
|
||||
vae_height_tiles, vae_width_tiles = balanced_tile_split(dist.get_world_size(self.groups.vae_group))
|
||||
vae_tiling = TileCountConfig(
|
||||
height=DimensionTilingConfig(num_tiles=vae_height_tiles, overlap=4),
|
||||
width=DimensionTilingConfig(num_tiles=vae_width_tiles, overlap=4),
|
||||
)
|
||||
pipeline.video_decoder._decoder_builder = DistributedDecoderBuilder( # type: ignore[assignment]
|
||||
inner=pipeline.video_decoder._decoder_builder,
|
||||
queue=vae_queue,
|
||||
vae_group=self.groups.vae_group,
|
||||
vae_tiling=vae_tiling,
|
||||
driver_rank=_DRIVER_RANK,
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
self._pipeline = pipeline
|
||||
|
||||
@torch.inference_mode()
|
||||
def __call__( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
output_path: str,
|
||||
prompt: str,
|
||||
negative_prompt: str,
|
||||
seed: int,
|
||||
height: int,
|
||||
width: int,
|
||||
num_frames: int,
|
||||
frame_rate: float,
|
||||
num_inference_steps: int,
|
||||
video_guider_params: MultiModalGuiderParams,
|
||||
audio_guider_params: MultiModalGuiderParams,
|
||||
images: list | None = None,
|
||||
) -> Iterator[str | None]:
|
||||
# The pipeline raises ValueError on invalid input (symmetric across ranks); the controller
|
||||
# catches that and turns it into a recoverable RunnerError. Anything else is fatal.
|
||||
video, audio = self._pipeline(
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
seed=seed,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
frame_rate=frame_rate,
|
||||
num_inference_steps=num_inference_steps,
|
||||
video_guider_params=video_guider_params,
|
||||
audio_guider_params=audio_guider_params,
|
||||
images=images or [],
|
||||
tiling_config=None,
|
||||
stage_2_sigmas=TDP_DISTILLED_SIGMAS,
|
||||
)
|
||||
if dist.get_rank() != _DRIVER_RANK:
|
||||
yield None # workers: nothing to encode
|
||||
return
|
||||
encode_video(
|
||||
video=video,
|
||||
fps=frame_rate,
|
||||
audio=audio,
|
||||
output_path=output_path,
|
||||
video_chunks_number=get_video_chunks_number(num_frames, TilingConfig.default()),
|
||||
)
|
||||
yield output_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from ltx_pipelines.utils.args import hq_2_stage_arg_parser
|
||||
from ltx_pipelines.utils.constants import LTX_2_3_HQ_PARAMS
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||
args = hq_2_stage_arg_parser(params=LTX_2_3_HQ_PARAMS).parse_args()
|
||||
|
||||
vae_queue = torch.multiprocessing.get_context("spawn").SimpleQueue()
|
||||
controller = MGPUController(TI2VidTwoStagesHQRunner)
|
||||
controller.start(
|
||||
checkpoint_path=args.checkpoint_path,
|
||||
gemma_root=args.gemma_root,
|
||||
spatial_upsampler_path=args.spatial_upsampler_path,
|
||||
vae_queue=vae_queue,
|
||||
distilled_lora_path=args.distilled_lora[0].path,
|
||||
distilled_lora_strength_stage_1=args.distilled_lora_strength_stage_1,
|
||||
distilled_lora_strength_stage_2=args.distilled_lora_strength_stage_2,
|
||||
compilation_config=args.compile,
|
||||
)
|
||||
try:
|
||||
for _ in controller.stream(
|
||||
output_path=args.output_path,
|
||||
prompt=args.prompt,
|
||||
negative_prompt=args.negative_prompt,
|
||||
seed=args.seed,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
frame_rate=args.frame_rate,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
video_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=args.video_cfg_guidance_scale,
|
||||
stg_scale=args.video_stg_guidance_scale,
|
||||
rescale_scale=args.video_rescale_scale,
|
||||
modality_scale=args.a2v_guidance_scale,
|
||||
skip_step=args.video_skip_step,
|
||||
stg_blocks=args.video_stg_blocks,
|
||||
),
|
||||
audio_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=args.audio_cfg_guidance_scale,
|
||||
stg_scale=args.audio_stg_guidance_scale,
|
||||
rescale_scale=args.audio_rescale_scale,
|
||||
modality_scale=args.v2a_guidance_scale,
|
||||
skip_step=args.audio_skip_step,
|
||||
stg_blocks=args.audio_stg_blocks,
|
||||
),
|
||||
images=args.images,
|
||||
):
|
||||
pass # drive the job to completion; the runner writes the file as a side effect
|
||||
finally:
|
||||
controller.shutdown()
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Multi-GPU two-stage text/image-to-video runner.
|
||||
Runs :class:`TI2VidTwoStagesPipeline` across multiple GPUs with:
|
||||
- **Stage 1** -- sequence parallelism (SP)
|
||||
- **Stage 2** -- tiled data parallelism (TDP) on height + width with overlap
|
||||
- **Gemma** -- Accelerate-based parallelization
|
||||
- **VAE** -- distributed decoding
|
||||
Requires ``ltx-kernels`` to be installed (transitive via SP builder).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Iterator
|
||||
from multiprocessing import SimpleQueue
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from ltx_core.components.guiders import MultiModalGuiderParams
|
||||
from ltx_core.loader import LTXV_LORA_COMFY_RENAMING_MAP, LoraPathStrengthAndSDOps
|
||||
from ltx_core.loader.registry import StateDictRegistry
|
||||
from ltx_core.model.transformer.compiling import CompilationConfig
|
||||
from ltx_core.model.video_vae import get_video_chunks_number
|
||||
from ltx_core.model.video_vae.tiling import TilingConfig
|
||||
from ltx_core.multigpu.transformer.attention import AttentionManager
|
||||
from ltx_core.quantization import QuantizationPolicy
|
||||
from ltx_core.quantization.fp8_cast import build_policy as _build_fp8_cast_policy
|
||||
from ltx_core.tiling import DimensionTilingConfig, TileCountConfig, balanced_tile_split
|
||||
from ltx_pipelines.multigpu.controller import MGPUController
|
||||
from ltx_pipelines.multigpu.gemma_builders import AccelerateGemmaBuilder
|
||||
from ltx_pipelines.multigpu.runner import MGPURunner
|
||||
from ltx_pipelines.multigpu.sp_builder import SequenceParallelBuilder
|
||||
from ltx_pipelines.multigpu.tdp_builder import TiledDataParallelBuilder
|
||||
from ltx_pipelines.multigpu.vae_builders import DistributedDecoderBuilder
|
||||
from ltx_pipelines.multigpu.weight_tracker import TransformerWeightTracker
|
||||
from ltx_pipelines.ti2vid_two_stages import TI2VidTwoStagesPipeline
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.constants import TDP_DISTILLED_SIGMAS
|
||||
from ltx_pipelines.utils.media_io import encode_video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stage 1 at 512x768, 121 frames = 6144 video tokens + audio tokens.
|
||||
_DEFAULT_SP_MAX_TOKENS = 32768
|
||||
# Rank that collects distributed-VAE tiles and encodes the assembled video.
|
||||
_DRIVER_RANK = 0
|
||||
|
||||
|
||||
class TI2VidTwoStagesRunner(MGPURunner):
|
||||
"""Distributed :class:`TI2VidTwoStagesPipeline`: SP stage 1 + TDP stage 2 + Gemma + distributed VAE."""
|
||||
|
||||
@torch.inference_mode()
|
||||
def setup(
|
||||
self,
|
||||
*,
|
||||
checkpoint_path: str,
|
||||
gemma_root: str,
|
||||
spatial_upsampler_path: str,
|
||||
vae_queue: SimpleQueue,
|
||||
distilled_lora_path: str,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
sp_max_tokens: int = _DEFAULT_SP_MAX_TOKENS,
|
||||
quantization: Callable[[], QuantizationPolicy] | None = None,
|
||||
) -> None:
|
||||
# quantization is a picklable zero-arg builder (built per worker, post-spawn); default fp8-cast.
|
||||
quantization_policy = quantization() if quantization is not None else _build_fp8_cast_policy(checkpoint_path)
|
||||
distilled_lora = [LoraPathStrengthAndSDOps(distilled_lora_path, 1.0, LTXV_LORA_COMFY_RENAMING_MAP)]
|
||||
registry = StateDictRegistry()
|
||||
pipeline = TI2VidTwoStagesPipeline(
|
||||
checkpoint_path=checkpoint_path,
|
||||
distilled_lora=distilled_lora,
|
||||
spatial_upsampler_path=spatial_upsampler_path,
|
||||
gemma_root=gemma_root,
|
||||
loras=[],
|
||||
registry=registry,
|
||||
quantization=quantization_policy,
|
||||
compilation_config=compilation_config,
|
||||
alloc_trim_strategy=AllocatorTrimStrategy.DEFER,
|
||||
)
|
||||
tracker = TransformerWeightTracker(group=self.groups.transformer_group)
|
||||
|
||||
# Stage 1: sequence parallelism.
|
||||
model_cfg = pipeline.stage_1._transformer_builder.model_config().get("transformer", {})
|
||||
attn_mgr = AttentionManager(
|
||||
max_tokens=sp_max_tokens,
|
||||
num_heads=model_cfg["num_attention_heads"],
|
||||
head_dim=model_cfg["attention_head_dim"],
|
||||
tensor_dtype=pipeline.dtype,
|
||||
group=self.groups.transformer_group,
|
||||
)
|
||||
pipeline.stage_1._transformer_builder = SequenceParallelBuilder(
|
||||
inner=pipeline.stage_1._transformer_builder,
|
||||
attn_mgr=attn_mgr,
|
||||
registry=registry,
|
||||
tracker=tracker,
|
||||
)
|
||||
|
||||
# Stage 2: tiled data parallelism -- balanced 2D spatial grid over the group (one tile/rank).
|
||||
# height takes the smaller factor of world_size, width the larger; size-aware split is a follow-up.
|
||||
tdp_height_tiles, tdp_width_tiles = balanced_tile_split(dist.get_world_size(self.groups.transformer_group))
|
||||
tdp_tiling = TileCountConfig(
|
||||
height=DimensionTilingConfig(num_tiles=tdp_height_tiles, overlap=5),
|
||||
width=DimensionTilingConfig(num_tiles=tdp_width_tiles, overlap=5),
|
||||
)
|
||||
pipeline.stage_2._transformer_builder = TiledDataParallelBuilder(
|
||||
inner=pipeline.stage_2._transformer_builder,
|
||||
group=self.groups.transformer_group,
|
||||
tiling=tdp_tiling,
|
||||
registry=registry,
|
||||
tracker=tracker,
|
||||
)
|
||||
|
||||
# Accelerate Gemma parallelization.
|
||||
pipeline.prompt_encoder._text_encoder_builder = AccelerateGemmaBuilder(
|
||||
gemma_root_path=gemma_root,
|
||||
gemma_group=self.groups.gemma_group,
|
||||
broadcast_group=self.groups.transformer_group,
|
||||
registry=registry,
|
||||
src_rank=_DRIVER_RANK,
|
||||
dtype=pipeline.dtype,
|
||||
)
|
||||
|
||||
# Distributed VAE decoding: balanced 2D spatial grid over the group (one tile/rank).
|
||||
# height takes the smaller factor of world_size, width the larger; size-aware split is a follow-up.
|
||||
vae_height_tiles, vae_width_tiles = balanced_tile_split(dist.get_world_size(self.groups.vae_group))
|
||||
vae_tiling = TileCountConfig(
|
||||
height=DimensionTilingConfig(num_tiles=vae_height_tiles, overlap=4),
|
||||
width=DimensionTilingConfig(num_tiles=vae_width_tiles, overlap=4),
|
||||
)
|
||||
pipeline.video_decoder._decoder_builder = DistributedDecoderBuilder(
|
||||
inner=pipeline.video_decoder._decoder_builder,
|
||||
queue=vae_queue,
|
||||
vae_group=self.groups.vae_group,
|
||||
vae_tiling=vae_tiling,
|
||||
driver_rank=_DRIVER_RANK,
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
self._pipeline = pipeline
|
||||
|
||||
@torch.inference_mode()
|
||||
def __call__( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
output_path: str,
|
||||
prompt: str,
|
||||
negative_prompt: str,
|
||||
seed: int,
|
||||
height: int,
|
||||
width: int,
|
||||
num_frames: int,
|
||||
frame_rate: float,
|
||||
num_inference_steps: int,
|
||||
video_guider_params: MultiModalGuiderParams,
|
||||
audio_guider_params: MultiModalGuiderParams,
|
||||
images: list | None = None,
|
||||
) -> Iterator[str | None]:
|
||||
# The pipeline raises ValueError on invalid input (symmetric across ranks); the controller
|
||||
# catches that and turns it into a recoverable RunnerError. Anything else is fatal.
|
||||
video, audio = self._pipeline(
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
seed=seed,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
frame_rate=frame_rate,
|
||||
num_inference_steps=num_inference_steps,
|
||||
video_guider_params=video_guider_params,
|
||||
audio_guider_params=audio_guider_params,
|
||||
images=images or [],
|
||||
tiling_config=None,
|
||||
stage_2_sigmas=TDP_DISTILLED_SIGMAS,
|
||||
)
|
||||
if dist.get_rank() != _DRIVER_RANK:
|
||||
yield None # workers: nothing to encode
|
||||
return
|
||||
encode_video(
|
||||
video=video,
|
||||
fps=frame_rate,
|
||||
audio=audio,
|
||||
output_path=output_path,
|
||||
video_chunks_number=get_video_chunks_number(num_frames, TilingConfig.default()),
|
||||
)
|
||||
yield output_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from ltx_pipelines.utils.args import (
|
||||
default_2_stage_arg_parser,
|
||||
resolve_cli_params,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||||
params = resolve_cli_params()
|
||||
args = default_2_stage_arg_parser(params=params).parse_args()
|
||||
|
||||
vae_queue = torch.multiprocessing.get_context("spawn").SimpleQueue()
|
||||
controller = MGPUController(TI2VidTwoStagesRunner)
|
||||
controller.start(
|
||||
checkpoint_path=args.checkpoint_path,
|
||||
gemma_root=args.gemma_root,
|
||||
spatial_upsampler_path=args.spatial_upsampler_path,
|
||||
vae_queue=vae_queue,
|
||||
distilled_lora_path=args.distilled_lora[0].path,
|
||||
compilation_config=args.compile,
|
||||
)
|
||||
try:
|
||||
for _ in controller.stream(
|
||||
output_path=args.output_path,
|
||||
prompt=args.prompt,
|
||||
negative_prompt=args.negative_prompt,
|
||||
seed=args.seed,
|
||||
height=args.height,
|
||||
width=args.width,
|
||||
num_frames=args.num_frames,
|
||||
frame_rate=args.frame_rate,
|
||||
num_inference_steps=args.num_inference_steps,
|
||||
video_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=args.video_cfg_guidance_scale,
|
||||
stg_scale=args.video_stg_guidance_scale,
|
||||
rescale_scale=args.video_rescale_scale,
|
||||
modality_scale=args.a2v_guidance_scale,
|
||||
skip_step=args.video_skip_step,
|
||||
stg_blocks=args.video_stg_blocks,
|
||||
),
|
||||
audio_guider_params=MultiModalGuiderParams(
|
||||
cfg_scale=args.audio_cfg_guidance_scale,
|
||||
stg_scale=args.audio_stg_guidance_scale,
|
||||
rescale_scale=args.audio_rescale_scale,
|
||||
modality_scale=args.v2a_guidance_scale,
|
||||
skip_step=args.audio_skip_step,
|
||||
stg_blocks=args.audio_stg_blocks,
|
||||
),
|
||||
images=args.images,
|
||||
):
|
||||
pass # drive the job to completion; the runner writes the file as a side effect
|
||||
finally:
|
||||
controller.shutdown()
|
||||
@@ -0,0 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AllocatorTrimStrategy(Enum):
|
||||
"""How a block releases its model's memory when its scope exits."""
|
||||
|
||||
TRIM = "trim" # sync, release storage (to meta), and empty_cache() back to the OS
|
||||
DEFER = "defer" # skip teardown; let GC reclaim it and keep the CUDA cache warm
|
||||
@@ -1,5 +1,6 @@
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
@@ -14,6 +15,7 @@ from ltx_pipelines.utils.constants import (
|
||||
LTX_2_3_HQ_PARAMS,
|
||||
LTX_2_3_PARAMS,
|
||||
PipelineParams,
|
||||
detect_params,
|
||||
)
|
||||
from ltx_pipelines.utils.quantization_factory import QuantizationKind
|
||||
from ltx_pipelines.utils.types import OffloadMode
|
||||
@@ -263,6 +265,24 @@ def detect_checkpoint_path(distilled: bool = False) -> str:
|
||||
return known.distilled_checkpoint_path if distilled else known.checkpoint_path
|
||||
|
||||
|
||||
def help_requested() -> bool:
|
||||
"""Whether ``-h``/``--help`` appears on the command line."""
|
||||
return "-h" in sys.argv or "--help" in sys.argv
|
||||
|
||||
|
||||
def resolve_cli_params(distilled: bool = False) -> PipelineParams:
|
||||
"""Return the model params a pipeline CLI uses to build its argument parser.
|
||||
Reads the model version from the checkpoint named on the command line so the
|
||||
parser's defaults match the target model.
|
||||
Args:
|
||||
distilled: Whether the pipeline takes a distilled checkpoint
|
||||
(``--distilled-checkpoint-path``) rather than a full one (``--checkpoint-path``).
|
||||
"""
|
||||
if help_requested():
|
||||
return LTX_2_3_PARAMS
|
||||
return detect_params(detect_checkpoint_path(distilled=distilled))
|
||||
|
||||
|
||||
def basic_arg_parser(
|
||||
params: PipelineParams = LTX_2_3_PARAMS,
|
||||
distilled: bool = False,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Pipeline blocks — each block owns its model lifecycle.
|
||||
Blocks build a model on each ``__call__``, use it, then free GPU memory.
|
||||
This eliminates manual ``del model; cleanup_memory()`` in pipelines and
|
||||
removes the need for :class:`ModelLedger`.
|
||||
This eliminates manual ``del model; cleanup_memory()`` in pipelines: each
|
||||
block is self-contained, so no central model-coordinator object is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -77,6 +77,7 @@ from ltx_core.text_encoders.gemma.embeddings_processor import EmbeddingsProcesso
|
||||
from ltx_core.tools import AudioLatentTools, LatentTools, VideoLatentTools
|
||||
from ltx_core.types import Audio, AudioLatentShape, LatentState, VideoLatentShape, VideoPixelShape
|
||||
from ltx_core.utils import find_matching_file
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.gpu_model import gpu_model
|
||||
from ltx_pipelines.utils.helpers import (
|
||||
cleanup_memory,
|
||||
@@ -136,23 +137,25 @@ def _apply_compile_ops(
|
||||
@contextmanager
|
||||
def _streaming_model(
|
||||
builder: StreamingModelBuilder,
|
||||
offload_mode: OffloadMode,
|
||||
target_device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> Iterator:
|
||||
"""Build a streaming wrapper, yield it, then tear down and free memory."""
|
||||
cpu_slots_count = DISK_CPU_SLOTS if offload_mode == OffloadMode.DISK else None
|
||||
wrapped = builder.build(
|
||||
device=target_device,
|
||||
dtype=dtype,
|
||||
cpu_slots_count=cpu_slots_count,
|
||||
)
|
||||
"""Build a streaming wrapper, yield it, then tear down and free memory.
|
||||
The builder's own ``cpu_slots_count`` selects RAM vs disk streaming.
|
||||
``teardown()`` always runs -- it releases non-memory resources (forward
|
||||
hooks, the disk I/O worker thread, open file handles) that GC would not
|
||||
reclaim promptly. ``alloc_trim_strategy=DEFER`` only skips the eager allocator
|
||||
reclaim (``to("meta")`` + ``cleanup_memory()``), leaving param storage for GC.
|
||||
"""
|
||||
wrapped = builder.build(device=target_device, dtype=dtype)
|
||||
try:
|
||||
yield wrapped
|
||||
finally:
|
||||
wrapped.teardown()
|
||||
wrapped.to("meta")
|
||||
cleanup_memory()
|
||||
if alloc_trim_strategy == AllocatorTrimStrategy.TRIM:
|
||||
wrapped.to("meta")
|
||||
cleanup_memory()
|
||||
|
||||
|
||||
def _build_state(
|
||||
@@ -177,9 +180,13 @@ def _build_state(
|
||||
return state
|
||||
|
||||
|
||||
def _cleanup_iter(it: Iterator[torch.Tensor], model: torch.nn.Module) -> Iterator[torch.Tensor]:
|
||||
"""Wrap an iterator to clean up *model* memory once it is exhausted or abandoned."""
|
||||
with gpu_model(model):
|
||||
def _cleanup_iter(
|
||||
it: Iterator[torch.Tensor],
|
||||
model: torch.nn.Module,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> Iterator[torch.Tensor]:
|
||||
"""Wrap an iterator to release *model* memory (per ``alloc_trim_strategy``) once exhausted or abandoned."""
|
||||
with gpu_model(model, alloc_trim_strategy=alloc_trim_strategy):
|
||||
yield from it
|
||||
|
||||
|
||||
@@ -190,12 +197,41 @@ def _cleanup_iter(it: Iterator[torch.Tensor], model: torch.nn.Module) -> Iterato
|
||||
|
||||
class DiffusionStage:
|
||||
"""Owns transformer lifecycle. Builds on each call, frees on exit.
|
||||
Replaces the manual ``model_ledger.transformer()`` / ``del transformer``
|
||||
pattern in every pipeline.
|
||||
Replaces the manual build-transformer / ``del transformer`` pattern that
|
||||
every pipeline previously repeated.
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
def __init__(
|
||||
self,
|
||||
transformer_builder: ModelBuilderProtocol[LTXModelProtocol],
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
*,
|
||||
quantization: QuantizationPolicy | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> None:
|
||||
"""Construct a stage from a single pre-built transformer ``builder``.
|
||||
Holds only that builder plus build-time configuration (dtype, device,
|
||||
quantization, compilation). Turning a checkpoint path + LoRA set into a
|
||||
builder -- and choosing a :class:`StreamingModelBuilder` when offloading --
|
||||
lives in :meth:`from_checkpoint`, which is how pipelines normally create a
|
||||
stage. A :class:`StreamingModelBuilder` selects the block-streaming build
|
||||
path; any other builder uses the standard (all-on-GPU) path.
|
||||
``quantization`` and ``compilation_config`` are applied lazily on the
|
||||
standard path; on the streaming path they are already baked into the
|
||||
streaming builder by :meth:`from_checkpoint` and these fields are unused.
|
||||
"""
|
||||
self._transformer_builder = transformer_builder
|
||||
self._dtype = dtype
|
||||
self._device = device
|
||||
self._quantization = quantization
|
||||
self._compilation_config = compilation_config
|
||||
self._alloc_trim_strategy = alloc_trim_strategy
|
||||
|
||||
@classmethod
|
||||
def from_checkpoint( # noqa: PLR0913
|
||||
cls,
|
||||
checkpoint_path: str,
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
@@ -203,17 +239,24 @@ class DiffusionStage:
|
||||
quantization: QuantizationPolicy | None = None,
|
||||
registry: Registry | None = None,
|
||||
compilation_config: CompilationConfig | None = None,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
transformer_builder: ModelBuilderProtocol[LTXModelProtocol] | None = None,
|
||||
model_configurator: type[ModelConfigurator] = LTXModelConfigurator,
|
||||
model_sd_ops: SDOps = LTXV_MODEL_COMFY_RENAMING_MAP,
|
||||
) -> None:
|
||||
self._checkpoint_path = checkpoint_path
|
||||
self._dtype = dtype
|
||||
self._device = device
|
||||
self._quantization = quantization
|
||||
self._compilation_config = compilation_config
|
||||
self._offload_mode = offload_mode
|
||||
) -> "DiffusionStage":
|
||||
"""Build a stage from a checkpoint path and LoRA set.
|
||||
Constructs a single transformer builder from ``checkpoint_path`` +
|
||||
``loras`` + ``quantization`` and delegates to ``__init__``. When
|
||||
``offload_mode != OffloadMode.NONE`` that builder is a
|
||||
:class:`StreamingModelBuilder` (with quantization/compilation baked in and
|
||||
its ``cpu_slots_count`` set for the requested mode); otherwise it is the
|
||||
standard single-GPU builder. This is the high-level entry point used by
|
||||
pipelines; ``__init__`` itself takes an already-built builder.
|
||||
``model_configurator`` / ``model_sd_ops`` let callers (e.g. the audio-only
|
||||
T2A pipeline) override the model class configurator and the state-dict key
|
||||
mapping. A quantization policy that pins its own configurator takes
|
||||
precedence over ``model_configurator``.
|
||||
"""
|
||||
# A quantization policy may pin its own configurator; otherwise use the one
|
||||
# provided by the caller (defaults to the audio-video LTXModelConfigurator).
|
||||
configurator = (
|
||||
@@ -221,51 +264,75 @@ class DiffusionStage:
|
||||
if quantization is not None and quantization.model_configurator is not None
|
||||
else model_configurator
|
||||
)
|
||||
if transformer_builder is not None:
|
||||
self._transformer_builder = transformer_builder
|
||||
else:
|
||||
self._transformer_builder = Builder(
|
||||
|
||||
transformer_builder: ModelBuilderProtocol[LTXModelProtocol]
|
||||
if offload_mode == OffloadMode.NONE:
|
||||
transformer_builder = Builder(
|
||||
model_path=checkpoint_path,
|
||||
model_class_configurator=configurator,
|
||||
model_sd_ops=model_sd_ops,
|
||||
loras=tuple(loras),
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
|
||||
if offload_mode != OffloadMode.NONE:
|
||||
# WeightsProvider currently only supports plain bf16 + fp8_cast LoRA fusion
|
||||
# (no companion-key emission). Quantization policies that emit
|
||||
# companion keys (e.g. ``.weight_scale``) cannot be streamed yet.
|
||||
if quantization is not None and quantization.fuse_rule is not fp8_cast_fuse_rule:
|
||||
raise ValueError(
|
||||
"Block streaming is not supported with this quantization policy "
|
||||
"(only bf16 and fp8_cast are currently supported)."
|
||||
)
|
||||
streaming_sd_ops: SDOps = model_sd_ops
|
||||
streaming_module_ops: tuple[ModuleOps, ...] = ()
|
||||
streaming_loras = tuple(loras)
|
||||
|
||||
if compilation_config:
|
||||
number_of_layers = self._transformer_builder.model_config()["transformer"]["num_layers"]
|
||||
streaming_sd_ops, streaming_module_ops, streaming_loras = _apply_compile_ops(
|
||||
streaming_sd_ops, streaming_module_ops, streaming_loras, number_of_layers
|
||||
)
|
||||
if quantization is not None:
|
||||
streaming_sd_ops, streaming_module_ops = _chain_quantization(
|
||||
streaming_sd_ops, streaming_module_ops, quantization
|
||||
)
|
||||
self._streaming_builder = StreamingModelBuilder(
|
||||
model_class_configurator=configurator,
|
||||
model_path=checkpoint_path,
|
||||
model_sd_ops=streaming_sd_ops,
|
||||
module_ops=streaming_module_ops,
|
||||
loras=streaming_loras,
|
||||
else:
|
||||
transformer_builder = cls._build_streaming_builder(
|
||||
checkpoint_path=checkpoint_path,
|
||||
configurator=configurator,
|
||||
model_sd_ops=model_sd_ops,
|
||||
loras=tuple(loras),
|
||||
quantization=quantization,
|
||||
registry=registry or DummyRegistry(),
|
||||
fuse_rule=quantization.fuse_rule if quantization is not None else bf16_fuse_rule,
|
||||
blocks_attr="transformer_blocks",
|
||||
blocks_prefix="transformer_blocks",
|
||||
offload_mode=offload_mode,
|
||||
)
|
||||
|
||||
return cls(
|
||||
transformer_builder,
|
||||
dtype,
|
||||
device,
|
||||
quantization=quantization,
|
||||
compilation_config=compilation_config,
|
||||
alloc_trim_strategy=alloc_trim_strategy,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_streaming_builder(
|
||||
*,
|
||||
checkpoint_path: str,
|
||||
configurator: type[ModelConfigurator],
|
||||
model_sd_ops: SDOps,
|
||||
loras: tuple[LoraPathStrengthAndSDOps, ...],
|
||||
quantization: QuantizationPolicy | None,
|
||||
registry: Registry,
|
||||
offload_mode: OffloadMode,
|
||||
) -> StreamingModelBuilder:
|
||||
"""Construct the streaming transformer builder for an offloading stage.
|
||||
Holds only raw config (``model_sd_ops`` / ``loras``); compilation and
|
||||
quantization are applied at build time by :meth:`_prepared_builder`, exactly
|
||||
as on the standard path -- so the builder's LoRA set stays raw and
|
||||
:meth:`with_loras` swaps it consistently. ``cpu_slots_count`` is pinned for
|
||||
the requested ``offload_mode`` (disk streaming uses a small slot count;
|
||||
CPU/RAM streaming pins every block).
|
||||
"""
|
||||
# WeightsProvider currently only supports plain bf16 + fp8_cast LoRA fusion
|
||||
# (no companion-key emission). Quantization policies that emit
|
||||
# companion keys (e.g. ``.weight_scale``) cannot be streamed yet.
|
||||
if quantization is not None and quantization.fuse_rule is not fp8_cast_fuse_rule:
|
||||
raise ValueError(
|
||||
"Block streaming is not supported with this quantization policy "
|
||||
"(only bf16 and fp8_cast are currently supported)."
|
||||
)
|
||||
return StreamingModelBuilder(
|
||||
model_class_configurator=configurator,
|
||||
model_path=checkpoint_path,
|
||||
model_sd_ops=model_sd_ops,
|
||||
loras=loras,
|
||||
registry=registry,
|
||||
fuse_rule=quantization.fuse_rule if quantization is not None else bf16_fuse_rule,
|
||||
blocks_attr="transformer_blocks",
|
||||
blocks_prefix="transformer_blocks",
|
||||
cpu_slots_count=DISK_CPU_SLOTS if offload_mode == OffloadMode.DISK else None,
|
||||
)
|
||||
|
||||
def with_attention(self, attention: AttentionFunction | AttentionCallable | None) -> "DiffusionStage":
|
||||
"""Return a new ``DiffusionStage`` that pins the transformer build to ``attention``.
|
||||
Functional: never mutates ``self``. The returned stage shares all other
|
||||
@@ -280,41 +347,64 @@ class DiffusionStage:
|
||||
new._transformer_builder = self._transformer_builder.with_module_ops(
|
||||
(*self._transformer_builder.module_ops, op),
|
||||
)
|
||||
if self._offload_mode != OffloadMode.NONE:
|
||||
new._streaming_builder = self._streaming_builder.with_module_ops(
|
||||
(*self._streaming_builder.module_ops, op),
|
||||
)
|
||||
return new
|
||||
|
||||
def _build_transformer(self, *, device: torch.device | None = None, **kwargs: object) -> X0Model:
|
||||
target = device or self._device
|
||||
sd_ops = self._transformer_builder.model_sd_ops
|
||||
module_ops = self._transformer_builder.module_ops
|
||||
loras = self._transformer_builder.loras
|
||||
def with_builder(self, builder: ModelBuilderProtocol[LTXModelProtocol]) -> "DiffusionStage":
|
||||
"""Return a new ``DiffusionStage`` that builds its transformer from ``builder``.
|
||||
Functional: never mutates ``self``; shares all other configuration (dtype, device,
|
||||
quantization, compilation). Affects the standard (non-offload) build path.
|
||||
"""
|
||||
new = copy.copy(self)
|
||||
new._transformer_builder = builder
|
||||
return new
|
||||
|
||||
def with_loras(self, loras: tuple[LoraPathStrengthAndSDOps, ...]) -> "DiffusionStage":
|
||||
"""Return a new ``DiffusionStage`` built with exactly ``loras`` (replacing the current set)."""
|
||||
return self.with_builder(self._transformer_builder.with_loras(loras))
|
||||
|
||||
def _prepared_builder(self) -> ModelBuilderProtocol[LTXModelProtocol]:
|
||||
"""Return the configured builder with the stage's build-time ops applied.
|
||||
Compilation and quantization live on the stage (not on the builder) and are
|
||||
applied here, lazily, for both the standard and streaming paths. This keeps
|
||||
the builder holding only raw sd_ops/module_ops/LoRAs, so ``with_loras`` /
|
||||
``with_builder`` swap them consistently regardless of the build path. The
|
||||
returned copy preserves the builder's concrete type (e.g. a
|
||||
``StreamingModelBuilder`` stays one).
|
||||
"""
|
||||
builder = self._transformer_builder
|
||||
sd_ops = builder.model_sd_ops
|
||||
module_ops = builder.module_ops
|
||||
loras = builder.loras
|
||||
if self._compilation_config is not None:
|
||||
number_of_layers = self._transformer_builder.model_config()["transformer"]["num_layers"]
|
||||
number_of_layers = builder.model_config()["transformer"]["num_layers"]
|
||||
sd_ops, module_ops, loras = _apply_compile_ops(
|
||||
sd_ops, module_ops, loras, number_of_layers, self._compilation_config
|
||||
)
|
||||
if self._quantization is not None:
|
||||
sd_ops, module_ops = _chain_quantization(sd_ops, module_ops, self._quantization)
|
||||
|
||||
builder = self._transformer_builder.with_module_ops(module_ops).with_sd_ops(sd_ops).with_loras(loras)
|
||||
if self._quantization is not None:
|
||||
builder = builder.with_fuse_rule(self._quantization.fuse_rule)
|
||||
return X0Model(builder.build(device=target, **kwargs)).to(target).eval()
|
||||
return builder.with_module_ops(module_ops).with_sd_ops(sd_ops).with_loras(loras)
|
||||
|
||||
def _build_transformer(self, *, device: torch.device | None = None, **kwargs: object) -> X0Model:
|
||||
target = device or self._device
|
||||
return X0Model(self._prepared_builder().build(device=target, **kwargs)).to(target).eval()
|
||||
|
||||
@property
|
||||
def _is_streaming(self) -> bool:
|
||||
"""Whether the configured builder uses the block-streaming build path."""
|
||||
return isinstance(self._transformer_builder, StreamingModelBuilder)
|
||||
|
||||
@contextmanager
|
||||
def _streaming_transformer_ctx(self) -> Iterator[X0Model]:
|
||||
with _streaming_model(
|
||||
self._streaming_builder, self._offload_mode, self._device, self._dtype
|
||||
) as streaming_wrapper:
|
||||
builder = self._prepared_builder()
|
||||
assert isinstance(builder, StreamingModelBuilder)
|
||||
with _streaming_model(builder, self._device, self._dtype, self._alloc_trim_strategy) as streaming_wrapper:
|
||||
yield X0Model(streaming_wrapper).eval()
|
||||
|
||||
def _transformer_ctx(self, **kwargs: object) -> AbstractContextManager:
|
||||
if self._offload_mode != OffloadMode.NONE:
|
||||
if self._is_streaming:
|
||||
return self._streaming_transformer_ctx()
|
||||
return gpu_model(self._build_transformer(**kwargs))
|
||||
return gpu_model(self._build_transformer(**kwargs), alloc_trim_strategy=self._alloc_trim_strategy)
|
||||
|
||||
def model_context(self, **kwargs: object) -> AbstractContextManager:
|
||||
"""Build the transformer, yield it, then free its memory on exit.
|
||||
@@ -419,8 +509,8 @@ class DiffusionStage:
|
||||
v_shape = VideoLatentShape.from_pixel_shape(pixel_shape)
|
||||
video_tools = VideoLatentTools(VideoLatentPatchifier(patch_size=1), v_shape, fps)
|
||||
|
||||
mode = "streaming" if self._offload_mode != OffloadMode.NONE else "standard"
|
||||
logger.info("Building transformer (%s) from %s", mode, self._checkpoint_path)
|
||||
mode = "streaming" if self._is_streaming else "standard"
|
||||
logger.info("Building transformer (%s) from %s", mode, self._transformer_builder.checkpoint)
|
||||
with self._transformer_ctx(video_tools=video_tools) as transformer:
|
||||
logger.info(
|
||||
"Running denoising loop (%d steps, %dx%d %d frames @ %.1f fps)",
|
||||
@@ -467,12 +557,14 @@ class PromptEncoder:
|
||||
registry: Registry | None = None,
|
||||
offload_mode: OffloadMode = OffloadMode.NONE,
|
||||
text_encoder_builder: BuilderProtocol | None = None,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> None:
|
||||
self._gemma_root = gemma_root
|
||||
self._checkpoint_path = checkpoint_path
|
||||
self._dtype = dtype
|
||||
self._device = device
|
||||
self._offload_mode = offload_mode
|
||||
self._alloc_trim_strategy = alloc_trim_strategy
|
||||
|
||||
if text_encoder_builder is not None:
|
||||
if offload_mode != OffloadMode.NONE:
|
||||
@@ -501,6 +593,7 @@ class PromptEncoder:
|
||||
registry=registry or DummyRegistry(),
|
||||
blocks_attr="model.model.language_model.layers",
|
||||
blocks_prefix="model.model.language_model.layers",
|
||||
cpu_slots_count=DISK_CPU_SLOTS if offload_mode == OffloadMode.DISK else None,
|
||||
)
|
||||
self._embeddings_processor_builder = Builder(
|
||||
model_path=checkpoint_path,
|
||||
@@ -519,8 +612,10 @@ class PromptEncoder:
|
||||
|
||||
def _text_encoder_ctx(self) -> AbstractContextManager:
|
||||
if self._offload_mode != OffloadMode.NONE:
|
||||
return _streaming_model(self._streaming_text_encoder_builder, self._offload_mode, self._device, self._dtype)
|
||||
return gpu_model(self._build_text_encoder())
|
||||
return _streaming_model(
|
||||
self._streaming_text_encoder_builder, self._device, self._dtype, self._alloc_trim_strategy
|
||||
)
|
||||
return gpu_model(self._build_text_encoder(), alloc_trim_strategy=self._alloc_trim_strategy)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
@@ -541,7 +636,9 @@ class PromptEncoder:
|
||||
raw_outputs = text_encoder.encode(prompts)
|
||||
logger.info("Text encoder done, building embeddings processor from %s", self._checkpoint_path)
|
||||
|
||||
with gpu_model(self._build_embeddings_processor()) as embeddings_processor:
|
||||
with gpu_model(
|
||||
self._build_embeddings_processor(), alloc_trim_strategy=self._alloc_trim_strategy
|
||||
) as embeddings_processor:
|
||||
result = [embeddings_processor.process_hidden_states(hs, mask) for hs, mask in raw_outputs]
|
||||
logger.info("Prompt encoding complete")
|
||||
return result
|
||||
@@ -563,6 +660,7 @@ class ImageConditioner:
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
registry: Registry | None = None,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> None:
|
||||
self._dtype = dtype
|
||||
self._device = device
|
||||
@@ -572,13 +670,14 @@ class ImageConditioner:
|
||||
model_sd_ops=VAE_ENCODER_COMFY_KEYS_FILTER,
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
self._alloc_trim_strategy = alloc_trim_strategy
|
||||
|
||||
def _build_encoder(self) -> VideoEncoder:
|
||||
return self._encoder_builder.build(device=self._device, dtype=self._dtype).eval()
|
||||
|
||||
def __call__(self, fn: Callable[[VideoEncoder], T]) -> T:
|
||||
"""Build video encoder → call *fn(encoder)* → free encoder."""
|
||||
with gpu_model(self._build_encoder()) as encoder:
|
||||
with gpu_model(self._build_encoder(), alloc_trim_strategy=self._alloc_trim_strategy) as encoder:
|
||||
return fn(encoder)
|
||||
|
||||
|
||||
@@ -597,6 +696,7 @@ class VideoUpsampler:
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
registry: Registry | None = None,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> None:
|
||||
self._upsampler_path = upsampler_path
|
||||
self._dtype = dtype
|
||||
@@ -612,13 +712,20 @@ class VideoUpsampler:
|
||||
model_class_configurator=LatentUpsamplerConfigurator,
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
self._alloc_trim_strategy = alloc_trim_strategy
|
||||
|
||||
def __call__(self, latent: torch.Tensor) -> torch.Tensor:
|
||||
"""Upsample *latent* using video encoder + spatial upsampler, then free both."""
|
||||
logger.info("Building video encoder + spatial upsampler from %s", self._upsampler_path)
|
||||
with (
|
||||
gpu_model(self._encoder_builder.build(device=self._device, dtype=self._dtype).eval()) as encoder,
|
||||
gpu_model(self._upsampler_builder.build(device=self._device, dtype=self._dtype).eval()) as upsampler,
|
||||
gpu_model(
|
||||
self._encoder_builder.build(device=self._device, dtype=self._dtype).eval(),
|
||||
alloc_trim_strategy=self._alloc_trim_strategy,
|
||||
) as encoder,
|
||||
gpu_model(
|
||||
self._upsampler_builder.build(device=self._device, dtype=self._dtype).eval(),
|
||||
alloc_trim_strategy=self._alloc_trim_strategy,
|
||||
) as upsampler,
|
||||
):
|
||||
return upsample_video(latent=latent, video_encoder=encoder, upsampler=upsampler)
|
||||
|
||||
@@ -641,6 +748,7 @@ class VideoDecoder:
|
||||
registry: Registry | None = None,
|
||||
memory_efficient: bool = True,
|
||||
decoder_builder: BuilderProtocol | None = None,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> None:
|
||||
self._checkpoint_path = checkpoint_path
|
||||
self._dtype = dtype
|
||||
@@ -655,6 +763,7 @@ class VideoDecoder:
|
||||
registry=registry or DummyRegistry(),
|
||||
module_ops=(MEMORY_EFFICIENT_DECODE,) if memory_efficient else (),
|
||||
)
|
||||
self._alloc_trim_strategy = alloc_trim_strategy
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
@@ -665,7 +774,11 @@ class VideoDecoder:
|
||||
"""Decode *latent* to pixel-space video chunks. Decoder freed after exhaustion."""
|
||||
logger.info("Building video decoder from %s", self._checkpoint_path)
|
||||
decoder = self._decoder_builder.build(device=self._device, dtype=self._dtype).eval()
|
||||
return _cleanup_iter(decoder.decode_video(latent, tiling_config, generator), decoder)
|
||||
return _cleanup_iter(
|
||||
decoder.decode_video(latent, tiling_config, generator),
|
||||
decoder,
|
||||
alloc_trim_strategy=self._alloc_trim_strategy,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -682,6 +795,7 @@ class AudioDecoder:
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
registry: Registry | None = None,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> None:
|
||||
self._checkpoint_path = checkpoint_path
|
||||
self._dtype = dtype
|
||||
@@ -698,13 +812,25 @@ class AudioDecoder:
|
||||
model_sd_ops=VOCODER_COMFY_KEYS_FILTER,
|
||||
registry=registry or DummyRegistry(),
|
||||
)
|
||||
self._alloc_trim_strategy = alloc_trim_strategy
|
||||
|
||||
def __call__(self, latent: torch.Tensor) -> Audio:
|
||||
"""Decode audio *latent* through VAE decoder + vocoder, then free both."""
|
||||
logger.info("Building audio decoder + vocoder from %s", self._checkpoint_path)
|
||||
# The vocoder always runs in fp32 (bf16 accumulation degrades spectral
|
||||
# metrics). On CUDA/CPU it is stored in bf16 and autocast upcasts per-op to
|
||||
# save memory; MPS has no fp32 autocast, so store it in fp32 directly and
|
||||
# avoid the per-call cast. Negligible footprint for this small model.
|
||||
vocoder_dtype = torch.float32 if self._device.type == "mps" else self._dtype
|
||||
with (
|
||||
gpu_model(self._decoder_builder.build(device=self._device, dtype=self._dtype).eval()) as decoder,
|
||||
gpu_model(self._vocoder_builder.build(device=self._device, dtype=self._dtype).eval()) as vocoder,
|
||||
gpu_model(
|
||||
self._decoder_builder.build(device=self._device, dtype=self._dtype).eval(),
|
||||
alloc_trim_strategy=self._alloc_trim_strategy,
|
||||
) as decoder,
|
||||
gpu_model(
|
||||
self._vocoder_builder.build(device=self._device, dtype=vocoder_dtype).eval(),
|
||||
alloc_trim_strategy=self._alloc_trim_strategy,
|
||||
) as vocoder,
|
||||
):
|
||||
return vae_decode_audio(latent, decoder, vocoder)
|
||||
|
||||
@@ -726,9 +852,11 @@ class AudioConditioner:
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
registry: Registry | None = None,
|
||||
alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM,
|
||||
) -> None:
|
||||
self._dtype = dtype
|
||||
self._device = device
|
||||
self._alloc_trim_strategy = alloc_trim_strategy
|
||||
self._encoder_builder = Builder(
|
||||
model_path=checkpoint_path,
|
||||
model_class_configurator=AudioEncoderConfigurator,
|
||||
@@ -738,5 +866,8 @@ class AudioConditioner:
|
||||
|
||||
def __call__(self, fn: Callable[[torch.nn.Module], T]) -> T:
|
||||
"""Build audio encoder → call *fn(encoder)* → free encoder."""
|
||||
with gpu_model(self._encoder_builder.build(device=self._device, dtype=self._dtype).eval()) as encoder:
|
||||
with gpu_model(
|
||||
self._encoder_builder.build(device=self._device, dtype=self._dtype).eval(),
|
||||
alloc_trim_strategy=self._alloc_trim_strategy,
|
||||
) as encoder:
|
||||
return fn(encoder)
|
||||
|
||||
@@ -20,6 +20,8 @@ STAGE_2_DISTILLED_SIGMA_VALUES = [0.909375, 0.725, 0.421875, 0.0]
|
||||
|
||||
DISTILLED_SIGMAS = torch.tensor(DISTILLED_SIGMA_VALUES)
|
||||
STAGE_2_DISTILLED_SIGMAS = torch.tensor(STAGE_2_DISTILLED_SIGMA_VALUES)
|
||||
# Stage 2 schedule for the tiled-data-parallel multi-GPU runner.
|
||||
TDP_DISTILLED_SIGMAS = torch.tensor([0.625, 0.4, 0.0])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -164,17 +164,24 @@ def _guided_denoise( # noqa: PLR0913,PLR0915
|
||||
enabled=not a_skip,
|
||||
)
|
||||
|
||||
# Replicate each pass's PerturbationConfig to all `orig_b` samples it
|
||||
# carries, so `BatchedPerturbationConfig.mask_like` returns a per-sample
|
||||
# mask (length n*orig_b) instead of a per-pass mask (length n). Without
|
||||
# this expansion the mask is broadcast against a (n*orig_b, T, D) tensor
|
||||
# and the multiplication fails with a batch-dim mismatch whenever
|
||||
# `orig_b > 1` (e.g. multi-prompt benchmark panels).
|
||||
# Replicate each pass's PerturbationConfig to all `orig_b` samples it carries, so the keep-mask
|
||||
# has one row per sample (length n*orig_b) instead of per-pass (length n). Without this
|
||||
# expansion the mask broadcasts against a (n*orig_b, T, D) tensor and the multiplication fails
|
||||
# with a batch-dim mismatch whenever `orig_b > 1` (e.g. multi-prompt benchmark panels).
|
||||
batched_ptb_configs = [ptb for ptb in ptb_configs for _ in range(orig_b)]
|
||||
|
||||
all_v, all_a = transformer(
|
||||
video=batched_video, audio=batched_audio, perturbations=BatchedPerturbationConfig(batched_ptb_configs)
|
||||
# Build the config with num_blocks/device/dtype so it precomputes its per-block mask tensor
|
||||
# on init (transformer.num_blocks delegates through the SP/TDP/BatchSplit/X0 wrappers). The
|
||||
# compiled forward then reads perturbation as a runtime tensor, not by querying the config
|
||||
# in-graph -- so it doesn't recompile per perturbation config.
|
||||
ref_modality = batched_video if batched_video is not None else batched_audio
|
||||
perturbations = BatchedPerturbationConfig(
|
||||
batched_ptb_configs,
|
||||
num_blocks=transformer.num_blocks,
|
||||
device=ref_modality.latent.device,
|
||||
dtype=ref_modality.latent.dtype,
|
||||
)
|
||||
all_v, all_a = transformer(video=batched_video, audio=batched_audio, perturbations=perturbations)
|
||||
|
||||
# Split results back and combine via guiders.
|
||||
splits_v = list(all_v.chunk(n)) if all_v is not None else [0.0] * n
|
||||
|
||||
@@ -4,27 +4,32 @@ from typing import TypeVar
|
||||
|
||||
import torch
|
||||
|
||||
from ltx_core.devices import synchronize_device
|
||||
from ltx_pipelines.utils.allocator_trim_strategy import AllocatorTrimStrategy
|
||||
from ltx_pipelines.utils.helpers import cleanup_memory
|
||||
|
||||
_M = TypeVar("_M", bound=torch.nn.Module)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def gpu_model(model: _M) -> Iterator[_M]:
|
||||
def gpu_model(model: _M, alloc_trim_strategy: AllocatorTrimStrategy = AllocatorTrimStrategy.TRIM) -> Iterator[_M]:
|
||||
"""Context manager that yields a model and releases its memory on exit.
|
||||
Moves all parameters and buffers to ``meta`` device on exit, which
|
||||
immediately releases the underlying storage on **both** GPU and CPU,
|
||||
then runs ``cleanup_memory()`` to reclaim fragmented CUDA memory.
|
||||
On ``TRIM`` (default): synchronize, move parameters/buffers to the ``meta``
|
||||
device (releasing GPU+CPU storage), then ``cleanup_memory()`` to return
|
||||
cached blocks to the OS. ``DEFER`` skips this -- the model's storage is
|
||||
reclaimed by normal GC and the CUDA caching allocator stays warm for the
|
||||
next build (cheaper for back-to-back runs).
|
||||
Usage::
|
||||
with gpu_model(build_encoder()) as encoder:
|
||||
... # use encoder — typed as the concrete class
|
||||
... # use encoder -- typed as the concrete class
|
||||
# GPU + CPU memory freed automatically
|
||||
"""
|
||||
try:
|
||||
yield model
|
||||
finally:
|
||||
torch.cuda.synchronize()
|
||||
# .to("meta") releases storage for all parameters/buffers regardless
|
||||
# of their original device (CUDA or CPU).
|
||||
model.to("meta")
|
||||
cleanup_memory()
|
||||
if alloc_trim_strategy == AllocatorTrimStrategy.TRIM:
|
||||
synchronize_device()
|
||||
# .to("meta") releases storage for all parameters/buffers regardless
|
||||
# of their original device (CUDA or CPU).
|
||||
model.to("meta")
|
||||
cleanup_memory()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import gc
|
||||
import logging
|
||||
|
||||
import torch
|
||||
@@ -9,6 +8,7 @@ from ltx_core.conditioning import (
|
||||
VideoConditionByKeyframeIndex,
|
||||
VideoConditionByLatentIndex,
|
||||
)
|
||||
from ltx_core.devices import cleanup_accelerator_memory, get_preferred_device
|
||||
from ltx_core.model.audio_vae import encode_audio
|
||||
from ltx_core.model.transformer import Modality
|
||||
from ltx_core.model.video_vae import TilingConfig, VideoEncoder
|
||||
@@ -28,20 +28,11 @@ from ltx_pipelines.utils.media_io import (
|
||||
|
||||
|
||||
def get_device() -> torch.device:
|
||||
if torch.cuda.is_available():
|
||||
return torch.device("cuda", torch.cuda.current_device())
|
||||
return torch.device("cpu")
|
||||
return get_preferred_device()
|
||||
|
||||
|
||||
def cleanup_memory() -> None:
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.synchronize()
|
||||
try:
|
||||
if hasattr(torch._C, "_host_emptyCache"):
|
||||
torch._C._host_emptyCache()
|
||||
except Exception:
|
||||
logging.warning("Host empty cache cleanup failed; ignoring.", exc_info=True)
|
||||
cleanup_accelerator_memory()
|
||||
|
||||
|
||||
def _conform_latent_length(latent: torch.Tensor, expected_frames_count: int) -> torch.Tensor:
|
||||
|
||||
@@ -258,16 +258,22 @@ def decode_image(image_path: str) -> np.ndarray:
|
||||
return np_array
|
||||
|
||||
|
||||
def _write_audio(container: av.container.Container, audio_stream: av.audio.AudioStream, audio: Audio) -> None:
|
||||
def _validate_audio_waveform(audio: Audio) -> None:
|
||||
"""Raise ValueError if the waveform is empty or not stereo ``(2, N)`` / ``(N, 2)``."""
|
||||
samples = audio.waveform
|
||||
if samples.ndim == 1:
|
||||
samples = samples[:, None]
|
||||
if samples.numel() == 0:
|
||||
raise ValueError("audio.waveform is empty; pass audio=None for no audio.")
|
||||
if samples.ndim != 2 or 2 not in samples.shape:
|
||||
raise ValueError(f"audio.waveform must be stereo (2, N) or (N, 2); got shape {tuple(samples.shape)}.")
|
||||
|
||||
if samples.shape[1] != 2 and samples.shape[0] == 2:
|
||||
samples = samples.T
|
||||
|
||||
if samples.shape[1] != 2:
|
||||
raise ValueError(f"Expected samples with 2 channels; got shape {samples.shape}.")
|
||||
def _normalize_audio_waveform(samples: torch.Tensor) -> torch.Tensor:
|
||||
"""Transpose a validated stereo waveform to channel-last ``(N, 2)``."""
|
||||
return samples.T if samples.shape[1] != 2 else samples
|
||||
|
||||
|
||||
def _write_audio(container: av.container.Container, audio_stream: av.audio.AudioStream, audio: Audio) -> None:
|
||||
samples = _normalize_audio_waveform(audio.waveform)
|
||||
|
||||
# Convert to int16 packed for ingestion; resampler converts to encoder fmt.
|
||||
if samples.dtype != torch.int16:
|
||||
@@ -335,13 +341,35 @@ def encode_video(
|
||||
preset: str = "veryfast",
|
||||
thread_count: int = 0,
|
||||
) -> None:
|
||||
"""Encode RGB frames to an H.264 file, optionally muxing an audio track.
|
||||
Args:
|
||||
video: RGB frames as a ``(F, H, W, C)`` float ``[0, 1]`` tensor, or an iterator of
|
||||
such per-chunk tensors (e.g. the VAE decoder output). An empty iterator raises.
|
||||
fps: Output frame rate.
|
||||
audio: Audio track to mux, or None for a video-only file. Waveform must be stereo
|
||||
``(2, N)`` or ``(N, 2)``.
|
||||
output_path: Destination path. Partial output is removed if encoding fails.
|
||||
video_chunks_number: Number of chunks yielded by ``video``, for the progress bar.
|
||||
frame_converter: Float-to-pixel converter (default YUV420p BT.709).
|
||||
crf: libx264 constant rate factor; lower is higher quality (0-51).
|
||||
preset: libx264 speed/compression preset.
|
||||
thread_count: libx264 thread count (0 = auto).
|
||||
Raises:
|
||||
ValueError: On an empty ``video`` or a non-stereo ``audio.waveform``.
|
||||
"""
|
||||
if audio is not None:
|
||||
_validate_audio_waveform(audio)
|
||||
|
||||
if isinstance(video, torch.Tensor):
|
||||
video = iter([video])
|
||||
|
||||
def convert(chunk: torch.Tensor) -> torch.Tensor:
|
||||
return frame_converter(chunk.movedim(-1, -3))
|
||||
|
||||
first_chunk = convert(next(video))
|
||||
first_raw_chunk = next(video, None)
|
||||
if first_raw_chunk is None:
|
||||
raise ValueError("video is empty; expected at least one frame chunk.")
|
||||
first_chunk = convert(first_raw_chunk)
|
||||
|
||||
if frame_converter.pixel_format == PixelFormat.RGB24:
|
||||
height, width = first_chunk.shape[-3], first_chunk.shape[-2]
|
||||
@@ -397,6 +425,7 @@ def encode_audio(audio: Audio, output_path: str) -> None:
|
||||
the only difference is a PCM (``pcm_s16le``) stream in a WAV container instead of
|
||||
the AAC stream used for muxed video.
|
||||
"""
|
||||
_validate_audio_waveform(audio)
|
||||
container = av.open(output_path, mode="w")
|
||||
audio_stream = container.add_stream("pcm_s16le", rate=audio.sampling_rate)
|
||||
audio_stream.codec_context.sample_rate = audio.sampling_rate
|
||||
|
||||
@@ -8,6 +8,7 @@ from tqdm import tqdm
|
||||
|
||||
from ltx_core.components.diffusion_steps import EulerCfgPpDiffusionStep, Res2sDiffusionStep
|
||||
from ltx_core.components.protocols import DiffusionStepProtocol
|
||||
from ltx_core.devices import highest_precision_float
|
||||
from ltx_core.model.transformer import X0Model
|
||||
from ltx_core.utils import to_denoised, to_velocity
|
||||
from ltx_pipelines.utils.helpers import post_process_latent, timesteps_from_mask
|
||||
@@ -157,7 +158,10 @@ def _channelwise_normalize(x: torch.Tensor) -> torch.Tensor:
|
||||
|
||||
|
||||
def _get_new_noise(x: torch.Tensor, generator: torch.Generator) -> torch.Tensor:
|
||||
noise = torch.randn(x.shape, generator=generator, dtype=torch.float64, device=generator.device)
|
||||
# float64 on CUDA/CPU for numerical stability; MPS has no float64, so degrade to float32.
|
||||
noise = torch.randn(
|
||||
x.shape, generator=generator, dtype=highest_precision_float(generator.device), device=generator.device
|
||||
)
|
||||
noise = (noise - noise.mean()) / noise.std()
|
||||
return _channelwise_normalize(noise)
|
||||
|
||||
@@ -175,10 +179,11 @@ def _inject_sde_noise(
|
||||
eta: float = 0.5,
|
||||
) -> torch.Tensor:
|
||||
sigmas_copy = sigmas.clone()
|
||||
hp = highest_precision_float(state.denoise_mask.device)
|
||||
new_noise = new_noise_fn(state.latent, step_noise_generator)
|
||||
if not legacy_mode:
|
||||
timesteps = timesteps_from_mask(state.denoise_mask.double(), sigmas_copy[step_idx].double())
|
||||
next_timesteps = timesteps_from_mask(state.denoise_mask.double(), sigmas_copy[step_idx + 1].double())
|
||||
timesteps = timesteps_from_mask(state.denoise_mask.to(hp), sigmas_copy[step_idx].to(hp))
|
||||
next_timesteps = timesteps_from_mask(state.denoise_mask.to(hp), sigmas_copy[step_idx + 1].to(hp))
|
||||
sigmas = torch.stack([timesteps, next_timesteps])
|
||||
step_idx = 0
|
||||
x_next = stepper.step(
|
||||
@@ -249,6 +254,8 @@ def res2s_audio_video_denoising_loop( # noqa: PLR0913,PLR0915,PLR0912
|
||||
if present_state is None:
|
||||
raise ValueError("At least one of video_state or audio_state must be provided")
|
||||
state_device = present_state.latent.device
|
||||
# float64 on CUDA/CPU for ODE numerical stability; MPS has no float64, so degrade to float32.
|
||||
hp = highest_precision_float(state_device)
|
||||
|
||||
# Initialize noise generators with different seeds
|
||||
if noise_seed_substep is None:
|
||||
@@ -270,19 +277,19 @@ def res2s_audio_video_denoising_loop( # noqa: PLR0913,PLR0915,PLR0912
|
||||
if sigmas[-1] == 0:
|
||||
sigmas = torch.cat([sigmas[:-1], torch.tensor([0.0011, 0.0], device=sigmas.device)], dim=0)
|
||||
# Compute step sizes in hyperbolic space
|
||||
hs = -torch.log(sigmas[1:].double().cpu() / (sigmas[:-1].double().cpu()))
|
||||
hs = -torch.log(sigmas[1:].to(hp).cpu() / (sigmas[:-1].to(hp).cpu()))
|
||||
|
||||
# Initialize phi cache for reuse across loop iterations
|
||||
phi_cache = {}
|
||||
c2 = 0.5 # Midpoint for res_2s
|
||||
|
||||
for step_idx in tqdm(range(n_full_steps)):
|
||||
sigma = sigmas[step_idx].double()
|
||||
sigma_next = sigmas[step_idx + 1].double()
|
||||
sigma = sigmas[step_idx].to(hp)
|
||||
sigma_next = sigmas[step_idx + 1].to(hp)
|
||||
|
||||
# Initialize anchor point
|
||||
x_anchor_video = video_state.latent.clone().double() if video_state is not None else None
|
||||
x_anchor_audio = audio_state.latent.clone().double() if audio_state is not None else None
|
||||
x_anchor_video = video_state.latent.clone().to(hp) if video_state is not None else None
|
||||
x_anchor_audio = audio_state.latent.clone().to(hp) if audio_state is not None else None
|
||||
|
||||
# ====================================================================
|
||||
# STAGE 1: Evaluate at current point
|
||||
@@ -307,15 +314,15 @@ def res2s_audio_video_denoising_loop( # noqa: PLR0913,PLR0915,PLR0912
|
||||
# Compute substep x using RK coefficient a21
|
||||
# ====================================================================
|
||||
if x_anchor_video is not None and denoised_video_1 is not None:
|
||||
eps_1_video = denoised_video_1.double() - x_anchor_video
|
||||
x_mid_video = x_anchor_video.double() + h * a21 * eps_1_video
|
||||
eps_1_video = denoised_video_1.to(hp) - x_anchor_video
|
||||
x_mid_video = x_anchor_video.to(hp) + h * a21 * eps_1_video
|
||||
else:
|
||||
eps_1_video = None
|
||||
x_mid_video = None
|
||||
|
||||
if x_anchor_audio is not None and denoised_audio_1 is not None:
|
||||
eps_1_audio = denoised_audio_1.double() - x_anchor_audio
|
||||
x_mid_audio = x_anchor_audio.double() + h * a21 * eps_1_audio
|
||||
eps_1_audio = denoised_audio_1.to(hp) - x_anchor_audio
|
||||
x_mid_audio = x_anchor_audio.to(hp) + h * a21 * eps_1_audio
|
||||
else:
|
||||
eps_1_audio = None
|
||||
x_mid_audio = None
|
||||
@@ -347,10 +354,10 @@ def res2s_audio_video_denoising_loop( # noqa: PLR0913,PLR0915,PLR0912
|
||||
for _ in range(bongmath_max_iter):
|
||||
if x_mid_video is not None and eps_1_video is not None:
|
||||
x_anchor_video = x_mid_video - h * a21 * eps_1_video
|
||||
eps_1_video = denoised_video_1.double() - x_anchor_video
|
||||
eps_1_video = denoised_video_1.to(hp) - x_anchor_video
|
||||
if x_mid_audio is not None and eps_1_audio is not None:
|
||||
x_anchor_audio = x_mid_audio - h * a21 * eps_1_audio
|
||||
eps_1_audio = denoised_audio_1.double() - x_anchor_audio
|
||||
eps_1_audio = denoised_audio_1.to(hp) - x_anchor_audio
|
||||
|
||||
# ====================================================================
|
||||
# STAGE 2: Evaluate at substep point (WITH NOISE)
|
||||
@@ -384,13 +391,13 @@ def res2s_audio_video_denoising_loop( # noqa: PLR0913,PLR0915,PLR0912
|
||||
# FINAL COMBINATION: Compute x_next using RK coefficients
|
||||
# ====================================================================
|
||||
if x_anchor_video is not None and eps_1_video is not None and denoised_video_2 is not None:
|
||||
eps_2_video = denoised_video_2.double() - x_anchor_video
|
||||
eps_2_video = denoised_video_2.to(hp) - x_anchor_video
|
||||
x_next_video = x_anchor_video + h * (b1 * eps_1_video + b2 * eps_2_video)
|
||||
else:
|
||||
x_next_video = None
|
||||
|
||||
if x_anchor_audio is not None and eps_1_audio is not None and denoised_audio_2 is not None:
|
||||
eps_2_audio = denoised_audio_2.double() - x_anchor_audio
|
||||
eps_2_audio = denoised_audio_2.to(hp) - x_anchor_audio
|
||||
x_next_audio = x_anchor_audio + h * (b1 * eps_1_audio + b2 * eps_2_audio)
|
||||
else:
|
||||
x_next_audio = None
|
||||
|
||||
Reference in New Issue
Block a user