Rate this Page
★ ★ ★ ★ ★
torch.compile End-to-End Tutorial">

torch.compile End-to-End Tutorial#

Author: William Wen

torch.compile is the new way to speed up your PyTorch code! torch.compile makes PyTorch code run faster by JIT-compiling PyTorch code into optimized kernels, while requiring minimal code changes.

This tutorial covers an end-to-end example of training and evaluating a real model with torch.compile. For a gentle introduction to torch.compile, please check out the introduction to torch.compile tutorial.

Required pip Dependencies

  • torch >= 2.0

  • torchvision

What you will learn
  • How to apply torch.compile to a real model

  • torch.compile speedups on a real model

  • torch.compile’s first few iterations are expected to be slower due to compilation overhead

# NOTE: a modern NVIDIA GPU (H100, A100, or V100) is recommended for this tutorial in
# order to reproduce the speedup numbers shown below and documented elsewhere.

import torch
import warnings

gpu_ok = False
if torch.cuda.is_available():
    device_cap = torch.cuda.get_device_capability()
    if device_cap in ((7, 0), (8, 0), (9, 0)):
        gpu_ok = True

if not gpu_ok:
    warnings.warn(
        "GPU is not NVIDIA V100, A100, or H100. Speedup numbers may be lower "
        "than expected."
    )
