Note
Go to the end to download the full example code.
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.0torchvision
How to apply
torch.compileto a real modeltorch.compilespeedups on a real modeltorch.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.27395892333984373
compile: 58.20736328125
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.019311616897583008
eager eval time 1: 0.017672191619873046
eager eval time 2: 0.0174335994720459
eager eval time 3: 0.01775916862487793
eager eval time 4: 0.017738752365112305
eager eval time 5: 0.01679052734375
eager eval time 6: 0.016719871520996094
eager eval time 7: 0.016785408020019533
eager eval time 8: 0.01737113571166992
eager eval time 9: 0.016684032440185546
~~~~~~~~~~
compile eval time 0: 0.07837286376953125
compile eval time 1: 0.008549375534057617
compile eval time 2: 0.008856575965881347
compile eval time 3: 0.00794316816329956
compile eval time 4: 0.007939072132110595
compile eval time 5: 0.007944191932678223
compile eval time 6: 0.007940095901489258
compile eval time 7: 0.007967743873596191
compile eval time 8: 0.007953407764434815
compile eval time 9: 0.007923711776733398
~~~~~~~~~~
(eval) eager median: 0.017402367591857912, compile median: 0.00794879984855652, speedup: 2.1893075588031237x
~~~~~~~~~~
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.2522060546875
eager train time 1: 0.053326847076416016
eager train time 2: 0.05080985641479492
eager train time 3: 0.050925567626953126
eager train time 4: 0.05074431991577148
eager train time 5: 0.050618366241455076
eager train time 6: 0.05064089584350586
eager train time 7: 0.051108863830566405
eager train time 8: 0.05100851058959961
eager train time 9: 0.0505272331237793
~~~~~~~~~~
W0903 00:37:53.357000 18799 torch/_logging/_internal.py:1394] [3/0] Profiler record function <class 'torch.autograd.profiler.record_function'> will be ignored
compile train time 0: 171.085125
compile train time 1: 2.392924072265625
compile train time 2: 0.022525951385498046
compile train time 3: 0.019933183670043944
compile train time 4: 0.019294208526611328
compile train time 5: 0.019320831298828126
compile train time 6: 0.019280895233154297
compile train time 7: 0.019342336654663086
compile train time 8: 0.019263456344604492
compile train time 9: 0.019280895233154297
~~~~~~~~~~
(train) eager median: 0.05086771202087402, compile median: 0.019331583976745607, speedup: 2.6313266456625555x
~~~~~~~~~~
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 54.947 seconds)