Skip to content

[Performance] Accelerate PerceptualLoss with torch.compile #9175

Open

Description

@StijnvWijn

Is your feature request related to a problem? Please describe.
The VGG-based PerceptualLoss had a large positive effect on the image quality of my trained models, but it decreased my training speed quite a lot.

Describe the solution you'd like
My testing showed that we can speed up each iteration after the first one by 30% by adding self.perceptual_function = torch.compile(self.perceptual_function) to the class init

My testing even shows that it speeds things up if you calculate the loss on the CPU, but I am not sure that is correct.

Describe alternatives you've considered
I have already tuned the fake_3d_ratio, which is also a nice feature!

I can clean up the code on my end and open a PR if you would be open to it.

Additional context
Volume size: (2,1, 128,128,128)
network_type: 'vgg'
Iterations: 50

GPU total time first iteration subsequent iterations
RTX 3090 (not compiled) 21 seconds 2.6 seconds 0.387 seconds
RTX 3090 (compiled) 20 seconds 6 seconds 0.292 seconds
RTX PRO 5000 (not compiled) 11.4 seconds 0.49 seconds 0.22 seconds
RTX PRO 5000 (compiled) 8.6 seconds 1.0 seconds 0.15 seconds

Script:

from __future__ import annotations

import logging

import torch
import pytest

from monai.losses import PerceptualLoss

import time

@pytest.mark.parametrize("use_compile", [False, True])
@pytest.mark.parametrize("device", ["cpu", "cuda"])
@pytest.mark.parametrize("input_shape", [(2,1, 128,128,128)])
def test_perceptual_loss_speed(use_compile, device, input_shape):

    # Create random input tensors
    input_tensor = torch.rand(input_shape).to(device)
    target_tensor = torch.rand(input_shape).to(device)

    # Initialize the PerceptualLoss with the specified use_compile option
    loss_fn = PerceptualLoss(spatial_dims=len(input_shape) - 2, network_type="vgg", use_compile=use_compile).to(device)
    if device == "cuda":
        torch.cuda.synchronize()  # Ensure all CUDA operations are complete before timing
        r = 50
    else:
        r = 1
    start_time = time.time()
    for _ in range(r):
        iteration_start = time.time()
        loss_value = loss_fn(input_tensor, target_tensor)
        iteration_end = time.time()
        logging.warning(f"Iteration time {iteration_end - iteration_start:.6f} seconds")
    end_time = time.time()

    logging.warning(f"PerceptualLoss forward pass time (use_compile={use_compile}, device={device}, input_shape={input_shape}): {end_time - start_time:.6f} seconds")

