← Back to writing

What is the difference between forward and reverse mode automatic differentiation?

Forward and reverse mode automatic differentiation compute the same chain of matrix products in different orders — and that order determines their cost.

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 f=fN∘⋯∘f1f = f_N \circ \dots \circ f_1 composed of elemental mathematical building blocks fnf_n and intermediate vector-valued states

xn+1=fn+1(xn).x_{n + 1} = f_{n + 1}(x_n).

We are interested in computing the output y=f(x)y = f(x) and its gradient dfdx(x)\frac{\mathrm{d} f}{\mathrm{d} x}(x) for a given input xx. The chain rule states that

dfdx=dfNdxN−1dfN−1dxN−2…df2dx1df1dx,\frac{\mathrm{d} f}{\mathrm{d} x} = \frac{\mathrm{d} f_{N}}{\mathrm{d} x_{N - 1}} \frac{\mathrm{d} f_{N - 1}}{\mathrm{d} x_{N - 2}} \dots \frac{\mathrm{d} f_{2}}{\mathrm{d} x_{1}} \frac{\mathrm{d} f_{1}}{\mathrm{d} x},

where dfn+1dxn=dfn+1dxn(xn)\frac{\mathrm{d} f_{n + 1}}{\mathrm{d} x_{n}} = \frac{\mathrm{d} f_{n + 1}}{\mathrm{d} x_{n}}(x_n) is the Jacobian matrix of fn+1f_{n + 1} at the point xnx_n. The size is size⁡(xn+1)×size⁡(xn)\operatorname{size}(x_{n + 1}) \times \operatorname{size}(x_{n}). 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. (AB)C=A(BC)(A B) C = A (B C) for all matrices AA, BB, and CC, we can choose to write the chain rule in two ways:

dfdx=dfNdxN−1(…df3dx2(df2dx1(df1dx))… )=(…((dfNdxN−1)dfN−1dxN−2)dfN−2dxN−3… )df1dx.\begin{split} \frac{\mathrm{d} f}{\mathrm{d} x} & = \frac{\mathrm{d} f_{N}}{\mathrm{d} x_{N - 1}} \left( \dots \frac{\mathrm{d} f_{3}}{\mathrm{d} x_{2}} \left( \frac{\mathrm{d} f_{2}}{\mathrm{d} x_{1}} \left( \frac{\mathrm{d} f_{1}}{\mathrm{d} x} \right) \right) \dots \right) \\ & = \left( \dots \left( \left( \frac{\mathrm{d} f_{N}}{\mathrm{d} x_{N - 1}} \right) \frac{\mathrm{d} f_{N - 1}}{\mathrm{d} x_{N - 2}} \right) \frac{\mathrm{d} f_{N - 2}}{\mathrm{d} x_{N - 3}} \dots \right) \frac{\mathrm{d} f_{1}}{\mathrm{d} x} . \end{split}

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 ff is defined as

f˙(x,x˙)=dfdx(x)x˙,\dot{f}(x, \dot{x}) = \frac{\mathrm{d} f}{\mathrm{d} x}(x) \dot{x},

where xx is a given state vector and x˙\dot{x} is a gradient seed being “pushed forward” in the computational chain. The seed x˙\dot{x} can be a column vector or a matrix (collection of column vectors) of the same size as xx.

Since both the gradients and the states can be computed at the same time, a sequential program f=fN∘⋯∘f1f = f_N \circ \dots \circ f_1 can be executed with a single for-loop without saving any intermediate states:

  1. Initialize the state x0=xx_0 = x and the seed x˙0=I(x)\dot{x}_0 = I(x) given as an identity matrix of the same size as xx.
  2. For n∈{0,…,N−1}n \in \{ 0, \dots, N - 1 \}, compute xn+1=fn+1(xn),x˙n+1=f˙n+1(xn,x˙n).\begin{split} x_{n + 1} & = f_{n + 1}(x_n), \\ \dot{x}_{n + 1} & = \dot{f}_{n + 1}(x_n, \dot{x}_n). \end{split} Now xnx_n and x˙n\dot{x}_n are no longer needed and can be discarded.
  3. Return the full gradient x˙N=dfdx(x)\dot{x}_N = \frac{\mathrm{d} f}{\mathrm{d} x}(x).

