File size: 14,562 Bytes
0839907
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

from typing import List, Dict, Callable
from pathlib import Path
import html
import contextlib
import attrs
from collections.abc import Mapping, Iterable
from contextlib import contextmanager
import ftfy
import gc
import re
import random
from typing import TYPE_CHECKING, Any
from einops import rearrange
import imageio.v3 as iio
from PIL import Image
import numpy as np
import torch
from omegaconf import DictConfig, OmegaConf

from fastgen.utils.distributed import world_size, get_rank
import fastgen.utils.logging_utils as logger

if TYPE_CHECKING:
    from fastgen.configs.config import BaseConfig

PRECISION_MAP = {
    "float16": torch.float16,
    "bfloat16": torch.bfloat16,
    "float32": torch.float32,
    "float64": torch.float64,
}


def basic_clean(text):
    """
    Clean text by fixing encoding issues and unescaping HTML entities.
    """
    text = ftfy.fix_text(text)
    text = html.unescape(html.unescape(text))
    return text.strip()


def whitespace_clean(text):
    """
    Clean text by replacing multiple spaces with a single space and removing leading/trailing whitespace.
    """
    text = re.sub(r"\s+", " ", text)
    text = text.strip()
    return text


def prompt_clean(text):
    """
    Clean text by applying basic and whitespace cleaning.
    """
    text = whitespace_clean(basic_clean(text))
    return text


def ensure_trailing_slash(s):
    return s if s.endswith("/") else s + "/"


def get_batch_size_total(config: BaseConfig):
    # accumulated batch size per GPU
    batch_size = config.dataloader_train.batch_size * config.trainer.grad_accum_rounds
    return batch_size * world_size()


def to_str(obj: Any) -> str | Dict[Any, str]:
    """Print the object in a readable format. Typically used for batches of data."""
    if isinstance(obj, torch.Tensor):
        return f"Tensor{list(obj.shape)}"
    elif isinstance(obj, str):
        dots = "..." if len(obj) > 10 else ""
        return f"{dots}{obj[-10:]}"
    elif isinstance(obj, Mapping):
        return {k: to_str(v) for k, v in obj.items()}
    elif isinstance(obj, Iterable):
        return str([to_str(v) for v in obj])
    return str(obj)


@contextmanager
def inference_mode(*modules: torch.nn.Module, precision_amp: torch.dtype | None = None, device_type: str = "cuda"):
    """
    Wraps torch.inference_mode() and temporarily sets the provided modules
    to .eval() mode. If precision_amp is not None, it also wraps the context in torch.autocast().

    Args:
        *modules: Modules to set temporarily to eval mode.
        precision_amp: If not None, wraps the context in torch.autocast().
        device_type: Device type to use for autocast.

    Returns:
        Generator that yields the context manager.

    Upon exit, it restores the original .training state of each module.
    """
    # 1. Capture the original training state of each module
    #    (True if in train mode, False if in eval mode)
    modules = [mod for mod in modules if isinstance(mod, torch.nn.Module)]
    previous_states = [mod.training for mod in modules]

    try:
        # 2. Set all specific modules to eval mode
        #    This is crucial for layers like Dropout and BatchNorm
        for mod in modules:
            mod.eval()

        # 3. Enter strict inference mode (disables gradients, etc.) and autocast if needed
        with torch.inference_mode(), torch.autocast(
            dtype=precision_amp, device_type=device_type, enabled=precision_amp is not None
        ):
            yield

    finally:
        # 4. Restore the original state of each module
        for mod, was_training in zip(modules, previous_states):
            mod.train(was_training)


def set_random_seed(
    seed: int, iteration: int = 0, by_rank: bool = False, devices: List[torch.device | str | int] | None = None
) -> int:
    """Set random seed for `random, numpy, Pytorch, cuda`.

    Args:
        seed (int): Random seed.
        by_rank (bool): if set to true, each GPU will use a different random seed.
        devices (List[torch.device] | None): devices to set the seed on. If None, will set the seed on all devices.
    Returns:
        The final random seed for the current rank.
    """
    seed += iteration
    if by_rank:
        seed += get_rank()
    seed %= 1 << 31
    logger.info(f"Using random seed {seed}.")
    random.seed(seed)
    np.random.seed(seed)
    if devices is None:
        # sets seed on the current CPU & all GPUs
        torch.manual_seed(seed)
    else:
        # set the seed on cpu
        torch.default_generator.manual_seed(seed)
        # set the seed on devices
        for device in devices:
            # get device index (as in torch.cuda.set_rng_state)
            if isinstance(device, str):
                device = torch.device(device)
            elif isinstance(device, int):
                device = torch.device("cuda", device)
            idx = device.index
            if idx is None:
                idx = torch.cuda.current_device()
            torch.cuda.default_generators[idx].manual_seed(seed)
    return seed