Activity

  1. Willy1118 commented on Oct 26, 2026

    @Willy1118

    Hi @StijnvWijn and maintainers,

    I'm interested in working on this. I've set up a dev environment and can reproduce the setup (torch.compile works on my machine), so I'd like to check a few things before starting:

    1. @StijnvWijn, do you still plan to open the PR yourself? If so, I'm happy to help with testing or benchmarking instead. If not, I'd like to take this on.

    2. Maintainers, is this a feature MONAI would accept? In particular, would an opt-in use_compile: bool = False argument on PerceptualLoss (off by default, so existing behavior doesn't change) be an acceptable interface?

    3. Since the reported numbers show the first iteration gets slower when compiled, I plan to measure the break-even point (how many iterations before compiling pays off) for a few input sizes, and to fall back to the uncompiled function with a warning if torch.compile fails or is unavailable on the platform.

    Please let me know if you have any preferences on the design before I start. Thanks!

  2. StijnvWijn commented on Oct 27, 2026

    @StijnvWijn
    Author

    Hi @Willy1118

    I do still plan to open a PR if the maintainers agree. What kind of hardware do you have access to for testing and benchmarking?

    Your proposal for the opt-in argument is exactly what I have implemented as well. However, I think that letting the loss error out if compilation fails is a better solution, as it is most likely experimental training code and you don't want to waste GPU-hours on a training with a slow loss.

    Let's wait on what the maintainers prefer and then we can start work on the implementation.

  3. Hoyant-Su commented on Nov 1, 2026

    @Hoyant-Su

    I ran the proposed VGG compilation change on one H100 80 GB, including the backward pass used during training.

    The inputs were generated tensors with shape (2, 1, D, D, D), fake_3d_ratio=0.5, and values in [-1, 1]. The environment was PyTorch 2.6.0+cu124 and torchvision 0.21.0+cu124, using float32 with TF32 disabled and this MONAI source revision. Each measurement used five warmup steps and thirty timed steps, with CUDA synchronization around forward and backward. Compilation was applied to loss.perceptual_function.

    Volume Eager step Compiled step Speedup First step, eager / compiled Estimated break-even Peak allocated memory, eager / compiled
    128³ 399.31 ms 356.97 ms 1.119× 0.65 / 15.81 s 360 steps 17.28 / 18.85 GiB
    64³ 65.26 ms 58.43 ms 1.117× 0.36 / 15.19 s 2,173 steps 2.41 / 2.84 GiB

    The break-even estimate divides the extra first-step cost by the median time saved per subsequent step. These measurements favor longer training runs, particularly for the larger volume.

    The largest loss-value difference was 1.79e-7. Across three matched slice seeds per volume, the relative L2 difference in input gradients ranged from 0.048% to 0.173%, with maximum absolute differences up to 2.87e-6. Repeated eager runs differed by at most 5.10e-11 per gradient element. The CPU random-generator state after each compared step matched between eager and compiled execution.

    The benchmark script and analysis were prepared with OpenAI Codex.

    Reproduction script

    Install the linked MONAI revision with lpips==0.1.4 in the stated PyTorch environment. Save this script as benchmark.py, then run it in a fresh process for each volume size:

    CUDA_VISIBLE_DEVICES=0 python benchmark.py --size 64 --weights ./model_cache --output size_64.json
    CUDA_VISIBLE_DEVICES=0 python benchmark.py --size 128 --weights ./model_cache --output size_128.json

    Use an empty TorchInductor and Triton cache for each run when comparing first-step compilation costs. Pretrained VGG weights can be reused.

    import argparse
    import json
    import math
    import platform
    import time
    from pathlib import Path
    
    import monai
    import numpy as np
    import torch
    import torchvision
    from monai.losses import PerceptualLoss
    
    
    def measure(loss, prediction, target, seeds, warmup):
        timings = []
        values = []
        gradients = []
        rng_states = []
        torch.cuda.reset_peak_memory_stats()
        for index, seed in enumerate(seeds):
            torch.manual_seed(seed)
            prediction.grad = None
            loss.zero_grad(set_to_none=True)
            torch.cuda.synchronize()
            start = time.perf_counter()
            value = loss(prediction, target)
            value.backward()
            torch.cuda.synchronize()
            timings.append(time.perf_counter() - start)
            values.append(value.item())
            if index < 3:
                gradients.append(prediction.grad.detach().cpu().clone())
                rng_states.append(torch.get_rng_state())
        return {
            "first_step_seconds": timings[0],
            "steady_median_seconds": float(np.median(timings[warmup:])),
            "steady_p25_seconds": float(np.quantile(timings[warmup:], 0.25)),
            "steady_p75_seconds": float(np.quantile(timings[warmup:], 0.75)),
            "peak_allocated_bytes": torch.cuda.max_memory_allocated(),
            "peak_reserved_bytes": torch.cuda.max_memory_reserved(),
            "step_seconds": timings,
            "loss_values": values,
        }, gradients, rng_states
    
    
    def main():
        parser = argparse.ArgumentParser()
        parser.add_argument("--size", type=int, required=True)
        parser.add_argument("--batch-size", type=int, default=2)
        parser.add_argument("--ratio", type=float, default=0.5)
        parser.add_argument("--steps", type=int, default=30)
        parser.add_argument("--warmup", type=int, default=5)
        parser.add_argument("--seed", type=int, default=20261101)
        parser.add_argument("--weights", type=Path, required=True)
        parser.add_argument("--output", type=Path, required=True)
        args = parser.parse_args()
        torch.set_num_threads(4)
        torch.backends.cuda.matmul.allow_tf32 = False
        torch.backends.cudnn.allow_tf32 = False
        torch.backends.cudnn.benchmark = False
        torch.hub.set_dir(str(args.weights))
        torch.manual_seed(args.seed)
        shape = (args.batch_size, 1, args.size, args.size, args.size)
        prediction = (torch.rand(shape, device="cuda") * 2 - 1).requires_grad_()
        target = torch.rand(shape, device="cuda") * 2 - 1
        loss = PerceptualLoss(
            spatial_dims=3, network_type="vgg", fake_3d_ratio=args.ratio
        ).cuda().eval()
        seeds = range(args.seed, args.seed + args.warmup + args.steps)
        eager, eager_gradients, eager_rng = measure(loss, prediction, target, seeds, args.warmup)
        repeated, repeated_gradients, _ = measure(loss, prediction, target, range(args.seed, args.seed + 3), 0)
        loss.perceptual_function = torch.compile(loss.perceptual_function)
        compiled, compiled_gradients, compiled_rng = measure(loss, prediction, target, seeds, args.warmup)
        loss_errors = np.abs(np.array(eager["loss_values"]) - compiled["loss_values"])
        gradient_errors = []
        for reference, actual in zip(eager_gradients, compiled_gradients):
            gradient_errors.append({
                "max_absolute_error": (actual - reference).abs().max().item(),
                "relative_l2_error": ((actual - reference).norm() / reference.norm()).item(),
                "close_at_rtol_1e_4_atol_1e_7": bool(torch.allclose(actual, reference, rtol=1e-4, atol=1e-7)),
                "outside_tolerance_fraction": float((~torch.isclose(actual, reference, rtol=1e-4, atol=1e-7)).float().mean()),
            })
        saving = eager["steady_median_seconds"] - compiled["steady_median_seconds"]
        first_step_overhead = compiled["first_step_seconds"] - eager["first_step_seconds"]
        break_even = None
        if saving > 0:
            break_even = max(1, math.ceil(first_step_overhead / saving) + 1)
        report = {
            "monai": monai.__version__,
            "python": platform.python_version(),
            "torch": torch.__version__,
            "torchvision": torchvision.__version__,
            "cuda": torch.version.cuda,
            "gpu": torch.cuda.get_device_name(),
            "shape": shape,
            "fake_3d_ratio": args.ratio,
            "warmup_steps": args.warmup,
            "measured_steps": args.steps,
            "seed": args.seed,
            "eager": eager,
            "compiled": compiled,
            "max_loss_absolute_error": float(loss_errors.max()),
            "gradient_checks": gradient_errors,
            "forward_close_at_rtol_1e_4_atol_1e_6": bool(np.allclose(compiled["loss_values"], eager["loss_values"], rtol=1e-4, atol=1e-6)),
            "same_cpu_rng_after_step": [bool(torch.equal(a, b)) for a, b in zip(eager_rng, compiled_rng)],
            "repeated_eager_max_loss_error": float(np.max(np.abs(np.array(eager["loss_values"][:3]) - repeated["loss_values"]))),
            "repeated_eager_gradient_max_errors": [(a - b).abs().max().item() for a, b in zip(eager_gradients, repeated_gradients)],
            "steady_speedup": eager["steady_median_seconds"] / compiled["steady_median_seconds"],
            "estimated_break_even_training_steps": break_even,
        }
        args.output.write_text(json.dumps(report, indent=2) + "\n")
        print(json.dumps({k: report[k] for k in ["shape", "steady_speedup", "estimated_break_even_training_steps", "max_loss_absolute_error"]}), flush=True)
    
    
    if __name__ == "__main__":
        main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions