AI Infra
0%
Part IX · Chapter 63

The Software Substrate: Frameworks and Autodiff

AuthorChangkun Ou
Reading time~17 min

Between the model's mathematics and the accelerators of Chapter 62 sits a layer this book has so far taken for granted. Every training recipe in Part I assumed that one line of code, loss.backward(), produces the gradient of a scalar loss with respect to billions of parameters, and produces it cheaply. That assumption rests on a theorem, an implementation technique, and two decades of framework design fighting over how to expose them. This chapter works through all three: why the gradient of a program costs only a small constant times the program itself, how frameworks record and replay the computation to collect it, how the field's decade-long argument between graph compilers and eager execution resolved, and what a framework has become now that sharding a model across a cluster is part of the programming interface. By the end, a reader can say what actually happens between typing a loss function and the hardware floor doing arithmetic, and why that path looks the way it does.

The one-liner the stack rests on

Training, as Part I established, is gradient descent: compute the loss, compute its gradient with respect to every parameter, step downhill, repeat. The loss is easy; it is one forward run of the model. The gradient is the part that needs an idea, because a naive reading of "differentiate the model" suggests two approaches, and both fail at scale.

The first is numerical differencing: nudge one parameter by a small hh, rerun the model, and divide the change in loss by hh. That costs one forward run per parameter, which for a billion parameters is a billion forward runs per step, and the answer is corrupted from both sides, by truncation error when hh is too large and by floating-point round-off when hh is too small (Baydin et al. 2018). The second is symbolic differentiation, the computer-algebra move: manipulate the loss as a mathematical expression and emit a formula for its derivative. But the derivative formula of a composed expression can grow exponentially larger than the expression itself, a failure mode known as expression swell, and a formula cannot follow the branches and loops of an actual program (Baydin et al. 2018).

automatic differentiation takes a third route. Instead of differentiating a formula, it differentiates the execution: run the program, and at each elementary operation, a multiply, an add, a sin, apply the derivative rule for that one operation to the numerical values flowing through. The chain rule then composes those local derivatives into the exact gradient of whatever the program actually did, branches and loops included, correct to machine precision. There is no step size to tune and no expression to swell. What remains is the question of cost, and its answer is the founding theorem of this layer.

Reverse mode: one sweep for every weight

Write the program's execution as a list of elementary steps, each producing an intermediate value viv_i from earlier ones. This linearized trace has a name older than the field: a Wengert list, after the two-page 1964 paper that introduced it (Wengert 1964). Differentiation then has a direction. Forward mode pushes a derivative alongside the values, answering "if this one input wiggles, how does everything downstream move?"; one pass yields the sensitivity to one input, so the gradient of a scalar loss with respect to nn parameters costs nn passes. Reverse mode runs the trace once forward, storing the intermediates, then sweeps backward from the output, asking of each intermediate "how much does the final loss care about you?" and accumulating the answer, conventionally written vˉi=L/vi\bar v_i = \partial L / \partial v_i, called the adjoint of viv_i. One forward pass plus one backward sweep yields the sensitivity of the single output to every input at once (Baydin et al. 2018).

For training, the choice is not close. The loss is one scalar; the parameters number in the billions. Reverse mode gets the entire gradient for one forward and one backward sweep, and the cheap gradient principle makes the bound precise: counting multiplications, the gradient costs at most 3 times the function evaluation, a result of Baur and Strassen (Baur and Strassen 1983); counting all operations, the conservative bound is 5, regardless of how many inputs the function has (Griewank and Walther 2008; Griewank 2012). Backpropagation, rediscovered for layered networks and made famous in 1986 (Rumelhart et al. 1986), is exactly this reverse mode specialized to neural networks; the general form had been published for numerical programs a decade earlier (Linnainmaa 1976; Speelpenning 1980). It is worth pausing on how strange the result is. Sensitivity to one input and sensitivity to a billion inputs cost the same small multiple of the forward run. Nothing comparable holds for full Jacobians, the matrix of every output's sensitivity to every input; the theorem is a property of the many-to-one shape of a loss function, and the whole economics of deep learning leans on it.