@contextlib.contextmanager
def set_tmp_random_seed(
    seed, iteration: int = 0, by_rank: bool = False, devices: List[torch.device | str | int] | None = None
):
    """A context manager to temporarily set the random seeds.

    Args:
        seed (int): Random seed.
        iteration (int): Iteration number.
        by_rank (bool): if set to true, each GPU will use a different random seed.
        devices (List[torch.device] | None): devices to set the seed on. If None, will set the seed on all devices.
    """
    if seed is None:
        yield
        return

    # Save the original random states
    np_state = np.random.get_state()
    py_state = random.getstate()

    try:
        # Fork torch state
        with torch.random.fork_rng(devices=devices):
            # Set the new seeds
            set_random_seed(seed, iteration=iteration, by_rank=by_rank, devices=devices)
            yield
    finally:
        # Restore the original random states
        np.random.set_state(np_state)
        random.setstate(py_state)


def to(
    data: Any,
    device: str | torch.device | None = None,
    dtype: torch.dtype | None = None,
) -> Any:
    """Recursively cast data into the specified device, dtype, and/or memory_format.

    The input data can be a tensor, a list of tensors, a dict of tensors.
    See the documentation for torch.Tensor.to() for details.

    Args:
        data (Any): Input data.
        device (str | torch.device): GPU device (default: None).
        dtype (torch.dtype): data type (default: None).

    Returns:
        data (Any): Data cast to the specified device, dtype, and/or memory_format.
    """
    assert device is not None or dtype is not None, "at least one of device, dtype should be specified"
    if isinstance(data, torch.Tensor):
        is_cpu = (isinstance(device, str) and device == "cpu") or (
            isinstance(device, torch.device) and device.type == "cpu"
        )
        if data.dtype == torch.int64:
            # t variable is int64 for some networks (e.g. CogVideoX, Stable Diffusion)
            dtype = torch.int64

        data = data.to(
            device=device,
            dtype=dtype,
            non_blocking=(not is_cpu),
        )
        return data
    elif isinstance(data, (list, tuple)):
        return type(data)(to(d, device, dtype) for d in data)
    elif isinstance(data, dict):
        return {k: to(v, device, dtype) for k, v in data.items()}
    else:
        return data


def convert_cfg_to_dict(cfg) -> dict:
    """Convert config to dictionary, handling both OmegaConf and attrs cases.

    Args:
        cfg: Either a DictConfig (from OmegaConf/Hydra) or Config (attrs class)

    Returns:
        Dictionary representation of the config
    """
    if isinstance(cfg, DictConfig):
        # Production case: OmegaConf DictConfig
        return OmegaConf.to_container(cfg, resolve=True)
    else:
        # Test case: attrs SampleTConfig class
        return attrs.asdict(cfg)


def detach(
    data: Any,
) -> Any:
    """Recursively detach data if it is a tensor.

    Args:
        data (Any): Input data.
    Returns:
        data (Any): Data detached from the computation graph.
    """
    if isinstance(data, torch.Tensor):
        return data.detach()
    elif isinstance(data, (list, tuple)):
        return type(data)(detach(d) for d in data)
    elif isinstance(data, dict):
        return {k: detach(v) for k, v in data.items()}
    else:
        return data


def str2bool(v):
    if isinstance(v, bool):
        return v
    if v.lower() in ("yes", "true", "t", "1"):
        return True
    elif v.lower() in ("no", "false", "f", "0"):
        return False
    else:
        raise ValueError("Boolean value expected.")


def save_media(
    tensor: torch.Tensor,
    save_path: str,
    vae: Callable | None = None,
    precision_amp: torch.dtype | None = None,
    **kwargs,
):
    """Save a tensor of images or videos to disk.

    Args:
        tensor: Media tensor to save. Can be [B, C, H, W] (image) or [B, C, T, H, W] (video).
        vae: Optional VAE decoder to decode the tensor.
        save_path: Path to save the media. If multiple instances are saved, an index will be added to the file name.
        precision_amp: If not None, wraps the VAE decode in torch.amp.autocast() with the given precision.
        **kwargs: Additional encoding parameters for save_image and save_video.
    """
    logger.debug(f"πŸ” Media tensor input shape: {tensor.shape}")

    if vae is not None:
        with inference_mode(vae, precision_amp=precision_amp, device_type=tensor.device.type):
            tensor = vae.decode(tensor)
            logger.debug(f"πŸ” After VAE decode shape: {tensor.shape}")

    # iterate over the batch dimension
    save_path = Path(save_path)
    save_path.parent.mkdir(parents=True, exist_ok=True)

    for i, media_tensor in enumerate(tensor.unbind()):
        if tensor.shape[0] > 1:
            # multiple instances, add the index to the save path
            media_save_path = save_path.parent / save_path.stem + f"_{i:03d}" + save_path.suffix
        else:
            # single instance, use the orginal save path
            media_save_path = save_path

        if media_tensor.ndim == 3:
            save_image(media_tensor, media_save_path, **kwargs)
        elif media_tensor.ndim == 4:
            save_video(media_tensor, media_save_path, **kwargs)
        else:
            raise ValueError(f"Tensor has invalid shape: {tensor.shape}")


