Backpropagation

July 2026Vladislav Kruglikov

Recently, I implemented a small automatic differentiation engine in C++. In this post, I want to share some of the ideas behind it.

Gradient

For a scalar-valued function of several variables, the gradient at a point is a vector that points in the direction of steepest increase. Its magnitude is the maximum rate of increase at that point. Taking the gradient at every point gives a gradient vector field. At any particular point, one vector from that field describes the local direction and rate.

For a two-dimensional matrix XX and a scalar loss LL, every element XijX_{ij} can affect the loss, so L/X\partial L / \partial X contains one partial derivative for each matrix element. Conceptually, we can flatten the independent scalar entries of XX into a vector. By definition, their derivatives form an ordinary gradient vector. The matrix-shaped gradient is a view of that vector, described by a shape and strides over its storage, and does not need to be contiguous.

Backpropagation therefore starts from a scalar objective, usually the loss. A vector-valued output has a Jacobian, not one gradient vector, and the vector itself does not define a unique meaning of «better». We first need a preference that combines its components into a scalar.

That scalar objective can encode the preference as a weighted combination. During backpropagation, its upstream gradient supplies those weights, reducing the output Jacobian to the single gradient direction relevant to the loss.

Jacobian

The gradient is the Jacobian of a scalar-valued function, up to whether derivatives are written as a row or a column.

Let JabJ_{ab} be the Jacobian of a vector a\mathbf{a} with respect to a vector b\mathbf{b}. Its entry in row ii and column jj is

(Jab)ij=aibj(J_{ab})_{ij} = \frac{\partial a_i}{\partial b_j}

Therefore, the full Jacobian is

Jab=ab=[a1b1a1b2a1bna2b1a2b2a2bnamb1amb2ambn]J_{ab} = \frac{\partial \mathbf{a}}{\partial \mathbf{b}} = \begin{bmatrix} \frac{\partial a_1}{\partial b_1} & \frac{\partial a_1}{\partial b_2} & \cdots & \frac{\partial a_1}{\partial b_n} \\ \frac{\partial a_2}{\partial b_1} & \frac{\partial a_2}{\partial b_2} & \cdots & \frac{\partial a_2}{\partial b_n} \\ \vdots & \vdots & \ddots & \vdots \\ \frac{\partial a_m}{\partial b_1} & \frac{\partial a_m}{\partial b_2} & \cdots & \frac{\partial a_m}{\partial b_n} \end{bmatrix}

If c\mathbf{c} depends on a\mathbf{a} and a\mathbf{a} depends on b\mathbf{b}, the Jacobians compose by matrix multiplication

Jcb=JcaJabJ_{cb} = J_{ca}J_{ab}

To prove it, expand entry (i,j)(i,j) of the matrix product using (Jab)ij=aibj(J_{ab})_{ij}=\frac{\partial a_i}{\partial b_j}

(JcaJab)ij=k(Jca)ik(Jab)kj=kciakakbj=cibj=(Jcb)ij\begin{aligned} (J_{ca}J_{ab})_{ij} &= \sum_k (J_{ca})_{ik}(J_{ab})_{kj} \\ &= \sum_k \frac{\partial c_i}{\partial a_k} \frac{\partial a_k}{\partial b_j} \\ &= \frac{\partial c_i}{\partial b_j} \\ &= (J_{cb})_{ij} \end{aligned}

Every entry of JcaJabJ_{ca}J_{ab} is therefore equal to the corresponding entry of JcbJ_{cb}.

Now consider a sequence of dependencies

abcdl\mathbf{a}\to\mathbf{b}\to\mathbf{c}\to\mathbf{d}\to l

The Jacobian from a\mathbf{a} to ll is the product of the Jacobians along the path. Applying the same rule to the rightmost pair after each equals sign gives

JldJdcJcbJba=JldJdcJca=JldJda=Jla\begin{aligned} J_{ld}J_{dc}J_{cb}J_{ba} &= J_{ld}J_{dc}J_{ca} \\ &= J_{ld}J_{da} \\ &= J_{la} \end{aligned}