tape x x = 2.0 x̄ = ȳ·(∂v₁/∂x) + z̄·(∂v₂/∂x) = y + cos(x) v1 v₁ = x·y x->v1 v2 v₂ = sin(x) x->v2 y y = 3.0 ȳ = x y->v1 z z = v₁ + v₂ z̄ = 1 v1->z v2->z
Figure 63.1. The Wengert list of z = x·y + sin(x), forward values on the way up, adjoints on the way back down. One reverse sweep charges the loss's sensitivity back through each recorded operation, and x, used twice, accumulates contributions from both consumers.

The runnable below is a complete reverse-mode engine in about fifteen lines, and it is not a toy in principle: PyTorch's autograd is this exact design with an industrial op set. Each arithmetic operation records its parents and the local derivative of its output with respect to each parent; that record is the Wengert list, built by ordinary operator overloading as the forward pass runs. Calling backward() on the output walks the records in reverse, multiplying and accumulating adjoints. Note that x feeds two operations, so its gradient arrives in two pieces and must be summed, which is why the field's tapes accumulate into .grad rather than assign, and why PyTorch programs call zero_grad() every step.

import math

class Var:
    def __init__(self, val, parents=()):
        self.val, self.parents, self.grad = val, parents, 0.0
    def __add__(self, o): return Var(self.val + o.val, [(self, 1.0), (o, 1.0)])
    def __mul__(self, o): return Var(self.val * o.val, [(self, o.val), (o, self.val)])
    def backward(self, seed=1.0):           # walk the tape in reverse
        self.grad += seed                   # += : a value used twice gets two contributions
        for parent, local in self.parents:
            parent.backward(seed * local)

def sin(v): return Var(math.sin(v.val), [(v, math.cos(v.val))])

x, y = Var(2.0), Var(3.0)
z = x * y + sin(x)          # the forward pass records the tape as a side effect
z.backward()                # one sweep, every input's gradient
print(x.grad, "== y + cos(x) =", 3.0 + math.cos(2.0))
print(y.grad, "== x")

One caveat separates the sketch from a real engine: this version recurses along every path, so a value reachable by many routes is visited many times. Production tapes topologically sort the graph and visit each node once, but the arithmetic they perform is the same.

The price is a tape in memory

The theorem bounds time, and it quietly sends the bill to memory. The backward sweep needs the intermediate values of the forward pass, the activations, so the tape's memory grows with the length of the computation. For a deep network, that is every layer's activations for the whole batch held live until the backward pass consumes them, and it is the reason training a model takes several times the memory of running it.

The remedy is as old as the field and still in production. Griewank showed in 1992 that placing checkpoints along the trace and recomputing the segments between them during the backward sweep trades a logarithmic factor of extra compute for logarithmic memory (Griewank 1992). The deep-learning rediscovery, sublinear-memory training, made the trade concrete for layer-structured networks: keep activations only at O(n)O(\sqrt{n}) of the layers, recompute the rest on demand, and a thousand-layer network's training memory drops from 48 GB to 7 GB for roughly 30% more time (Chen et al. 2016). Frameworks ship this as activation recomputation, and the reader has already seen its fingerprint from the hardware side.

Constraint arrow

The gap between HFU and MFU in Chapter 62 is this chapter's theorem casting a shadow upward. Reverse mode's tape makes training memory proportional to computation length; recomputation buys that memory back by redoing forward work; and the redone work is the arithmetic that HFU counts and MFU refuses to. A profile in which HFU sits above MFU is the autodiff memory bill, paid in FLOPs, visible on a hardware dashboard two layers up.

From graphs to tape: the framework wars

A framework is the industrialization of the machinery above, and the field needed a decade to agree on its shape. The argument was about one design decision: when does the framework see the program?

