LLM + RoboticsModule 08 · Stage 01

Tensor maths & custom autograd

Open the box. Learn how a tensor really sits in memory, how gradients flow backwards through each operation, and how to write your own GPU kernel when the built-in one is too slow.

PrerequisitesPython, NumPy, basic calculus (chain rule), matrix multiplication
Time40–80 h part-time: ~2 weeks for autograd and strides, 2–4 weeks for Triton
HardwareLaptop for autograd and strides · a CUDA GPU (Colab is fine) for Triton
Key papersPyTorch (Paszke et al., 2019) · FlashAttention (Dao et al., 2022) · Triton (Tillet, Kung & Cox, 2019)

Source: Suraj Sharma's roadmap post on X, stage 1 · all 12 stages

Start here (no maths needed)

A neural network is a long chain of simple maths steps. To make it learn, the computer runs the chain forwards, then walks it backwards to work out which knobs to turn. This stage is about building that backwards walk yourself.

Think of it like this

Imagine a row of dominoes that ends by knocking a cup off a table. The cup lands in the wrong place. To fix it, you walk back along the row: "the last domino pushed the cup too far left, because the one before it was tilted, because the one before that..." Each domino only needs to know how it passed the push on. Walking back along the chain and blaming each piece a little is exactly what "backpropagation" does.

There's a second idea: where things are kept. A tensor is a grid of numbers, but the computer stores it as one long line. Picture a class photo stored as one long list of names, row by row. To flip the photo sideways, you don't need to rewrite the list. You just change the rule for how you read it. That reading rule is called the stride.

How it works

  1. Forward: numbers go in, each step does a little maths, and an answer comes out. The computer remembers what each step saw.
  2. Score: compare the answer with the right one. The difference is the "loss".
  3. Backward: starting from the loss, each step works out how much its inputs were to blame, using only what it remembered. This is the chain rule.
  4. Update: nudge every knob a tiny bit in the direction that lowers the loss. Repeat millions of times.
  5. Speed up: on a graphics chip, moving numbers around is slower than doing maths on them. So experts "fuse" several steps into one, so the numbers are fetched only once.

Why robots care

A robot arm's brain runs this same forward step dozens of times every second on a small computer inside the robot. If one step is slow because data is stored in an awkward order, the arm reacts late. And when a robot's training suddenly goes wrong (its scores jump to "NaN", meaning "not a number"), the person who can read the backward pass step by step is the one who finds the bug.

Try it

Words to know

Tensor
A grid of numbers with any number of directions (a list, a table, a stack of tables...).
Gradient
For each knob, how much the loss would change if you nudged it.
Backpropagation
Working out all the gradients by walking the chain of steps backwards.
Stride
How far to jump in the memory line to move one step along a direction of the grid.
Kernel
A small program that runs on the graphics chip, on many numbers at once.

Going deeper: everything below is the full version, with some maths and code. On a first read, skim the headings and the "Why it matters for robotics" box, and come back for the rest later.

1. What this stage covers

TL;DR

Learn what PyTorch does underneath loss.backward(). A tensor is a flat block of memory plus a shape, strides and an offset. Most "reshaping" operations only change that metadata. Autograd records a graph of operations during the forward pass and applies each operation's backward rule in reverse. You can plug in your own operation with a forward and a hand-written backward. When an operation is limited by memory traffic rather than arithmetic, you write a fused kernel, today usually in Triton, so data crosses the slow GPU memory once instead of many times. FlashAttention is the classic example.

2. Concepts, explained

PyTorch internals: storage, views and the autograd graph

A PyTorch tensor is a view onto a storage: a flat, typed block of memory. The view adds a shape, a stride for each dimension, and a storage offset. Element [i, j] lives at:

address(i, j) = offset + i·stride[0] + j·stride[1]

x = torch.arange(12).view(3, 4)   # shape (3, 4), stride (4, 1), offset 0
x.T                               # shape (4, 3), stride (1, 4): same memory, no copy
x[:, ::2]                         # shape (3, 2), stride (4, 2)
x[1:]                             # shape (2, 4), stride (4, 1), offset 4

When you run operations on tensors with requires_grad=True, PyTorch records each one as a node in a graph (you can see it in y.grad_fn). Each node keeps what it needs for its backward rule, such as its inputs. Calling backward() walks this graph in reverse topological order. It multiplies the incoming gradient by each node's local derivative and adds up the contributions where paths merge. The graph is rebuilt on every forward pass ("define-by-run", Paszke et al., 2019). This is reverse-mode automatic differentiation. Baydin et al.'s survey (2015, revised 2018) explains how it differs from symbolic and numerical differentiation.