If ll is scalar and xRn\mathbf{x}\in\mathbb{R}^n, the final Jacobian JlxJ_{lx} has shape 1×n1\times n. Its entries are lxi\frac{\partial l}{\partial x_i}, so this row vector contains the gradient information for x\mathbf{x}. Under the column-gradient convention,

xl=JlxT\nabla_{\mathbf{x}}l = J_{lx}^\mathsf{T}

If x\mathbf{x} is a parameter vector, gradient descent updates it as

xxηxl\mathbf{x}\leftarrow\mathbf{x}-\eta\nabla_{\mathbf{x}}l

Computing the gradient this way requires materializing every intermediate Jacobian, which can be a huge matrix.

These Jacobians are often sparse because one input usually affects only part of the output, leaving most entries equal to zero.

Consider a dependency chain abcdla \to b \to c \to d \to l, where ll is a scalar. Suppose we already know JldJ_{ld}. This is the upstream gradient. Because ll is scalar, JldJ_{ld} contains exactly one derivative for each element of dd. Mathematically it has shape 1×d1\times |d| when dd is flattened, but in code those entries are stored or viewed with the same tensor shape as dd. How do we compute JlcJ_{lc}?

Jlc=JldJdcJ_{lc} = J_{ld}J_{dc}

There are two approaches.

  1. Materialize JdcJ_{dc} and multiply JldJ_{ld} by it. This is memory-heavy because JdcJ_{dc} can be huge and mostly sparse.

  2. Compute the vector-Jacobian product directly, without forming JdcJ_{dc}.

Automatic differentiation

Matrix multiplication can be decomposed into scalar multiplications and additions, often executed as fused multiply-add operations. Therefore, if we know how to backpropagate through scalar multiplication and addition, we can also backpropagate through matrix multiplication.

Doing this one scalar at a time would be extremely inefficient. Computing each output element would require traversing the graph and making many function calls, creating far more overhead than useful work. A much better approach is to represent the entire matrix multiplication as a single operation and execute it with a CUDA kernel built using CUTLASS. We can do exactly that while preserving automatic differentiation.

Deriving gradients by brute force

A general way to derive the backward pass for any operation is to start with every possible path from an input to the loss. Most of those paths usually have zero influence, so the full expression quickly collapses into something simple.

Consider a matrix multiplication

Y=XWY = XW

Let the upstream gradient be G=LYG = \frac{\partial L}{\partial Y}, with the same shape as YY. For one element XabX_{ab}, the chain rule initially sums over every element of YY

LXab=ijYijXabLYij\frac{\partial L}{\partial X_{ab}} = \sum_i \sum_j \frac{\partial Y_{ij}}{\partial X_{ab}} \frac{\partial L}{\partial Y_{ij}}

Now substitute Yij=kXikWkjY_{ij} = \sum_k X_{ik}W_{kj} into the full expression

LXab=ijXab(kXikWkj)LYij\frac{\partial L}{\partial X_{ab}} = \sum_i \sum_j \frac{\partial}{\partial X_{ab}} \left(\sum_k X_{ik}W_{kj}\right) \frac{\partial L}{\partial Y_{ij}}

First, move the derivative inside the sum over kk

LXab=ij(k(XikWkj)Xab)LYij\frac{\partial L}{\partial X_{ab}} = \sum_i \sum_j \left( \sum_k \frac{\partial \left(X_{ik}W_{kj}\right)}{\partial X_{ab}} \right) \frac{\partial L}{\partial Y_{ij}}

Because WW does not depend on XX, we can move it outside the derivative

LXab=ijkWkjXikXabLYij\frac{\partial L}{\partial X_{ab}} = \sum_i \sum_j \sum_k W_{kj} \frac{\partial X_{ik}}{\partial X_{ab}} \frac{\partial L}{\partial Y_{ij}}

Next, split the sum over ii into the case i=ai=a and all cases where iai\ne a