Here we assume that the computer has access to the pushforward functions of all the elemental functions (fn)n=1N(f_n)_{n = 1}^N in the program (e.g. the rule cos⁡˙(x,x˙)=−sin⁡(x)x˙\dot{\cos}(x, \dot{x}) = - \sin(x) \dot{x} must be hard-coded somewhere if you want to use cos⁡(x)\cos(x) 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 xx contains millions of neural network weights and the output y=f(x)y = f(x) is a scalar loss function value. In this case, at each step in the program but the last, x˙n\dot{x}_n is a large matrix of size millions-times-millions, even though the final gradient dfdx\frac{\mathrm{d} f}{\mathrm{d} x} is just a vector of the same size as xx.

Reverse mode differentiation

The pullback function (or vector-Jacobian product) of a differentiable function ff is defined as

fˉ(x,yˉ)=yˉdfdx(x).\bar{f}(x, \bar{y}) = \bar{y} \frac{\mathrm{d} f}{\mathrm{d} x}(x).

where xx is a given state vector and yˉ\bar{y} is an adjoint variable being “pulled back” in the computational chain. The adjoint variable yˉ\bar{y} should be a row vector or a matrix (collection of row vectors) of the same size as the column vector y=f(x)y = f(x).

For convenience, we also define the (partially applied) pullback function at a given state xx as

fˉ(x):yˉ↦fˉ(x,yˉ).\bar{f}(x) : \bar{y} \mapsto \bar{f}(x, \bar{y}).

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:

  1. Assign the initial state x0=xx_0 = x.
  2. Forward pass: for n∈{0,…,N−1}n \in \{ 0, \dots, N - 1 \}, compute xn+1=fn(xn),gn+1=fˉn+1(xn),\begin{split} x_{n + 1} & = f_{n}(x_{n}), \\ g_{n + 1} & = \bar{f}_{n + 1}(x_{n}), \end{split} and store the pullback gn+1g_{n + 1} for later use (this may require storing the full state xnx_{n} so that gn+1g_{n + 1} can be called).
  3. Initialize the final adjoint seed xˉN=I(xN)\bar{x}_N = I(x_N) as an identity matrix of the same size as the output vector xNx_N (typically xNx_N and xˉN=1\bar{x}_N = 1 are both scalars).
  4. Backward pass: for decreasing n∈{N,…,1}n \in \{ N, \dots, 1 \}, compute xˉn−1=gn(xˉn).\bar{x}_{n - 1} = g_{n}(\bar{x}_{n}). Now xˉn\bar{x}_n and gng_n are no longer needed and can be discarded.
  5. Return the full gradient xˉ0=dfdx(x)\bar{x}_0 = \frac{\mathrm{d} f}{\mathrm{d} x}(x).

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 (xn)n=0N−1(x_n)_{n = 0}^{N - 1} 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 xNx_N, but do not store any intermediate states. In the backward pass, compute xn−1=fn−1(xn)x_{n - 1} = f_n^{-1}(x_n) alongside xˉn−1=gn(xˉn)\bar{x}_{n - 1} = g_n(\bar{x}_n). However, the program components (fn)n=1N(f_n)_{n = 1}^N 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 (fn)n=1N(f_n)_{n = 1}^N 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

exp⁡(x)=1+x+x22+x36+…\exp(x) = 1 + x + \frac{x^2}{2} + \frac{x^3}{6} + \dots

which is probably implemented as some variant of

sN(x)=∑n=0Nxnn!,s_N(x) = \sum_{n = 0}^{N} \frac{x^n}{n!},

where NN depends on the desired accuracy. A naive AD engine that only knows how to differentiate polynomials might decide to compute the derivative

dsNdx(x)=∑n=1Nnxn−1n!=sN−1(x)\frac{\mathrm{d} s_N}{\mathrm{d} x}(x) = \sum_{n = 1}^{N} n \frac{x^{n - 1}}{n!} = s_{N - 1}(x)

which, if the coefficients properly merged, gives the same expression as sN(x)s_N(x) but with precision N−1N - 1 instead of NN. In addition, during a forward mode AD pass, the AD engine would compute both y=sN(x)y = s_N(x) and dsNdx(x)\frac{\mathrm{d} s_N}{\mathrm{d} x}(x), not knowing that it could in fact reuse the value yy in y˙=y\dot{y} = y. With a hard-coded pushforward rule for sN(x)s_N(x), we could tell the AD system to compute y=sN(x)y = s_N(x) once, and then return (y,yx˙)(y, y \dot{x}). Since a pushforward rule for sNs_N exists in the rule table, the AD system would decide not to decompose sNs_N further, and instead use the rule directly.

Similar arguments can be made for larger algorithms, such as solving a linear system Ay=xA y = x using an iterative solver. The function f:x↦A−1xf : x \mapsto A^{-1} x is then implemented using a for-loop where we compute matrix-vector products such as AxnA x_n for intermediate guesses xnx_n. The gradient of ff is given by dfdx(x)=A−1\frac{\mathrm{d} f}{\mathrm{d} x}(x) = A^{-1}, and so the pushforward rule is f˙(x,x˙)=A−1x˙=f(x˙)\dot{f}(x, \dot{x}) = A^{-1} \dot{x} = f(\dot{x}). Instead of differentiating the entire for-loop, we could just do a new linear solve to compute f˙(x,x˙)\dot{f}(x, \dot{x}), 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 AA, creating two for-loops with a forward pass and a backward pass would be disastrous, when we know that the pullback is

fˉ(x,yˉ)=yˉdfdx(x)=yˉA−1=(A−1yˉT)T=f(yˉT)T,\bar{f}(x, \bar{y}) = \bar{y} \frac{\mathrm{d} f}{\mathrm{d} x}(x) = \bar{y} A^{-1} = \left( A^{-1} \bar{y}^\mathsf{T} \right)^\mathsf{T} = f\left(\bar{y}^\mathsf{T}\right)^\mathsf{T},

since (A−1)T=A−1(A^{-1})^\mathsf{T} = A^{-1}. We could just run the linear solver twice: once for y=f(x)y = f(x), and once for xˉ=f(yˉT)T\bar{x} = f(\bar{y}^\mathsf{T})^\mathsf{T}.

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

Find something interesting