Automated PR - 2026-01-15

This commit is contained in:
sync-bot
2026-01-15 19:19:42 +00:00
parent bd92a5f408
commit 310103a53e
13 changed files with 722 additions and 207 deletions
@@ -5,7 +5,7 @@ This module provides captioning capabilities for videos with audio using:
- Gemini Flash: Cloud-based API for audio-visual captioning
Requirements:
- Qwen2.5-Omni: transformers>=4.50, torch
- Gemini Flash: google-generativeai (pip install google-generativeai)
- Gemini Flash: google-generativeai (uv pip install google-generativeai)
Set GEMINI_API_KEY or GOOGLE_API_KEY environment variable
"""
@@ -0,0 +1,135 @@
# ruff: noqa: PLC0415
"""
8-bit Gemma text encoder loading utilities.
This module provides functionality for loading the Gemma text encoder in 8-bit precision
using bitsandbytes, which significantly reduces GPU memory usage.
Example usage:
from ltx_trainer.gemma_8bit import load_8bit_gemma
text_encoder = load_8bit_gemma(
checkpoint_path="/path/to/ltx2.safetensors",
gemma_model_path="/path/to/gemma",
)
"""
from __future__ import annotations
import logging
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING
import torch
from ltx_core.loader.sft_loader import SafetensorsModelStateDictLoader
from ltx_core.text_encoders.gemma.embeddings_connector import Embeddings1DConnectorConfigurator
from ltx_core.text_encoders.gemma.encoders.av_encoder import (
AV_GEMMA_TEXT_ENCODER_KEY_OPS,
AVGemmaTextEncoderModel,
)
from ltx_core.text_encoders.gemma.feature_extractor import GemmaFeaturesExtractorProjLinear
from ltx_core.text_encoders.gemma.tokenizer import LTXVGemmaTokenizer
if TYPE_CHECKING:
from ltx_core.text_encoders.gemma import AVGemmaTextEncoderModel
def load_8bit_gemma(
checkpoint_path: str | Path,
gemma_model_path: str | Path,
dtype: torch.dtype = torch.bfloat16,
) -> "AVGemmaTextEncoderModel":
"""Load the Gemma text encoder in 8-bit precision using bitsandbytes.
This function bypasses ltx-core's standard loading path to enable 8-bit quantization
via the bitsandbytes library. The Gemma model is loaded with load_in_8bit=True and
torch_dtype=bfloat16, while the feature extractor and connector weights are loaded
from the LTX-2 checkpoint.
Args:
checkpoint_path: Path to the LTX-2 safetensors checkpoint file
gemma_model_path: Path to Gemma model directory
dtype: Data type for non-quantized model weights (feature extractor, connectors)
Returns:
Loaded AVGemmaTextEncoderModel with 8-bit quantized Gemma backbone
Raises:
ImportError: If bitsandbytes is not installed
FileNotFoundError: If required model files are not found
"""
try:
from transformers import BitsAndBytesConfig, Gemma3ForConditionalGeneration
except ImportError as e:
raise ImportError(
"8-bit text encoder loading requires bitsandbytes. Install it with: uv pip install bitsandbytes"
) from e
# Find paths within gemma_model_path
gemma_path = _find_gemma_subpath(gemma_model_path, "model*.safetensors")
tokenizer_path = _find_gemma_subpath(gemma_model_path, "tokenizer.model")
quantization_config = BitsAndBytesConfig(load_in_8bit=True)
with _suppress_accelerate_memory_warnings():
gemma_model = Gemma3ForConditionalGeneration.from_pretrained(
gemma_path,
quantization_config=quantization_config,
torch_dtype=torch.bfloat16,
device_map="auto",
local_files_only=True,
)
# Load tokenizer
tokenizer = LTXVGemmaTokenizer(tokenizer_path, 1024)
# Load config and weights from the LTX-2 checkpoint
loader = SafetensorsModelStateDictLoader()
config = loader.metadata(str(checkpoint_path))
sd = loader.load(str(checkpoint_path), sd_ops=AV_GEMMA_TEXT_ENCODER_KEY_OPS)
# Helper to extract state dict for a given prefix
def extract_state_dict(prefix: str) -> dict[str, torch.Tensor]:
return {k.replace(prefix, ""): v for k, v in sd.sd.items() if k.startswith(prefix)}
# Create and load feature extractor
feature_extractor = GemmaFeaturesExtractorProjLinear()
feature_extractor.load_state_dict(extract_state_dict("feature_extractor_linear."))
feature_extractor = feature_extractor.to(device=gemma_model.device, dtype=dtype)
# Create and load video embeddings connector
embeddings_connector = Embeddings1DConnectorConfigurator.from_config(config)
embeddings_connector.load_state_dict(extract_state_dict("embeddings_connector."))
embeddings_connector = embeddings_connector.to(device=gemma_model.device, dtype=dtype)
# Create and load audio embeddings connector
audio_embeddings_connector = Embeddings1DConnectorConfigurator.from_config(config)
audio_embeddings_connector.load_state_dict(extract_state_dict("audio_embeddings_connector."))
audio_embeddings_connector = audio_embeddings_connector.to(device=gemma_model.device, dtype=dtype)
# Construct the text encoder
text_encoder = AVGemmaTextEncoderModel(
feature_extractor_linear=feature_extractor,
embeddings_connector=embeddings_connector,
audio_embeddings_connector=audio_embeddings_connector,
tokenizer=tokenizer,
model=gemma_model,
)
return text_encoder
def _find_gemma_subpath(root_path: str | Path, pattern: str) -> str:
"""Find a file matching a glob pattern and return its parent directory."""
matches = list(Path(root_path).rglob(pattern))
if not matches:
raise FileNotFoundError(f"No files matching pattern '{pattern}' found under {root_path}")
return str(matches[0].parent)
@contextmanager
def _suppress_accelerate_memory_warnings() -> Generator[None, None, None]:
"""Temporarily suppress INFO warnings from accelerate about memory allocation."""
accelerate_logger = logging.getLogger("accelerate.utils.modeling")
old_level = accelerate_logger.level
accelerate_logger.setLevel(logging.WARNING)
try:
yield
finally:
accelerate_logger.setLevel(old_level)
@@ -0,0 +1,90 @@
"""GPU memory management utilities for training and inference."""
import functools
import gc
import subprocess
from typing import Callable, TypeVar
import torch
from ltx_trainer import logger
F = TypeVar("F", bound=Callable)
def free_gpu_memory(log: bool = False) -> None:
"""Free GPU memory by running garbage collection and emptying CUDA cache.
Args:
log: If True, log memory stats after clearing
"""
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
if log:
allocated = torch.cuda.memory_allocated() / 1024**3
reserved = torch.cuda.memory_reserved() / 1024**3
logger.debug(f"GPU memory freed. Allocated: {allocated:.2f}GB, Reserved: {reserved:.2f}GB")
class free_gpu_memory_context: # noqa: N801
"""Context manager and decorator to free GPU memory before and/or after execution.
Can be used as a decorator:
@free_gpu_memory_context(after=True)
def my_function():
...
Or as a context manager:
with free_gpu_memory_context():
heavy_operation()
Args:
before: Free memory before execution (default: False)
after: Free memory after execution (default: True)
log: Log memory stats when freeing (default: False)
"""
def __init__(self, *, before: bool = False, after: bool = True, log: bool = False) -> None:
self.before = before
self.after = after
self.log = log
def __enter__(self) -> "free_gpu_memory_context":
if self.before:
free_gpu_memory(log=self.log)
return self
def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object) -> None:
if self.after:
free_gpu_memory(log=self.log)
def __call__(self, func: F) -> F:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> object:
with self:
return func(*args, **kwargs)
return wrapper # type: ignore
def get_gpu_memory_gb(device: torch.device) -> float:
"""Get current GPU memory usage in GB using nvidia-smi.
Args:
device: torch.device to get memory usage for
Returns:
Current GPU memory usage in GB
"""
try:
device_id = device.index if device.index is not None else 0
result = subprocess.check_output(
[
"nvidia-smi",
"--query-gpu=memory.used",
"--format=csv,nounits,noheader",
"-i",
str(device_id),
],
encoding="utf-8",
)
return float(result.strip()) / 1024 # Convert MB to GB
except (subprocess.CalledProcessError, FileNotFoundError, ValueError) as e:
logger.error(f"Failed to get GPU memory from nvidia-smi: {e}")
# Fallback to torch
return torch.cuda.memory_allocated(device) / 1024**3
@@ -191,6 +191,7 @@ def load_text_encoder(
gemma_model_path: str | Path,
device: Device = "cpu",
dtype: torch.dtype = torch.bfloat16,
load_in_8bit: bool = False,
) -> "AVGemmaTextEncoderModel":
"""Load the Gemma text encoder.
Args:
@@ -198,9 +199,22 @@ def load_text_encoder(
gemma_model_path: Path to Gemma model directory
device: Device to load model on
dtype: Data type for model weights
load_in_8bit: Whether to load the Gemma model in 8-bit precision using bitsandbytes.
When True, the model is loaded with device_map="auto" and the device argument
is ignored for the Gemma backbone (feature extractor still uses dtype).
Returns:
Loaded AVGemmaTextEncoderModel
"""
if not Path(gemma_model_path).is_dir():
raise ValueError(f"Gemma model path is not a directory: {gemma_model_path}")
# Use 8-bit loading path if requested
if load_in_8bit:
from ltx_trainer.gemma_8bit import load_8bit_gemma
return load_8bit_gemma(checkpoint_path, gemma_model_path, dtype)
# Standard loading path
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder
from ltx_core.text_encoders.gemma.encoders.av_encoder import (
AV_GEMMA_TEXT_ENCODER_KEY_OPS,
@@ -208,9 +222,6 @@ def load_text_encoder(
)
from ltx_core.text_encoders.gemma.encoders.base_encoder import module_ops_from_gemma_root
if not Path(gemma_model_path).is_dir():
raise ValueError(f"Gemma model path is not a directory: {gemma_model_path}")
torch_device = _to_torch_device(device)
text_encoder = SingleGPUModelBuilder(
model_path=str(checkpoint_path),
@@ -1,8 +1,9 @@
# Adapted from: https://github.com/bghira/SimpleTuner/blob/main/helpers/training/quantisation/__init__.py
# Adapted from: https://github.com/bghira/SimpleTuner
# With improvements from: https://github.com/ostris/ai-toolkit
from typing import Literal
import torch
from optimum.quanto import qtype
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
from ltx_trainer import logger
@@ -14,55 +15,161 @@ QuantizationOptions = Literal[
"fp8uz-quanto",
]
# Modules to exclude from quantization.
# These are glob patterns passed to quanto's `exclude` parameter.
# When quantizing the full model at once, these patterns match against full module paths.
# When quantizing block-by-block, we also use SKIP_ROOT_MODULES for top-level modules.
EXCLUDE_PATTERNS = [
# Input/output projection layers
"patchify_proj",
"audio_patchify_proj",
"proj_out",
"audio_proj_out",
# Timestep embedding layers - int4 tinygemm requires strict bfloat16 input
# and these receive float32 sinusoidal embeddings that are cast to bfloat16
"*adaln*",
"time_proj",
"timestep_embedder*",
# Caption/text projection layers
"caption_projection*",
"audio_caption_projection*",
# Normalization layers (usually excluded from quantization)
"*norm*",
]
# Top-level modules to skip entirely during block-by-block quantization.
# These are exact matches against model.named_children() names.
# (Needed because quanto's exclude patterns don't work when calling quantize() directly on a module)
SKIP_ROOT_MODULES = {
"patchify_proj",
"audio_patchify_proj",
"proj_out",
"audio_proj_out",
"audio_caption_projection",
}
def quantize_model(
model: torch.nn.Module,
precision: QuantizationOptions,
quantize_activations: bool = False,
device: torch.device | str | None = None,
) -> torch.nn.Module:
"""
Quantize a model using the specified precision settings.
Quantize a model using optimum-quanto.
For large models with transformer_blocks, this function quantizes block-by-block
on GPU then moves back to CPU, which is much faster than quantizing on CPU and
uses less peak VRAM than loading the entire model to GPU at once.
Args:
model: The model to quantize.
precision: The precision level to quantize to (e.g. "int8-quanto", "fp8-quanto").
precision: The quantization precision (e.g. "int8-quanto", "fp8-quanto").
quantize_activations: Whether to quantize activations in addition to weights.
device: Device to use for quantization. If None, uses CUDA if available, else CPU.
Returns:
The quantized model, or the original model if no quantization is performed.
The quantized model.
"""
from optimum.quanto import freeze, quantize # noqa: PLC0415
weight_quant = _quanto_type_map(precision)
extra_quanto_args = {
"exclude": [
# Input/output projection layers
"patchify_proj",
"audio_patchify_proj",
"proj_out",
"audio_proj_out",
# Timestep embedding layers - int4 tinygemm requires strict bfloat16 input
# and these receive float32 sinusoidal embeddings that are cast to bfloat16
"*adaln*",
"time_proj",
"timestep_embedder*",
# Caption/text projection layers
"caption_projection*",
"audio_caption_projection*",
# Normalization layers (usually excluded from quantization)
"*norm*",
]
}
if device is None:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
elif isinstance(device, str):
device = torch.device(device)
weight_quant = _get_quanto_dtype(precision)
if quantize_activations:
logger.debug("Quantizing model weights and activations")
extra_quanto_args["activations"] = weight_quant
activations_quant = weight_quant
else:
logger.debug("Quantizing model weights only")
activations_quant = None
# Remember original device to restore after quantization
original_device = next(model.parameters()).device
# Check if model has transformer_blocks for block-by-block quantization
if hasattr(model, "transformer_blocks"):
logger.debug("Quantizing model using block-by-block approach for memory efficiency")
_quantize_blockwise(
model,
weight_quant=weight_quant,
activations_quant=activations_quant,
device=device,
)
else:
# Fallback: quantize entire model at once
model.to(device)
quantize(model, weights=weight_quant, activations=activations_quant, exclude=EXCLUDE_PATTERNS)
freeze(model)
# Restore model to original device
model.to(original_device)
quantize(model, weights=weight_quant, **extra_quanto_args)
freeze(model)
return model
def _quanto_type_map(precision: QuantizationOptions) -> torch.dtype | qtype | None:
def _quantize_blockwise(
model: torch.nn.Module,
weight_quant: torch.dtype,
activations_quant: torch.dtype | None,
device: torch.device,
) -> None:
"""Quantize a model block-by-block using optimum-quanto.
This approach:
1. Moves each transformer block to GPU
2. Quantizes on GPU (fast!)
3. Freezes the quantized weights
4. Moves back to CPU
This is much faster than quantizing on CPU and uses less peak VRAM
than loading the entire model to GPU.
"""
from optimum.quanto import freeze, quantize # noqa: PLC0415
original_dtype = next(model.parameters()).dtype
transformer_blocks = list(model.transformer_blocks)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
transient=True,
) as progress:
task = progress.add_task("Quantizing transformer blocks", total=len(transformer_blocks))
for block in transformer_blocks:
# Move block to GPU
block.to(device, dtype=original_dtype, non_blocking=True)
# Quantize on GPU
quantize(block, weights=weight_quant, activations=activations_quant, exclude=EXCLUDE_PATTERNS)
freeze(block)
# Move back to CPU to free up VRAM for next block
block.to("cpu", non_blocking=True)
progress.advance(task)
# Quantize remaining non-transformer-block modules (e.g., embeddings, timestep projections)
# Skip modules that should not be quantized (patchify_proj, proj_out, etc.)
logger.debug("Quantizing remaining model components")
for name, module in model.named_children():
if name == "transformer_blocks":
continue # Already quantized
if name in SKIP_ROOT_MODULES:
logger.debug(f"Skipping quantization for module: {name}")
continue # Don't quantize these modules
# Move to device, quantize, freeze, move back
module.to(device, dtype=original_dtype, non_blocking=True)
quantize(module, weights=weight_quant, activations=activations_quant, exclude=EXCLUDE_PATTERNS)
freeze(module)
module.to("cpu", non_blocking=True)
def _get_quanto_dtype(precision: QuantizationOptions) -> torch.dtype:
"""Map precision string to quanto dtype."""
from optimum.quanto import ( # noqa: PLC0415
qfloat8,
qfloat8_e4m3fnuz,
@@ -79,14 +186,10 @@ def _quanto_type_map(precision: QuantizationOptions) -> torch.dtype | qtype | No
return qint8
elif precision in ("fp8-quanto", "fp8uz-quanto"):
if torch.backends.mps.is_available():
logger.warning(
"MPS doesn't support dtype float8. "
"you must select another precision level such as int2, int8, or int8.",
)
return None
raise ValueError("FP8 quantization is not supported on MPS devices. Use int2, int4, or int8 instead.")
if precision == "fp8-quanto":
return qfloat8
elif precision == "fp8uz-quanto":
return qfloat8_e4m3fnuz
raise ValueError(f"Invalid quantisation level: {precision}")
raise ValueError(f"Invalid quantization precision: {precision}")
@@ -31,6 +31,7 @@ from ltx_trainer import logger
from ltx_trainer.config import LtxTrainerConfig
from ltx_trainer.config_display import print_config
from ltx_trainer.datasets import PrecomputedDataset
from ltx_trainer.gpu_utils import free_gpu_memory, free_gpu_memory_context, get_gpu_memory_gb
from ltx_trainer.hf_hub_utils import push_to_hub
from ltx_trainer.model_loader import load_model as load_ltx_model
from ltx_trainer.model_loader import load_text_encoder
@@ -38,7 +39,7 @@ from ltx_trainer.progress import TrainingProgress
from ltx_trainer.quantization import quantize_model
from ltx_trainer.timestep_samplers import SAMPLERS
from ltx_trainer.training_strategies import get_training_strategy
from ltx_trainer.utils import get_gpu_memory_gb, open_image_as_srgb, save_image
from ltx_trainer.utils import open_image_as_srgb, save_image
from ltx_trainer.validation_sampler import CachedPromptEmbeddings, GenerationConfig, ValidationSampler
from ltx_trainer.video_utils import read_video, save_video
@@ -330,6 +331,7 @@ class LtxvTrainer:
return loss
@free_gpu_memory_context(after=True)
def _load_text_encoder_and_cache_embeddings(self) -> list[CachedPromptEmbeddings] | None:
"""Load text encoder, computes and returns validation embeddings."""
@@ -342,17 +344,13 @@ class LtxvTrainer:
# Load text encoder on GPU
logger.debug("Loading text encoder...")
if self._config.acceleration.load_text_encoder_in_8bit:
logger.warning(
"⚠️ load_text_encoder_in_8bit is set to True but 8-bit text encoder loading "
"is not currently implemented. The text encoder will be loaded in bfloat16 precision."
)
self._text_encoder = load_text_encoder(
checkpoint_path=self._config.model.model_path,
gemma_model_path=self._config.model.text_encoder_path,
device="cuda",
dtype=torch.bfloat16,
load_in_8bit=self._config.acceleration.load_text_encoder_in_8bit,
)
# Cache validation embeddings if prompts are configured
@@ -378,7 +376,6 @@ class LtxvTrainer:
self._text_encoder.model = None
self._text_encoder.tokenizer = None
self._text_encoder.feature_extractor_linear = None
torch.cuda.empty_cache()
logger.debug("Validation prompt embeddings cached. Gemma model unloaded")
return cached_embeddings
@@ -724,6 +721,7 @@ class LtxvTrainer:
# Note: Use @torch.no_grad() instead of @torch.inference_mode() to avoid FSDP inplace update errors after validation
@torch.no_grad()
@free_gpu_memory_context(after=True)
def _sample_videos(self, progress: TrainingProgress) -> list[Path] | None:
"""Run validation by generating videos from validation prompts."""
use_images = self._config.validation.images is not None
@@ -731,10 +729,9 @@ class LtxvTrainer:
generate_audio = self._config.validation.generate_audio
inference_steps = self._config.validation.inference_steps
# Free up GPU memory before validation sampling.
# Zero gradients and empty the cache to reclaim memory.
# Zero gradients and free GPU memory to reclaim memory before validation sampling
self._optimizer.zero_grad(set_to_none=True)
torch.cuda.empty_cache()
free_gpu_memory()
# Start sampling progress tracking
sampling_ctx = progress.start_sampling(
@@ -831,9 +828,6 @@ class LtxvTrainer:
# Clean up progress tasks
sampling_ctx.cleanup()
# Clear GPU cache after validation
torch.cuda.empty_cache()
rel_outputs_path = output_dir.relative_to(self._config.output_dir)
logger.info(f"🎥 Validation samples for step {self._global_step} saved in {rel_outputs_path}")
return video_paths
@@ -1,5 +1,4 @@
import io
import subprocess
from pathlib import Path
import numpy as np
@@ -7,35 +6,6 @@ import torch
from PIL import ExifTags, Image, ImageCms, ImageOps
from PIL.Image import Image as PilImage
from ltx_trainer import logger
def get_gpu_memory_gb(device: torch.device) -> float:
"""
Get current GPU memory usage in GB using nvidia-smi
Args:
device: torch.device to get memory usage for
Returns:
Current GPU memory usage in GB
"""
try:
device_id = device.index if device.index is not None else 0
result = subprocess.check_output(
[
"nvidia-smi",
"--query-gpu=memory.used",
"--format=csv,nounits,noheader",
"-i",
str(device_id),
],
encoding="utf-8",
)
return float(result.strip()) / 1024 # Convert MB to GB
except (subprocess.CalledProcessError, FileNotFoundError, ValueError) as e:
logger.error(f"Failed to get GPU memory from nvidia-smi: {e}")
# Fallback to torch
return torch.cuda.memory_allocated(device) / 1024**3
def open_image_as_srgb(image_path: str | Path | io.BytesIO) -> PilImage:
"""