The first camp answered "before it runs." Theano, from Bengio's lab in 2010, had the user build a symbolic expression graph, which the framework then optimized, differentiated as a graph transformation, and compiled to C++ or CUDA (Bergstra et al. 2010). TensorFlow scaled the same define-and-run design to Google's fleet in 2015, with a session executing a static dataflow graph that could be partitioned across hundreds of machines (Abadi et al. 2016); its predecessor DistBelief had already trained billion-parameter networks on tens of thousands of CPU cores, but as a system configured rather than programmed (Dean et al. 2012). The whole-graph view bought real things: the framework could fuse operations, plan memory, serialize the model, and place subgraphs on devices. What it cost was the programmer. A graph program is a program that builds a program, so a shape error surfaces far from the line that caused it, a debugger cannot step through what has not run yet, and a loop must be written as a graph operator rather than a for statement.

The second camp answered "while it runs." Chainer named the alternative define-by-run in 2015: execute ordinary host-language code eagerly, record the operations as they happen, and differentiate the recorded tape, so the network's structure is just the trace of whatever Python did, branches, recursion, and all (Tokui et al. 2015). Torch7 had cultivated the same imperative culture in Lua for years (Collobert et al. 2011), and the HIPS autograd project showed the tape could shadow plain NumPy (Maclaurin et al. 2015). PyTorch fused these lineages in January 2017, an eager tape with Torch's C libraries underneath, engineered so that Python stays off the hot path while usability stays in it (Paszke et al. 2019).

Research voted with its feet, and the margin was measurable: by 2019, PyTorch accounted for 69% of framework-identified CVPR papers and strong majorities at the major NLP venues, with TensorFlow's research share flat or shrinking (He 2019); OpenAI standardized on PyTorch in January 2020 (OpenAI 2020). The graph camp conceded the interface in the same season: TensorFlow 2.0 made eager execution the default in 2019. The tape had won the argument over how a model is written. Whether it also won the argument over how a model is run is the next chapter's question, because the graph's optimizations were never the part that was wrong.

familytree theano Theano (2010) symbolic graphs, ended 2017 tf TensorFlow 1 (2015) define-and-run at fleet scale theano->tf idea jax JAX (2018) pure functions traced to XLA theano->jax trace-and-compile db DistBelief (2012) db->tf rebuilt as tf2 TensorFlow 2 (2019) eager by default tf->tf2 torch7 Torch7 (2011) imperative, Lua pytorch PyTorch (2017) eager + tape torch7->pytorch C libraries chainer Chainer (2015) define-by-run, ended 2019 chainer->pytorch define-by-run hips HIPS autograd (2015) tape over NumPy hips->pytorch tape design hips->jax same authors pt2 PyTorch 2 (2023) torch.compile: graph capture pytorch->pt2
Figure 63.2. The framework family tree. The define-and-run lane (left) and define-by-run lane (right) exchanged moves for a decade and converged on the same design point: eager semantics with graph capture underneath.

JAX, open-sourced by Google in December 2018, is the synthesis rather than a third camp. Its programs look eager, plain NumPy on arrays, but its transformations, grad, jit, vmap, work by tracing a pure function into a graph and handing it to the XLA compiler (Frostig et al. 2018). The price of the synthesis is purity: side effects vanish under tracing, and data-dependent Python control flow must be written with structured combinators, the same concession TF1 asked for, now scoped to the compiled region. The descendants of HIPS autograd thus split the design space in two: PyTorch kept the host language and retrofits the compiler, JAX kept the compiler and disciplines the host language.

The convergence, and what it left unresolved

Read as a single history, the war ended in a trade. Eager semantics won the programming model because researchers debug programs, not graphs; graph execution won the runtime because Chapter 62's hardware floor rewards fused, planned, ahead-of-time-scheduled work. Every surviving stack is now some negotiation between the two. TensorFlow 2 runs eagerly and stages graphs through tf.function. JAX runs traced-pure functions under jit. PyTorch tried a static-language subset first, TorchScript, which its own retrospective describes as working on roughly half of real-world models because it demanded the whole program obey it; torch.compile, stable since March 2023, inverted the bet by capturing what it can and falling back to Python where it cannot, keeping eager semantics by construction (Ansel et al. 2024). How that capture works, and what the compiler under it does with the captured graph, is the subject of Chapter 64.

