torch.compile Deep Dive¶
Overview¶
torch.compile is the default path to speed: it wraps a module, captures the computation with Dynamo (a graph-tracing compiler front-end), and lowers it with Inductor (a Triton/C++ kernel generator). On modern GPUs it commonly delivers 1.5-2x training and 2-4x inference speedups — sometimes with zero code changes.
torch.compile(model)→ cached compiled artifact; subsequent calls hit compiled kernels.- Dynamo captures frames (Python bytecode) into FX-style graphs; unsupported parts get "graph breaks".
- Inductor (default backend) generates fused Triton kernels and CUDA graphs.
- Modes:
default,reduce-overhead(CUDA graphs),max-autotune,max-autotune-no-cudagraphs. - Options:
fullgraph=True(no graph breaks),dynamic=True,backend=....
💡 The first call is slow (compilation + autotuning). Measure steady-state throughput, not the first iteration.
The Simplest Win¶
import torch, torch.nn as nn, time
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.layers = nn.Sequential(*[nn.Linear(256, 256) for _ in range(8)])
def forward(self, x):
return self.layers(x).relu()
model = MLP().cuda()
compiled = torch.compile(model) # returns a wrapped module
x = torch.randn(128, 256, device='cuda')
def bench(fn, n=20):
for _ in range(3): fn() # warm-up (compilation happens here)
torch.cuda.synchronize()
t0 = time.perf_counter()
for _ in range(n): fn()
torch.cuda.synchronize()
return (time.perf_counter() - t0) / n * 1e3
print("eager : %.2f ms" % bench(lambda: model(x)))
print("compile: %.2f ms" % bench(lambda: compiled(x)))
How It Works (the two-stage machine)¶
Python forward
│ Dynamo: trace bytecode, build FX graph
│ └─ graph breaks on unsupported control flow -> pieced graphs
▼
FX graph
│ Inductor: generate Triton kernels, fuse, autotune, cudagraphs
▼
compiled kernels (cached per (code, shapes, device, options))
TORCH_LOGS for transparency¶
TORCH_LOGS=graph_breaks python train.py # see where graphs break
TORCH_LOGS=inductor python train.py # see generated code
TORCH_COMPILE_DEBUG=1 python train.py # dump artifacts
Modes & Backends¶
| Mode | What it adds | When |
|---|---|---|
default |
Inductor kernels | most cases |
reduce-overhead |
CUDA graph capture | small models / launch-bound |
max-autotune |
wider autotune + cudagraphs | inference / fixed shapes |
max-autotune-no-cudagraphs |
autotune w/o capture | dynamic shapes |
compiled_fast = torch.compile(model, mode="reduce-overhead")
compiled_max = torch.compile(model, mode="max-autotune", fullgraph=True)
⚠️
max-autotunecompiles much longer; only worth it when warm shapes are stable.
What Compilation Actually Fixes¶
- Python overhead — hundreds of tiny scalar ops in
forwarddisappear. - Kernel launch overhead — fused regions = fewer launches.
- Memory bandwidth waste — fusion keeps intermediates in registers/SRAM.
- Redundant copies/layout conversions — Inductor picks layouts.
It does NOT fix: bad algorithms, unavoidable memory traffic, or CPU-bound data loaders.
Graph Breaks — The Silent Killer¶
def bad_forward(self, x):
if x.sum() > 0: # tensor data check -> graph break!
return self.a(x)
return self.b(x)
Every if tensor, for over Python range with data, len(tensor), print(tensor), or arbitrary python lib call can break the graph. Breaks leave fragments compiled separately → overhead partially returns.
Fixing the usual suspects¶
# 1. Prefer vectorized ops instead of data-dependent branching
mask = (x > 0).float()
out = mask * self.a(x) + (1 - mask) * self.b(x)
# 2. Use torch ops, not python control flow, where possible
# 3. Set fullgraph=True to *fail fast* and reveal breaks
torch.compile(model, fullgraph=True)
Dynamic Shapes¶
torch.compile(model, dynamic=True) # allow varying batch sizes
torch.compile(model, dynamic=False) # static: best for fixed shapes
- Dynamic shapes trigger recompiles; guard with
dynamic=Trueif batch varies. torch._dynamo.config.cache_size_limitcontrols recompile budget.
When NOT to Use torch.compile¶
| Situation | Why |
|---|---|
| Tiny models (launch-bound) | compile overhead > gain |
| Highly dynamic control flow | breaks everywhere |
| One-off inference | compilation cost per run |
| Ops with no Inductor kernels | falls back to eager anyway |
| Debugging numerics | compiled path hides eager semantics |
Classic rule: measure.
torch.compileis 90% free win, but it is not 100%.
Interop with the Rest of the Stack¶
- Works with DDP/FSDP (
torch.compile+ distributed is the default path now). - Works with AMP (
torch.amp.autocast) — compile after wrapping. - Export:
torch.compileis for speed,torch.exportfor deployment (Ch 07). - Custom ops: fusable via
torch.ops/ inductor pattern matches.
Key Takeaways¶
torch.compile(model)= Dynamo capture + Inductor codegen; measure steady state.- Modes trade compile time for runtime: start
default, escalate toreduce-overhead/max-autotune. - Kill graph breaks (data-dependent control flow) with vectorization or
fullgraph=Truechecks. - Classic wins: python overhead, launches, bandwidth; not algorithmic changes.
- Always benchmark on your shapes/devices before assuming.