The Elements of Differentiable Programming

Chapter 8Automatic differentiation

In Chapter 2, we reviewed the fundamentals of differentiation and stressed the importance of two linear maps: the Jacobian-vector product (JVP) and its adjoint, the vector-Jacobian product (VJP). In this chapter, we review forward-mode and reverse-mode autodiff using these two linear maps. We start with computation chains and then generalize to feedforward networks and general computation graphs. We also review checkpointing, reversible layers and randomized estimators.

8.1 Computation chains

To begin with, consider a computation chain (Section 4.1.1) representing a function f ⁣:S0→SKf \colon \cS_0 \to \cS_K expressed as a sequence of compositions f≔fK∘⋯∘f1f \coloneqq f_K \circ \dots \circ f_1, where fk ⁣:Sk−1→Skf_k \colon \cS_{k-1} \to \cS_k. The computation of ff can be unrolled into a sequence of operations s0∈S0s1≔f1(s0)∈S1⋮sK≔fK(sK−1)∈SKf(s0)≔sK.\begin{align*} \s_0 &\in \cS_0 \\ \s_1 &\coloneqq f_1(\s_0) \in \cS_1 \\ & \hspace{6pt} \vdots \\ \s_K &\coloneqq f_K(\s_{K-1}) \in \cS_K \\ f(\s_0) &\coloneqq \s_K. \tag{8.1}\end{align*} Our goal is to compute the variations of ff around a given input s0\s_0. In a feedforward network, this amounts to estimating the influence of a given input s0\s_0 for fixed parameters (we will see how to estimate the variations w.r.t. parameters w\w in the sequel).

Jacobian matrix.

We first consider the computation of the full Jacobian ∂f(s0)\jac f(\s_0), seen as a matrix, as the notation ∂\bm{\partial} indicates. Following Proposition 2.2, we have ∂f(s0)=∂fK(sK−1)…∂f1(s0),(8.2) \jac f(\s_0) = \jac f_K(\s_{K-1}) \ldots \jac f_1(\s_0), \tag{8.2} where ∂fk(sk−1)\jac f_k(\s_{k-1}) are the Jacobians of the intermediate functions computed at s0,…,sK\s_0, \ldots, \s_K, as defined in Eq. (8.1). We may also want to compute the transpose of the Jacobian ∂f(s0)⊤=∂f1(s0)⊤…∂fK(sK−1)⊤. \jac f(\s_0)^\top = \jac f_1(\s_0)^\top \ldots \jac f_K(\s_{K-1})^\top. In both cases, the main drawback of this approach is computational: computing the full ∂f(s0)\jac f(\s_0) requires materializing the intermediate Jacobians in memory and performing matrix-matrix multiplications. However, in practice, computing the full Jacobian is rarely needed. Indeed, oftentimes, we only need to right-multiply or left-multiply with ∂f(s0)\jac f(\s_0). This gives rise to forward-mode and reverse-mode autodiff, respectively.

8.1.1 Forward-mode

We now interpret the Jacobian ∂f(s0)\partial f(\s_0) as a linear map, as the non-bold ∂\partial indicates. Following Proposition 2.6, ∂f(s0)\partial f(\s_0) is the composition of the intermediate linear maps, ∂f(s0)=∂fK(sK−1)∘…∘∂f1(s0). \partial f(\s_0) = \partial f_K(\s_{K-1}) \circ \ldots \circ \partial f_1(\s_0). Evaluating ∂f(s0)\partial f(\s_0) on an input direction v∈S0\v \in \cS_0 can be decomposed, like the function Eq. (8.1) itself, into intermediate computations t0≔vt1≔∂f1(s0)[t0]⋮tK≔∂fK(sK−1)[tK−1]∂f(s0)[v]≔tK.\begin{align*} \t_0 & \coloneqq \v \\ \t_1 & \coloneqq \partial f_1(\s_0)[\t_0] \\ & \hspace{6pt} \vdots\\ \t_K & \coloneqq \partial f_K(\s_{K-1})[\t_{K-1}]\\ \partial f(\s_0)[\v] &\coloneqq \t_K. \end{align*} Each intermediate ∂fk(sk−1)[tk−1]\partial f_k(\s_{k-1})[\t_{k-1}] amounts to a Jacobian-vector product (JVP) and can be performed in a forward manner, alongside the computation of the intermediate states sk\s_k. This can also be seen as multiplying the matrix defined in Eq. (8.2) with a vector, from right to left. This is illustrated in Fig. 8.1 and the procedure is summarized in Algorithm 8.1.

Figure 8.1: Forward-mode autodiff for a computation chain. For readability, we denoted the intermediate JVP as a function of two variables ∂fk:sk−1,tk−1↦∂fk(sk−1)[tk−1]\partial f_k: \s_{k-1}, \t_{k-1} \mapsto \partial f_k(\s_{k-1})[\t_{k-1}] with ∂fk(sk−1)[tk−1]=tk\partial f_k(\s_{k-1})[\t_{k-1}]= \t_k.

Algorithm 8.1: Forward-mode autodiff for computation chains

Functions: f≔fK∘…∘f1f \coloneqq f_K \circ \ldots \circ f_1

Inputs: input s0∈S0\s_0 \in \cS_0, input direction v∈S0\v \in \cS_0

Initialize t0≔v\t_0 \coloneqq \v

for k≔1,…,Kk \coloneqq 1, \ldots, K do

Compute sk≔fk(sk−1)∈Sk\s_k \coloneqq f_k(\s_{k-1}) \in \cS_k

Compute tk≔∂fk(sk−1)[tk−1]∈Sk\t_k \coloneqq \partial f_k(\s_{k-1})[\t_{k-1}] \in \cS_k

Outputs: f(s0)≔sKf(\s_0) \coloneqq \s_K, ∂f(s0)[v]=tK\partial f(\s_0)[\v] = \t_K

Computational complexity.

The JVP follows exactly the computations of ff, with an additional variable tk\t_k being propagated. If we consider that computing ∂fk\partial f_k is roughly as costly as computing fkf_k, then computing a JVP has roughly twice the computational cost of ff. See Section 8.3.3 for a more general and more formal statement.

Memory usage.

The memory usage of a program at a given evaluation step is the number of variables that need to be stored in memory to ensure the execution of all remaining steps. The memory cost of a program is then the maximal memory usage over all evaluation steps. For our purposes, we analyze the memory usage and memory cost by examining the given program. Formal definitions of operations on memory such as read, write, delete and associated memory costs are presented by Griewank and Walther (2008, 4).

For example, to execute the chain f=fK∘⋯∘f1f = f_K \circ \dots \circ f_1, at each step kk, we only need to have access to sk−1\s_{k-1} to execute the rest of the program. As we compute sk\s_k, we can delete sk−1\s_{k-1} from memory and replace it by sk\s_k. Therefore, the memory cost associated with the evaluation of ff is just the maximal dimension of the sk\s_k variables.

For forward mode autodiff, as we follow the computations of ff, at each step kk, we only need to have access to sk−1\s_{k-1} and tk−1\t_{k-1} to execute the rest of the program. The memory used by sk−1\s_{k-1} and tk−1\t_{k-1} can directly be used for sk,tk\s_k, \t_k once they are computed. The memory usage associated with the JVP is summarized in Fig. 8.2. Overall the memory cost of the JVP is then exactly twice the memory cost of the function itself.

Figure 8.2: Memory usage of forward-mode autodiff for a computation chain. Here t0=v\t_0 = \v, sK=f(s0)\s_K = f(\s_0), tK=∂f(s0)[v]\t_K = \partial f(\s_0)[\v].

8.1.2 Reverse-mode

In machine learning, most functions whose gradient we need to compute take the form ℓ∘f\ell \circ f, where ℓ\ell is a scalar-valued loss function and ff is a network. As seen in Proposition 2.3, the gradient takes the form ∇(ℓ∘f)(s0)=∂f(s0)∗[∇ℓ(f(s0))]. \nabla (\ell \circ f)(\s_0) = \partial f(\s_0)^*[\nabla \ell(f(\s_0))]. This motivates the need for applying the adjoint ∂f(s0)∗\partial f(\s_0)^* to ∇ℓ(f(s0))∈SK\nabla \ell(f(\s_0)) \in \cS_K and more generally to any output direction u∈SK\u \in \cS_K. From Proposition 2.7, we have ∂f(s0)∗=∂f1(s0)∗∘…∘∂fK(sK−1)∗. \partial f(\s_0)^* = \partial f_1(\s_0)^* \circ \ldots \circ \partial f_K(\s_{K-1})^*. Evaluating ∂f(s0)∗\partial f(\s_0)^* on an output direction u∈SK\u \in \cS_K is decomposed as rK≔urK−1≔∂fK(sK−1)∗[rK]⋮r0≔∂f1(s0)∗[r1]∂f(s0)∗[u]≔r0.\begin{align*} \r_K & \coloneqq \u \\ \r_{K-1} &\coloneqq \partial f_K(\s_{K-1})^*[\r_K] \\ & \hspace{6pt} \vdots \\ \r_0 &\coloneqq \partial f_1(\s_0)^*[\r_1] \\ \partial f(\s_0)^*[\u] &\coloneqq \r_0. \end{align*} Each intermediate adjoint ∂fk(sk−1)∗\partial f_k(\s_{k-1})^* amounts to a vector-Jacobian product (VJP). The key difference with the forward mode is that the procedure runs backward through the chain, hence the name reverse mode autodiff. This can also be seen as multiplying Eq. (8.2) from left to right. The procedure is illustrated in Fig. 8.3 and summarized in Algorithm 8.2.

