Joshveer Grewal

PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation 

Introduces two extensions which implement torch.compile in PyTorch 2:

  1. TorchDynamo: JIT compiler which enables graph compilation in PyTorch by dynamically modifying Python bytecode before execution while extracting sequences of PyTorch operations into an FX graph. An FX graph is a data structure representing operations and how their inputs/outputs connect.
  2. TorchInductor: The default compiler backend for TorchDynamo that translates PyTorch programs into Triton for GPUs and C++/OpenMP for CPUs.

Eager mode executes operations as your program reaches them. Graph mode first represents operations as a computation graph, then executes the graph (which can be reused across many executions when its assumptions still hold).

Basically a user is able to write eager-style PyTorch code, and TorchDynamo captures regions into FX graphs that TorchInductor can compile and optimize. Unsupported regions can execute normally in Python/eager mode through graph breaks.

It is easier to optimize in an FX graph because you can see the operations in the captured region together, whereas eager mode dispatches operations individually and generally cannot optimize across those operation boundaries.

Prior Work

These approaches explore different ways to capture or represent computation:

  1. torch.jit.trace: Creates a TorchScript graph by running a function with example inputs and recording the tensor operations that happen. Limitation is that it records only the operations executed for the example inputs, so the graph may be wrong when new inputs require a different execution path.
  2. torch.jit.script: Similar to torch.jit.trace except it constructs a TorchScript graph by parsing the Python AST and performing static analysis. Limitation is that it only supports a subset of Python, so unsupported features can prevent compilation and require code changes.
  3. Lazy Tensors: Delays tensor computations so the system can collect them into a graph before executing them. The system hashes each accumulated graph to look for a cached compiled version. Python execution and graph capture still happen each iteration, even when compilation is reused. Limitations: 1) Extra CPU overhead 2) Delayed accelerator execution 3) Recompilation.
  4. torch.fx.symbolic_trace: Builds an FX graph by running a function with placeholder objects (Proxies) instead of real input tensors. Limitations: 1) Proxy has no input value so Python cannot use it to decide which branch to take 2) There can be Python behavior outside Proxy operations that happen during tracing but are not recorded.
  5. torch.onnx.export: Converts a PyTorch model into ONNX, a representation that other tools and runtimes can consume. In the pipeline described in the paper, it uses tracing or scripting for graph capture. Limitation is that ONNX export depends on successful graph capture and successful translation into ONNX.
  6. Graph Capture in JAX: Originally designed around compiler-friendly code while PyTorch began with flexible eager execution.

TorchDynamo

The primary API is torch.compile. A custom CPython frame evaluation hook intercepts function calls and rewrites eligible frames to extract sequences of PyTorch operations and call their backend-compiled versions. This rewrite process is cached, but assumptions about dynamic properties require guards.

A frame holds a function call’s execution state. PEP 523 introduced CPython’s frame evaluation hook which allows Dynamo to intervene before the frame executes in this sequence:

  1. Check whether to skip the frame.
  2. Look for a cached version whose guards pass.
  3. Else analyze the bytecode.
  4. Compile the graph.
  5. Generate replacement bytecode.
  6. Cache and execute it.

Figure 1: TorchDynamo frame evaluation, graph capture, compilation, and caching.

Figure 1 from PyTorch 2, Ansel et al. (2024).

Guards check whether the assumptions used to compile a function still hold so Dynamo can reuse the compiled version. Symbolic evaluation is how Dynamo follows Python bytecode while building a graph of tensor operations. It models what instructions do using tracked representations of values. Dynamo models the Python structures surrounding tensors so it can extract tensor computation while preserving intended program behavior.

TorchDynamo inlines function calls to capture larger graphs, rolling back and inserting a graph break if analysis fails. It specializes branches and unrolls loops when their behavior can be determined during analysis, using guards to ensure those assumptions remain valid. Branches that depend on tensor values run in Python through graph breaks. Dynamo also tracks variables captured by closures, optimizing away closures used entirely within the captured region and reconstructing those that escape it to preserve their behavior.

To handle mutation/side effects it separates tensor computation from Python state updates while preserving the values each operation observes and the final visible state. Graph breaks let Python execute unsupported code, while continuation functions preserve the needed state and allow Dynamo to resume capture/compilation afterward.

AOTAutograd makes the captured computation usable for training by generating graphs for both the forward and backward passes. It captures both passes, splits the graph, and then prepares the operations for compilation.

TorchInductor

TorchInductor takes captured FX graphs and generates optimized code. It is implemented in Python and targets Triton for GPUs and C++/OpenMP for CPUs.

Its compilation process can be broken down into these stages:

  1. Decompose complex operators into simpler operations.
  2. Lower the FX graph into a loop-level IR.
  3. Schedule operations and apply fusion where possible.
  4. Generate kernels.
  5. Generate wrapper code.

Decompositions reduce the number of operators the compiler must handle directly. Lowering describes how to compute each tensor element using Python functions, with variables representing tensor sizes and element positions. This IR supports analysis/code gen while modeling views, aliasing, and mutation. Unsupported operators use fallback kernels.

Scheduling determines kernel order, fusion, and buffer removal/reuse. Memory dependencies constrain legal fusions. A greedy algorithm ranks opportunities using factors including fusion category and estimated memory traffic saved.

Triton code generation handles indexing, masking, reductions, and templates for complex operations. CPU code generation uses vectorization and OpenMP parallelism.

Wrapper code launches kernels, calculates tensor sizes, and manages allocations. Python wrappers offer flexibility while C++ wrappers reduce overhead. CUDA Graphs can further reduce overhead when applicable.

Dynamic Shapes

Dynamic shape support allows compiled code to handle changing tensor sizes, reducing recompilation for workloads with varying batch sizes, sequence lengths, or data-dependent outputs. The paper supports changing dimension sizes, but not changing the number of dimensions.

TorchDynamo represents sizes symbolically while keeping size hints from the inputs used during capture. For shape-dependent branches, it uses those hints to choose a path and generates guards to ensure that path remains valid. Meta functions calculate intermediate tensor shapes from input metadata without performing the tensor computations, allowing guards to be checked before execution.

The system reduces the cost of reasoning about shapes by:

  1. Inferring which dimensions need to remain dynamic.
  2. Specializing dimensions equal to zero or one.
  3. Simplifying symbolic expressions and removing guards already implied by known facts.

Unbacked symbolic integers come from data-dependent operations such as nonzero() or item() and lack hints during capture. Control flow requiring their values can cause graph breaks. It reduces unnecessary breaks by avoiding redundant tensor-property calculations, delaying unused calculations, and allowing users to specify bounds on these values.

Experimental Results

Across TorchBench, HuggingFace, and TIMM, TorchDynamo captured graphs for more models than TorchScript while adding very little runtime overhead. TorchInductor generally outperformed the six other tested backends, with reported geometric mean speedups of 2.27× for inference and 1.41× for training over eager PyTorch on an NVIDIA A100.