LXab=jkWkjXakXabLYaj+iajkWkjXikXabLYij\frac{\partial L}{\partial X_{ab}} = \sum_j \sum_k W_{kj} \frac{\partial X_{ak}}{\partial X_{ab}} \frac{\partial L}{\partial Y_{aj}} + \sum_{i\ne a}\sum_j\sum_k W_{kj} \frac{\partial X_{ik}}{\partial X_{ab}} \frac{\partial L}{\partial Y_{ij}}

Every derivative in the second term is zero because XikX_{ik} and XabX_{ab} are different elements when iai\ne a

LXab=jkWkjXakXabLYaj\frac{\partial L}{\partial X_{ab}} = \sum_j \sum_k W_{kj} \frac{\partial X_{ak}}{\partial X_{ab}} \frac{\partial L}{\partial Y_{aj}}

Now split the sum over kk into the case k=bk=b and all cases where kbk\ne b

LXab=jWbjXabXabLYaj+jkbWkjXakXabLYaj\frac{\partial L}{\partial X_{ab}} = \sum_j W_{bj} \frac{\partial X_{ab}}{\partial X_{ab}} \frac{\partial L}{\partial Y_{aj}} + \sum_j\sum_{k\ne b} W_{kj} \frac{\partial X_{ak}}{\partial X_{ab}} \frac{\partial L}{\partial Y_{aj}}

Every derivative in the second term is zero because XakX_{ak} and XabX_{ab} are different elements when kbk\ne b. Only the left term remains

LXab=jWbjXabXabLYaj\frac{\partial L}{\partial X_{ab}} = \sum_j W_{bj} \frac{\partial X_{ab}}{\partial X_{ab}} \frac{\partial L}{\partial Y_{aj}}

Finally, XabXab=1\frac{\partial X_{ab}}{\partial X_{ab}}=1, so

LXab=jWbjLYaj\frac{\partial L}{\partial X_{ab}} = \sum_j W_{bj}\frac{\partial L}{\partial Y_{aj}}

This is already very close to matrix multiplication. To produce element (a,b)(a,b), we need the dot product of row aa and column bb. Move the upstream gradient to the left, then rewrite WbjW_{bj} as (WT)jb(W^\mathsf{T})_{jb}

LXab=jLYaj(WT)jb\frac{\partial L}{\partial X_{ab}} = \sum_j \frac{\partial L}{\partial Y_{aj}} (W^\mathsf{T})_{jb}

Now the indices have exactly the form of matrix multiplication. We take row aa of LY\frac{\partial L}{\partial Y} and column bb of WTW^\mathsf{T}. Therefore

LX=GWT\frac{\partial L}{\partial X} = GW^\mathsf{T}

We can therefore compute the gradient with a fast CUDA GEMM instead of traversing a graph of scalar multiplications and additions.

Gradient with Respect to the Weights

The gradient with respect to the weights follows exactly the same process. Start by summing every possible effect of WabW_{ab} on the output

LWab=ijYijWabLYij\frac{\partial L}{\partial W_{ab}} = \sum_i \sum_j \frac{\partial Y_{ij}}{\partial W_{ab}} \frac{\partial L}{\partial Y_{ij}}

Substitute Yij=kXikWkjY_{ij} = \sum_k X_{ik}W_{kj} into the full expression

LWab=ijWab(kXikWkj)LYij\frac{\partial L}{\partial W_{ab}} = \sum_i \sum_j \frac{\partial}{\partial W_{ab}} \left(\sum_k X_{ik}W_{kj}\right) \frac{\partial L}{\partial Y_{ij}}

Move the derivative inside the sum over kk

LWab=ij(k(XikWkj)Wab)LYij\frac{\partial L}{\partial W_{ab}} = \sum_i \sum_j \left( \sum_k \frac{\partial\left(X_{ik}W_{kj}\right)}{\partial W_{ab}} \right) \frac{\partial L}{\partial Y_{ij}}

Because XX does not depend on WW, we can move it outside the derivative

LWab=ijkXikWkjWabLYij\frac{\partial L}{\partial W_{ab}} = \sum_i\sum_j\sum_k X_{ik} \frac{\partial W_{kj}}{\partial W_{ab}} \frac{\partial L}{\partial Y_{ij}}

