the pytorch path · 0/12
start the path

the PyTorch path · Bridges · lesson 03 of 5

Three modes, one machine

Eager mode on an XLAThe compiler: brilliant at fusing along dataflow edges, structurally unable to change your algorithm. That gap is why kernels exist.taught in /l/xla → device is not a second execution engine. It is one boolean and a call to the same sync function after every op, which is why its cache, its lowering, and its recompiles behave exactly like the lazy path you already know.

the goal For lazy tracing, eager mode, torch_xla.compile and torch.compile with the openxla backend, name what each changes about the machinery from lesson one, and say which one a given workload should use and what it costs.

mastery work · this chapter0/3
  1. go →
manual items are your word; auto items complete from your streaks, labs, and can-you ticks · stored in your browser only
§ 01

Eager is a bool and a one-line function

The whole Python surface of eager mode is 32 lines: a setter, a getter, and a context manager that saves and restores. On the C++ side it is one boolean on the graph executor. Three call sites in the tensor layer read that boolean, and when it is set they call ApplyEagerSync on the tensors they just created.

ApplyEagerSync is one statement. It syncs the given tensors with wait false and sync_ltc_data false, which is to say it runs the same barrier machinery, per op, without turning everything into device data and without blocking. Same lowering, same builder, same LRU computation cache.

The cache part has an observable consequence the test suite pins. Run logsumexp on a 5 by 5 tensor twice and the eager compile counter stays at one while the eager execute counter goes to two. Eager mode is compile-per-op-shape, not compile-per-op. Multi-output ops go further: they construct every output with execution delayed, then call ApplyEagerSync once, so a multi-output op costs one compilation rather than N.

verbatim, torch_xla/csrc/xla_graph_executor.cpp:281-283 at 41398bf: the entire difference between eager and lazy
void XLAGraphExecutor::ApplyEagerSync(std::vector<XLATensorPtr>& tensors) {
  SyncTensorsGraph(&tensors, {}, /*wait=*/false, /*sync_ltc_data=*/false);
}
§ 02

What per-op sync costs, in their numbers

The repo publishes a measurement rather than an adjective, and since nothing in this course has a TPU attached, their measurement is the one to quote. A two-layer decoder-only model, fake data, 300 steps, one chip of a v4-8.

The caveat under the table matters more than the table. Pure eager reaches about 45 percent of compiled performance for decoder-only models, and about 1 percent for ResNet50. A number that swings by a factor of forty across two ordinary models is not a performance characteristic you can carry between workloads.

The docs draw the conclusion themselves: eager mode is for data preprocessing, random number generation, custom utilities and debugging, where immediate execution beats throughput. Not for the training loop, and not for the inference loop.

modetoken/s
tracing mode (base line)147
eager mode65
eager + torch_xla compile147
quoted from docs/source/learn/eager.md at 41398bf; measured by the maintainers on one chip of a v4-8, not on any machine this course touched
§ 03

Torch_xla.compile flushes at both ends

The decorator that recovers the lost throughput is a context manager, and its body is a sequence you can read in one screen. It turns eager off inside the region. It names the pending graph, syncs to flush whatever was already queued, then renames the graph to yours, so dataloading does not leak into the step graph and show up as a shape change.

It sets an allow_execution flag to the negation of full_graph. It optionally opens a dynamic-shape-detector session with a cap on how many distinct graphs the region may produce. Then it yields, and on the way out it restores everything and syncs again.

The full_graph=True enforcement is the part worth pausing on, because there is no strict-mode code path. When allow_execution is false, any execution inside the region reaches the debug analyzer, which prints an unexpected-execution report and then raises. The diagnostic is the enforcement mechanism. That also means the report you get names the Python frame that broke your full graph, which is more than a strict mode would have told you.

verbatim, torch_xla/torch_xla.py:190-215 at 41398bf, the body of the compile context manager
  @contextlib.contextmanager
  def _compile():
    saved_eager_mode_status = torch_xla._XLAC._get_use_eager_mode()
    saved_allow_execution = torch_xla._XLAC._get_allow_execution()
    saved_current_graph_name = torch_xla._XLAC._get_current_graph_name()
    torch_xla._XLAC._set_use_eager_mode(False)
    if name is not None:
      torch_xla._XLAC._set_current_graph_name(name + '_clear_pending')
    # Clear pending operations
    _clear_pending_ops_before_compile()

    if name is not None:
      torch_xla._XLAC._set_current_graph_name(name)

    # if full_graph sets to true execution can not happen before the sync below
    torch_xla._XLAC._set_allow_execution(not full_graph)

    if max_different_graphs is not None:
      torch_xla._XLAC._dynamic_shape_detector_set_max_different_graphs(
          max_different_graphs)
      torch_xla._XLAC._dynamic_shape_detector_start_session(current_id)

    try:
      yield
    finally:
      torch_xla._XLAC._set_allow_execution(saved_allow_execution)
