Skip to main content

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 f=fL∘⋯∘f1f = f_L \circ \cdots \circ f_1, the Jacobian factors as

J=JLJL−1⋯J1,J = J_L J_{L-1} \cdots J_1,

and the two modes are the two association orders of this product. Forward mode computes right to left, propagating J1vJ_1 v then J2(J1v)J_2(J_1 v); reverse mode computes left to right, propagating wTJLw^{\mathsf{T}}J_L then (wTJL)JL−1(w^{\mathsf{T}}J_L)J_{L-1}.

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:

MethodAccuracyCost of ∇f\nabla f, nn inputsEffort
Finite differencesO(u)O(\sqrt{u}) relative(n+1)(n+1) evaluationsNone
SymbolicExactExpression swellManual or CAS
Automatic, forwardExact to roundingn⋅O(1)n \cdot O(1) evaluationsType substitution
Automatic, reverseExact to roundingO(1)O(1) evaluationsTape or transformation

The reverse-mode row is the one that matters: a gradient of a scalar function of nn variables costs a small constant multiple of one function evaluation, independent of nn. For n=106n = 10^6 this is the difference between feasible and impossible.

Formal formulation​

Forward mode evaluates on dual numbers, D={a+bε:ε2=0}\mathbb{D} = \{a + b\varepsilon : \varepsilon^2 = 0\}, with arithmetic

(a+bε)(c+dε)=ac+(ad+bc)ε,(a + b\varepsilon)(c + d\varepsilon) = ac + (ad + bc)\varepsilon,

so that evaluating ff at x+εx + \varepsilon yields f(x)+f′(x)εf(x) + f'(x)\varepsilon by the algebra alone. For f:Rn→Rmf : \mathbb{R}^n \to \mathbb{R}^m, one forward sweep with seed vv produces the Jacobian–vector product JvJv at cost

cost(Jv)≤cfwd⋅cost(f),cfwd≈2–3,\mathrm{cost}(Jv) \le c_{\text{fwd}} \cdot \mathrm{cost}(f), \qquad c_{\text{fwd}} \approx 2\text{–}3,

so the full Jacobian costs nn sweeps.

Reverse mode records the computation, then propagates adjoints vˉi=∂y/∂vi\bar{v}_i = \partial y / \partial v_i backwards:

vˉi=∑j:i≺jvˉj∂vj∂vi.\bar{v}_i = \sum_{j : i \prec j} \bar{v}_j \frac{\partial v_j}{\partial v_i}.

One reverse sweep produces the vector–Jacobian product wTJw^{\mathsf{T}}J at cost

cost(wTJ)≤crev⋅cost(f),crev≈3–5,\mathrm{cost}(w^{\mathsf{T}}J) \le c_{\text{rev}} \cdot \mathrm{cost}(f), \qquad c_{\text{rev}} \approx 3\text{–}5,

independent of nn. 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 ∇2f⋅v\nabla^2 f \cdot v at O(1)O(1) 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 nn small, for Jacobian–vector products, and for code that must remain allocation-free. Vector forward mode (carrying kk derivatives per value) amortises the function evaluation over kk 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 L\sqrt{L} points of an LL-step computation and recomputing between them reduces storage to O(L)O(\sqrt{L}) 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 x=A−1bx = A^{-1}b, the adjoint is obtained from a solve with ATA^{\mathsf{T}}, at the cost of one additional solve rather than of differentiating the factorization.

Complexity​

Complexity
OperationTimeSpaceRemarks
Function evaluationCΘ(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 kk directions carried simultaneously is the vectorization-friendly form: the kk derivative components occupy consecutive lanes, and the arithmetic is unit-stride. It also amortises the value computation across directions, so kk directions cost far less than kk 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 10910^9 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 u\sqrt{u} 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.

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.