Home · ASPLOS 2024 · Compilers & Programming Languages

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

Jason Ansel, Edward Z. Yang, Horace He, Natalia Gimelshein, Animesh Jain, Michael Voznesensky and 43 others

Conference
ASPLOS 2024
Published
2024
Pages
929-947
Citations
650

PyTorch runs Python line by line, which is easy to debug but slow, so torch.compile rewrites the program's bytecode while it runs to capture the maths into a graph, then compiles that graph into fast GPU code.

The argument in one glance

  1. Problem

    Flexibility and speed pull in opposite directions

    PyTorch runs each operation immediately, as the Python line executes. This is easy to write and easy to debug. It is also slow, because the compiler never sees more than one operation at a time and cannot optimise across them.

  2. What people did

    Capture the program as a graph, then compile the graph

    If the framework can record the whole sequence of operations first, a compiler can fuse them, remove redundant memory traffic, and generate one fast kernel. Earlier tools tried to capture this graph by tracing or by parsing the Python source.

  3. Why it fell short

    Capture kept failing on real Python

    Real model code contains conditionals, loops over data, prints, and calls into arbitrary libraries. Earlier capture methods either silently produced a wrong graph, refused to run, or forced the user to rewrite the model.

  4. This paper's idea

    Rewrite the bytecode as the program runs

    TorchDynamo intercepts Python at the bytecode level just before execution. It extracts the parts it understands into a graph and leaves the rest to run as ordinary Python. TorchInductor then compiles that graph into GPU and CPU code.

Why the flexible version is slow

When you write PyTorch, each line does its work immediately:

y = a + b        # runs now, produces a tensor
z = y.relu()     # runs now, produces another tensor
w = z * 2        # runs now

This is called eager mode — the framework is eager, and does each operation the moment it is asked. The advantage is enormous for a person writing models. You can print a value in the middle. You can set a breakpoint. An error points at the line that caused it.

The cost is hidden in the memory system. Each of those three lines writes a complete tensor to GPU memory, and the next line reads it straight back. For large tensors, the arithmetic is trivial and the memory traffic dominates. The GPU spends most of its time moving numbers rather than computing with them.

A compiler that could see all three lines at once would do something obvious: compute (a + b), apply relu, and multiply by two while the values are still in registers, writing to memory only once. Combining several operations into one like this is called fusion. Eager mode cannot do it, because when line one runs, lines two and three have not happened yet.

What people tried before, and where it broke

The alternative is graph mode: capture the whole computation as a graph first, then hand that graph to a compiler.

The difficulty is the capture step, and the reason is that PyTorch models are written in Python — a language where almost anything can happen at any point. A model’s forward method may contain a conditional that depends on the input values, a loop whose length depends on the data, a call into a third-party library, or a print.

Earlier capture approaches ran into one of three walls:

  • Tracing runs the model once with example inputs and records what happened. Anything data-dependent is silently baked in. A conditional that took the if branch during tracing will take it forever, even when it should not.
  • Source parsing reads the Python text and tries to translate it. It fails on anything dynamic, and Python is a very dynamic language.
  • Rewrite the model puts the burden on the user, which defeats the purpose of a framework people chose for being pleasant to write.

The abstract states the goal precisely: capture graphs without sacrificing the flexibility of Python.

Where the two new pieces sit

  1. Your Python model

    Unchanged. Still ordinary PyTorch code.

  2. TorchDynamo new

    Rewrites bytecode before it runs; extracts operations into an FX graph.

  3. FX graph new

    The captured sequence of PyTorch operations.

  4. TorchInductor new

    Compiles the graph to Triton for GPUs, C++ for CPUs.

  5. Fast kernels

    What actually runs on the hardware.

TorchDynamo captures; TorchInductor compiles. They are separate on purpose — the abstract notes TorchDynamo supports many extensible backends, and TorchInductor is the default one.

The idea: intercept below Python, not above it

Both earlier approaches worked at the level of the Python source. TorchDynamo works one level lower, at the level of Python bytecode.

Python does not execute your source text directly. It first compiles each function into

bytecode A compact instruction format that the Python interpreter actually executes, produced automatically from your source code.
, a simple instruction sequence, and the interpreter runs that. TorchDynamo hooks into the moment just before a function’s bytecode executes, and rewrites it.

This position is what makes the difference. At the bytecode level, TorchDynamo can see exactly what the interpreter is about to do, including the actual types and values involved, which the source text alone never reveals.

It then divides the function into two kinds of work:

  1. Operations it understands — PyTorch tensor operations — which it extracts into an FX graph, PyTorch’s own graph representation.
  2. Everything else — the prints, the unusual library calls, the parts it cannot reason about — which it leaves running as normal Python.

This division is the key move. Earlier systems treated an unsupported construct as a failure of the whole capture. TorchDynamo treats it as a boundary: capture what you can, run the rest normally, and carry on.

What happens the first time a compiled function runs

Bytecode = the instruction format the Python interpreter executes. FX graph = PyTorch's representation of a sequence of tensor operations. Triton = a language for writing GPU kernels.

Step 1 of 5

You ask for compilation

You wrap your model with torch.compile. Nothing is compiled yet. PyTorch simply registers that this function should be intercepted when it is next called.

model = torch.compile(model)
# no work done yet

Why it works

Because interception happens at the bytecode level rather than the source level, TorchDynamo sees the actual operations and actual types about to execute. → Because it sees them, it can decide operation by operation what it understands, instead of accepting or rejecting the whole function. → Because unsupported code becomes a boundary rather than a failure, capture succeeds on real models that contain prints, conditionals and third-party calls. → Because the captured graph contains several operations rather than one, the backend can fuse them and write to memory once instead of after every step. → Because compiled results are reused across calls, the compilation cost is paid once and the speedup accrues on every later call.

That chain is what the abstract summarises as capturing graphs more robustly than prior approaches while adding minimal overhead.

What it costs

  • The first call is slow. Capture and compilation happen at run time. A program that calls a function only a few times may never earn the cost back.
  • Two new systems to understand. When results are wrong or slow, the engineer now debugs a capture stage and a compilation stage as well as their model.
  • The escape hatch has a price. Falling back to Python keeps capture working, but each fallback shrinks the graph, and a smaller graph gives the compiler less to optimise across.

What the measurements showed

The abstract reports the following, and this page reports nothing beyond it.

Reported results

PropertyEager PyTorch (baseline)With torch.compile
Inference speed1.00× by definition
Training speed1.00× by definition
Models tested—
Compared against—
All figures from the paper's abstract. Hardware: one NVIDIA A100 GPU. The full text was not reachable, so no further experimental detail is reported here.Underlined values explain themselves when clicked.

Check your understanding

  1. 1. Why is eager mode slower than a compiled graph, even though it runs the same arithmetic?

  2. 2. What is the advantage of intercepting at the bytecode level rather than reading the Python source?

  3. 3. Predict the outcome: you compile a model whose runtime is dominated by one very large matrix multiplication. What speedup should you expect?

  4. 4. TorchDynamo meets a print statement in the middle of a model. What does it do?

Related papers on Compilers & Programming Languages

See all 162 →