§ 04

The openxla backend is not in this repository

Go looking for where torch.compile(backend="openxla") gets registered and you will not find it in pytorch/xla. There is no register_backend call for that name anywhere in the tree. Registration lives in PyTorch itself, and the definition is three lines that decide the shape of everything after.

openxla is aot_autograd wrapping a forward compiler. Because it goes through AOTAutograd, forward and backward reach the torch_xla bridge as separate FX graphs. That single fact is the source of the training-side cost, and the docs state the consequence outright: three graphs per training step instead of one.

What dynamo buys in exchange is real. Per step it skips FX interpretation, ATen dispatch, IR construction, the post-order walk, hashing and cache lookup by graph content, because the bridge holds a precomputed hash and jumps straight to execution. What it still pays per step is a Python loop splitting tensors from symbolic constants, an input materialization check that blocks if any input carries pending IR, an input list rebuild, and a copy back into every in-place-updated argument.

verbatim from torch/_dynamo/backends/torchxla.py in pytorch itself (definition at line 30, registration at 70-73, joined here); checked against an installed torch 2.2.2 on this machine
def openxla_eval_boxed(model, fake_tensor_inputs):
    return xla_backend_helper(model, fake_tensor_inputs, boxed=True)


openxla = aot_autograd(
    fw_compiler=openxla_eval_boxed,
)
register_backend(name="openxla", compiler_fn=openxla)
§ 05

The cliff at the end of the dynamo path

The lazy path can always recover from a cache miss by lowering the graph again. The dynamo path cannot, and the source says so with a TODO next to the check that fails.

ExecuteComputationWithBarrier looks its computation up by hash alone. There is no graph left to re-lower, because the whole point was to stop retracing. If the entry has been evicted from the 2048-entry LRU, the run throws. A long job with more distinct shapes than cache slots does not degrade; it stops.

Two smaller cliffs sit next to it. Under SPMD, dynamo cannot see an input sharding change, so the bridge compares shardings for a few steps and then stops checking; a sharding change after that crashes in the execution thread and leaves the result as a placeholder. And a dynamic-shape program does not retrace under dynamo but still compiles a fresh HLO per new shape, so the compile bill arrives even when the trace bill does not.

The lazy path can always re-lower. The dynamo path has thrown away the graph, so eviction is an error, not a recompile.
verbatim, torch_xla/csrc/xla_graph_executor.cpp:756-762 at 41398bf, in the dynamo execution path
  // TODO implement a fallback mechanism, or make sure those entries
  // never get kicked out
  XLA_CHECK(cachedComputation)
      << "Failed to get computation by hash " << torch::lazy::HashToString(hash)
      << ". Maybe the entry get "
         "kicked out of the LRU cache";
§ 06

Which mode, for what

The repo's own recommendation as of this commit splits by workload. Wrap the whole step function in torch_xla.compile for training. Use torch.compile(backend="openxla") for inference, where it lowers tracing overhead and the three-graph problem does not arise because there is no backward. The docs add that the long-term aim is for torch.compile to be the single compilation API for both.

Eager mode sits outside that split, as a debugging and utility mode. It is still torch_xla.experimental.eager_mode at this commit, and the docs say it is likely to become the default in future releases. The last lesson in this arc explains why that future release is probably not torch_xla.

Whichever mode you pick, the machinery under it is the one from lesson one. The hash rules do not change, the cut points do not change, and the seam under all three is the same class, which is where this arc goes next.

before you move on

Check yourself

01 What exactly changes when eager mode is on?

One boolean on the graph executor, read at three tensor-layer call sites that then call ApplyEagerSync, which is one call to SyncTensorsGraph with sync_ltc_data false and wait false. Same lowering, same builder, same LRU cache.

02 Why does the openxla backend produce three graphs per training step?

Because it is registered as aot_autograd, so forward and backward arrive at the torch_xla bridge as separate FX graphs, with the sync-input graph alongside them. The lazy path gets one graph per step.

03 What happens when a dynamo graph hash is evicted from the computation cache?

The run throws. ExecuteComputationWithBarrier looks up by hash alone and XLA_CHECKs on the miss, next to a TODO about a fallback. The lazy path never hits this because it can lower the graph again.

assigned

Readings