TLDR; Mathematically, forward and reverse mode differentiation differ only in what order we choose to compute a sequence of matrix products. In practice, reverse mode differentiation is more complicated and should always-and-only be used for functions with many inputs and few outputs. Otherwise use forward mode differentiation.
Consider a computer program composed of elemental mathematical building blocks and intermediate vector-valued states
We are interested in computing the output and its gradient for a given input . The chain rule states that
where is the Jacobian matrix of at the point . The size is . In particular, if the input size is larger than the output size, the Jacobian is a “flat” matrix (and “tall” in the other case).
By associativity of matrix multiplication, i.e. for all matrices , , and , we can choose to write the chain rule in two ways:
The first form, where we evaluate the expression from right to left, is called forward mode differentiation. The second form, where we evaluate the expression from left to right, is called reverse mode differentiation. In both cases, when we evaluate the full gradient, we do not need to build all these Jacobian matrices explicitly (which could be costly for large sizes). Instead, it is sufficient to be able to compute the Jacobian-vector products for forward mode and vector-Jacobian products for reverse mode.
Forward mode differentiation
The pushforward function (or Jacobian-vector product) of a differentiable function is defined as
where is a given state vector and is a gradient seed being “pushed forward” in the computational chain. The seed can be a column vector or a matrix (collection of column vectors) of the same size as .
Since both the gradients and the states can be computed at the same time, a sequential program can be executed with a single for-loop without saving any intermediate states:
- Initialize the state and the seed given as an identity matrix of the same size as .
- For , compute Now and are no longer needed and can be discarded.
- Return the full gradient .
Here we assume that the computer has access to the pushforward functions of all the elemental functions in the program (e.g. the rule must be hard-coded somewhere if you want to use in your program).
At first glance, forward mode differentiation seems simple and efficient. It does not require storing all previous states. However, for use cases where the input of the program is high-dimensional and the output is low dimensional, it can be computationally expensive. This is often the case in deep learning, where the input contains millions of neural network weights and the output is a scalar loss function value. In this case, at each step in the program but the last, is a large matrix of size millions-times-millions, even though the final gradient is just a vector of the same size as .
Reverse mode differentiation
The pullback function (or vector-Jacobian product) of a differentiable function is defined as
where is a given state vector and is an adjoint variable being “pulled back” in the computational chain. The adjoint variable should be a row vector or a matrix (collection of row vectors) of the same size as the column vector .
For convenience, we also define the (partially applied) pullback function at a given state as
The canonical reverse mode differentiation algorithm is implemented using two for-loops; a forward pass to compute (and store!) the states, and a reverse pass to compute the gradients:
- Assign the initial state .
- Forward pass: for , compute and store the pullback for later use (this may require storing the full state so that can be called).
- Initialize the final adjoint seed as an identity matrix of the same size as the output vector (typically and are both scalars).
- Backward pass: for decreasing , compute Now and are no longer needed and can be discarded.
- Return the full gradient .
For high-dimensional inputs and low-dimensional outputs, reverse mode differentiation is the go-to method of choice. However, the double for-loops in opposite order (“forward” and “back-propagation”) and the requirement to store all the intermediate states of the forward pass can cause quite some headaches (and computer memory issues). Multiple strategies extist to mitigate these issues:
- Checkpointing: If storing all the states takes up too much space, we can store every 10th state and recompute the 9 missing states between the current and next stored state when needed.
- Reverse accumulation (for the bravehearted): Do a forward pass to compute , but do not store any intermediate states. In the backward pass, compute alongside . However, the program components may be badly conditioned or even non-invertible, in which case this method should not be used.
How “automatic” is automatic differentiation?
AD engines work by decomposing programs into mathematical building blocks that it knows how to differentiate. This knowledge needs to be hard-coded by humans. The ChainRules.jl ecosystem in Julia provides a nice framework for specifying pushforward and pullback rules, along with pre-defined rules for many common functions. In computers, most mathematical functions are implemented as some form of truncated power series expansions, for example the exponential function
which is probably implemented as some variant of
where depends on the desired accuracy. A naive AD engine that only knows how to differentiate polynomials might decide to compute the derivative
which, if the coefficients properly merged, gives the same expression as but with precision instead of . In addition, during a forward mode AD pass, the AD engine would compute both and , not knowing that it could in fact reuse the value in . With a hard-coded pushforward rule for , we could tell the AD system to compute once, and then return . Since a pushforward rule for exists in the rule table, the AD system would decide not to decompose further, and instead use the rule directly.
Similar arguments can be made for larger algorithms, such as solving a linear system using an iterative solver. The function is then implemented using a for-loop where we compute matrix-vector products such as for intermediate guesses . The gradient of is given by , and so the pushforward rule is . Instead of differentiating the entire for-loop, we could just do a new linear solve to compute , possibly converging in a different number of iterations.
For forward mode, it is probably fine not to implement the rule for iterative solvers. For reverse mode differentiation of a linear solve using the conjugate gradient method for a symmetric positive definite , creating two for-loops with a forward pass and a backward pass would be disastrous, when we know that the pullback is
since . We could just run the linear solver twice: once for , and once for .
Finally, the gradient of a program that solves a (partial) differential equation might (or might not) be better computed by obtaining a mathematical equation for the gradient of the exact continous solution and then discretize, instead of discretizing the equation and then differentiate.
Conclusion
While many programs can be differentiated using a naive AD engine knowing only generic rules for elementary functions, large programs that are costly to evaluate should be analyzed for potential performance improvements. This is especially important for reverse mode differentiation, where it can also be rewarding to choose a checkpointing strategy etc. The users should also consider whether they are interested in the exact gradient of the numerical implementation, or whether the gradient of the mathematical function the program approximates can be computed more efficiently.
See also
- ChainRules.jl documentation: Many nice explanations
- The SciML book: A book on scientific machine learning, including AD of differential equations
- Automatic differentiation from scratch: Nice example of forward mode AD in Julia