What the convergence did not settle is everything this bought and cost. An eager step pays Python dispatch per operation, tolerable when each operation is a large matrix multiply, ruinous when kernels are microseconds long; a captured graph amortizes dispatch but must detect when its assumptions break and recompile, so the trade returns as compile latency and cache misses. The chapter's earlier theorem is indifferent to the choice; the constant factors are not, and production systems live in the constant factors.

What a framework is now

Strip the branding and a 2026 framework is five commitments layered on the autodiff core, and the last of them is new.

The first is a tensor library with a stable operator surface, hundreds of mathematical operations with agreed shapes and dtypes. The second is the autodiff engine, the industrial Wengert list. The third is a dispatcher that routes each operation, by device, precision, and mode, to a concrete kernel, the mechanism that lets one program run on an NVIDIA GPU, a TPU, or a laptop by swapping the bottom of the stack. The fourth is a device runtime, and it is easy to underestimate: streams to keep the accelerator asynchronous from Python, and a caching memory allocator that exists because the vendor's own free routine can synchronize the entire device, so the framework must recycle GPU memory itself rather than return it (Paszke et al. 2019). Every framework that reached production grew an allocator; its behavior under fragmentation is a recurring operational incident in Chapter 31's world.

The fifth commitment is the cluster. Distribution used to live outside the framework, in wrapper libraries and launcher scripts; it has moved inside the programming model as sharding, annotations declaring how a tensor is split across a device mesh, with the system inserting the communication. GSPMD demonstrated the design inside XLA, a few annotations propagated through the whole graph by the compiler (Xu et al. 2021), and it now surfaces as JAX's named shardings and shard_map, and as PyTorch's DTensor over a DeviceMesh, with FSDP as the packaged sharded-training recipe on top (Zhao et al. 2023). The parallelism mathematics is Chapter 10's; what belongs to this chapter is the interface fact that the framework, not a script around it, is now where a run's layout is written down, which is precisely why the bandwidth tiers of Chapter 62 reach the programmer as a type signature rather than a deployment detail.

Institutionally, the layer has stabilized the way infrastructure does: PyTorch moved from Meta into a Linux Foundation entity in 2022, which by 2025 also hosted vLLM and DeepSpeed, while JAX remains Google-led open source with its compiler substrate governed separately under OpenXLA. As of mid-2026 the stable lines are PyTorch 2.12 and JAX 0.10, with PyTorch's sharded-tensor machinery documented but still formally alpha, a fair snapshot of how young commitment number five remains.

What's contested

Two arguments run through this layer in 2026. The first is functional versus imperative at frontier scale. Google trains Gemini on JAX and Pathways, with a single Python process orchestrating the entire run (Gemini Team, Google 2023); xAI's released Grok code is JAX; Anthropic has never published its training stack, though Google names it as a JAX user and its TPU and Trainium fleets point the same way. Meta and OpenAI train on PyTorch. Advocates of the functional side cite reproducibility, bitwise-deterministic restarts, and compiler leverage; the imperative side answers that ecosystem gravity, hiring, and the infrastructure seams decide real projects, and documented migrations off JAX over cluster-networking integration exist (Lechner 2025). The second argument is whether the framework layer is dissolving: with PyTorch lowering to a compiler and JAX born as one's front end, one camp reads frameworks as thinning veneers over a shared compiler-and-kernel substrate, while the other observes that the veneer, the API, its packaging, and its community, is exactly where every previous winner was decided. The next chapter's subject, the compiler underneath, is where both arguments now point.

