Interactive Learning

How PyTorch
Actually Compiles

Eager Mode · Computation Graphs · Kernel Fusion
Learn through animations and interactive demos.

Eager Mode torch.compile Forward Graph Backward Graph Kernel Fusion

Eager Mode — The Default

In eager mode, Python is the "driver". Every PyTorch operation executes immediately when Python hits that line — one by one, with Python overhead on every single GPU call.

⚡

Immediate Execution

Each op fires on the GPU the moment Python reaches that line. No buffering, no planning ahead.

🐍

Python as the Driver

Python calls into CUDA/C++ for each op. That Python overhead happens on every single operation.

🔍

Easy to Debug

Print tensors mid-computation, add breakpoints, inspect shapes. Great for research and experimentation.

 eager_mode.py
class TinyNet(nn.Module):
    def forward(self, x):
        # (1) Python fires CUDA matmul RIGHT NOW
        x = self.linear1(x)
        # (2) Python fires CUDA relu RIGHT NOW
        x = self.relu(x)
        # (3) Python fires CUDA matmul RIGHT NOW
        x = self.linear2(x)
        return x

# Python -> CUDA -> Python -> CUDA -> Python -> CUDA ...

Interactive Demo — Watch Ops Execute

Hit Run on both panels. Eager fires ops one at a time. Compiled fuses and fires them together.

Eager Mode
linear1(x) — matmul
bias_add
relu(x)
linear2(x) — matmul
bias_add
softmax(x)
torch.compile
Tracing... (warm-up)
[Fused] linear1 + bias + relu
[Fused] linear2 + bias + softmax
The Bottleneck Every operation = one Python overhead. In a large model with hundreds of ops, that is hundreds of Python-to-GPU round trips per forward pass. The GPU sits partially idle between calls, and the compiler can never see across op boundaries to optimize.

torch.compile — The 3-Stage Pipeline

torch.compile(model) replaces the Python-per-op loop with a single compiled execution plan. Three specialized stages run before any GPU kernel fires.

 compiled_mode.py
# One line changes everything
model = torch.compile(model)
output = model(x)  # first call: traces + compiles (warm-up)
output = model(x)  # all subsequent: runs optimized compiled graph

# Modes:
torch.compile(model, backend="inductor")       # default
torch.compile(model, mode="reduce-overhead")   # low latency
torch.compile(model, mode="max-autotune")      # max throughput

Click Each Stage to Explore

Stage 01
TorchDynamo
Bytecode tracing
›
Stage 02
AOT Autograd
Joint fwd+bwd graph
›
Stage 03
TorchInductor
Kernel fusion + codegen

Forward Computation Graph

The forward graph is a DAG (Directed Acyclic Graph) — data flows in one direction: input tensors through operations to the output. Click Animate to watch data propagate node by node.

Watch activations propagate through each layer
x : [32, 512]Input tensor weight1 [512,256]Parameter bias1 [256]Parameter MatMul + Addx @ W1 + b1 [32,256] ReLUmax(0, x) [32,256] weight2 [256,10]Parameter bias2 [10]Parameter MatMul + Addx @ W2 + b2 [32,10] y_hat : [32, 10]Predictions
Key PropertyBecause it is a DAG (no cycles), the compiler can reason about dependencies, safely reorder independent operations, and fuse adjacent ops into single GPU kernels.

Backward Graph — Model Correction

The backward graph is how the model learns. Gradients flow upstream through the same operations in reverse — computing how much each weight contributed to the error.

The 4-Step Training Loop(1) Forward pass: compute prediction  |  (2) Loss: measure error  |  (3) Backward: compute dL/dw for every weight  |  (4) Optimizer: w = w - lr * dL/dw
Watch gradients propagate upstream through the network
Loss : scalarCrossEntropyLoss Add backwardpasses dL/dlogits upstream MatMul backwarddL/dx = grad @ W2T dL/dW2 = xT @ gradWeight gradient ReLU backwardgrad * (x > 0) MatMul backwarddL/dx = grad @ W1T All dL/dw computedOptimizer updates weights
 training_step.py
optimizer.zero_grad()           # clear old gradients
logits = model(x)               # FORWARD -- build computation graph
loss   = criterion(logits, y)   # LOSS -- scalar error value
loss.backward()                  # BACKWARD -- dL/dw for all weights
optimizer.step()               # UPDATE -- w = w - lr * dL/dw

# With torch.compile: fwd + bwd compiled as one unit
model = torch.compile(model)   # backward graph compiled once, reused every step