Figure 8.3: Reverse mode of automatic differentiation for a computation chain. For readability, we denoted the intermediate VJPs as functions of two variables ∂fk∗:(sk−1,rk)↦∂fk(sk−1)∗[rk]\partial f_k^* : (\s_{k-1}, \r_k) \mapsto \partial f_k(\s_{k-1})^*[\r_k], with ∂fk(sk−1)∗[rk]=rk−1\partial f_k(\s_{k-1})^*[\r_k]= \r_{k-1}.

Algorithm 8.2: Reverse-mode autodiff for computation chains

Functions: f≔fK∘…∘f1f \coloneqq f_K \circ \ldots \circ f_1,

Inputs: input s0∈S0\s_0 \in \cS_0, output direction u∈SK\u \in \cS_K

for k≔1,…,Kk\coloneqq 1, \ldots, K do ▷  Forward pass

Compute sk≔fk(sk−1)∈Sk\s_k \coloneqq f_k(\s_{k-1}) \in \cS_k

Initialize rK≔u\r_K \coloneqq \u.

for k≔K,…,1k\coloneqq K, \ldots, 1 do ▷  Backward pass

Compute rk−1≔∂fk(sk−1)∗[rk]∈Sk−1\r_{k-1} \coloneqq \partial f_k(\s_{k-1})^*[\r_k] \in \cS_{k-1}

Outputs: f(s0)≔sKf(\s_0) \coloneqq \s_K, ∂f(s0)∗[u]=r0\partial f(\s_0)^*[\u] = \r_0

Computational complexity.

In terms of number of operations, the VJP simply passes two times through the chain, once forward, then backward. If we consider the intermediate VJPs to be roughly as costly as the intermediate functions themselves, the VJP amounts to just twice the cost of the original function, just as the JVP. See Section 8.3.3 for a more generic and formal statement.

Memory usage.

Recall that the memory usage of a program at a given evaluation step is the number of variables that need to be stored in memory to ensure the execution of the remaining steps. If we inspect Algorithm 8.3, to execute all backward steps, that is the loop in line 4, we need to have access to all the intermediate inputs s0,…,sK−1\s_0, \ldots, \s_{K-1}. Therefore, the memory cost of reverse-mode autodiff is proportional to the length of the chain KK. Fig. 8.4 illustrates the memory usage during reverse mode autodiff. It grows linearly until the end of the forward pass and then progressively decreases until it outputs the value of the function and the VJP. The memory cost can be mitigated by means of checkpointing techniques presented in Section 8.5.

Figure 8.4: Memory usage of reverse mode autodiff for a computation chain.

Decoupled function and VJP evaluations.

The additional memory cost of reverse mode autodiff comes with some advantages. If we need to compute ∂f(s0)∗[ui]\partial f(\s_0)^*[\u_i] for nn different output directions ui\u_i, we only need to compute and store once the intermediate computations sk\s_k and then make nn calls to the backward pass. In other words, by storing in memory the intermediate computations sk\s_k, we may instantiate a VJP operator, which we may apply to any u\u through the backward pass. Formally, the forward and backward passes can be decoupled as forward(f,s0)≔(f(s0),∂f(s0)∗) \mathrm{forward}(f, \s_0) \coloneqq (f(\s_0), \partial f(\s_0)^*) where ∂f(s0)∗[u]≔backward(u;s0,…,sK−1). \partial f(\s_0)^*[\u] \coloneqq \mathrm{backward}(\u; \s_0, \ldots, \s_{K-1}). In functional programming terminology, the VJP ∂f(s0)∗\partial f(\s_0)^* is a closure, as it contains the intermediate computations s0,…,sK−1\s_0, \dots, \s_{K-1}. The same can be done for the JVP ∂f(s0)\partial f(\s_0) if we want to apply it to multiple directions vi\v_i.

Example 8.1 (Multilayer perceptron with fixed parameters)

As a running example, consider a multilayer perceptron (MLP) with one hidden layer and (for now) given fixed weights. As presented in Chapter 4, an MLP can be decomposed as s0=xs1=f1(s0)=σ(A1s0+b1)s2=f2(s1)=A2s1+b2f(x)=s2,\begin{align*} \s_0 & = \x \\ \s_1 & = f_1(\s_0) = \sigma(\A_1 \s_0 + \b_1) \\ \s_2 & = f_2(\s_1) = \A_2 \s_1 + \b_2 \\ f(\x) & = \s_2, \end{align*} for A1,A2,b1,b2\A_1, \A_2, \b_1, \b_2 some fixed parameters and σ\sigma an activation function such as the softplus activation function σ(x)=log⁡(1+ex)\sigma(x) = \log(1+e^x) with derivative σ′(x)=ex/(1+ex)\sigma'(x) = e^x/(1+e^x).

Evaluating the JVP of ff on an input x\x along a direction v\v can then be decomposed as t0=vt1=σ′(A1s0+b1)⊙(A1t0)t2=A2t1∂f(x)[v]=t2,\begin{align*} \t_0 & = \v \\ \t_1 & = \sigma'(\A_1 \s_0 + \b_1) \odot (\A_1 \t_0) \\ \t_2 & = \A_2 \t_1 \\ \partial f(\x)[\v] & = \t_2, \end{align*} where we used in the second line the JVP of element-wise functions as in Example 8.4.

