diff --git a/docs/en/quantization.md b/docs/en/quantization.md index b3e84c6d..0f56858e 100644 --- a/docs/en/quantization.md +++ b/docs/en/quantization.md @@ -79,6 +79,26 @@ TorchAO and PyTorch must be version-compatible. Check the [TorchAO release compatibility table](https://github.com/pytorch/ao/releases) instead of installing the newest TorchAO release blindly; an import warning or failure means that configuration has not been validated. +### tf-kernel FP8: online W8A8 + +MiniMax H3 can use TeleFuser's tf-kernel FP8 GEMM wrapper for online W8A8 inference directly from the original BF16 +checkpoint. Activations are quantized per token at each forward, weights are quantized per output channel and cached +on first use, and `tf_kernel.fp8_scaled_mm` produces BF16 output. This path requires a tf-kernel wheel built for the +runtime's PyTorch/CUDA ABI and GPU architecture; on H100, build the SM90 wheel from `tf-kernel/` with the Makefile. + +```python +quant_config = QuantConfig( + enabled=True, + quant_type=QuantType.FP8, + kernel_backend=QuantKernelBackend.TF_KERNEL, +) +``` + +For MiniMax H3, use `quantization="tf-kernel-fp8"` with +`examples/minimax_h3/minimax_h3_fl2va_h100.py`. This backend is single-GPU only and keeps the FP8 +weights resident after first-use conversion. It is distinct from the scaled-FP8 checkpoint path below: the latter +expects weights and scales already serialized in the checkpoint. + ### bitsandbytes NF4: W4A16 NF4 uses a non-uniform 4-bit codebook designed for approximately normal weight distributions. TeleFuser replaces selected Linear layers with `bitsandbytes.nn.Linear4bit`, uses BF16 compute, and enables compressed quantization statistics. diff --git a/examples/minimax_h3/README.md b/examples/minimax_h3/README.md index eabdc2f4..4ee99174 100644 --- a/examples/minimax_h3/README.md +++ b/examples/minimax_h3/README.md @@ -291,6 +291,71 @@ The Ulysses degree must divide 56 attention heads. Scripts must run from their g processes can spawn safely. H100 examples request packed FlashAttention 4 and fall back to packed PyTorch SDPA when FlashAttention 4 is unavailable. +## Online DiT Quantization + +MiniMax H3 supports three single-GPU online quantization backends for the DiT transformer Linear layers: + +| CLI value | Backend | Weight/activation path | +|---|---|---| +| torchao-fp8 | TorchAO | FP8 dynamic activation and FP8 weight when supported, otherwise TorchAO's FP8 weight-only path | +| tf-kernel-fp8 | TeleFuser tf-kernel | Per-token activation and per-output-channel weight FP8 (W8A8), BF16 output | +| bnb-nf4 | bitsandbytes | NF4 weight-only with BF16 compute | + +All three paths convert the 258 Linear layers in the main and token-refiner transformer blocks. The FP32 video/audio +patch projections, timestep embedding, output projections, text encoder, and VAEs retain their reference dtypes. +The BF16 DiT is loaded from the original shards, moved to CUDA after text encoding, quantized on first denoising use, +and then kept resident for the pipeline lifetime. This ordering avoids a simultaneous BF16 text encoder and DiT on +one GPU and avoids unsupported CPU transfers of quantized tensor subclasses. +TorchAO and tf-kernel conversion have a transient memory peak near the BF16 footprint; use the full 80 GB device without colocated workloads. + +Use the single FL2VA example and choose the quantization backend with `--quantization`: + +~~~bash +python examples/minimax_h3/minimax_h3_fl2va_h100.py \ + --mode t2va \ + --quantization torchao-fp8 \ + --duration 5 \ + --output outputs/minimax_h3_torchao_fp8.mp4 +python examples/minimax_h3/minimax_h3_fl2va_h100.py \ + --mode t2va \ + --quantization bnb-nf4 \ + --duration 5 \ + --output outputs/minimax_h3_bnb_nf4.mp4 +python examples/minimax_h3/minimax_h3_fl2va_h100.py \ + --mode t2va \ + --quantization tf-kernel-fp8 \ + --duration 5 \ + --output outputs/minimax_h3_tf_kernel_fp8.mp4 +~~~ + +The FL2VA CLI accepts `--quantization` with `torchao-fp8`, `tf-kernel-fp8`, or `bnb-nf4`; omit it for BF16. The Python +loader accepts the same names: + +~~~python +from examples.minimax_h3.common import load_minimax_h3_pipeline + +pipeline = load_minimax_h3_pipeline( + "/path/to/MiniMaxAI_MiniMax-H3", + partition="FL2VA", + quantization="tf-kernel-fp8", +) +~~~ + +Online quantization currently requires ulysses_degree=1, tp_degree=1, and FSDP disabled. Quantizing before TP/FSDP +would invalidate those wrappers' BF16 parameter-sharding contract, so unsupported combinations fail before checkpoint +loading. + +For matched BF16/TorchAO-FP8/tf-kernel-FP8/NF4 profiling, use the validation benchmark. It writes the synchronized MP4 plus a JSON report +containing load time, end-to-end generation time, stage timings, and denoising allocator peaks: + +~~~bash +python tools/validation/benchmark_minimax_h3_quantization.py \ + --backend tf-kernel-fp8 \ + --duration 5 \ + --steps 50 \ + --output outputs/minimax_h3_tf_kernel_fp8_50step.mp4 +~~~ + For multi-GPU resident profiles, `WorkerTensorChannel` transports text conditioning, visual condition rows, and the final video latent directly between worker groups. CUDA intermediates therefore do not stage through the parent process or CPU. The pipeline reports media, text, condition VAE, denoising, video/audio decode, allocator peak, and @@ -314,10 +379,10 @@ The standard four-GPU profile already uses Ulysses2 x TP2 and therefore leaves F `load_minimax_h3_pipeline` directly to construct another supported combination; the product of Ulysses and TP degrees must be 1, 2, or 4. -Ring attention, CFG parallelism, pipeline parallelism, sparse attention, quantization, and `torch.compile` are not -enabled for H3. Video-VAE parallelism is spatial tiling over the existing TP process group, not parameter tensor -parallelism. The dedicated service manifests expose the pipeline without adding framework-level configuration fields -or changing the shared request schema. +Ring attention, CFG parallelism, pipeline parallelism, sparse attention, and `torch.compile` are not enabled for H3. +Video-VAE parallelism is spatial tiling over the existing TP process group, not parameter tensor parallelism. The +dedicated service manifests expose the pipeline without adding framework-level configuration fields or changing the +shared request schema. ## Four-GPU Regression diff --git a/examples/minimax_h3/common.py b/examples/minimax_h3/common.py index ac7270fa..60c043e3 100644 --- a/examples/minimax_h3/common.py +++ b/examples/minimax_h3/common.py @@ -19,6 +19,9 @@ ModelRuntimeConfig, OffloadConfig, ParallelConfig, + QuantConfig, + QuantKernelBackend, + QuantType, WeightOffloadType, ) from telefuser.core.module_manager import ModuleManager @@ -127,6 +130,36 @@ def _checkpoint_shards(component: Path) -> list[str]: return shards +def minimax_h3_quant_config(quantization: str | QuantType | None) -> QuantConfig: + """Resolve a public MiniMax H3 online-quantization name to runtime config.""" + if quantization is None: + return QuantConfig() + if isinstance(quantization, str): + normalized = quantization.strip().lower().replace("_", "-") + names = { + "torchao-fp8": QuantType.TORCHAO_FP8, + "bnb-nf4": QuantType.BNB_NF4, + "tf-kernel-fp8": QuantType.FP8, + } + try: + quant_type = names[normalized] + except KeyError as exc: + raise ValueError("quantization must be 'torchao-fp8', 'tf-kernel-fp8', 'bnb-nf4', or None") from exc + elif isinstance(quantization, QuantType): + quant_type = quantization + else: + raise TypeError("quantization must be a string, QuantType, or None") + + backends = { + QuantType.TORCHAO_FP8: QuantKernelBackend.TORCHAO, + QuantType.BNB_NF4: QuantKernelBackend.BITSANDBYTES, + QuantType.FP8: QuantKernelBackend.TF_KERNEL, + } + if quant_type not in backends: + raise ValueError(f"MiniMax H3 does not support online quantization type {quant_type.name}") + return QuantConfig(enabled=True, quant_type=quant_type, kernel_backend=backends[quant_type]) + + def load_minimax_h3_pipeline( model_root: str | Path, *, @@ -141,6 +174,7 @@ def load_minimax_h3_pipeline( feature_cache_config: FeatureCacheConfig | None = None, adaln_cache_path: str | Path | None = None, online_adaln_cache: bool = False, + quantization: str | QuantType | None = None, ) -> MiniMaxH3Pipeline: if adaln_cache_path is not None and online_adaln_cache: raise ValueError("Choose either adaln_cache_path or online_adaln_cache, not both.") @@ -162,6 +196,11 @@ def load_minimax_h3_pipeline( raise ValueError("enable_fsdp requires multi-GPU sequence parallelism without tensor parallelism") if (adaln_cache_path is not None or online_adaln_cache) and resolved_enable_fsdp: raise ValueError("AdaLN cache modes do not yet support FSDP deployment.") + quant_config = minimax_h3_quant_config(quantization) + if quant_config.enabled and world_size != 1: + raise ValueError("MiniMax H3 online quantization currently requires a single-GPU profile") + if quant_config.enabled and resolved_enable_fsdp: + raise ValueError("MiniMax H3 online quantization cannot be combined with FSDP") if isinstance(attn_impl, str): try: attn_impl = AttnImplType[attn_impl] @@ -171,6 +210,8 @@ def load_minimax_h3_pipeline( if not component_root.is_dir(): raise FileNotFoundError(f"MiniMax H3 partition not found: {component_root}") runtime_device = torch.device(device) + if quant_config.enabled and runtime_device.type != "cuda": + raise ValueError("MiniMax H3 online quantization requires a CUDA device") use_resident_modules = world_size > 1 or resolved_enable_fsdp resident_offload = OffloadConfig( offload_type=( @@ -178,6 +219,11 @@ def load_minimax_h3_pipeline( ), pin_cpu_memory=False, ) + dit_offload = ( + OffloadConfig(offload_type=WeightOffloadType.NO_CPU_OFFLOAD, pin_cpu_memory=False) + if quant_config.enabled + else resident_offload + ) text_parallel = ( ParallelConfig( device_ids=list(range(resolved_encoder_tp)), @@ -203,9 +249,10 @@ def load_minimax_h3_pipeline( device_type=runtime_device.type, device_id=runtime_device.index or 0, torch_dtype=torch.bfloat16, - offload_config=resident_offload, + offload_config=dit_offload, attention_config=AttentionConfig.dense_attention(attn_impl), feature_cache_config=feature_cache_config or FeatureCacheConfig(), + quant_config=quant_config, parallel_config=ParallelConfig( device_ids=list(range(world_size)), sp_ulysses_degree=ulysses_degree, @@ -344,6 +391,7 @@ def save_generation(result: MiniMaxH3Generation, output_path: str | Path) -> Non "load_minimax_h3_pipeline", "load_minimax_h3_request", "minimax_h3_adaln_cache_timesteps", + "minimax_h3_quant_config", "partition_for_minimax_h3_request", "run_minimax_h3_request", "save_generation", diff --git a/examples/minimax_h3/minimax_h3_fl2va_h100.py b/examples/minimax_h3/minimax_h3_fl2va_h100.py index 7521f334..57af404b 100644 --- a/examples/minimax_h3/minimax_h3_fl2va_h100.py +++ b/examples/minimax_h3/minimax_h3_fl2va_h100.py @@ -38,6 +38,7 @@ "feature_cache_model_type": "MiniMax-H3-Base", "feature_cache_n_derivatives": 1, "feature_cache_taylor_threshold": 2, + "quantization": None, } @@ -88,6 +89,7 @@ def get_pipeline( feature_cache_model_type: str = PPL_CONFIG["feature_cache_model_type"], feature_cache_n_derivatives: int = PPL_CONFIG["feature_cache_n_derivatives"], feature_cache_taylor_threshold: int = PPL_CONFIG["feature_cache_taylor_threshold"], + quantization: str | None = PPL_CONFIG["quantization"], ) -> MiniMaxH3Pipeline: """Load the FL2VA checkpoint partition for one, two, or four GPUs.""" tp_degree = 2 if parallelism == 4 else 1 @@ -108,6 +110,7 @@ def get_pipeline( n_derivatives=feature_cache_n_derivatives, taylor_threshold=feature_cache_taylor_threshold, ), + quantization=quantization, ) @@ -241,7 +244,7 @@ def run_with_file( return {"output_path": str(Path(output_path))} -def main() -> None: +def _main(default_quantization: str | None = PPL_CONFIG["quantization"]) -> None: parser = argparse.ArgumentParser(description="Generate MiniMax H3 T2VA/FL2VA audio-video on H100 GPUs") parser.add_argument("--model-root", default=PPL_CONFIG["model_root"]) parser.add_argument("--mode", choices=("t2va", "first-frame", "last-frame", "first-last")) @@ -265,6 +268,12 @@ def main() -> None: parser.add_argument("--flow-shift", type=float, default=PPL_CONFIG["flow_shift"]) parser.add_argument("--audio-flow-shift", type=float, default=PPL_CONFIG["audio_flow_shift"]) parser.add_argument("--device", default=PPL_CONFIG["device"]) + parser.add_argument( + "--quantization", + choices=("torchao-fp8", "tf-kernel-fp8", "bnb-nf4"), + default=default_quantization, + help="Online DiT Linear quantization backend (single GPU only).", + ) parser.add_argument("--gpu-num", "--ulysses-degree", dest="gpu_num", type=int, choices=(1, 2, 4), default=1) parser.add_argument( "--attn-impl", @@ -319,6 +328,7 @@ def main() -> None: feature_cache_model_type=args.feature_cache_model_type, feature_cache_n_derivatives=args.feature_cache_n_derivatives, feature_cache_taylor_threshold=args.feature_cache_taylor_threshold, + quantization=args.quantization, ) try: result = run_with_file( @@ -339,5 +349,9 @@ def main() -> None: pipeline.stop() +def main() -> None: + _main() + + if __name__ == "__main__": main() diff --git a/examples/minimax_h3/minimax_h3_ref2va_h100.py b/examples/minimax_h3/minimax_h3_ref2va_h100.py index 0c2db8b2..a7de40fe 100644 --- a/examples/minimax_h3/minimax_h3_ref2va_h100.py +++ b/examples/minimax_h3/minimax_h3_ref2va_h100.py @@ -36,6 +36,7 @@ "device": "cuda:0", "enable_fsdp": None, "online_adaln_cache": True, + "quantization": None, } PIPELINE_MANIFEST = build_pipeline_manifest( @@ -91,6 +92,7 @@ def get_pipeline( num_inference_steps: int = PPL_CONFIG["num_inference_steps"], enable_fsdp: bool | None = PPL_CONFIG["enable_fsdp"], online_adaln_cache: bool = PPL_CONFIG["online_adaln_cache"], + quantization: str | None = PPL_CONFIG["quantization"], ) -> MiniMaxH3Pipeline: """Load the Ref2VA checkpoint partition for one, two, or four GPUs.""" tp_degree = 2 if parallelism == 4 else 1 @@ -104,6 +106,7 @@ def get_pipeline( text_encoder_tp_degree=parallelism, enable_fsdp=enable_fsdp, online_adaln_cache=online_adaln_cache, + quantization=quantization, ) @@ -260,6 +263,7 @@ def main() -> None: parser.add_argument("--flow-shift", type=float, default=PPL_CONFIG["flow_shift"]) parser.add_argument("--audio-flow-shift", type=float, default=PPL_CONFIG["audio_flow_shift"]) parser.add_argument("--device", default=PPL_CONFIG["device"]) + parser.add_argument("--quantization", choices=("torchao-fp8", "tf-kernel-fp8", "bnb-nf4")) parser.add_argument("--gpu-num", "--ulysses-degree", dest="gpu_num", type=int, choices=(1, 2, 4), default=1) fsdp_group = parser.add_mutually_exclusive_group() fsdp_group.add_argument("--enable-fsdp", dest="enable_fsdp", action="store_true") @@ -299,6 +303,7 @@ def main() -> None: num_inference_steps=request_steps, enable_fsdp=args.enable_fsdp, online_adaln_cache=online_adaln_cache, + quantization=args.quantization, ) try: if args.request is not None: diff --git a/telefuser/models/minimax_h3_dit.py b/telefuser/models/minimax_h3_dit.py index 52b064d2..98522b96 100644 --- a/telefuser/models/minimax_h3_dit.py +++ b/telefuser/models/minimax_h3_dit.py @@ -16,7 +16,7 @@ import torch.nn as nn from telefuser.core.base_model import BaseModel -from telefuser.core.config import AttentionConfig, AttnImplType +from telefuser.core.config import AttentionConfig, AttnImplType, QuantConfig, QuantKernelBackend, QuantType from telefuser.distributed.collectives import all_gather_cat, all_reduce_sum_ from telefuser.distributed.device_mesh import ( get_tp_group, @@ -31,6 +31,7 @@ from telefuser.ops import RMSNorm, apply_qk_norm_rope_neox, indexed_gate, indexed_scale_shift, silu_and_mul_reuse_input from telefuser.ops.attention import attention from telefuser.ops.rotary import apply_rotary_emb_neox +from telefuser.utils.logging import logger MINIMAX_H3_ADALN_MODALITY_NUM = 3 MINIMAX_H3_FP32_PARAM_NAMES = frozenset( @@ -1209,6 +1210,65 @@ def enable_usp(self, device_mesh: Any | None = None) -> None: for block in self.blocks: block.attn.set_ulysses_group(group, communicator) + def enable_quant(self, quant_type: QuantConfig | str | torch.dtype) -> None: + """Apply supported online quantization to transformer Linear layers.""" + if not isinstance(quant_type, QuantConfig): + super().enable_quant(quant_type) + return + if not quant_type.enabled: + return + + include_names = quant_type.quantize_modules or ("blocks.",) + if quant_type.quant_type == QuantType.TORCHAO_FP8: + from telefuser.ops.torchao_fp8_linear import replace_linear_layers_with_torchao_fp8 + + replaced = replace_linear_layers_with_torchao_fp8( + self, + include_names=include_names, + exclude_names=quant_type.skip_modules, + ) + self.torchao_fp8_replaced_linear = replaced + elif quant_type.quant_type == QuantType.BNB_NF4: + from telefuser.ops.bnb_nf4_linear import replace_linear_layers_with_bnb_nf4 + + replaced = replace_linear_layers_with_bnb_nf4( + self, + compute_dtype=torch.bfloat16, + include_names=include_names, + exclude_names=quant_type.skip_modules, + ) + self.bnb_nf4_replaced_linear = replaced + elif quant_type.quant_type == QuantType.FP8: + if quant_type.kernel_backend not in (QuantKernelBackend.AUTO, QuantKernelBackend.TF_KERNEL): + raise ValueError( + "MiniMax H3 FP8 online quantization requires the tf-kernel backend; " + f"got {quant_type.kernel_backend.name}" + ) + from telefuser.ops.fp8_gemm import FP8GemmOptions, count_linear_layers, enable_fp8_gemm + + def module_filter(name: str, _module: nn.Module) -> bool: + return any(token in name for token in include_names) and not any( + token and token in name for token in quant_type.skip_modules + ) + + replaced = count_linear_layers(self, module_filter=module_filter) + enable_fp8_gemm( + self, + options=FP8GemmOptions( + fp16_weight_storage="keep" if quant_type.keep_fp16_weight else "discard", + materialize_fp8_on_wrap=True, + ), + module_filter=module_filter, + ) + self.tf_kernel_fp8_replaced_linear = replaced + else: + raise ValueError(f"MiniMax H3 does not support online quantization type {quant_type.quant_type.name}") + + if replaced == 0: + raise RuntimeError("MiniMax H3 online quantization did not select any Linear layers") + self.quant_type = quant_type.quant_type + logger.info(f"MiniMax H3 {quant_type.quant_type.name} converted {replaced} transformer Linear layers") + def enable_tp(self, device_mesh: Any | None = None) -> None: self.device_mesh = device_mesh if device_mesh is not None else self.device_mesh world_size = get_tp_world_size(self.device_mesh) diff --git a/telefuser/ops/fp8_gemm.py b/telefuser/ops/fp8_gemm.py index 92293843..0cd04e32 100644 --- a/telefuser/ops/fp8_gemm.py +++ b/telefuser/ops/fp8_gemm.py @@ -274,7 +274,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: bias = bias.to(dtype=out_dtype) x_shape = x_fp.shape - x_2d = x_fp.reshape(-1, x_shape[-1]) + x_2d = x_fp.reshape(-1, x_shape[-1]).contiguous() qinput = torch.empty_like(x_2d, dtype=torch.float8_e4m3fn) input_scale = torch.empty((x_2d.shape[0], 1), dtype=torch.float32, device=x_fp.device) self._tf_kernel.tf_per_token_quant_fp8(x_2d, qinput, input_scale) @@ -340,3 +340,24 @@ def _recurse(prefix: str, parent: nn.Module) -> None: _recurse("", model) return model + + +def count_linear_layers( + model: nn.Module, + *, + module_filter: Optional[Callable[[str, nn.Module], bool]] = None, +) -> int: + """Count the ``nn.Linear`` modules that ``enable_fp8_gemm`` would wrap.""" + + def _count(prefix: str, parent: nn.Module) -> int: + count = 0 + for child_name, child in parent.named_children(): + full_name = f"{prefix}.{child_name}" if prefix else child_name + if isinstance(child, nn.Linear): + if module_filter is None or module_filter(full_name, child): + count += 1 + else: + count += _count(full_name, child) + return count + + return _count("", model) diff --git a/telefuser/pipelines/minimax_h3/denoising.py b/telefuser/pipelines/minimax_h3/denoising.py index 9a45daf6..a6096a71 100644 --- a/telefuser/pipelines/minimax_h3/denoising.py +++ b/telefuser/pipelines/minimax_h3/denoising.py @@ -125,6 +125,20 @@ def __init__(self, module_manager: ModuleManager, model_runtime_config: ModelRun self.model_names = ["transformer"] self._request_serial = 0 + def _ensure_online_quantized(self) -> None: + quant_config = self.model_runtime_config.quant_config + if not quant_config.enabled: + return + if self.transformer.quant_type == quant_config.quant_type: + return + if self.transformer.quant_type is not None: + raise RuntimeError( + f"MiniMax H3 DiT is already quantized as {self.transformer.quant_type}, " + f"cannot apply {quant_config.quant_type}" + ) + self.transformer.enable_quant(quant_config) + current_platform.empty_cache() + def parallel_models(self) -> None: parallel_config = self.model_runtime_config.parallel_config unsupported = { @@ -222,6 +236,7 @@ def denoise( num_inference_steps: int, _transport_video: bool = False, ) -> MiniMaxH3DenoiseResult: + self._ensure_online_quantized() if isinstance(text, dict): text = MiniMaxH3TextCondition(**text) conditions = [ diff --git a/tests/unit/models/test_minimax_h3_dit.py b/tests/unit/models/test_minimax_h3_dit.py index 5baa8c5a..35147de9 100644 --- a/tests/unit/models/test_minimax_h3_dit.py +++ b/tests/unit/models/test_minimax_h3_dit.py @@ -4,7 +4,7 @@ import pytest import torch -from telefuser.core.config import AttentionConfig, AttnImplType +from telefuser.core.config import AttentionConfig, AttnImplType, QuantConfig, QuantKernelBackend, QuantType from telefuser.models.minimax_h3_dit import ( MINIMAX_H3_FP32_BUFFER_NAMES, MINIMAX_H3_FP32_PARAM_NAMES, @@ -644,3 +644,77 @@ def gather_rank_copies(tensor: torch.Tensor, *, dim: int, **_: object) -> torch. torch.testing.assert_close(actual_video, expected_video) torch.testing.assert_close(actual_audio, expected_audio) + + +@pytest.mark.parametrize( + ("quant_type", "helper_path", "count_attribute"), + [ + ( + QuantType.TORCHAO_FP8, + "telefuser.ops.torchao_fp8_linear.replace_linear_layers_with_torchao_fp8", + "torchao_fp8_replaced_linear", + ), + ( + QuantType.BNB_NF4, + "telefuser.ops.bnb_nf4_linear.replace_linear_layers_with_bnb_nf4", + "bnb_nf4_replaced_linear", + ), + ], +) +def test_online_quantization_selects_only_transformer_blocks( + monkeypatch: pytest.MonkeyPatch, + quant_type: QuantType, + helper_path: str, + count_attribute: str, +) -> None: + model = MiniMaxH3DiT(_small_config()) + calls = [] + + def fake_replace(module: torch.nn.Module, **kwargs: object) -> int: + calls.append((module, kwargs)) + return 15 + + monkeypatch.setattr(helper_path, fake_replace) + model.enable_quant(QuantConfig(enabled=True, quant_type=quant_type)) + + assert calls[0][0] is model + assert calls[0][1]["include_names"] == ("blocks.",) + assert getattr(model, count_attribute) == 15 + assert model.quant_type == quant_type + + +def test_tf_kernel_fp8_quantization_uses_filtered_linear_wrapper(monkeypatch: pytest.MonkeyPatch) -> None: + model = MiniMaxH3DiT(_small_config()) + calls: list[tuple[str, object]] = [] + + def fake_count(module: torch.nn.Module, **kwargs: object) -> int: + calls.append(("count", kwargs["module_filter"])) + return 15 + + def fake_enable(module: torch.nn.Module, **kwargs: object) -> torch.nn.Module: + calls.append(("enable", kwargs)) + return module + + monkeypatch.setattr("telefuser.ops.fp8_gemm.count_linear_layers", fake_count) + monkeypatch.setattr("telefuser.ops.fp8_gemm.enable_fp8_gemm", fake_enable) + + model.enable_quant( + QuantConfig( + enabled=True, + quant_type=QuantType.FP8, + kernel_backend=QuantKernelBackend.TF_KERNEL, + ) + ) + + assert calls[0][0] == "count" + assert calls[1][0] == "enable" + options = calls[1][1]["options"] + assert options.fp16_weight_storage == "discard" + assert getattr(model, "tf_kernel_fp8_replaced_linear") == 15 + assert model.quant_type == QuantType.FP8 + + +def test_online_quantization_rejects_unsupported_type() -> None: + model = MiniMaxH3DiT(_small_config()) + with pytest.raises(ValueError, match="does not support"): + model.enable_quant(QuantConfig(enabled=True, quant_type=QuantType.INT8)) diff --git a/tests/unit/ops/test_fp8_gemm.py b/tests/unit/ops/test_fp8_gemm.py index 4bf95a19..256fb3f8 100644 --- a/tests/unit/ops/test_fp8_gemm.py +++ b/tests/unit/ops/test_fp8_gemm.py @@ -37,7 +37,8 @@ def test_fp8_linear_keeps_cpu_fallback(monkeypatch: pytest.MonkeyPatch) -> None: def test_fp8_linear_tf_kernel_forward() -> None: torch.manual_seed(0) linear = nn.Linear(64, 128, device="cuda", dtype=torch.bfloat16) - inputs = torch.randn(2, 3, 64, device="cuda", dtype=torch.bfloat16) + inputs = torch.randn(2, 64, 3, device="cuda", dtype=torch.bfloat16).transpose(1, 2) + assert not inputs.is_contiguous() expected = linear(inputs) wrapped = fp8_gemm.FP8Linear( linear, diff --git a/tests/unit/pipelines/minimax_h3/test_examples.py b/tests/unit/pipelines/minimax_h3/test_examples.py index 92963930..4655763f 100644 --- a/tests/unit/pipelines/minimax_h3/test_examples.py +++ b/tests/unit/pipelines/minimax_h3/test_examples.py @@ -9,8 +9,10 @@ MINIMAX_H3_DEFAULT_FL2VA_IMAGE, MINIMAX_H3_DEFAULT_REF2VA_AUDIO, MINIMAX_H3_DEFAULT_REF2VA_VIDEO, + load_minimax_h3_pipeline, load_minimax_h3_request, minimax_h3_adaln_cache_timesteps, + minimax_h3_quant_config, partition_for_minimax_h3_request, ) from examples.minimax_h3.minimax_h3_cache_calibrate import _apply_cache_profile @@ -20,7 +22,7 @@ default_ref2va_conditions, parse_ref2va_ordered_materials, ) -from telefuser.core.config import AttnImplType, FeatureCacheConfig +from telefuser.core.config import AttnImplType, FeatureCacheConfig, QuantKernelBackend, QuantType from telefuser.pipelines.minimax_h3.task_profiles import MINIMAX_H3_FINITE_ASPECT_RATIOS from telefuser.service.core.pipeline_contract import PipelineContract @@ -138,6 +140,7 @@ def fake_loader(model_root: str, **kwargs: object) -> object: n_derivatives=1, taylor_threshold=2, ), + "quantization": None, }, ) ] @@ -158,6 +161,66 @@ def test_cache_calibration_applies_validated_h3_profile(tmp_path: Path) -> None: assert params == {"K": 2, "retention_ratio": 0.2, "thresh": 0.03} +@pytest.mark.parametrize( + ("name", "quant_type", "backend"), + [ + ("torchao-fp8", QuantType.TORCHAO_FP8, QuantKernelBackend.TORCHAO), + ("torchao_fp8", QuantType.TORCHAO_FP8, QuantKernelBackend.TORCHAO), + ("tf-kernel-fp8", QuantType.FP8, QuantKernelBackend.TF_KERNEL), + ("bnb-nf4", QuantType.BNB_NF4, QuantKernelBackend.BITSANDBYTES), + ], +) +def test_quantization_names_resolve_to_runtime_config( + name: str, + quant_type: QuantType, + backend: QuantKernelBackend, +) -> None: + config = minimax_h3_quant_config(name) + assert config.enabled is True + assert config.quant_type == quant_type + assert config.kernel_backend == backend + + +def test_quantization_rejects_unsupported_parallel_and_cpu_profiles(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="single-GPU"): + load_minimax_h3_pipeline( + tmp_path, + partition="FL2VA", + ulysses_degree=2, + quantization="torchao-fp8", + ) + + (tmp_path / "FL2VA").mkdir() + with pytest.raises(ValueError, match="CUDA"): + load_minimax_h3_pipeline( + tmp_path, + partition="FL2VA", + device="cpu", + quantization="bnb-nf4", + ) + + +@pytest.mark.parametrize("quantization", ["torchao-fp8", "tf-kernel-fp8", "bnb-nf4"]) +def test_standard_example_forwards_selected_quantization( + monkeypatch: pytest.MonkeyPatch, + quantization: str, +) -> None: + calls = [] + sentinel = object() + + def fake_get_pipeline(*args: object, **kwargs: object) -> object: + calls.append((args, kwargs)) + return sentinel + + monkeypatch.setattr(fl2va_example, "load_minimax_h3_pipeline", fake_get_pipeline) + assert fl2va_example.get_pipeline(1, "/models/h3", device="cuda:1", quantization=quantization) is sentinel + assert len(calls) == 1 + assert calls[0][0] == ("/models/h3",) + assert calls[0][1]["device"] == "cuda:1" + assert calls[0][1]["quantization"] == quantization + assert fl2va_example.PIPELINE_MANIFEST["pipeline_name"] == fl2va_example.PPL_CONFIG["name"] + + def test_fl2va_run_maps_standard_service_tasks_to_model_conditions() -> None: calls = [] marker = object() diff --git a/tests/unit/pipelines/minimax_h3/test_parallelism.py b/tests/unit/pipelines/minimax_h3/test_parallelism.py index 11794772..379366a0 100644 --- a/tests/unit/pipelines/minimax_h3/test_parallelism.py +++ b/tests/unit/pipelines/minimax_h3/test_parallelism.py @@ -3,7 +3,14 @@ import pytest import torch -from telefuser.core.config import ModelRuntimeConfig, OffloadConfig, ParallelConfig, WeightOffloadType +from telefuser.core.config import ( + ModelRuntimeConfig, + OffloadConfig, + ParallelConfig, + QuantConfig, + QuantType, + WeightOffloadType, +) from telefuser.pipelines.minimax_h3.denoising import ( MiniMaxH3DenoisingStage, _build_local_embedding_layout, @@ -116,6 +123,23 @@ def test_parallel_models_rejects_tp_with_fsdp() -> None: stage.parallel_models() +def test_online_quantization_is_applied_once_after_stage_onload() -> None: + stage, transformer = _stage(ParallelConfig()) + stage.model_runtime_config.quant_config = QuantConfig(enabled=True, quant_type=QuantType.TORCHAO_FP8) + transformer.quant_type = None + + def enable_quant(config: QuantConfig) -> None: + transformer.quant_type = config.quant_type + + transformer.enable_quant.side_effect = enable_quant + with patch("telefuser.pipelines.minimax_h3.denoising.current_platform.empty_cache") as empty_cache: + stage._ensure_online_quantized() + stage._ensure_online_quantized() + + transformer.enable_quant.assert_called_once_with(stage.model_runtime_config.quant_config) + empty_cache.assert_called_once_with() + + def test_text_encoder_direct_handoff_keeps_token_tags_on_cpu() -> None: manager = MagicMock() manager.fetch_module.return_value = MagicMock() diff --git a/tests/unit/pipelines/minimax_h3/test_pipeline.py b/tests/unit/pipelines/minimax_h3/test_pipeline.py index 5b64ec4f..9d65abb3 100644 --- a/tests/unit/pipelines/minimax_h3/test_pipeline.py +++ b/tests/unit/pipelines/minimax_h3/test_pipeline.py @@ -479,6 +479,19 @@ def init(self, manager, config) -> None: assert config.video_vae_config.parallel_config.tp_degree == 4 assert config.audio_vae_config.offload_config.offload_type is WeightOffloadType.NO_CPU_OFFLOAD + common.load_minimax_h3_pipeline( + tmp_path, + partition="Ref2VA", + device="cuda:0", + quantization="torchao-fp8", + ) + quantized_config = captured["config"] + assert quantized_config.dit_config.quant_config.enabled is True + assert quantized_config.dit_config.offload_config.offload_type is WeightOffloadType.NO_CPU_OFFLOAD + assert quantized_config.text_encoder_config.offload_config.offload_type is WeightOffloadType.MODEL_CPU_OFFLOAD + assert quantized_config.video_vae_config.offload_config.offload_type is WeightOffloadType.MODEL_CPU_OFFLOAD + assert quantized_config.audio_vae_config.offload_config.offload_type is WeightOffloadType.MODEL_CPU_OFFLOAD + def test_example_writer_preserves_complete_generated_audio( monkeypatch: pytest.MonkeyPatch, diff --git a/tools/validation/benchmark_minimax_h3_quantization.py b/tools/validation/benchmark_minimax_h3_quantization.py new file mode 100644 index 00000000..9ec5ea18 --- /dev/null +++ b/tools/validation/benchmark_minimax_h3_quantization.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Benchmark MiniMax H3 BF16 and online-quantized single-GPU profiles.""" + +from __future__ import annotations + +import argparse +import json +import time +from importlib import metadata +from pathlib import Path + +from examples.minimax_h3.common import load_minimax_h3_pipeline, save_generation +from telefuser.core.config import AttnImplType + + +def _package_version(name: str) -> str | None: + try: + return metadata.version(name) + except metadata.PackageNotFoundError: + return None + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model-root", default="/hhb-data/aigc/model_zoo/MiniMaxAI_MiniMax-H3") + parser.add_argument("--backend", choices=("bf16", "torchao-fp8", "tf-kernel-fp8", "bnb-nf4"), required=True) + parser.add_argument("--prompt", default="Steam rises from the ramen while the family talks in the background.") + parser.add_argument("--duration", type=float, default=5.0) + parser.add_argument("--steps", type=int, default=50) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--aspect-ratio", default="16:9") + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--metrics-json", type=Path) + args = parser.parse_args() + + quantization = None if args.backend == "bf16" else args.backend + load_started = time.perf_counter() + pipeline = load_minimax_h3_pipeline( + args.model_root, + partition="FL2VA", + device=args.device, + num_inference_steps=args.steps, + attn_impl=AttnImplType.FLASH_ATTN_4, + quantization=quantization, + ) + load_seconds = time.perf_counter() - load_started + try: + generation_started = time.perf_counter() + result = pipeline( + task="t2va", + prompt=args.prompt, + conditions=[], + target={ + "short_edge": 768, + "aspect_ratio": args.aspect_ratio, + "duration_seconds": args.duration, + }, + seed=args.seed, + ) + generation_seconds = time.perf_counter() - generation_started + save_started = time.perf_counter() + save_generation(result, args.output) + save_seconds = time.perf_counter() - save_started + finally: + pipeline.stop() + + report = { + "backend": args.backend, + "model_root": str(Path(args.model_root)), + "output": str(args.output), + "prompt": args.prompt, + "duration_seconds": args.duration, + "num_inference_steps": args.steps, + "seed": args.seed, + "aspect_ratio": args.aspect_ratio, + "load_seconds": load_seconds, + "generation_seconds": generation_seconds, + "save_seconds": save_seconds, + "runtime_metrics": result.runtime_metrics, + "versions": { + "torch": _package_version("torch"), + "torchao": _package_version("torchao"), + "bitsandbytes": _package_version("bitsandbytes"), + "telefuser": _package_version("telefuser"), + }, + } + metrics_path = args.metrics_json or args.output.with_suffix(".metrics.json") + metrics_path.parent.mkdir(parents=True, exist_ok=True) + metrics_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps(report, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main()