The Hitchhiker's Guide — Phase 04: torch.compile, FX Graph, Custom Backends
"The first time you look at what torch.compile actually does to your model, you will have one of two reactions: either 'this is incredibly clever' or 'this is incredibly frightening.' Both reactions are correct." — anonymous PyTorch contributor
Section 1: torch.compile Architecture — Three Layers
torch.compile is not a single tool. It is a three-layer compiler pipeline:
Python code
↓
[Layer 1] TorchDynamo — bytecode rewriting, Python frame capture
↓
FX Graph (Aten/Prims ops only, no Python control flow)
↓
[Layer 2] AOTAutograd — ahead-of-time differentiation, lifts grad into the graph
↓
Joint forward+backward FX graph
↓
[Layer 3] Backend — inductor (default), nvfuser, custom backend
↓
Optimized code / Triton kernels
Layer 1: TorchDynamo — Bytecode Rewriting
TorchDynamo intercepts Python execution at the CPython bytecode level. It does not use tracing in the classical sense — it evaluates Python code and builds an FX graph from the operations it observes.
The key mechanism is torch._dynamo.eval_frame.set_eval_frame(), which installs a callback that intercepts CPython's frame.f_back evaluation. When a guarded region encounters Python control flow (an if statement, a for loop with dynamic bounds), Dynamo breaks the graph at that point, compiling everything before the break into one subgraph and resuming Python execution.
Graph breaks are the primary performance enemy of torch.compile. Every graph break means:
- A Triton/C++ dispatch overhead
- Loss of fusion opportunities across the break
- Potential synchronization
Common graph break causes:
# 1. data-dependent control flow
if x.sum() > 0: # .sum() returns a Python scalar → break
...
# 2. non-PyTorch library calls
import numpy as np
y = np.array(x.numpy()) # leaves PyTorch graph → break
# 3. custom __torch_function__ with Python fallback
class MyTensor(torch.Tensor):
def __add__(self, other):
return super().__add__(other) # may break depending on implementation
# 4. print statements
print(x.shape) # accesses tensor metadata but causes Python escape → break
# 5. in-place ops on tensors that require grad
x += 1 # may cause aliasing issues → break
How to debug graph breaks:
import torch._dynamo
torch._dynamo.config.verbose = True
# Or use explain():
explanation = torch._dynamo.explain(model)(x)
print(explanation.graphs) # compiled subgraphs
print(explanation.break_reasons) # why each break happened
Guards: for each compiled subgraph, Dynamo generates guard conditions — Python predicates that must be true for the compiled version to be valid. If guards fail (e.g., input shape changed), Dynamo recompiles (within the cache_size_limit).
Example guard for a linear layer:
# Dynamo generates something like:
def guard_fn(input):
return (
input.dtype == torch.float32 and
input.device.type == 'cuda' and
input.shape[1] == 512 # static shape guard
)
Dynamic shapes (torch.compile(dynamic=True)) replace static shape guards with symbolic shape guards using torch.fx.experimental.symbolic_shapes.
Section 2: FX — Functional Transformation of PyTorch Graphs
torch.fx is the intermediate representation used throughout torch.compile. It represents a PyTorch program as a directed acyclic graph of nodes.
FX Node Types
| NodeKind | Example | Description |
|---|---|---|
placeholder | x: f32[B,512] | Function argument |
get_attr | self.weight | Module parameter access |
call_function | torch.relu(x) | Free function call |
call_method | x.sum() | Tensor method call |
call_module | self.linear(x) | nn.Module forward call |
output | return y | Function output |
Capturing an FX Graph
import torch
import torch.fx as fx
model = torch.nn.Sequential(
torch.nn.Linear(512, 256),
torch.nn.ReLU(),
torch.nn.Linear(256, 10)
)
# Symbolic tracing (static, follows Python code)
tracer = fx.symbolic_trace(model)
print(tracer.graph)
# torch.export (newer, handles more cases, produces aten-level graph)
from torch.export import export
exported = export(model, args=(torch.randn(1, 512),))
print(exported.graph)
Writing FX Passes
An FX pass is a function that takes a GraphModule, transforms its graph, and returns the modified GraphModule.
Pattern: Replace all ReLU with LeakyReLU
import torch.fx as fx
def replace_relu_with_leaky(gm: fx.GraphModule) -> fx.GraphModule:
for node in gm.graph.nodes:
if node.op == 'call_function' and node.target == torch.nn.functional.relu:
with gm.graph.inserting_after(node):
new_node = gm.graph.call_function(
torch.nn.functional.leaky_relu,
args=node.args,
kwargs={'negative_slope': 0.01}
)
node.replace_all_uses_with(new_node)
gm.graph.erase_node(node)
gm.recompile()
return gm
Pattern: Fuse Linear + ReLU → LinearReLU
def fuse_linear_relu(gm: fx.GraphModule) -> fx.GraphModule:
graph = gm.graph
for node in list(graph.nodes):
# Match: relu(linear(x))
if (node.op == 'call_module' and
isinstance(gm.get_submodule(node.target), torch.nn.ReLU)):
linear_node = node.args[0]
if (linear_node.op == 'call_module' and
isinstance(gm.get_submodule(linear_node.target), torch.nn.Linear)):
# Create fused module
linear = gm.get_submodule(linear_node.target)
fused = torch.nn.utils.fusion.fuse_linear_bn_eval(linear, None)
# ... (register fused module, replace both nodes with one)
gm.recompile()
return gm
Pattern: Constant folding
def constant_fold(gm: fx.GraphModule) -> fx.GraphModule:
"""Evaluate ops with constant inputs at compile time."""
graph = gm.graph
for node in list(graph.nodes):
if node.op == 'call_function':
# Check if all inputs are constants (graph inputs are not)
if all(isinstance(arg, (int, float, bool)) or
(isinstance(arg, fx.Node) and arg.op == 'get_attr')
for arg in node.args):
try:
# Execute the op with concrete values
const_value = node.target(*[
gm.get_parameter(a.target) if isinstance(a, fx.Node) else a
for a in node.args
])
# Replace node with constant
with graph.inserting_before(node):
const_node = graph.get_attr(f'_const_{node.name}')
gm.register_buffer(f'_const_{node.name}', const_value)
node.replace_all_uses_with(const_node)
graph.erase_node(node)
except Exception:
pass # can't fold — skip
gm.recompile()
return gm
Section 3: Registering a Custom torch.compile Backend
A custom backend receives an FX GraphModule and returns a callable (typically a Python function wrapping compiled code).
from torch._dynamo.backends.common import aot_autograd
import torch._dynamo as dynamo
def my_custom_backend(gm: torch.fx.GraphModule, example_inputs):
"""
gm: FX GraphModule (after AOTAutograd, contains aten-level ops)
example_inputs: list of example input tensors (for shape/dtype info)
Must return: callable with same signature as gm.forward
"""
print(f"Custom backend received graph with {len(list(gm.graph.nodes))} nodes")
# Option 1: Apply transformations and return gm itself
gm = my_optimization_pass(gm)
return gm
# Option 2: JIT compile the graph (e.g., via TorchScript)
scripted = torch.jit.script(gm)
return scripted
# Option 3: Lower to custom ISA and return simulation
npu_program = lower_to_npu(gm, example_inputs)
return npu_program.simulate # callable that simulates execution
# Register the backend
@dynamo.register_backend
def my_backend(gm, example_inputs):
return my_custom_backend(gm, example_inputs)
# Use it:
model = MyModel()
compiled = torch.compile(model, backend="my_backend")
output = compiled(x)
Using AOT Autograd with a custom backend:
from torch._dynamo.backends.common import aot_autograd
def my_inference_backend(gm, example_inputs):
# Receives joint graph (fwd+bwd combined)
# Lower to hardware
return lower_to_hardware(gm)
my_backend_with_aot = aot_autograd(fw_compiler=my_inference_backend)
compiled = torch.compile(model, backend=my_backend_with_aot)
Section 4: Triton Kernels — When and How
Triton is a Python-based GPU kernel language that compiles to PTX. It is the backend for TorchInductor's generated kernels.
Key concepts:
- Programs: Triton launches many programs (like CUDA blocks); each program handles a tile of data
tl.program_id(axis): which tile this program handlestl.load/tl.store: vectorized memory ops with optional maskingtl.dot: matrix multiply of tiles (uses tensor cores)tl.constexpr: compile-time constant (like CUDAconstexprtemplate param)
Softmax kernel pattern:
import triton
import triton.language as tl
@triton.jit
def softmax_kernel(
input_ptr, output_ptr,
n_rows, n_cols,
BLOCK_SIZE: tl.constexpr # must be power of 2, ≥ n_cols
):
row_idx = tl.program_id(0)
row_start = input_ptr + row_idx * n_cols
cols = tl.arange(0, BLOCK_SIZE)
mask = cols < n_cols
# Load row (with masking for out-of-bounds)
row = tl.load(row_start + cols, mask=mask, other=float('-inf'))
# Numerically stable softmax
row_max = tl.max(row, axis=0)
row -= row_max # broadcast
row_exp = tl.exp(row)
row_sum = tl.sum(row_exp, axis=0)
softmax_output = row_exp / row_sum
# Store result
output_start = output_ptr + row_idx * n_cols
tl.store(output_start + cols, softmax_output, mask=mask)
Autotuning:
@triton.autotune(
configs=[
triton.Config({'BLOCK_SIZE': 128}),
triton.Config({'BLOCK_SIZE': 256}),
triton.Config({'BLOCK_SIZE': 512}),
triton.Config({'BLOCK_SIZE': 1024}),
],
key=['n_cols'] # re-tune when n_cols changes
)
@triton.jit
def softmax_kernel(..., BLOCK_SIZE: tl.constexpr):
...
Integrating Triton kernel as a custom torch.op:
class TritonSoftmax(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
n_rows, n_cols = x.shape
output = torch.empty_like(x)
BLOCK_SIZE = triton.next_power_of_2(n_cols)
softmax_kernel[(n_rows,)](
x, output, n_rows, n_cols, BLOCK_SIZE=BLOCK_SIZE
)
ctx.save_for_backward(output)
return output
@staticmethod
def backward(ctx, grad_output):
output, = ctx.saved_tensors
# Softmax backward: grad_in = output * (grad_out - (grad_out * output).sum(dim=-1, keepdim=True))
...
triton_softmax = TritonSoftmax.apply
Section 5: FX Graph Optimization Passes — Reference List
These are the passes TorchInductor applies (and you can apply in a custom backend):
| Pass | What It Does | When Useful |
|---|---|---|
| Dead Code Elimination (DCE) | Remove nodes whose outputs are never used | After any graph modification |
| Common Subexpression Elimination (CSE) | Share identical computations | Repeated ops in transformers |
| Constant Folding | Evaluate constant subgraphs at compile time | Bias initialization, shape computations |
| Operator Fusion | Combine adjacent ops into one kernel | Memory-bound op sequences |
| Layout Propagation | Propagate preferred memory layouts (NHWC vs NCHW) | Conv-heavy models |
| Quantization Insertion | Insert Q/DQ nodes around quantizable ops | INT8/INT4 deployment |
| Shape Propagation | Infer output shapes for all nodes | Enables downstream static allocation |
| Alias Analysis | Track tensor aliasing for in-place op safety | Before in-place mutations |
Section 6: Interview Pitfalls
| Question | Wrong Answer | Right Answer |
|---|---|---|
| "What does torch.compile do?" | "It makes PyTorch faster" | "Three-layer pipeline: TorchDynamo (bytecode capture) → AOTAutograd (lift grads) → Inductor/custom backend (code generation)" |
| "What is a graph break?" | "When the model doesn't compile" | "A point where Dynamo cannot trace ahead — due to data-dependent control flow, non-PyTorch ops, or unsupported Python constructs. Breaks the program into compiled subgraphs with Python glue." |
| "How do you write an FX pass?" | "You subclass nn.Module" | "You iterate over gm.graph.nodes, match patterns, use inserting_before/after, replace_all_uses_with, erase_node, then recompile()." |
| "How does torch.compile handle control flow?" | "It traces both branches" | "Dynamo breaks the graph at control flow and falls back to Python. For dynamic control flow, you can use torch.cond (aten-level if) or accept the graph break." |
"What is the difference between symbolic_trace and torch.export?" | (unsure) | "symbolic_trace does Python-level tracing (can miss dynamic behaviors); torch.export uses TorchDynamo under the hood, produces a strict aten-level graph that is guaranteed to have no Python dependencies — suitable for mobile/server export" |
Section 7: Resources
- PyTorch torch.compile tutorial — https://pytorch.org/tutorials/intermediate/torch_compile_tutorial.html
- TorchDynamo overview — Jason Ansel et al., "TorchDynamo: Towards Principled Python JIT Compilation" (Meta AI, 2022)
- TorchInductor — blog post on the default backend architecture: https://dev-discuss.pytorch.org/t/torchinductor-a-pytorch-native-compiler-with-define-by-run-ir-and-symbolic-shapes
- Triton paper — Philippe Tillet et al., "Triton: An Intermediate Language and Compiler for Tiled Neural Network Computations" (MLSys 2019)
- FX documentation — https://pytorch.org/docs/stable/fx.html
- torch.export documentation — https://pytorch.org/docs/stable/export.html
- AOTAutograd internals — https://github.com/pytorch/pytorch/blob/main/torch/_functorch/aot_autograd.py (read the docstring)