Evaluating the VJP of ff at x\x requires evaluating the intermediate VJPs at the stored activations r2=ur1=∂f2(s1)∗[r2]=A2⊤r2r0=∂f1(s0)∗[r1]=A1⊤(σ′(A1s0+b1)⊙r1)∂f(x)∗[u]=r0.\begin{align*} \r_2 & = \u \\ \r_1 & = \partial f_2(\s_1)^*[\r_2] = \A_2^\top \r_2 \\ \r_0 & = \partial f_1(\s_0)^*[\r_1] = \A_1^\top (\sigma'(\A_1 \s_0 + \b_1) \odot \r_1) \\ \partial f(\x)^*[\u] & = \r_0. \end{align*}

8.1.3 Complexity of computing entire Jacobians

In this section, we analyze the time and space complexities of forward-mode and reverse-mode autodiff for computing the entire Jacobian matrix ∂f(s0)\jac f(\s_0) of a computation chain f=fK∘⋯∘f1f = f_K \circ \dots \circ f_1, where fk ⁣:Sk−1→Skf_k \colon \cS_{k-1} \to \cS_k. We assume Sk⊆RDk\cS_k \subseteq \RR^{D_k}, DK=MD_K = M and D0=DD_0 = D. Therefore, we have f ⁣:RD→RMf \colon \RR^D \to \RR^M and ∂f(s0)∈RM×D\jac f(\s_0) \in \RR^{M \times D}.

Complexity of forward-mode autodiff

Using Definition 2.9, we find that we can extract each column [∂f(s0)]:,j∈RM[\jac f(\s_0)]_{:,j} \in \RR^M of the Jacobian matrix, for j∈[D]j \in [D], by multiplying with the standard basis vector ej∈RD\e_j \in \RR^D: [∂f(s0)]:,1=∂f(s0)[e1]⋮[∂f(s0)]:,D=∂f(s0)[eD].\begin{align*} [\jac f(\s_0)]_{:, 1} &= \partial f(\s_0)[\e_1] \\ &\vdots \\ [\jac f(\s_0)]_{:, D} &= \partial f(\s_0)[\e_D]. \end{align*} Computing the full Jacobian matrix therefore requires DD JVPs with vectors in RD\RR^D. Assuming each fkf_k in the chain composition has the form fk:RDk−1→RDkf_k: \R^{D_{k-1}} \rightarrow \R^{D_k}, seen as a matrix, ∂fk(sk−1)\jac f_k(\s_{k-1}) has size Dk×Dk−1D_{k} \times D_{k-1}. Therefore, the computational cost of DD JVPs is O(D∑k=1KDkDk−1)O\left(D \sum_{k=1}^K D_k D_{k-1}\right). The memory cost is O(max⁡k∈[K]Dk)O(\max_{k \in [K]} D_k), since we can release intermediate computations after each layer is processed. Setting D1=⋯=DK−1=DD_1 = \dots = D_{K-1} = D for simplicity and using DK=MD_K=M, we obtain that the computational cost of computing DD JVPs and therefore of computing the full Jacobian matrix by forward-mode autodiff is O(MD2+KD3)O(MD^2 + KD^3). The memory cost is O(max⁡{D,M})O(\max\{D, M\}). If a function has a single input (D=1D=1), then the forward mode computes the entire Jacobian at once, which reduces to a single directional derivative.

Complexity of reverse-mode autodiff

Using Definition 2.9, we find that we can extract each row of the Jacobian matrix [∂f(s0)]i∈RD[\jac f(\s_0)]_{i} \in \RR^D, for i∈[M]i \in [M], by multiplying with the standard basis vector ei∈RM\e_i \in \RR^M: [∂f(s0)]1=∂f(s0)∗[e1]⋮[∂f(s0)]M=∂f(s0)∗[eM].\begin{align*} [\jac f(\s_0)]_{1} &= \partial f(\s_0)^*[\e_1] \\ &\vdots \\ [\jac f(\s_0)]_{M} &= \partial f(\s_0)^*[\e_M]. \end{align*} Computing the full Jacobian matrix therefore requires MM VJPs with vectors in RM\RR^M. Assuming as before that each fkf_k in the chain composition has the form fk:RDk−1→RDkf_k: \R^{D_{k-1}} \rightarrow \R^{D_k}, the computational cost of MM VJPs is O(M∑k=1KDkDk−1)O\left(M \sum_{k=1}^K D_k D_{k-1}\right). However, the memory cost is O(∑k=1KDk)O(\sum_{k=1}^{K} D_k), as we need to store the intermediate computations for each of the KK layers. Setting D0=⋯=DK−1=DD_0 = \dots = D_{K-1} = D for simplicity and using DK=MD_K=M, we obtain that the computational cost of computing MM VJPs and therefore of computing the full Jacobian matrix by reverse-mode autodiff is O(M2D+KMD2)O(M^2D + KMD^2). The memory cost is O(KD+M)O(KD + M). If the function has a single output (M=1M=1), reverse-mode autodiff computes the entire Jacobian at once, which reduces to the gradient.

When to use forward-mode vs. reverse-mode autodiff?

We summarize the time and space complexities in Table 8.1. Generally, if M<DM<D, reverse-mode is more advantageous at the price of some memory cost. If M≥DM\geq D, forward mode is more advantageous.

Forward-mode Reverse-mode
Time O(MD2+KD3)O(MD^2 + KD^3) O(M2D+KMD2)O(M^2D + KMD^2)
Space O(max⁡{M,D})O(\max\{M,D\}) O(KD+M)O(KD + M)

Table 8.1: Time and space complexities of forward-mode and reverse-mode autodiff for computing the full Jacobian of a chain of functions f=fK∘⋯∘f1f = f_K \circ \dots \circ f_1, where fk ⁣:RD→RDf_k \colon \RR^D \to \RR^D if k=1,…,K−1k = 1, \dots, K-1 and fK ⁣:RD→RMf_K \colon \RR^D \to \RR^M. We assume ∂fk\partial f_k is a dense linear operator. Forward mode requires DD JVPs. Reverse mode requires MM VJPs.

8.2 Feedforward networks

In the previous section, we derived forward-mode autodiff and reverse-mode autodiff for computation chains with an input s0∈S0\s_0 \in \cS_0. In this section, we now derive reverse-mode autodiff for feedforward networks, in which each layer fkf_k is now allowed to depend explicitly on some additional parameters wk∈Wk\w_k \in \cW_k. The recursion is s0≔x∈S0s1≔f1(s0,w1)∈S1⋮sK≔fK(sK−1,wK)∈SKf(x,w)≔sK,\begin{align*} \s_0 & \coloneqq \x \in \cS_0 \\ \s_1 & \coloneqq f_1(\s_0, \w_1) \in \cS_1 \\ & \hspace{6pt} \vdots \\ \s_K & \coloneqq f_K(\s_{K-1}, \w_K) \in \cS_K \\ f(\x, \w) & \coloneqq \s_K, \end{align*} where S0=X\cS_0 = \cX and w=(w1,…,wK)∈W1×⋯×WK\w = (\w_1, \dots, \w_K) \in \cW_1 \times \dots \times \cW_K. Each fkf_k is now a function of two arguments. The first argument depends on the previous layer, but the second argument does not. This is illustrated in Fig. 8.5. We now explain how to differentiate a feedforward network.

8.2.1 Computing the adjoint

The function has the form f ⁣:E→Ff \colon \cE \to \cF, where E≔X×(W1×⋯×WK)\cE \coloneqq \cX \times (\cW_1 \times \dots \times \cW_K) and F≔SK\cF \coloneqq \cS_K. From Section 2.3, we know that the VJP has the form ∂f(x,w)∗ ⁣:F→E\partial f(\x, \w)^* \colon \cF \to \cE. Therefore, we want to be able to compute ∂f(x,w)∗[u]∈E\partial f(\x, \w)^*[\u] \in \cE for any u∈F\u \in \cF.

Fortunately, the backward recursion is only a slight modification of the computation chain case. Indeed, since fk ⁣:Ek→Fkf_k \colon \cE_k \to \cF_k, where Ek≔Sk−1×Wk\cE_k \coloneqq \cS_{k-1} \times \cW_k and Fk≔Sk\cF_k \coloneqq \cS_k, the intermediate VJPs have the form ∂fk(sk−1,wk)∗ ⁣:Fk→Ek\partial f_k(\s_{k-1}, \w_k)^* \colon \cF_k \to \cE_k. We therefore arrive at the recursion rK=u∈SK(rK−1,gK)=∂fK(sK−1,wK)∗[rK]∈SK−1×WK⋮(r0,g1)=∂f1(s0,w1)∗[r1]∈S0×W1.\begin{align*} \r_K &= \u \in \cS_K \\ (\r_{K-1}, \g_K) &= \partial f_K(\s_{K-1}, \w_K)^* [\r_K] \in \cS_{K-1} \times \cW_K \\ &\hspace{6pt} \vdots \\ (\r_0, \g_1) &= \partial f_1(\s_0, \w_1)^* [\r_1] \in \cS_0 \times \cW_1. \end{align*} The final output is ∂f(x,w)∗[u]=(r0,(g1,…,gK)). \partial f(\x, \w)^*[\u] = (\r_0, (\g_1, \dots, \g_K)).

8.2.2 Computing the gradient

We often compose a network with a loss function L(w;x,y)≔ℓ(f(x,w);y)∈R. L(\w; \x, \y) \coloneqq \ell(f(\x, \w); \y) \in \RR. From Proposition 2.7, the gradient is given by ∇L(w;x,y)=(g1,…,gK)∈W1×⋯×WK \nabla L(\w; \x, \y) = (\g_1, \dots, \g_K) \in \cW_1 \times \dots \times \cW_K where ∂f(x,w)∗[u]=(r0,(g1,…,gK)), \partial f(\x, \w)^*[\u] = (\r_0, (\g_1, \dots, \g_K)), with u=∇ℓ(f(x,w);y)∈SK\u = \nabla \ell(f(\x, \w); \y) \in \cS_K. The output r0∈S0\r_0 \in \cS_0, where S0=X\cS_0 = \cX, corresponds to the gradient w.r.t. x∈X\x \in \cX and is typically not needed, except in generative modeling settings. The full procedure is summarized in Algorithm 8.3.

Figure 8.5: Computation graph of an MLP as a function of its parameters.

Figure 8.6: Reverse mode of automatic differentiation, a.k.a., gradient back-propagation to compute the gradient of the loss of an MLP on an input label pair. For readability, we denoted the intermediate VJPs as functions of three variables ∂fk∗:(sk−1,wk−1,rk)↦∂fk(sk−1,wk)∗[rk]\partial f_k^*: (\s_{k-1}, \w_{k-1}, \r_k) \mapsto \partial f_k(\s_{k-1}, \w_k)^*[\r_k] with ∂fk(sk−1,wk)∗[rk]=(rk−1,gk)\partial f_k(\s_{k-1}, \w_k)^*[\r_k] = (\r_{k-1}, \g_k).

Algorithm 8.3: Gradient back-propagation for feedforward networks

Functions: f1,…,fKf_1, \dots, f_K in sequential order

Inputs: data point (x,y)∈X×Y(\x,\y) \in \cX \times \cY

parameters w=(w1,…,wK)∈W1×⋯×WK\w= (\w_1, \ldots, \w_K) \in \cW_1 \times \dots \times \cW_K

Initialize s0≔x\s_0 \coloneqq \x ▷  Forward pass

for k≔1,…,Kk \coloneqq 1, \ldots, K do

Compute and store sk≔fk(sk−1,wk)∈Sk\s_k \coloneqq f_k(\s_{k-1}, \w_k) \in \cS_k

Compute ℓ(sK;y)\ell(\s_K; \y) and u≔∇ℓ(sK;y)∈SK\u \coloneqq \nabla \ell(\s_K; \y) \in \cS_K

Initialize rK≔u∈SK\r_K \coloneqq \u \in \cS_K ▷  Backward pass

for k≔K,…,1k \coloneqq K, \ldots, 1 do

Compute (rk−1,gk)≔∂fk(sk−1,wk)∗[rk]∈Sk−1×Wk(\r_{k-1}, \g_k) \coloneqq \partial f_k(\s_{k-1}, \w_k)^*[\r_k] \in \cS_{k-1} \times \cW_k

Outputs: L(w;x,y)≔ℓ(sK;y)L(\w; \x, \y) \coloneqq \ell(\s_K; \y), ∇L(w;x,y)=(g1,…,gK)\nabla L(\w; \x, \y) = (\g_1, \ldots, \g_K)

8.3 Computation graphs

In the previous sections, we reviewed autodiff for computation chains and its extension to feedforward networks. In this section, we review its generalization to computation graphs, introduced in Section 4.1.3. Our formalism assumes, without loss of generality, that functions can take multiple inputs but only produce a single output, as we can always group multiple outputs as a tuple. This enables a one-to-one correspondence between output variables sk\s_k and functions fkf_k.

8.3.1 Forward mode

The forward mode corresponds to computing the JVP of the program in an input direction v∈S0\v \in \cS_0. In the case of a computation chain f≔fK∘⋯∘f1f \coloneqq f_K \circ \dots \circ f_1, each fkf_k takes a single input sk−1∈Sk−1\s_{k-1} \in \cS_{k-1} and each ∂fk(sk−1)\partial f_k(\s_{k-1}) takes a single input direction tk−1∈Sk−1\t_{k-1} \in \cS_{k-1}. The forward pass on iteration k∈[K]k \in [K] is then, starting from t0≔v\t_0 \coloneqq \v, sk≔fk(sk−1)∈Sktk≔∂fk(sk−1)[tk−1]∈Sk.\begin{align*} \s_k &\coloneqq f_k(\s_{k-1}) \in \cS_k \\ \t_k &\coloneqq \partial f_k(\s_{k-1})[\t_{k-1}] \in \cS_k. \end{align*} In the case of a computation graph, specified by functions f1,…,fKf_1, \dots, f_K in topological order, each fkf_k may now take multiple inputs (si1,…,sipk)∈Si1×⋯×Sipk(\s_{i_1}, \dots, \s_{i_{p_k}}) \in \cS_{i_1} \times \dots \times \cS_{i_{p_k}}, and each ∂fk(si1,…,sipk)\partial f_k(\s_{i_1}, \dots, \s_{i_{p_k}}) takes as many input directions (ti1,…,tipk)∈Si1×⋯×Sipk(\t_{i_1}, \dots, \t_{i_{p_k}}) \in \cS_{i_1} \times \dots \times \cS_{i_{p_k}}, where (i1,…,ipk)≔pa⁡(k)(i_1, \ldots, i_{p_k}) \coloneqq \parent(k) are the parents of fkf_k. The forward pass on step k∈[K]k \in [K] then computes both intermediate inputs and directions as sk≔fk(si1,…,sipk)∈Sktk≔∂fk(si1,…,sipk)[ti1,…,tipk]∈Sk.\begin{align*} \s_k &\coloneqq f_k(\s_{i_1}, \dots, \s_{i_{p_k}}) \in \cS_k \\ \t_k &\coloneqq \partial f_k(\s_{i_1}, \dots, \s_{i_{p_k}})[\t_{i_1}, \dots, \t_{i_{p_k}}] \in \cS_k. \end{align*} Let us recall that ∂ifk\partial_i f_k means that we differentiate fkf_k w.r.t.  its iith argument. Using the fan-in rule in Proposition 2.8, we obtain that the derivatives are propagated as tk=∑j=1pk∂jfk(si1,…,sipk)[tij]∈Sk. \t_k = \sum_{j=1}^{p_k} \partial_{j} f_k(\s_{i_1}, \dots, \s_{i_{p_k}})[\t_{i_j}] \in \cS_k. The final output is ∂f(s0)[v]=tK\partial f(\s_0)[\v] = \t_K. The resulting generic forward-mode procedure is summarized in Fig. 8.7 and in Algorithm 8.4. Intuitively, the algorithm consists in computing and summing intermediate JVPs along the forward pass. Although not explicitly mentioned, we can release sk\s_k and tk\t_k from memory when no child node depends on node kk.

Figure 8.7: Forward mode automatic differentiation in a computation graph.

Algorithm 8.4: Forward-mode autodiff for computation graphs

Functions: f1,…,fKf_1, \dots, f_K in topological order

Inputs: input s0∈S0\s_0 \in \cS_0, input direction v∈S0\v \in \cS_0

Initialize t0≔v\t_0 \coloneqq \v

for k≔1,…,Kk \coloneqq 1, \ldots, K do ▷  Forward pass

Retrieve parent nodes (i1,…,ipk)≔pa⁡(k)(i_1, \dots, i_{p_k}) \coloneqq \parent(k)

Compute sk≔fk(si1,…,sipk)∈Sk\s_k \coloneqq f_k(\s_{i_1}, \dots, \s_{i_{p_k}}) \in \cS_k

Compute tk≔∂fk(si1,…,sipk)[ti1,…,tipk]=∑j=1pk∂jfk(si1,…,sipk)[tij]∈Sk.\begin{align*} \t_k &\coloneqq \partial f_k(\s_{i_1}, \dots, \s_{i_{p_k}})[\t_{i_1}, \dots, \t_{i_{p_k}}] \\ & = \sum_{j=1}^{p_k} \partial_{j} f_k(\s_{i_1}, \dots, \s_{i_{p_k}})[\t_{i_j}] \in \cS_k. \end{align*}

Outputs: f(s0)≔sK∈SKf(\s_0) \coloneqq \s_K \in \cS_K, ∂f(s0)[v]=tK∈SK\partial f(\s_0)[\v] = \t_K \in \cS_K

8.3.2 Reverse mode

The reverse mode corresponds to computing the VJP of the program in an output direction u∈SK\u \in \cS_K. In the case of a computation chain f≔fK∘⋯∘f1f \coloneqq f_K \circ \dots \circ f_1, each intermediate variable sk\s_k is used only once to compute the next variable sk+1≔fk+1(sk)\s_{k+1} \coloneqq f_{k+1}(\s_k). To compute the VJP of the chain, it then suffices to reverse the order of the operations and to use the corresponding VJPs. Since each function takes a single input, each VJP ∂fk(sk)∗[rk]\partial f_k(\s_k)^*[\r_k] produces a single output direction rk−1∈Sk\r_{k-1} \in \cS_k. A backward pass therefore computes from k≔Kk \coloneqq K to k≔1k \coloneqq 1, starting from rK≔u\r_K \coloneqq \u, rk−1≔∂fk(sk−1)∗[rk]∈Sk−1. \r_{k-1} \coloneqq \partial f_k(\s_{k-1})^*[\r_k] \in \cS_{k-1}. The final output is r0≔∂f(s0)∗[u]\r_0 \coloneqq \partial f(\s_0)^*[\u].

In computation graphs, intermediate variables may be used more than once, since a node kk may have several children nodes. This complicates reversing the computation graph. To circumvent this issue, following the formalism of Roy Frostig (Murphy, 2023, Section 6.2.2.2), we may formally distinguish between the output sk\s_k of fkf_k and the inputs sk→j\s_{k \rightarrow j} of fjf_j for j∈ch⁡(k)j \in \child(k), the children of fkf_k. We do so by introducing an operation that simply duplicates sk\s_k, dup(sk)≔(sk,…,sk).\begin{align*} \dup(\s_k) & \coloneqq (\s_k, \ldots, \s_k). \end{align*} The tuple output by dup\mathrm{dup} is of length ∣ch⁡(k)∣|\child(k)|. The forward pass can then be formally rewritten as, for k∈[K]k \in [K], sk≔fk(si1→k,…,sipk→k) \s_k \coloneqq f_k(\s_{i_1 \rightarrow k}, \ldots, \s_{i_{p_k} \rightarrow k}) where (i1,…,ipk)≔pa⁡(k)(i_1, \dots, i_{p_k}) \coloneqq \parent(k) followed by (sk→j1,…,sk→jck)≔dup(sk), (\s_{k \rightarrow j_1}, \ldots, \s_{k \rightarrow j_{c_k}}) \coloneqq \dup(\s_k), where (j1,…,jck)≔ch⁡(k)(j_1, \ldots, j_{c_k}) \coloneqq \child(k). The benefit of this approach is that each duplicated output is input only once to the subsequent child functions. Thanks to this, we can now associate to each variable, si→j\s_{i \rightarrow j} or sk\s_k, a single corresponding intermediate variable, ri→j\r_{i \rightarrow j} or rk\r_k, in the backward pass. The reverse mode can then simply be written by going through the VJPs of the functions fkf_k and the VJP of dup\dup in reverse order.

For the VJP of the dup\mathrm{dup} operation, let us denote the intermediate variables in the backward pass associated with sk→j1,…,sk→jck\s_{k \rightarrow j_1}, \ldots, \s_{k \rightarrow j_{c_k}} by rk→j1,…,rk→jck\r_{k \rightarrow j_1}, \dots, \r_{k \rightarrow j_{c_k}}. Following the fan-out rule in Proposition 2.9, the VJP of the dup\dup operation on iteration kk is rk=dup(sk)∗[rk→j1,…,rk→jck]=∑i=1ckdupi(sk)∗[rk→ji]=∑i=1ckrk→ji.\begin{align*} \r_k & = \dup(\s_k)^*[\r_{k \rightarrow j_1}, \dots, \r_{k \rightarrow j_{c_k}}] \\ & = \sum_{i=1}^{c_k} \dup_i(\s_k)^*[\r_{k \rightarrow {j_i}}]\\ & = \sum_{i=1}^{c_k} \r_{k \rightarrow {j_i}}. \end{align*} In the last line, we used that dupi(s)=s\dup_i(\s) = \s by definition of the duplication operation so ∂dupi(s)=I⁡\partial\dup_i(\s) = \idm and ∂dupi(s)∗=I⁡\partial\dup_i(\s)^* = \idm. The VJP of dup\dup justifies why, if an intermediate value sk\s_k is used by later functions fj1,…,fjckf_{j_1},\dots, f_{j_{c_k}}, for (j1,…,jck)≔ch⁡(k)(j_1,\dots,j_{c_k}) \coloneqq \child(k), the derivatives with respect to sk\s_k need to sum all the variations through the fjf_j functions into the variable rk\r_k.

For the VJP of the fkf_k functions, following the fan-in rule in Proposition 2.8, the VJP returns multiple output variations, ri1→k,…,ripk→k=∂fk(si1→k,…,sipk→k)∗[rk], \r_{i_1 \rightarrow k}, \dots, \r_{i_{p_k} \rightarrow k} = \partial f_k(\s_{i_1 \rightarrow k}, \ldots, \s_{i_{p_k} \rightarrow k})^*[\r_k], where (i1,…,ipk)≔pa⁡(k)(i_1, \dots, i_{p_k}) \coloneqq \parent(k), and where, for j∈{1,…,pk}j \in \{1, \dots, p_k\}, rij→k≔∂jfk(si1→k,…,sipk→k)∗[rk]∈Sij. \r_{i_j \rightarrow k} \coloneqq \partial_j f_k(\s_{i_1 \rightarrow k}, \ldots, \s_{i_{p_k} \rightarrow k})^*[\r_k] \in \cS_{i_j}. The overall formalism is summarized in Fig. 8.8.

Implementation

The duplication operation dup\dup is just a formalism to mathematically derive the reverse mode. In practice, the variables sk\s_k are not duplicated but accessed several times during the backward pass. The variables rk\r_k are computed by accumulating rk→j\r_{k \rightarrow j} into rk\r_k each time a variable rk→j\r_{k \rightarrow j} is computed. Therefore, for each k∈[K]k \in [K], we can compute the VJP and perform the in-place updates, ri1→k,…,ripk→k=∂fk(si1,…,sipk)∗[rk]rij←rij+rij→k∀j∈{1,…,pk}.\begin{align*} \r_{i_1 \rightarrow k}, \dots, \r_{i_{p_k} \rightarrow k} & = \partial f_k(\s_{i_1}, \ldots, \s_{i_{p_k}})^*[\r_k] \\ \r_{i_j} & \leftarrow \r_{i_j} + \r_{i_j \rightarrow k} \quad \forall j \in \{1, \dots, p_k\}. \end{align*} The topological ordering ensures that rk\r_k has been fully computed when we reach fkf_k. The resulting generic reverse-mode procedure is presented in Algorithm 8.5.

Figure 8.8: Reverse mode automatic differentiation in a computation graph.

Algorithm 8.5: Reverse-mode autodiff for computation graphs

Functions: f1,…,fKf_1, \dots, f_K in topological order

Inputs: input s0∈S0\s_0 \in \cS_0, output direction u∈SK\u \in \cS_K

for k≔1,…,Kk \coloneqq 1, \ldots, K do ▷  Forward pass

Retrieve parent nodes (i1,…,ipk)≔pa⁡(k)(i_1, \dots, i_{p_k}) \coloneqq \parent(k)

Compute sk≔fk(si1,…,sipk)∈Sk\s_k \coloneqq f_k(\s_{i_1}, \ldots, \s_{i_{p_k}}) \in \cS_k

Instantiate VJP lk≔∂fk(si1,…,sipk)∗l_k \coloneqq \partial f_k(\s_{i_1}, \ldots, \s_{i_{p_k}})^*

Initialize rK≔u\r_K \coloneqq \u, rk←0 ∀k∈{0,…,K−1}\r_k \leftarrow 0 ~ \forall k \in \{0, \ldots, K-1\} ▷  Backward pass

for k≔K,…,1k \coloneqq K, \ldots, 1 do

Retrieve parent nodes (i1,…,ipk)≔pa⁡(k)(i_1, \dots, i_{p_k}) \coloneqq \parent(k)

Compute ri1→k,…,ripk→k≔lk[rk]\r_{i_1 \rightarrow k}, \dots, \r_{i_{p_k} \rightarrow k} \coloneqq l_k[\r_k]

Compute rij←rij+rij→k∈Sij ∀j∈{1,…,pk}\r_{i_j} \leftarrow \r_{i_j} + \r_{i_j \rightarrow k} \in \cS_{i_j} ~ \forall j \in \{1, \dots, p_k\}

Outputs: f(s0)≔sK∈SKf(\s_0) \coloneqq \s_K \in \cS_K, ∂f(s0)∗[u]=r0∈S0\partial f(\s_0)^*[\u] = \r_0 \in \cS_0

Example 8.2 (Example of forward and reverse modes)

We use Fig. 8.9 to illustrate the forward and reverse modes in a computation graph. Let us assume that the intermediate variables s1,…,s7\s_1,\dots,\s_7 have readily been computed. The forward mode corresponds to t0≔vt1≔∂f1(s0)[t0]t2≔∂f2(s0)[t0]t3≔∂f3(s1)[t1]t4≔∂f4(s2,s3)[t2,t3]=∂1f4(s2,s3)[t2]+∂2f4(s2,s3)[t3]t5≔∂f5(s1,s4)[t1,t4]=∂1f5(s1,s4)[t1]+∂2f5(s1,s4)[t4]t6≔∂f6(s5)[t5]t7≔∂f7(s4,s6)[t4,t6]=∂1f7(s4,s6)[t4]+∂2f7(s4,s6)[t6].\begin{align*} \t_0 &\coloneqq \v \\ \t_1 &\coloneqq \partial f_1(\s_0)[\t_0] \\ \t_2 &\coloneqq \partial f_2(\s_0)[\t_0] \\ \t_3 &\coloneqq \partial f_3(\s_1)[\t_1] \\ \t_4 &\coloneqq \partial f_4(\s_2, \s_3)[\t_2, \t_3] = \partial_1 f_4(\s_2, \s_3)[\t_2] + \partial_2 f_4(\s_2, \s_3)[\t_3] \\ \t_5 &\coloneqq \partial f_5(\s_1, \s_4)[\t_1, \t_4] = \partial_1 f_5(\s_1, \s_4)[\t_1] + \partial_2 f_5(\s_1, \s_4)[\t_4] \\ \t_6 &\coloneqq \partial f_6(\s_5)[\t_5] \\ \t_7 &\coloneqq \partial f_7(\s_4, \s_6)[\t_4, \t_6] = \partial_1 f_7(\s_4, \s_6)[\t_4] + \partial_2 f_7(\s_4, \s_6)[\t_6]. \end{align*} The reverse mode corresponds to (r4→7,r6→7)≔∂f7(s4,s6)∗[r7]r7≔ur5→6≔∂f6(s5)∗[r6]r6≔r6→7(r1→5,r4→5)≔∂f5(s1,s4)∗[r5]r5≔r5→6(r2→4,r3→4)≔∂f4(s2,s3)∗[r4]r4≔r4→5+r4→7r1→3≔∂f3(s1)∗[r3]r3≔r3→4r0→2≔∂f2(s0)∗[r2]r2≔r2→4r0→1≔∂f1(s0)∗[r1]r1≔r1→3+r1→5r0≔r0→1+r0→2.\begin{align*} (\r_{4 \to 7}, \r_{6 \to 7}) &\coloneqq \partial f_7(\s_4, \s_6)^*[\r_7] \quad &&\r_7 \coloneqq \u \\ \r_{5 \to 6} &\coloneqq \partial f_6(\s_5)^*[\r_6] \quad &&\r_6 \coloneqq \r_{6 \to 7} \\ (\r_{1 \to 5}, \r_{4 \to 5}) &\coloneqq \partial f_5(\s_1, \s_4)^*[\r_5] \quad &&\r_5 \coloneqq \r_{5 \to 6} \\ (\r_{2 \to 4}, \r_{3 \to 4}) &\coloneqq \partial f_4(\s_2, \s_3)^*[\r_4] \quad &&\r_4 \coloneqq \r_{4 \to 5} + \r_{4 \to 7} \\ \r_{1 \to 3} &\coloneqq \partial f_3(\s_1)^*[\r_3] \quad &&\r_3 \coloneqq \r_{3 \to 4} \\ \r_{0 \to 2} &\coloneqq \partial f_2(\s_0)^*[\r_2] \quad &&\r_2 \coloneqq \r_{2 \to 4} \\ \r_{0 \to 1} &\coloneqq \partial f_1(\s_0)^*[\r_1] \quad &&\r_1 \coloneqq \r_{1 \to 3} + \r_{1 \to 5} \\ & && \r_0 \coloneqq \r_{0 \to 1} + \r_{0 \to 2}. \end{align*}

Figure 8.9: Same computation graph as Fig. 4.3 but without the actual definition of each fkf_k, for simplicity.

8.3.3 Complexity, the Baur-Strassen theorem

For computing the gradient of a function f ⁣:E→Rf \colon \cE \to \RR represented by a computation graph, we saw that the reverse mode is more efficient than the forward mode. As we previously stated, assuming that the elementary functions fkf_k in the DAG and their VJP have roughly the same computational complexity, then ff and ∇f\nabla f have roughly the same computational complexity. This fact is crucial and is the pillar on which modern machine learning relies: it allows us to optimize high-dimensional functions by gradient descent.

For arithmetic circuits, reviewed in Section 4.1.4, this crucial fact is made more precise in the celebrated Baur-Strassen theorem (Baur and Strassen, 1983). If ff is a polynomial, then so is its gradient ∇f\nabla f. The theorem gives an upper bound on the size of the best circuit for computing ∇f\nabla f from the size of the best circuit for computing ff.

Proposition 8.1 (Baur-Strassen’s theorem)

For any polynomial
f ⁣:E→Rf \colon \cE \to \RR, we have S(∇f)≤5⋅S(f), S(\nabla f) \le 5 \cdot S(f), where the size S(f)S(f) of a polynomial ff is defined in Definition 4.1.

A simpler proof by backward induction was given by Morgenstern (1985). See also the proof of Theorem 9.10 in Chen et al. (2011). For general computation graphs, that have more primitive functions than just ++ and ×\times, a similar result can be obtained; see, e.g., (Bolte et al., 2022, Theorem 2).

8.4 Implementation

8.4.1 Primitive functions

An autodiff system implements a set A\cA of primitive or elementary functions, which serve as building blocks for creating other functions, by function composition. For instance, we saw that in arithmetic circuits (Section 4.1.4), A={+,×}\cA = \{+, \times\}. More generally, A\cA may contain all the necessary functions for expressing programs. We emphasize, however, that A\cA is not necessarily restricted to low-level functions such as log and exp, but may also contain higher-level functions. For instance, even though the log-sum-exp can be expressed as the composition of elementary operations (log, sum, exp), it is usually included as a primitive on its own, both because it is a very commonly-used building block, but also for numerical stability reasons.

8.4.2 Closure under function composition

Each function fkf_k in a computation graph belongs to a set F\cF, the class of functions supported by the system. A desirable property of an autodiff implementation is that the set F\cF is closed under function composition, meaning that if f∈Ff \in \cF and g∈Fg \in \cF, then f∘g∈Ff \circ g \in \cF. This means that composed functions can themselves be used for composing new functions. This property is also crucial for supporting higher-order differentiation (Chapter 9) and automatic linear transposition (Section 8.4.4). When fkf_k is a composition of elementary functions in A\cA, then fkf_k itself is a nested DAG. However, we can always inline each composite function, such that all functions in the DAG belong to A\cA.

8.4.3 Examples of JVPs and VJPs

An autodiff system must implement for each f∈Af \in \cA its JVP for supporting the forward mode, and its VJP for supporting the reverse mode. We give a couple of examples. We start with the JVP and VJP of linear functions.

Example 8.3 (JVP and VJP of linear functions)

Consider the matrix-vector product f(W)=Wx∈RMf(\W) = \W\x \in \RR^M, where x∈RD\x \in \RR^D is fixed and W∈RM×D\W \in \RR^{M\times D}. As already mentioned in Section 2.3.1, the JVP of ff at W∈RM×D\W \in \RR^{M \times D} along an input direction V∈RM×D\V \in \RR^{M \times D} is simply ∂f(W)[V]=f(V)=Vx∈RM. \partial f(\W)[\V] = f(\V) = \V\x \in \RR^M. To find the associated VJP, we note that for any u∈RM\u \in \RR^M and V∈RM×D\V \in \RR^{M\times D}, we must have ⟨∂f(W)[V],u⟩=⟨V,∂f(W)∗[u]⟩\langle \partial f(\W)[\V], \u \rangle = \langle \V, \partial f(\W)^*[\u] \rangle. Using the properties of the trace, we have ⟨∂f(W)[V],u⟩=⟨Vx,u⟩=tr⁡(x⊤V⊤u)=⟨V,ux⊤⟩. \langle \partial f(\W)[\V], \u \rangle = \langle \V \x, \u \rangle = \Tr(\x^\top \V^\top \u ) = \langle \V, \u \x^\top \rangle. Therefore, we find that the VJP is given by ∂f(W)∗[u]=ux⊤∈RM×D.\partial f(\W)^*[\u] = \u \x^\top \in \RR^{M \times D}. Similarly, consider now a matrix-matrix product f(W)=WXf(\W) = \W\X, where W∈RM×D\W \in \RR^{M \times D} and where X∈RD×N\X \in \RR^{D \times N} is fixed. The JVP at W∈RM×D\W \in \RR^{M \times D} along an input direction V∈RM×D\V \in \RR^{M \times D} is simply ∂f(W)[V]=f(V)=VX∈RM×N. \partial f(\W)[\V] = f(\V) = \V\X \in \RR^{M\times N}. The VJP along the output direction U∈RM×N\U \in \RR^{M\times N} is ∂f(W)∗[U]=UX⊤∈RM×D. \partial f(\W)^*[\U] = \U \X^\top \in \RR^{M \times D}.

Another simple example is element-wise separable functions.

Example 8.4 (JVP and VJP of separable function)

Consider the function f(w)≔(g1(w1),…,gP(wP))f(\w) \coloneqq (g_1(w_1), \dots, g_P(w_P)), where each gi ⁣:R→Rg_i \colon \RR \to \RR has a derivative gi′g_i'. The Jacobian matrix is then a diagonal matrix ∂f(w)=diag⁡(g1′(w1),…,gP′(wP))∈RP×P. \jac f(\w) = \diag(g_1'(w_1), \dots, g_P'(w_P)) \in \RR^{P \times P}. In this case, the JVP and VJP are actually the same ∂f(w)[v]=∂f(w)∗[v]=(g1′(w1),…,gP′(wP))⊙v, \partial f(\w)[\v] = \partial f(\w)^*[\v] = (g_1'(w_1), \dots, g_P'(w_P)) \odot \v, where ⊙\odot indicates element-wise multiplication.

8.4.4 Automatic linear transposition

On first sight, if we want to support both forward and reverse modes, it appears like we need to implement both the JVP and the VJP for each primitive operation f∈Af \in \cA. Fortunately, there exists a way to recover VJPs from JVPs, and vice-versa.

We saw in Section 2.3 that if l(w)l(\w) is a linear map, then its JVP is ∂l(w)[v]=l(v)\partial l(\w)[\v] = l(\v) (independent of w\w). Conversely, the VJP is ∂l(w)∗[u]=l∗(u)\partial l(\w)^*[\u] = l^*(\u), where l∗l^* is the adjoint operator of ll (again, independent of w\w).

Let us define l(u;w)≔∂f(w)∗[u]l(\u; \w) \coloneqq \partial f(\w)^*[\u], i.e., the VJP of ff in the output direction u\u. Since l(u;w)l(\u; \w) is linear in u\u, we can apply the reasoning above to compute its VJP ∂l(u;w)∗[v]=l∗(v;w)=∂f(w)∗∗[v]=∂f(w)[v], \partial l(\u; \w)^*[\v] = l^*(\v; \w) = \partial f(\w)^{**}[\v] = \partial f(\w)[\v], which is independent of u\u. In words, the VJP of a VJP is the corresponding JVP! This means that we can implement forward-mode autodiff even if we only have access to VJPs. As an illustration and sanity check, we give the following example.

Example 8.5 (Automatic transpose of “dot”)

If we define
f(x,W)≔Wxf(\x, \W) \coloneqq \W \x, from Example 8.3, we know that ∂f(x,W)∗[u]=(W⊤u,ux⊤)=(f(u,W⊤),f(x⊤,u))≕l(u;x,W).\begin{align*} \partial f(\x, \W)^*[\u] &= (\W^\top \u, \u \x^\top) \\ &= (f(\u, \W^\top), f(\x^\top, \u)) \\ &\eqqcolon l(\u; \x, \W). \end{align*} Using Proposition 2.9, we obtain ∂l(u;x,W)∗[v,V]=f(v,W)+f(x,V)=Wv+Vx=∂f(x,W)[v,V].\begin{align*} \partial l(\u; \x, \W)^*[\v, \V] &= f(\v, \W) + f(\x, \V) \\ &= \W \v + \V \x \\ &= \partial f(\x, \W)[\v, \V]. \end{align*}

The other direction, automatically creating a VJP from a JVP, is also possible but is more technical and relies on the notion of partial evaluation (Frostig et al., 2021; Radul et al., 2022).

8.5 Checkpointing

We saw that forward-mode autodiff can release intermediate computations from memory along the way, while reverse-mode autodiff needs to cache all of them. This means that the memory complexity of reverse-mode autodiff, in its standard form, grows linearly with the number of nodes in the computation graph. A commonly-used technique to circumvent this issue is checkpointing, which trades-off computation time for better memory usage. Checkpointing works by selectively storing only a subset of the intermediate values, called checkpoints, and by recomputing others on the fly. The specific choice of the checkpoint locations in the computation graph determines the memory-computation trade-off. While it is possible to heuristically set checkpoints at user-specified locations, it is also possible to perform a checkpointing strategy algorithmically, as studied in depth by Griewank (1992; Griewank and Walther, 2008). In this section, we review two divide-and-conquer algorithms: recursive halving and dynamic programming. Our exposition focuses on computation chains f=fK∘…∘f1f = f_K \circ \ldots \circ f_1, with fk:RD→RDf_k: \RR^D \rightarrow \RR^D for simplicity.

Figure 8.10: Checkpointing trades-off computation time for better memory usage by selectively storing only a subset of the intermediate values, called checkpoints, and by recomputing others on the fly. Recursive halving and dynamic programming are two divide-and-conquer strategies to select the checkpoint locations.

Computational and memory complexities at two extremes.

Let C(K)\cC(K) be the number of calls to the individual functions fkf_k (we ignore the cost of computing the intermediate VJPs) and M(K)\cM(K) be the number of function inputs cached, when performing reverse-mode autodiff on a chain f=fK∘…∘f1f = f_K \circ \ldots \circ f_1. On one extreme, if we store all intermediate computations, as done in Algorithm 8.2, to compute only the VJP ∂f(s0)∗[u]\partial f(\s_0)^*[\u], we have C(K)=K−1andM(K)=K. \cC(K) = K-1 \quad \text{and} \quad \cM(K) = K. This is optimal w.r.t. computational complexity, but suboptimal w.r.t. memory. On the other extreme, if we only store the initial input, as done in Algorithm 8.6, then we have C(K)=K(K−1)/2andM(K)=1. \mathcal{C}(K) = K(K-1)/2 \quad \text{and} \quad \mathcal{M}(K) = 1. This is optimal w.r.t. memory but leads to a computational complexity that is quadratic in KK.

Algorithm 8.6: Reverse-mode autodiff with constant memory
vjp_full_recompute(fK∘…∘f1,s0,u)≔∂(fK∘…∘f1)(s0)∗[u]\texttt{vjp\_full\_recompute}(f_K \circ \ldots \circ f_1, \s_0, \u) \coloneqq \partial (f_K \circ \ldots \circ f_1)(\s_0)^*[\u]

Inputs: Chain fK∘…∘f1f_K \circ \ldots \circ f_1, input s0∈S0\s_0 \in \cS_0, output direction u∈SK\u \in \cS_K

if K=1K=1 then

return ∂f1(s0)∗[u]\partial f_1(\s_0)^*[\u]

else

Set rK=u\r_K = \u

for k≔K,…,1k \coloneqq K, \ldots, 1 do

Compute sk−1=(fk−1∘…∘f1)(s0)\s_{k-1} = (f_{k-1} \circ \ldots \circ f_1)(\s_0)

Compute rk−1=∂fk(sk−1)∗[rk]\r_{k-1} = \partial f_k(\s_{k-1})^*[\r_k]

return: r0\r_0

8.5.1 Recursive halving

As a first step towards obtaining a better computation-memory trade-off, we may split the chain sK=fK∘⋯∘f1(s0)\s_K = f_K \circ \dots \circ f_1(\s_0) as sK/2=fK/2∘…∘f1(s0)sK=fK∘…∘fK/2+1(sK/2),\begin{align*} \s_{K/2} &= f_{K/2} \circ \ldots \circ f_1(\s_0) \\ \s_{K} &= f_{K} \circ \ldots \circ f_{K/2+1}(\s_{K/2}), \end{align*} for KK even. Then, rather than recomputing all intermediate computations sk\s_k from the input s0\s_0 as in Algorithm 8.6, we can store sK/2\s_{K/2} and recompute sk\s_{k} for k>K/2k>K/2 starting from sK/2\s_{K/2}. Formally, this strategy amounts to the following steps.

  1. Compute sK/2=fK/2∘…∘f1(s0)\s_{K/2} = f_{K/2} \circ \ldots \circ f_1(\s_0)

  2. Compute rK/2=vjp_full_recompute(fK∘…∘fK/2+1,sK/2,u)\r_{K/2} = \mbox{\texttt{vjp\_full\_recompute}}(f_K \circ \ldots \circ f_{K/2 +1}, \s_{K/2}, \u)

  3. Compute r0=vjp_full_recompute(fK/2∘…∘f1,s0,rK/2)\r_0 = \mbox{\texttt{vjp\_full\_recompute}}(f_{K/2} \circ \ldots \circ f_1, \s_0, \r_{K/2})

At the expense of having to store the additional checkpoint sK/2\s_{K/2}, this already roughly halves the computational complexity compared to Algorithm 8.6.

Algorithm 8.7: Reverse-mode autodiff with recursive halving
vjp_halving(fK∘…∘f1,s0,u)≔∂(fK∘…∘f1)(s0)∗[u]\texttt{vjp\_halving}(f_K \circ \ldots \circ f_1, \s_0, \u) \coloneqq \partial (f_K \circ \ldots \circ f_1)(\s_0)^*[\u]

Functions: Chain fK∘…∘f1f_K \circ \ldots \circ f_1

Inputs: input s0∈S0\s_0 \in \cS_0, output direction u∈SK\u \in \cS_K

if K=1K=1 then

return ∂f1(s0)∗[u]\partial f_1(\s_0)^*[\u]

else

Compute sK/2=fK/2∘…∘f1(s0)\s_{K/2} = f_{K/2} \circ \ldots \circ f_1(\s_0)

Compute rK/2=vjp_halving(fK∘…∘fK/2+1,sK/2,u)\r_{K/2} = \mbox{\texttt{vjp\_halving}}(f_K \circ \ldots \circ f_{K/2 +1}, \s_{K/2}, \u)

Compute r0=vjp_halving(fK/2∘…∘f1,s0,rK/2)\r_0 = \mbox{\texttt{vjp\_halving}}(f_{K/2} \circ \ldots \circ f_1, \s_0, \r_{K/2})

return: r0\r_0

We can then apply this reasoning recursively, as formalized in  Algorithm 8.7. The algorithm is known as recursive binary schedule (Griewank, 2003) and illustrated in Fig. 8.11. In terms of number of function evaluations C(K)\mathcal{C}(K), for KK even, we make K/2K/2 function calls, and we call the procedure recursively twice, that is, C(K)=2C(K/2)+K/2.\begin{align*} \cC(K) & = 2\cC(K/2) + K/2. \end{align*} If the chain is of length 11, we directly use the VJP, so C(1)=0\cC(1) = 0. Hence, the number of function calls, if KK is a power of 22, is C(K)=K2log⁡2K. \cC(K) = \frac{K}{2}\log_2 K. In terms of memory usage, Algorithm 8.7 uses s0\s_0 not only at line 4 but also at line 6. So when the algorithm is called recursively on the second half of the chain at line 5, one memory slot is taken by s0\s_0. This line is called recursively until the chain is reduced to a single function. At that point, the total number of memory slots used is equal to the number of times we split the function in half, that is, log⁡2K\log_2 K for KK a power of 22. On the other hand, the input s0\s_0 is no longer used after line 6 of Algorithm 8.7. At that line, the memory slot taken by s0\s_0 can be consumed by the recursive call on the first-half. In other words, calling the algorithm recursively on the first half does not incur extra memory cost. So if KK is a power of 22, the memory cost of Algorithm 8.7 is M(K)=log⁡2K. \cM(K) = \log_2 K.

Figure 8.11: Illustration of checkpointing with recursive halving, for a chain of 88 functions. The chain is first fully evaluated while storing some computations as checkpoints in memory. Then, during the backward pass, we recompute some intermediate values from the latest checkpoint available. In contrast, vanilla reverse-mode autodiff (with full caching of the intermediate computations) would lead to a simple triangle shape.

8.5.2 Dynamic programming

Recursive halving requires log⁡2K\log_2 K memory slots for a chain of length KK. However, as illustrated in Fig. 8.11, at a given time step, all memory slots may not be exploited.

To optimize the approach, we observe that recursive halving is just one instance of a program that splits the chain and calls itself recursively on each part. In other words, it is a form of divide-and-conquer algorithm. Rather than splitting the chain in half, we may consider splitting the chain at some index ll. One split is used to reverse the computations from l+1l+1 to KK by a recursive call that consumes one memory slot. The other split is used on a recursive call that reverses the computations from 00 to ll. That second call does not require an additional memory slot, as it can use directly the memory slot used by the original input s0\s_0. To split the chain in such two parts, we need ll intermediate computations to go from s0\s_0 to sl\s_l. The computational complexity C(k,s)\mathcal{C}(k, s), counted as the number of function evaluations, for a chain of length kk with ss memory slots then satisfies the recurrence C(k,s)=C(k−l,s−1)+C(l,s)+l, \mathcal{C}(k, s) = \mathcal{C}(k-l, s-1) + \mathcal{C}(l, s) + l, for all l∈{1,…,k−1}l \in \{1, \dots, k-1\}. By simply taking l=k/2l = k/2, we recover exactly the computational complexity of recursive halving. To refine the latter, we may split the chain by selecting ll to minimize the complexity. An optimal scheme must satisfy the recursive equation, C∗(k,s)≔min⁡1≤l≤k−1{C∗(k−l,s−1)+C∗(l,s)+l}.(8.3) \mathcal{C}^*(k, s) \coloneqq \min_{1\leq l\leq k-1} \{\mathcal{C}^*(k-l, s-1) + \mathcal{C}^*(l, s) + l\}. \tag{8.3} Note that C∗(K,S)\mathcal{C}^*(K, S) can be computed from C∗(k,s) \mathcal{C}^*(k, s) for k=1,…,K−1k=1, \ldots, K-1, s=1,…,S−1s=1, \ldots, S-1. This suggests a dynamic programming approach to find an optimal scheme algorithmically. For a chain of length k=1k=1, the cost is null as we directly reverse the computation, so C∗(1,s)≔0\mathcal{C}^*(1, s) \coloneqq 0. On the other hand for a memory s=1s=1, there is only one possible scheme that saves only the initial input as in Algorithm 8.6, so C∗(k,1)≔(k(k−1))/2 \mathcal{C}^*(k, 1) \coloneqq (k(k-1))/2. The values C∗(k,s)\mathcal{C}^*(k, s) can then be computed incrementally from k=1k=1 to KK and s=1s=1 to SS using Eq. (8.3). The optimal splits can be recorded along the way as l∗(k,s)≔arg min⁡1≤l≤k−1{C∗(k−l,s−1)+C∗(l,s)+l}. l^*(k, s) \coloneqq \argmin_{1\leq l\leq k-1} \{\mathcal{C}^*(k-l, s-1) + \mathcal{C}^*(l, s) + l\}. The optimal split for K,SK, S can then be found by backtracking the optimal splits along both branches corresponding to C∗(k−l,s−1)\mathcal{C}^*(k-l, s-1) and C∗(l,s)\mathcal{C}^*(l, s). As the final output consists in traversing a binary tree, it was called treeverse (Griewank, 1992). Note that the dynamic programming procedure is generic and could a priori incorporate varying computational costs for the intermediate functions fkf_k.

Analytical formula

It turns out that we can also find an optimal scheme analytically. This scheme was found by Griewank (1992), following the analysis of optimal inversions of sequential programs by divide-and-conquer algorithms done by Grimm et al. (1996); see also Griewank (2003, Section 6) for a simple proof. The main idea consists in considering the number of times an evaluation step fkf_k is repeated. As we split the chain at ll, all steps from 11 to ll will be repeated at least once. In other words, treating the second half of the chain incurs one memory cost, while treating the first half of the chain incurs one repetition cost. Griewank (1992) shows that for fixed KK, SS, we can find the minimal number of repetitions analytically and build the corresponding scheme with simple formulas for the optimal splits.

Compared to the dynamic programming approach, it means that we do not need to compute the pointers l∗(k,s)l^*(k, s), and we can use a simple formula to set l∗(k,s)l^*(k, s). We still need to traverse the corresponding binary tree given K,SK, S and l∗(k,s)l^*(k, s) to obtain the schedules. Note that such an optimal scheme does not take into account varying computational costs for the functions fkf_k.

8.5.3 Online checkpointing

The optimal scheme presented above requires knowing the total number of nodes in the computation graph ahead of time. However, when differentiating through for example a while loop (Section 5.10), this is not the case. To circumvent this issue, online checkpointing schemes have been developed and proven to be nearly optimal (Stumm and Walther, 2010; Wang et al., 2009). These schemes start by defining a set of SS checkpoints with the first SS computations, then these checkpoints are rewritten dynamically as the computations keep going. Once the computations terminate, the optimal approach presented above for a fixed length is applied on the set of checkpoints recorded.

8.6 Reversible layers

8.6.1 General case

The memory requirements of reverse-mode autodiff can be completely alleviated when the functions fkf_k are invertible (meaning that fk−1f_k^{-1} exists) and when fk−1f_k^{-1} is easily accessible. In that case, rather than storing the intermediate computations sk−1\s_{k-1}, necessary to compute the VJP rk↦∂fk(sk−1)∗[rk]\r_k \mapsto \partial f_k(\s_{k-1})^*[\r_k], one can compute them on the fly during the backward pass from sk\s_k using sk−1=fk−1(sk)\s_{k-1} = f_k^{-1}(\s_k). We summarize the procedure for the case of computation chains in Algorithm 8.8. Compared to vanilla reverse-mode autodiff in Algorithm 8.2, the algorithm has optimal memory complexity, as we can release sk\s_k and rk\r_k as we go.

Algorithm 8.8: Reverse-mode autodiff for reversible chains.

Functions: f≔fK∘…∘f1f \coloneqq f_K \circ \ldots \circ f_1, with each fkf_k invertible

Inputs: input s0∈S0\s_0 \in \cS_0, output direction u∈SK\u \in \cS_K

Compute sK=fK∘…∘f1(s0)\s_K = f_K \circ \ldots \circ f_1(\s_0)

for k≔K,…,1k \coloneqq K, \ldots, 1 do

Compute sk−1=fk−1(sk)\s_{k-1} = f_k^{-1}(\s_k)

Compute rk−1=∂fk(sk−1)∗[rk]\r_{k-1} = \partial f_k(\s_{k-1})^*[\r_k]

Outputs: f(s0)≔sKf(\s_0) \coloneqq \s_K, ∂f(s0)∗[u]=r0\partial f(\s_0)^*[\u] = \r_0

In practice, fk−1f_k^{-1} often does not exist or may not be easily accessible. However, network architectures can be constructed to be easily invertible by design. Examples include reversible residual networks (Gomez et al., 2017), orthonormal RNNs (Helfrich et al., 2018), neural ODEs (Section 12.6), and momentum residual neural networks (Sander et al., 2021a); see also references therein.

8.6.2 Case of orthonormal JVPs

When the JVP of each fkf_k is an orthonormal linear mapping, i.e., ∂fk(sk−1)−1=∂fk(sk−1)∗, \partial f_k(\s_{k-1})^{-1} = \partial f_k(\s_{k-1})^*, it is easy to check that the VJP of f=fK∘…∘f1f = f_K \circ \ldots \circ f_1 is equal to the JVP of f−1=f1−1∘…∘fK−1f^{-1} = f_1^{-1} \circ \ldots \circ f_K^{-1}, that is ∂f(s0)∗[u]=∂f−1(sK)[u]. \partial f(\s_0)^*[\u] = \partial f^{-1}(\s_K)[\u]. In other words, in the case of orthonormal JVPs, reverse-mode autodiff of ff coincides with forward-mode autodiff of f−1f^{-1}!

8.7 Randomized forward-mode gradient estimator

Forward-mode autodiff does not require storing intermediate activations. However, for a function f ⁣:RP→Rf \colon \RR^P \to \RR, computing the gradient ∇f\nabla f using forward-mode autodiff requires PP JVPs, which is intractable if PP is large. Can we approximate ∇f\nabla f with fewer JVPs? The following proposition gives an unbiased estimator of ∇f\nabla f that only involves JVPs.

Proposition 8.2 (Unbiased forward-mode estimator of the gradient)

Let f ⁣:RP→Rf \colon \RR^P \to \RR be a differentiable function. Then, ∇f(μ)=EZ∼p[∂f(μ)[Z]Z]=EZ∼p[⟨∇f(μ),Z⟩Z],\begin{align*} \nabla f(\muv) &= \EE_{Z \sim p} \left[\partial f(\muv)[Z] Z \right] \\ &= \EE_{Z \sim p} \left[\langle \nabla f(\muv), Z \rangle Z \right], \end{align*} where p≔Normal(0,1)Pp \coloneqq \mathrm{Normal}(0,1)^P is the isotropic Gaussian distribution.

This estimator is for instance used by Baydin et al. (2022). It can be seen as the zero-temperature limit of the gradient of a perturbed function, estimated by the score-function estimator (SFE); see Section 14.4.6.

In practice, the expectation above can be approximated by drawing MM noise vectors z1,…,zM\z_1,\dots,\z_M, and averaging ⟨∇f(μ),zi⟩zi\langle \nabla f(\muv), \z_i \rangle \z_i over i∈[M]i \in [M].

A word of caution: while this estimator can be useful for example when we do not want to store the intermediate activations for memory reasons, this of course comes at the cost of increasing the variance, which influences the convergence rate of SGD, as seen in Section 16.2.

Figure 8.12: The randomized forward-mode gradient estimator only requires forward passes, but it suffers from high variance, even more so in high dimension.

8.8 Summary