/var/lib/workspace/intermediate_source/torch_compile_full_example.py:51: UserWarning: GPU is not NVIDIA V100, A100, or H100. Speedup numbers may be lower than expected.
  warnings.warn(

Let’s demonstrate how using torch.compile can speed up a real model. We will compare standard eager mode and torch.compile by evaluating and training a torchvision model on random data.

Before we start, we need to define some utility functions.

# Returns the result of running `fn()` and the time it took for `fn()` to run,
# in seconds. We use CUDA events and synchronization for the most accurate
# measurements.
def timed(fn):
    start = torch.cuda.Event(enable_timing=True)
    end = torch.cuda.Event(enable_timing=True)
    start.record()
    result = fn()
    end.record()
    torch.cuda.synchronize()
    return result, start.elapsed_time(end) / 1000


# Generates random input and targets data for the model, where `b` is
# batch size.
def generate_data(b):
    return (
        torch.randn(b, 3, 128, 128).cuda(),
        torch.randint(1000, (b,)).cuda(),
    )


N_ITERS = 10

from torchvision.models import densenet121


def init_model():
    return densenet121().cuda()

First, let’s compare inference.

Note that in the call to torch.compile, we have the additional mode argument, which we will discuss below.

model = init_model()

# Note that we generally recommend directly compiling a torch.nn.Module by calling
# its .compile() method.
model_opt = init_model()
model_opt.compile(mode="reduce-overhead")

inp = generate_data(16)[0]
with torch.no_grad():
    print("eager:", timed(lambda: model(inp))[1])
    print("compile:", timed(lambda: model_opt(inp))[1])
eager: 0.2745057373046875
compile: 57.909875

Notice that torch.compile takes a lot longer to complete compared to eager. This is because torch.compile compiles the model into optimized kernels as it executes. In our example, the structure of the model doesn’t change, and so recompilation is not needed. So if we run our optimized model several more times, we should see a significant improvement compared to eager.

eager_times = []
for i in range(N_ITERS):
    inp = generate_data(16)[0]
    with torch.no_grad():
        _, eager_time = timed(lambda: model(inp))
    eager_times.append(eager_time)
    print(f"eager eval time {i}: {eager_time}")

print("~" * 10)

compile_times = []
for i in range(N_ITERS):
    inp = generate_data(16)[0]
    with torch.no_grad():
        _, compile_time = timed(lambda: model_opt(inp))
    compile_times.append(compile_time)
    print(f"compile eval time {i}: {compile_time}")
print("~" * 10)

import numpy as np

eager_med = np.median(eager_times)
compile_med = np.median(compile_times)
speedup = eager_med / compile_med
assert speedup > 1
print(
    f"(eval) eager median: {eager_med}, compile median: {compile_med}, speedup: {speedup}x"
)
print("~" * 10)
eager eval time 0: 0.019516416549682617
eager eval time 1: 0.017726463317871095
eager eval time 2: 0.017560575485229494
eager eval time 3: 0.01661644744873047
eager eval time 4: 0.01657753562927246
eager eval time 5: 0.017367040634155274
eager eval time 6: 0.017516544342041016
eager eval time 7: 0.017514495849609374
eager eval time 8: 0.016549888610839843
eager eval time 9: 0.016590848922729492
~~~~~~~~~~
compile eval time 0: 0.07907328033447265
compile eval time 1: 0.008561663627624512
compile eval time 2: 0.009068544387817384
compile eval time 3: 0.008061951637268066
compile eval time 4: 0.008062975883483887
compile eval time 5: 0.008051712036132813
compile eval time 6: 0.008069120407104492
compile eval time 7: 0.008034303665161132
compile eval time 8: 0.008027135848999023
compile eval time 9: 0.008077312469482421
~~~~~~~~~~
(eval) eager median: 0.017440768241882322, compile median: 0.00806604814529419, speedup: 2.16224450037004x
~~~~~~~~~~

And indeed, we can see that running our model with torch.compile results in a significant speedup. Speedup mainly comes from reducing Python overhead and GPU read/writes, and so the observed speedup may vary on factors such as model architecture and batch size. For example, if a model’s architecture is simple and the amount of data is large, then the bottleneck would be GPU compute and the observed speedup may be less significant.

You may also see different speedup results depending on the chosen mode argument. The "reduce-overhead" mode uses CUDA graphs to further reduce the overhead of Python. For your own models, you may need to experiment with different modes to maximize speedup. You can read more about modes here.

You may might also notice that the second time we run our model with torch.compile is significantly slower than the other runs, although it is much faster than the first run. This is because the "reduce-overhead" mode runs a few warm-up iterations for CUDA graphs.

Now, let’s consider comparing training.

model = init_model()
opt = torch.optim.Adam(model.parameters())


def train(mod, data):
    opt.zero_grad(True)
    pred = mod(data[0])
    loss = torch.nn.CrossEntropyLoss()(pred, data[1])
    loss.backward()
    opt.step()


eager_times = []
for i in range(N_ITERS):
    inp = generate_data(16)
    _, eager_time = timed(lambda: train(model, inp))
    eager_times.append(eager_time)
    print(f"eager train time {i}: {eager_time}")
print("~" * 10)

model = init_model()
opt = torch.optim.Adam(model.parameters())

# Note that because we are compiling a regular Python function, we do not
# call any .compile() method.
train_opt = torch.compile(train, mode="reduce-overhead")

compile_times = []
for i in range(N_ITERS):
    inp = generate_data(16)
    _, compile_time = timed(lambda: train_opt(model, inp))
    compile_times.append(compile_time)
    print(f"compile train time {i}: {compile_time}")
print("~" * 10)

eager_med = np.median(eager_times)
compile_med = np.median(compile_times)
speedup = eager_med / compile_med
assert speedup > 1
print(
    f"(train) eager median: {eager_med}, compile median: {compile_med}, speedup: {speedup}x"
)
print("~" * 10)
eager train time 0: 0.25301504516601564
eager train time 1: 0.05255372619628906
eager train time 2: 0.0504002571105957
eager train time 3: 0.0503633918762207
eager train time 4: 0.051536865234375
eager train time 5: 0.05169868850708008
eager train time 6: 0.050351104736328124
eager train time 7: 0.050541568756103515
eager train time 8: 0.05119385528564453
eager train time 9: 0.050872352600097655
~~~~~~~~~~
W1009 01:18:51.360000 18799 torch/_logging/_internal.py:1394] [3/0] Profiler record function <class 'torch.autograd.profiler.record_function'> will be ignored
compile train time 0: 169.618953125
compile train time 1: 2.375520263671875
compile train time 2: 0.024030208587646484
compile train time 3: 0.020136959075927736
compile train time 4: 0.01948467254638672
compile train time 5: 0.019373056411743163
compile train time 6: 0.01943756866455078
compile train time 7: 0.01968332862854004
compile train time 8: 0.01944883155822754
compile train time 9: 0.019409919738769533
~~~~~~~~~~
(train) eager median: 0.0510331039428711, compile median: 0.01958400058746338, speedup: 2.605856945058495x
~~~~~~~~~~

Again, we can see that torch.compile takes longer in the first iteration, as it must compile the model, but in subsequent iterations, we see significant speedups compared to eager.

We remark that the speedup numbers presented in this tutorial are for demonstration purposes only. Official speedup values can be seen at the TorchInductor performance dashboard.

Conclusion#

In this tutorial, we applied torch.compile to training and inference on a real model, demonstrating speedups.

Importantly, we note that the first few iterations of a compiled model are slower than eager mode due to compilation overhead, but subsequent iterations are expected to have speedups.

For a gentle introduction to torch.compile, please check out the introduction to torch.compile tutorial.

To troubleshoot issues and to gain a deeper understanding of how to apply torch.compile to your code, check out the torch.compile programming model.

We hope that you will give torch.compile a try!

Total running time of the script: (3 minutes 53.233 seconds)