Further reading

  • Baydin et al., “Automatic Differentiation in Machine Learning: a Survey” (the precise separation of AD from symbolic and numerical differentiation), 2018. arXiv:1502.05767
    The definitive survey of automatic differentiation for machine learning: forward and reverse modes, their cost asymmetry, and why AD is neither symbolic nor numerical differentiation.
  • Griewank, “Who Invented the Reverse Mode of Differentiation?” (twelve readable pages of history and the complexity results), 2012. ftp.gwdg.de
    A readable history of reverse-mode differentiation from Linnainmaa's 1970 insight to modern practice, including the cheap gradient principle and checkpointing complexity results.
  • Wengert, “A simple automatic derivative evaluation program” (the two-page origin of the evaluation trace), 1964. dl.acm.org
    The two-page 1964 paper that introduced the evaluation trace, the linearized list of elementary operations that every tape-based autodiff engine still records.
  • Griewank, Andreas; Walther, Andrea. Evaluating Derivatives: Principles and Techniques of Algorithmic Differentiation (the canonical reference: the cheap gradient principle and optimal checkpointing). SIAM, 2008. epubs.siam.org
    The canonical reference on algorithmic differentiation: the cheap gradient principle, the complexity rules, and provably optimal checkpointing schedules.
  • Chen et al., “Training Deep Nets with Sublinear Memory Cost” (the paper behind activation recomputation), 2016. arXiv:1604.06174
    Keep activations at only O(sqrt(n)) of a network's layers and recompute the rest during the backward pass: a 1000-layer network's training memory drops from 48 GB to 7 GB for about 30
  • Bergstra et al., “Theano: A CPU and GPU Math Compiler in Python” (the ancestor: graphs, symbolic gradients, code generation), 2010. proceedings.scipy.org
    The ancestor of the deep-learning framework: build a symbolic expression graph in Python, differentiate it as a graph transformation, and compile it to CPU or GPU code.
  • Abadi et al., “TensorFlow: A system for large-scale machine learning” (define-and-run at industrial scale), 2016. arXiv:1605.08695
    Google's second-generation system: a static dataflow graph executed by a session, partitionable across hundreds of heterogeneous machines, rebuilt from the lessons of DistBelief.
  • Tokui et al., “Chainer: a Next-Generation Open Source Framework for Deep Learning” (where define-by-run was named), 2015. learningsys.org
    The paper that coined define-by-run: execute host-language code eagerly, record operations as they happen, and differentiate the recorded tape, with the case against static graphs made in print.
  • Paszke et al., “PyTorch: An Imperative Style, High-Performance Deep Learning Library” (eager plus tape as a deliberate design), 2019. arXiv:1912.01703
    PyTorch's design paper: an eager tape-based autodiff with the hot path in C++, a caching CUDA allocator, and the argument for why usability and performance are not opposites.
  • Frostig et al., “Compiling machine learning programs via high-level tracing” (four pages that define JAX), 2018. mlsys.org
    The four-page paper that defines JAX: trace pure Python/NumPy functions to a graph, lower to XLA, and expose differentiation, compilation, and vectorization as composable transformations.
  • He, “The State of Machine Learning Frameworks in 2019” (the framework war settled with counted papers), 2019. thegradient.pub
    Settles the framework war with counted conference papers instead of vibes: PyTorch at 69
  • Ansel et al., “PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation” (the graph-break design that let eager keep its semantics), 2024. docs.pytorch.org
    The retrospective on TorchScript's failure and the design of torch.compile: capture graphs from Python bytecode, break the graph where capture fails, and preserve eager semantics by construction.
  • Xu et al., “GSPMD: General and Scalable Parallelization for ML Computation Graphs” (sharding by annotation and propagation), 2021. arXiv:2105.04663
    GSPMD is a compiler-based automatic parallelization system that uses tensor sharding annotations to express data parallelism, tensor parallelism, and pipeline parallelism uniformly, achieving 50-62
  • Zhao et al., “PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel” (sharded training as a framework feature), 2023. arXiv:2304.11277
    What it takes to make sharded training a framework feature: FSDP co-designed with PyTorch's allocator, dispatcher, and CUDA caching semantics rather than bolted on outside.

Comments

Log in to comment