Kernel Fusion — The Secret Weapon

GPUs are fast at math but memory-bandwidth limited. Every separate kernel reads from and writes back to slow global GPU memory (HBM). Fusion keeps data in fast on-chip registers across multiple ops — eliminating those expensive round-trips.

The Core Idea3 separate ops = 3 kernels = 6 memory round-trips (3 reads + 3 writes to HBM).
3 fused ops = 1 kernel = 2 memory round-trips. Data lives in registers between ops. Registers are ~100x faster than HBM.
Memory Bandwidth Comparison
Animate to watch ops execute and compare memory usage
Eager Mode — 3 separate kernels
Kernel 1
Read x
→
x = x * 2
→
Write x
Kernel 2
Read x
→
x = relu(x)
→
Write x
Kernel 3
Read x
→
x = x + bias
→
Write x
Eager BW
0%
Fused Kernel — 1 kernel, data stays in registers
Kernel 1
Read x
→
x*2 → relu → +bias  (IN REGISTERS)
→
Write x
Fused BW
0%
🔗

Pointwise Fusion

relu, gelu, scale, add bias fuse trivially. Multiple element-wise ops collapse into one kernel pass through data.

📊

Reduction Fusion

softmax, layer_norm, mean combine a pointwise pass with a reduction. Fusing avoids writing partial results to HBM.

🔮

Flash Attention Style

Fuse QK matmul + softmax + AV matmul into one tiled kernel. ~10x less memory than naive attention implementation.

The Autograd Tape

Before loss.backward() can run, PyTorch must know what the backward graph is. It builds this during the forward pass by attaching a grad_fn to every output tensor. This chain of grad_fn nodes is the "tape" — the backward graph in disguise.

How It WorksWhen a tensor has requires_grad=True, every op on it records itself as a grad_fn node on the output. After loss.backward(), these nodes are walked in reverse to compute all gradients. The tape is discarded after backward to free memory.
Tape Builder — Forward Pass
Click Run Forward to watch grad_fn nodes attach to each output tensor as the forward pass executes
x [32,512]
requires_grad=True
+
W1 [512,256]
requires_grad=True
→ x@W1 →
mm1 [32,256]
MmBackward0
mm1
→ +bias →
h1 [32,256]
AddmmBackward0
h1
→ relu →
a1 [32,256]
ReluBackward0
a1
→ linear2 →
logits [32,10]
AddmmBackward0
→ loss →
loss (scalar)
NllLossBackward0
 inspect_tape.py
import torch
x = torch.randn(4, 4, requires_grad=True)
w = torch.randn(4, 4, requires_grad=True)
y = x @ w              # y.grad_fn = MmBackward0
z = torch.relu(y)    # z.grad_fn = ReluBackward0
print(z.grad_fn)               # ReluBackward0
print(z.grad_fn.next_functions) # [(MmBackward0, 0)]
# This chain IS the backward graph!
loss = z.sum()
loss.backward()  # walks chain in reverse, computes x.grad + w.grad
# Tape discarded after this to free memory

Graph Breaks — The Real-World Obstacle

A graph break happens when TorchDynamo encounters something it cannot trace. It stops the current graph, runs that part eagerly (Python overhead returns), then starts a new graph. Too many breaks kill your speedup.

The Key Rule1 graph = maximum speedup. Many small graphs with Python gaps = almost no speedup. Use torch._dynamo.explain(model)(x) to see exactly where breaks occur and why.

Click a pattern to see if it breaks the graph

Data-dependent branch
if x.sum() > 0:
Graph Break
print() inside forward
print(x.shape)
Graph Break
numpy() call
x.numpy()
Graph Break
Dynamic shape branch
if x.shape[0] == 32:
Graph Break
nn.Sequential
self.seq = nn.Sequential(...)
Safe
Fixed list comprehension
[layer(x) for layer in self.layers]
Safe
 detect_breaks.py
exp = torch._dynamo.explain(model)(x)
print(exp.graph_count)    # 1 = perfect
print(exp.break_reasons)  # why each break happened
# Strict: error if any break occurs
model = torch.compile(model, fullgraph=True)
# Handle dynamic shapes without recompilation
model = torch.compile(model, dynamic=True)

GPU Memory Hierarchy

Kernel fusion works because of how GPU memory is structured. Data in different memory levels has wildly different speeds. Click each level to understand why this makes fusion so powerful.