Why reverse mode? A network has millions of inputs (the weights) and one output (the loss). Reverse mode gets the gradient with respect to all of them for a small constant multiple of the forward cost. Forward mode would need one pass per input.

Custom forward and backward passes

You need your own backward when the automatic one is slow, uses too much memory, or is numerically unstable. Sometimes there's no automatic one at all, because the forward calls a custom kernel. In PyTorch you subclass torch.autograd.Function:

class Squareplus(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x, b=4.0):
        r = torch.sqrt(x * x + b)
        ctx.save_for_backward(x, r)     # keep only what backward needs
        return 0.5 * (x + r)

    @staticmethod
    def backward(ctx, g):               # g = dLoss/dOutput
        x, r = ctx.saved_tensors
        return g * 0.5 * (1 + x / r), None   # one gradient per forward input

torch.autograd.gradcheck(Squareplus.apply, torch.randn(8, dtype=torch.double, requires_grad=True))

The rule to remember: backward receives the gradient of the loss with respect to your output and returns the gradient with respect to each input. That's the vector-Jacobian product. For an element-wise function it's just g * f'(x). Always check a hand-written backward against finite differences in float64. gradcheck does this for you.

Squareplus is a real activation, from Barron (2021). It looks like softplus but uses only addition, multiplication and a square root. It's a good "novel" function for the practice project because nobody hands you its derivative in a library.

Triton programming

Triton (Tillet, Kung & Cox, 2019; now developed in the open by OpenAI and contributors) is a Python-embedded language for GPU kernels. In CUDA you program one thread. In Triton you program one block of data. Each program instance gets an ID from tl.program_id. It loads a tile with tl.load (using a mask at the edges), computes on it as if it were a small array, and writes with tl.store. The compiler handles the thread layout, shared memory and much of the vectorisation. You launch a kernel over a grid of program instances: kernel[grid](...).

The reason to write kernels at all is the memory hierarchy. A GPU has large but slow off-chip memory (HBM) and a small, fast on-chip memory (SRAM). Many operations (softmax, layer norm, activations) do only a few flops per byte they read. Their speed is set by memory traffic, not maths. Fusing three such ops into one kernel reads and writes the data once instead of three times.

Fused attention (the FlashAttention trick)

Standard attention builds the full n × n score matrix in HBM, softmaxes it, then multiplies by V. FlashAttention (Dao, Fu, Ermon, Rudra & Ré, 2022) never stores that matrix. It loops over tiles of K and V, keeps each block of Q in SRAM, and uses the online softmax: keep a running row maximum and a running denominator, and rescale the partial output whenever the maximum grows. That idea goes back to Milakov & Gimelshein (2018). The result is exact attention with memory linear in sequence length. In the backward pass, it recomputes the attention tiles from Q, K and V instead of storing them, trading cheap flops for expensive memory traffic. The paper reports a 3× training speedup on GPT-2 at sequence length 1K. FlashAttention-2 (Dao, 2023) reworked how the work is split across thread blocks and warps, for roughly another 2×.