First, split the sum over jj into the case j=bj=b and all cases where jbj\ne b

LWab=ikXikWkbWabLYib+ijbkXikWkjWabLYij\frac{\partial L}{\partial W_{ab}} = \sum_i\sum_k X_{ik} \frac{\partial W_{kb}}{\partial W_{ab}} \frac{\partial L}{\partial Y_{ib}} + \sum_i\sum_{j\ne b}\sum_k X_{ik} \frac{\partial W_{kj}}{\partial W_{ab}} \frac{\partial L}{\partial Y_{ij}}

Every derivative in the second term is zero because WkjW_{kj} and WabW_{ab} are different elements when jbj\ne b. Only the left term remains

LWab=ikXikWkbWabLYib\frac{\partial L}{\partial W_{ab}} = \sum_i\sum_k X_{ik} \frac{\partial W_{kb}}{\partial W_{ab}} \frac{\partial L}{\partial Y_{ib}}

Now split the sum over kk into the case k=ak=a and all cases where kak\ne a

LWab=iXiaWabWabLYib+ikaXikWkbWabLYib\frac{\partial L}{\partial W_{ab}} = \sum_i X_{ia} \frac{\partial W_{ab}}{\partial W_{ab}} \frac{\partial L}{\partial Y_{ib}} + \sum_i\sum_{k\ne a} X_{ik} \frac{\partial W_{kb}}{\partial W_{ab}} \frac{\partial L}{\partial Y_{ib}}

Every derivative in the second term is zero because WkbW_{kb} and WabW_{ab} are different elements when kak\ne a. Only the left term remains

LWab=iXiaWabWabLYib\frac{\partial L}{\partial W_{ab}} = \sum_i X_{ia} \frac{\partial W_{ab}}{\partial W_{ab}} \frac{\partial L}{\partial Y_{ib}}

Finally, WabWab=1\frac{\partial W_{ab}}{\partial W_{ab}}=1, so

LWab=iXiaLYib\frac{\partial L}{\partial W_{ab}} = \sum_i X_{ia}\frac{\partial L}{\partial Y_{ib}}

Again, this is close to matrix multiplication. Rewrite XiaX_{ia} as (XT)ai(X^\mathsf{T})_{ai}

LWab=i(XT)aiLYib\frac{\partial L}{\partial W_{ab}} = \sum_i (X^\mathsf{T})_{ai} \frac{\partial L}{\partial Y_{ib}}

This is row aa of XTX^\mathsf{T} multiplied by column bb of the upstream gradient. Therefore

LW=XTG\frac{\partial L}{\partial W} = X^\mathsf{T}G
Gradient of ReLU

ReLU is elementwise, so its backward rule is the same for a vector, matrix, or N-dimensional tensor. Conceptually, flatten the logical elements into a vector and apply the scalar rule independently to each element

yi=max(0,xi)y_i = \max(0, x_i)Lxi={Lyi,xi>00,xi0\frac{\partial L}{\partial x_i} = \begin{cases} \frac{\partial L}{\partial y_i}, & x_i > 0 \\ 0, & x_i \le 0 \end{cases}

At xi=0x_i=0, the implementation chooses the conventional subgradient 00. The result can then be viewed with the original shape. When the storage is contiguous, the implementation can process it as a flat contiguous buffer. Other tensor views may require respecting their strides.

Semantics of the backward pass

Each operation has a forward pass and a backward pass. The forward pass computes output tensors from input tensors and remembers the values needed to compute the local derivatives later.

Backpropagation starts from a scalar loss, whose derivative with respect to itself is 11.

The backward pass topologically sorts the computation graph and traverses it in reverse, from the result through the operations that produced it and toward the input tensors. Each operation consumes its accumulated upstream gradient, computes contributions to the gradients of its inputs, and accumulates those contributions.

Reverse topological order ensures that an operation is processed only after all of its downstream consumers have contributed. Its upstream gradient is therefore complete before the backward pass moves further toward the inputs.