Registers~20 TB/s  |  <1 KB/thread
Shared Memory / L1~19 TB/s  |  48-96 KB/SM
L2 Cache~4 TB/s  |  40-80 MB
HBM — Global Memory~2 TB/s  |  40-80 GB  ← the bottleneck
Click a memory level above to see why it matters for kernel fusion.
Why Fusion WinsEager: read HBM → compute → write HBM → repeat for each op. Fused: read HBM once, compute all ops in registers (~10x faster than HBM), write HBM once. For bandwidth-limited ops this is the entire speedup.

Should I Use torch.compile?

Not every model benefits. Use this decision tree to find the right answer for your use case.

✅

Use compile when…

Fixed shapes, production workload, model >10M params, training throughput matters, warmup budget available.

❌

Stay in eager when…

Debugging, research, tiny models, variable batch sizes every call, single-sample latency critical.

🔨

Consider alternatives when…

Cross-platform export needed (ONNX/TensorRT), mobile deployment (torch.export), quantization pipeline required.

torch.profiler — Measure, Don't Guess

The profiler shows exactly which GPU kernels are running and how long they take. Eager = many narrow bars. Compiled = few wide fused bars. Click to animate the comparison.

GPU Kernel Timeline Comparison
Each bar = one GPU kernel. Narrow many bars (eager) vs wide fused bars (compiled).
Eager Mode — 9 separate kernels, 8.2ms total
0ms2ms4ms6ms8ms
torch.compile — 2 fused kernels, 3.1ms total
0ms2ms4ms6ms8ms
 profile_model.py
from torch.profiler import profile, ProfilerActivity
with profile(activities=[ProfilerActivity.CUDA]) as prof:
    model(x)
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))
# Eager output: aten::mm 2.1ms | aten::relu 0.3ms | aten::add 0.4ms ...
# Compiled:     triton_fused_mm_relu_add 1.1ms  <-- all ONE kernel!
prof.export_chrome_trace("trace.json")  # open chrome://tracing

Real Benchmark Numbers

torch.compile is not always faster. Here are real numbers to calibrate your expectations on A100 GPU, batch=32, FP16.

ModelEager (ms)Compiled (ms)SpeedupNotes
ResNet-508.23.1
2.6x
Element-wise ops fuse heavily
GPT-2 (124M)14.75.8
2.5x
Attention + FFN fusion
BERT-base11.34.6
2.5x
LayerNorm + GeLU fusion key
LLaMA-7B (fwd)18274
2.5x
Flash Attention style
Simple 2-layer MLP1.11.4
0.8x
Compile overhead > savings
Variable batch sizes8.29.1
0.9x
Recompilation every step
TipTraining speedups are typically 1.5-2.5x (slightly less than inference due to backward pass complexity). The bigger the model and the more element-wise ops it has, the more compilation helps.
ApproachBest ForProsCons
EagerResearch, debuggingFull Python, easy debuggingSlow, no cross-op optimization
torch.compileProduction training + inference2-4x faster, no code changesWarm-up cost, dynamic shape issues
torch.jit.scriptDeployment, C++ runtimeSerializable, no Python neededStrict Python subset, manual annotation
torch.export + ONNXCross-framework (TensorRT)Runs anywhereComplex export, limited ops

Quick Quiz

4 questions to test what you have learned.

Cheat Sheet

Everything at a glance.

ConceptWhat It IsAnalogyImpact
Eager ModePython calls CUDA one op at a time, immediately.Reading a recipe one line at a time, then cookingBaseline
torch.compileTraces model into a graph, compiles via Inductor.Read whole recipe, then cook optimally2-4x faster
TorchDynamoIntercepts Python bytecode, captures FX Graph.Stenographer recording what you cook, not howEnables compilation
AOT AutogradDerives fwd + bwd graphs before training begins.Planning recipe AND cleanup before you startBwd 2-3x faster
Forward GraphDAG of ops: input through operations to prediction.Flowchart from ingredients to finished dishCompiled once
Backward GraphReverse graph: chain rule computes dL/dw for all params.Finding which ingredient made the dish wrongCompiled once
Kernel FusionMultiple GPU ops merged into one kernel, data in registers.Chopping + cooking in one pan, no switchingMassive BW saving
TorchInductorBackend compiler generating fused Triton/CUDA code.Master chef writing the most efficient methodEnables fusion
The Complete Pipeline
Your Python Code
  ↓
TorchDynamo — intercepts bytecode, emits FX Graph
  ↓
AOT Autograd — derives backward graph ahead of time
  ↓
TorchInductor — fuses ops, generates Triton/CUDA kernels
  ↓
GPU executes — 2-4x faster than eager mode