for each tile (K_j, V_j):
    S    = Q · K_jᵀ / √d
    m'   = max(m, rowmax(S))
    P    = exp(S − m')
    l    = l · exp(m − m') + rowsum(P)
    O    = O · exp(m − m') + P · V_j
    m    = m'
O = O / l

Memory layouts and stride calculations

A tensor is contiguous (row-major, "C order") when its strides are exactly what a freshly allocated tensor of that shape would have. Each stride equals the product of the sizes to its right. Size-1 dimensions don't count. Three consequences you will meet constantly:

Interactive: strides and contiguity

Top: the tensor as you see it (logical view). Bottom: the one flat line of memory (storage). Each cell shows the number stored there. The highlighted cell is element [i, j]. Shaded memory cells are ones this view can reach.

Shape
–
Stride
–
Offset
–
Address of [i, j]
–
Contiguous?
–

Try this: with a 3 × 4 tensor, press Transpose. Shape becomes (4, 3), stride (1, 4), and it is no longer contiguous, though nothing moved in memory. Press Reset, then Drop first row: the offset becomes 4 and it stays contiguous. Slice [:, ::2] leaves gaps in memory, so it is never contiguous.

3. The practice project

(a) Laptop / Colab version

Part 1: a backward pass with no autograd (laptop, NumPy only, an evening). Build a two-layer MLP with squareplus as the activation. Write the forward pass, derive the backward pass on paper, code it, and check it against finite differences.

import numpy as np
B = 4.0
sq  = lambda x: 0.5 * (x + np.sqrt(x * x + B))
dsq = lambda x: 0.5 * (1 + x / np.sqrt(x * x + B))

def forward(W1, W2, x, y):
    h = x @ W1; a = sq(h); p = a @ W2
    return ((p - y) ** 2).mean(), (x, h, a, p)

def backward(W1, W2, y, cache):
    x, h, a, p = cache
    dp = 2 * (p - y) / len(y)
    dW2 = a.T @ dp
    dh = (dp @ W2.T) * dsq(h)      # chain rule through the activation
    return x.T @ dh, dW2

rng = np.random.default_rng(0)
x, y = rng.normal(size=(32, 3)), rng.normal(size=(32, 1))
W1, W2 = rng.normal(size=(3, 8)), rng.normal(size=(8, 1))
dW1, _ = backward(W1, W2, y, forward(W1, W2, x, y)[1])
eps, E = 1e-6, np.zeros_like(W1); E[1, 2] = eps
num = (forward(W1 + E, W2, x, y)[0] - forward(W1 - E, W2, x, y)[0]) / (2 * eps)
print(dW1[1, 2], num)              # should agree to ~1e-8

We ran this sketch: the analytic and numerical gradients agreed to about 2 × 10−9. Then wrap the same maths in a torch.autograd.Function (section 2) and pass gradcheck.

Part 2: fused attention, first in NumPy, then in Triton (Colab GPU, a weekend or two). Write the tiled online-softmax loop from section 2 in NumPy and check it matches naive attention to about 1e-12 in float64. (Our version, with 64 queries, head dimension 32 and tiles of 16 keys, matched to ~7 × 10−16.) Then work through the official Triton tutorials in order: vector add, fused softmax, matmul, fused attention. Write your own forward-only attention kernel, test it against torch.nn.functional.scaled_dot_product_attention with torch.testing.assert_close, and time it with triton.testing.do_bench across sequence lengths. Triton has an interpreter mode (TRITON_INTERPRET=1) that runs kernels on the CPU for debugging, slowly.

What to measure: maximum absolute error against the reference in fp32 and fp16. Runtime and peak memory (torch.cuda.max_memory_allocated) as sequence length doubles. The naive version's memory should grow with the square of the length. Yours should grow linearly. Don't expect to beat PyTorch's built-in fused attention. Matching it within a small factor is a good result.

(b) The full version from the post

(c) Pitfalls

4. Reality check

The post's claim: you can't optimise or debug a collapsing loss curve if you treat networks as black-box APIs.

5. In this course

6. Why it matters for robotics

7. Self-check

A tensor of shape (3, 4) has stride (4, 1). What are the shape and stride of its transpose, and is it contiguous?

Shape (4, 3), stride (1, 4). It's not contiguous: a fresh (4, 3) tensor would have stride (3, 1). No data moved. Only the metadata changed.

Why does x.T.view(-1) raise an error while x.T.reshape(-1) works?

Flattening needs the elements to be evenly spaced in memory in row-major order. The transposed view's elements aren't, so no single stride can describe them. view refuses to copy. reshape silently makes a contiguous copy.

Your autograd.Function takes (x, b) where b is a Python float. What must backward return?

Two values: the gradient with respect to x (same shape as x) and None for b.

Why is reverse-mode autodiff the right choice for training networks?

There are millions of inputs (the parameters) and one scalar output (the loss). Reverse mode gives all the partial derivatives for a small constant multiple of one forward pass. Forward mode would need one pass per input.

How does FlashAttention avoid storing the n × n attention matrix, even in the backward pass?

Forward: it processes K/V in tiles and uses the online softmax (running max and running sum), so only O(n) statistics per row are kept. Backward: it recomputes the attention tiles from Q, K, V and those statistics instead of reading a stored matrix.

8. Courses & content

ResourceTypeUse it for
Stanford CS336 · Assignment 1 (Basics) + Assignment 2 (Systems)University course (free materials)Build tokenizer, Transformer and optimizer from scratch; then write FlashAttention2 in Triton and profile it
Karpathy · Neural Networks: Zero to HeroVideo course (free)Backprop by hand with micrograd, then up to a GPT
PyTorch docs · Extending PyTorch (custom autograd.Function)Official docsWriting your own forward/backward
Triton tutorialsOfficial tutorialsVector add → fused softmax → matmul → fused attention
GPU MODE lecturesLecture series (free)Profiling, CUDA and Triton kernels, memory layout

Start with Karpathy's micrograd video: in about two hours you build autograd yourself. Everything else on this page then feels like a bigger version of that.

Full course map for all 12 stages →

9. Papers & docs