Automatic Differentiation
Language revisions
- C++20
- Concepts allow a kernel to be written over any type modelling a field, which is what makes overloading-based AD transparent.
- C++23
- Deducing this simplifies expression-template hierarchies used by taping implementations.P0847R7
- C++26
- Reflection permits a tape or an adjoint to be generated from the structure of a function.P2996
Definition
For , the Jacobian factors as
and the two modes are the two association orders of this product. Forward mode computes right to left, propagating then ; reverse mode computes left to right, propagating then .
Motivation
Every gradient-based method in Optimization, every Newton iteration in Analysis, and every sensitivity analysis requires derivatives. The three ways of obtaining them differ sharply:
| Method | Accuracy | Cost of , inputs | Effort |
|---|---|---|---|
| Finite differences | relative | evaluations | None |
| Symbolic | Exact | Expression swell | Manual or CAS |
| Automatic, forward | Exact to rounding | evaluations | Type substitution |
| Automatic, reverse | Exact to rounding | evaluations | Tape or transformation |
The reverse-mode row is the one that matters: a gradient of a scalar function of variables costs a small constant multiple of one function evaluation, independent of . For this is the difference between feasible and impossible.
Formal formulation
Forward mode evaluates on dual numbers, , with arithmetic
so that evaluating at yields by the algebra alone. For , one forward sweep with seed produces the Jacobian–vector product at cost
so the full Jacobian costs sweeps.
Reverse mode records the computation, then propagates adjoints backwards:
One reverse sweep produces the vector–Jacobian product at cost
independent of . This is the cheap gradient principle, and it is the reason large-scale optimisation is possible at all.
Second derivatives follow by composition: forward-over-reverse gives a Hessian–vector product at function evaluations, which is exactly what the truncated-Newton methods of Optimization require.
C++ representation
Forward mode as a numeric type, usable by any generic kernel:
#include <cmath>
#include <concepts>
namespace hpc {
// A dual number: value plus one directional derivative.
template <std::floating_point T>
struct dual {
T value{};
T derivative{};
friend constexpr dual operator+(dual a, dual b) noexcept {
return {a.value + b.value, a.derivative + b.derivative};
}
friend constexpr dual operator-(dual a, dual b) noexcept {
return {a.value - b.value, a.derivative - b.derivative};
}
friend constexpr dual operator*(dual a, dual b) noexcept {
return {a.value * b.value, a.derivative * b.value + a.value * b.derivative};
}
friend constexpr dual operator/(dual a, dual b) noexcept {
return {a.value / b.value,
(a.derivative * b.value - a.value * b.derivative) / (b.value * b.value)};
}
friend dual sin(dual a) noexcept {
return {std::sin(a.value), a.derivative * std::cos(a.value)};
}
friend dual exp(dual a) noexcept {
const T e = std::exp(a.value);
return {e, a.derivative * e};
}
friend dual sqrt(dual a) noexcept {
const T s = std::sqrt(a.value);
return {s, a.derivative / (T{2} * s)};
}
};
// A kernel written against Field works unchanged on dual<double>.
template <class T>
constexpr T rosenbrock(T x, T y) noexcept {
const T a = T{1} - x;
const T b = y - x * x;
return a * a + T{100} * b * b;
}
} // namespace hpc
The last function is the point of the entry: it was written for double and differentiates
because it was written generically. Genericity, treated in
Generic Programming as an organising principle, here
pays a concrete dividend.
Reverse mode requires a record of the computation:
#include <cstddef>
#include <vector>
namespace hpc {
// A tape entry: the partials of one operation with respect to its operands.
struct tape_entry {
std::size_t lhs;
std::size_t rhs;
double lhs_partial;
double rhs_partial;
};
class tape {
public:
std::size_t record(std::size_t lhs, std::size_t rhs,
double lhs_partial, double rhs_partial);
// Propagate adjoints backwards from the output; Θ(tape length).
void backpropagate(std::size_t output, std::vector<double>& adjoints) const;
private:
std::vector<tape_entry> entries_;
};
} // namespace hpc
Implementation
Forward mode is implemented as a type and requires nothing else; it is the correct choice for small, for Jacobian–vector products, and for code that must remain allocation-free. Vector forward mode (carrying derivatives per value) amortises the function evaluation over directions and vectorizes naturally.
Reverse mode requires storage proportional to the number of operations executed, which for a long simulation is the binding constraint. Checkpointing trades recomputation for memory: storing the state at points of an -step computation and recomputing between them reduces storage to at twice the operation count, and the optimal schedule for a given memory budget is given by the revolve algorithm.
Elementary functions must be extended with their derivative rules, and the extension is where
correctness is lost most often: abs at zero, sqrt at zero, pow with a non-constant
exponent, and branches that make the function non-differentiable at the branch point. The rule
applied here is that a non-differentiable point is reported rather than assigned an arbitrary
subgradient.
Where a routine has a known analytic derivative (a linear solve, an eigenvalue, an iterative fixed point) differentiating through the implementation is wasteful and often unstable. The correct treatment is a custom rule: for , the adjoint is obtained from a solve with , at the cost of one additional solve rather than of differentiating the factorization.
Complexity
| Operation | Time | Space | Remarks |
|---|---|---|---|
| Function evaluation | C | Θ(1) | Baseline |
| Forward mode, one direction | ≈ 2–3 C | Θ(1) | Jv |
| Full Jacobian, forward, n inputs | ≈ 2–3 nC | Θ(1) | n sweeps |
| Reverse mode, one adjoint | ≈ 3–5 C | Θ(operations) | wᵀJ, independent of n |
| Full Jacobian, reverse, m outputs | ≈ 3–5 mC | Θ(operations) | m sweeps |
| Hessian–vector, forward over reverse | ≈ 10 C | Θ(operations) | ∇²f · v |
| Finite-difference gradient | (n+1) C | Θ(1) | Only √u relative accuracy |
| Reverse with √L checkpointing | ≈ 2 × reverse | Θ(√L) | L computational steps |
Performance considerations
Forward mode with directions carried simultaneously is the vectorization-friendly form: the derivative components occupy consecutive lanes, and the arithmetic is unit-stride. It also amortises the value computation across directions, so directions cost far less than separate sweeps.
Reverse mode's cost is dominated by tape memory traffic rather than by arithmetic. A tape entry of 32 bytes per operation means a computation performing operations produces 32 GB of tape; checkpointing is not an optimisation but a requirement at that scale.
Operator-overloading implementations obstruct vectorization of the original kernel, since the arithmetic becomes calls on a class type. Where the kernel is performance-critical and its derivative is needed, a hand-written adjoint of the kernel (verified against the automatic one) is typically several times faster.
The correctness check is cheap and should always be present: comparing an automatic derivative against a finite-difference approximation in a random direction detects most implementation errors, with agreement expected to about relative accuracy.
Limitations
Reverse-mode memory grows with the number of operations, and checkpointing mitigates rather than removes the growth.
Control flow is differentiated along the path taken. A function with a data-dependent branch is differentiated as though the branch condition were constant, which is correct almost everywhere and wrong exactly at the branch point, a set of measure zero that optimisation algorithms nevertheless visit.
Iterative solvers differentiated through their iterations produce derivatives of the approximation, not of the exact solution, and the derivative converges more slowly than the value. Custom rules based on the implicit function theorem are the correct treatment.
Higher-order derivatives by nesting cost exponentially in the order, and the expression growth makes third and higher derivatives impractical without specialised techniques such as Taylor mode.
Related entries
Optimization and Optimization for the consumers of gradients; Generic Programming for why a generic kernel differentiates for free; Numerical Analysis for the finite-difference comparison.
References
- A. Griewank and A. Walther, Evaluating Derivatives, 2nd ed., SIAM, 2008.
- A. Griewank and A. Walther, Algorithm 799: Revolve, ACM TOMS 26(1), 2000.
- C. Bischof et al., ADIC and ADIFOR, on source transformation.
- M. Blondel and V. Roulet, The Elements of Differentiable Programming, 2024.