def save_image(tensor: torch.Tensor, save_path: str):
    """Save a tensor image to disk.

    Args:
        tensor: Image tensor of shape [C, H, W] in range [-1, 1]
        save_path: Path to save the image
    """
    # Convert from [-1, 1] to [0, 255]
    tensor = (tensor + 1) / 2
    tensor = tensor.clamp(0, 1)
    tensor = (tensor * 255).to(torch.uint8)

    # Convert to PIL Image
    if tensor.dim() == 3:
        tensor = tensor.permute(1, 2, 0)  # [C, H, W] -> [H, W, C]

    logger.debug(f"πŸ” After permute shape: {tensor.shape}")
    logger.debug(f"πŸ” Max/min value: {tensor.max()}, {tensor.min()}")

    image = Image.fromarray(tensor.cpu().numpy())

    # Ensure directory exists
    Path(save_path).parent.mkdir(parents=True, exist_ok=True)
    image.save(save_path)
    logger.info(f"Saved image {save_path.name} to {save_path.parent}.")


def save_video(
    frames: torch.Tensor,
    save_path: str = "sample0.mp4",
    save_as_gif: bool = True,
    fps: int = 16,
    quality: int = 23,
    **kwargs,
):
    """
    Save video with basic quality control and silent encoding.

    Args:
        frames: Video frames tensor to save [C, T, H, W]
        save_path: Full path including filename
        save_as_gif: Whether to save as GIF or MP4
        fps: Frames per second for playback (not frame count)
        quality: Video quality 0-51 (lower=better, default: 23)
        **kwargs: Additional encoding parameters
    """
    frames = rearrange(frames, "C T H W -> T H W C")
    logger.debug(f"πŸ” After rearrange shape: {frames.shape}")
    logger.debug(f"πŸ” Final frame count: {frames.shape[0]} frames")
    logger.debug(f"πŸ” Expected duration at {fps}fps: {frames.shape[0]/fps:.1f} seconds")

    frames = ((frames.float() + 1) * 127.5).clip(0, 255).cpu().to(dtype=torch.uint8)

    # Ensure save directory exists
    save_path = Path(save_path)
    save_path.parent.mkdir(parents=True, exist_ok=True)

    if save_as_gif:
        # Save as GIF with proper extension handling
        save_path = save_path.with_suffix(".gif")
        iio.imwrite(
            save_path,
            frames,
            fps=fps,
            loop=kwargs.get("loop", 0),
            quantizer=kwargs.get("quantizer", "nq"),
        )
    else:
        # Save as MP4 with silent encoding and quality control
        output_params = [
            "-loglevel",
            "quiet",  # Silent encoding
            "-hide_banner",  # No ffmpeg banner
            "-nostats",  # No encoding stats
            "-crf",
            str(quality),  # Quality setting
            "-preset",
            kwargs.get("preset", "medium"),  # Encoding speed/quality balance
        ]

        iio.imwrite(
            save_path,
            frames,
            fps=fps,
            codec="libx264",  # Reliable, widely supported codec
            output_params=output_params,
        )
    logger.info(f"Saved video {save_path.name} to {save_path.parent}.")


def clear_gpu_memory():
    """
    Aggressively clear GPU memory and force garbage collection.

    This function performs comprehensive memory cleanup including:
    - PyTorch CUDA cache clearing
    - GPU synchronization
    - Python garbage collection
    - Memory defragmentation
    """
    if torch.cuda.is_available():
        # Clear PyTorch's CUDA cache
        torch.cuda.empty_cache()

        # Wait for all CUDA operations to complete
        torch.cuda.synchronize()

        # Reset peak memory statistics
        torch.cuda.reset_peak_memory_stats()

        # Force another cache clear after sync
        torch.cuda.empty_cache()

    # Force Python garbage collection multiple times
    for _ in range(3):
        gc.collect()

    # Additional CUDA cleanup if available
    if torch.cuda.is_available():
        torch.cuda.empty_cache()