Skip to content

Autograd Implementation: Forward and Backward Passes

Overview

Automatic differentiation (autograd) is PyTorch's core innovation: - Forward pass: Compute outputs and build computation graph - Backward pass: Compute gradients by traversing graph - Custom functions: Extend autograd for custom operations - Gradient tracking: requires_grad, grad_fn

Understanding autograd reveals how to build ML frameworks.


Core Concepts: Computation Graphs

Tensor Computation Graph

import torch

# Tensors track operations
x = torch.tensor([2.0], requires_grad=True)
y = torch.tensor([3.0], requires_grad=True)

# Operations create nodes in computation graph
z = x * y        # Multiply operation node
w = z + x        # Add operation node
loss = w.sum()   # Sum operation node

# Graph structure (computational dependency):
# x, y (leaf nodes)
    - #  │  │
  - #  └──*  (MulBackward)
  - #     │
- #     +──+  (AddBackward)
  - #        │
#       sum (SumBackward)
  - #        │
#      loss

print(loss.grad_fn)  # <SumBackward0>
print(z.grad_fn)     # <MulBackward0>
print(x.grad_fn)     # None (leaf tensor)

grad_fn Chain

Each intermediate tensor remembers its operation:

import torch

x = torch.tensor([2.0], requires_grad=True)

z = x ** 2
print(z.grad_fn)  # <PowBackward0>

w = z + 5
print(w.grad_fn)  # <AddBackward0>

# Each node remembers its inputs and operation
print(z.grad_fn.next_functions)  # References to previous grad_fn

# Follow the chain
print(w.grad_fn.next_functions[0])  # Points to z's grad_fn
print(w.grad_fn.next_functions[1])  # None (5 is constant)

Forward Pass: Building the Graph

Tracing Operations

import torch

def forward(x):
    """Simple forward pass."""
    y = x ** 2          # PowBackward
    z = y + 3           # AddBackward
    w = z * 2           # MulBackward
    return w.sum()      # SumBackward

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
loss = forward(x)

# At this point, computation graph is built
# But no gradients computed yet
print(x.grad)  # None

Tracking with requires_grad

import torch

# Only tensors with requires_grad=True are tracked
x = torch.tensor([1.0], requires_grad=True)
y = torch.tensor([2.0], requires_grad=False)  # Won't track

z = x * y  # z inherits requires_grad from x
print(z.requires_grad)  # True
print(z.grad_fn)  # <MulBackward>

# Tensors without requires_grad don't build graph
z2 = x * 10  # x has requires_grad=True
print(z2.grad_fn)  # <MulBackward>

# Detach stops tracking
z3 = x.detach() * y
print(z3.requires_grad)  # False
print(z3.grad_fn)  # None

No-Grad Context: Skip Graph Building

import torch

x = torch.tensor([1.0], requires_grad=True)

# Normal: builds graph
y = x ** 2  # Tracked

# Inference: skip graph building (faster, less memory)
with torch.no_grad():
    z = x ** 2  # Not tracked
    print(z.requires_grad)  # False
    print(z.grad_fn)  # None

# Typical use
model.eval()
with torch.no_grad():
    predictions = model(test_data)  # Much faster

Backward Pass: Computing Gradients

Backward Propagation

import torch

# Forward
x = torch.tensor([2.0, 3.0](/2.0,-3.0/), requires_grad=True)
W = torch.tensor([[1.0, 2.0], [3.0, 4.0]], requires_grad=True)

y = x @ W           # Matrix multiply
loss = y.sum()      # Sum all elements

# Backward: compute gradients
loss.backward()

# Gradients computed
print(x.grad)       # Gradient w.r.t. input
print(W.grad)       # Gradient w.r.t. weights
print(loss.grad)    # None (loss is scalar)

Manual Gradient Computation

To understand what backward() does:

import torch

# Forward
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
loss = y

# Manual backward computation
# loss = y = x^2
# ∂loss/∂x = 2x = 2 * 2 = 4

loss.backward()
print(x.grad)  # tensor([4.])

# Verify
print(2 * x.data)  # tensor([4.])

Chain Rule Implementation

PyTorch uses chain rule in backward pass:

import torch

# forward: z = (x * 2)^2
x = torch.tensor([3.0], requires_grad=True)

y = x * 2      # First operation
z = y ** 2     # Second operation

# Backward uses chain rule:
# dz/dx = dz/dy * dy/dx
# dz/dx = 2*y * 2 = 4*y = 4*6 = 24

z.backward()
print(x.grad)  # tensor([24.])

# Manual verification
# y = 3 * 2 = 6
# z = 6^2 = 36
# dz/dy = 2 * 6 = 12
# dy/dx = 2
# dz/dx = 12 * 2 = 24 ✓

Batch Backward

import torch

# Typical: batch processing
x = torch.randn(4, 10, requires_grad=True)  # Batch of 4
target = torch.randn(4, 5)

model = torch.nn.Linear(10, 5)

# Forward
output = model(x)
loss = torch.nn.functional.mse_loss(output, target)

# Backward: computes gradients for all batch samples
loss.backward()

# Gradients accumulated for entire batch
print(model.weight.grad.shape)  # (5, 10) - same as weight shape
print(x.grad.shape)  # (4, 10) - same as input shape

Custom Autograd Functions

Implementing Custom Forward/Backward

import torch

class ReLU(torch.autograd.Function):
    """Custom ReLU implementation."""

    @staticmethod
    def forward(ctx, input):
        """
        Compute ReLU: max(0, x)

        ctx: Context object to save information for backward
        """
        # Save input for backward
        ctx.save_for_backward(input)

        # Forward computation
        return torch.clamp(input, min=0)

    @staticmethod
    def backward(ctx, grad_output):
        """
        Compute gradient of ReLU

        grad_output: gradient from next layer (dL/dy)
        returns: gradient w.r.t. input (dL/dx)
        """
        # Retrieve saved tensors
        input, = ctx.saved_tensors

        # ReLU gradient: 1 if x > 0, else 0
        grad_input = grad_output.clone()
        grad_input[input < 0] = 0

        return grad_input

# Use custom function
def relu(x):
    return ReLU.apply(x)

# Test
x = torch.tensor([1.0, -2.0, 3.0], requires_grad=True)
y = relu(x)
loss = y.sum()
loss.backward()

print(x.grad)  # [1, 0, 1] - gradient only where x > 0

Complex Custom Function: Matrix Operation

import torch

class LinearWithCustomGrad(torch.autograd.Function):
    """Custom linear layer with modified gradient."""

    @staticmethod
    def forward(ctx, input, weight, bias=None):
        # Save for backward
        ctx.save_for_backward(input, weight, bias)

        # Forward: y = x @ W^T + b
        output = torch.matmul(input, weight.t())
        if bias is not None:
            output += bias

        return output

    @staticmethod
    def backward(ctx, grad_output):
        # Retrieve saved tensors
        input, weight, bias = ctx.saved_tensors

        # Compute gradients
        # ∂L/∂input = ∂L/∂output @ weight
        grad_input = torch.matmul(grad_output, weight)

        # ∂L/∂weight = ∂L/∂output^T @ input
        grad_weight = torch.matmul(grad_output.t(), input)

        # ∂L/∂bias = sum of ∂L/∂output
        grad_bias = grad_output.sum(0) if bias is not None else None

        # Return gradients in same order as forward arguments
        return grad_input, grad_weight, grad_bias

# Usage
linear = LinearWithCustomGrad()

x = torch.randn(32, 10, requires_grad=True)
W = torch.randn(5, 10, requires_grad=True)
b = torch.randn(5, requires_grad=True)

y = linear.apply(x, W, b)
loss = y.sum()
loss.backward()

print(x.grad.shape)  # (32, 10)
print(W.grad.shape)  # (5, 10)
print(b.grad.shape)  # (5,)

Gradient Accumulation

Default: Accumulation Behavior

import torch

x = torch.tensor([1.0], requires_grad=True)

# First backward
y = x ** 2
y.backward()
print(x.grad)  # tensor([2.])

# Second backward: gradients accumulate!
y = x ** 3
y.backward()
print(x.grad)  # tensor([2. + 3.]) = tensor([5.])

# Why? Useful for:
# - Multi-task learning
# - Gradient accumulation across batches
# - Meta-learning

Zero Gradients Between Updates

import torch

optimizer = torch.optim.SGD([x], lr=0.01)
model = torch.nn.Linear(10, 5)

for epoch in range(100):
    # Forward
    output = model(input_data)
    loss = criterion(output, target)

    # Backward accumulates gradients
    loss.backward()

    # Must zero gradients before next iteration!
    optimizer.zero_grad()  # or model.zero_grad()

    # Update parameters
    optimizer.step()

# If you forget zero_grad, gradients keep accumulating!

Intentional Gradient Accumulation

import torch

# Simulate large batch by accumulating gradients
model = torch.nn.Linear(10, 5)
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

effective_batch_size = 128
actual_batch_size = 32
accumulation_steps = effective_batch_size // actual_batch_size

for epoch in range(100):
    for batch_idx, (x, y) in enumerate(train_loader):
        # Forward
        pred = model(x)
        loss = criterion(pred, y) / accumulation_steps

        # Backward: gradients accumulate
        loss.backward()

        # Update after K batches
        if (batch_idx + 1) % accumulation_steps == 0:
            optimizer.step()
            optimizer.zero_grad()

Gradient Hooks: Monitoring/Modifying Gradients

Register Hooks

import torch

def print_grad_hook(grad):
    """Hook that prints gradient."""
    print(f"Gradient: {grad}")
    return grad

x = torch.randn(3, requires_grad=True)
hook_handle = x.register_hook(print_grad_hook)

y = (x ** 2).sum()
y.backward()
# Output: Gradient: tensor([...])

# Unregister hook if needed
hook_handle.remove()

Gradient Clipping Example

import torch

def clip_grad_hook(grad, max_norm=1.0):
    """Hook that clips gradient norm."""
    norm = grad.norm()
    if norm > max_norm:
        return grad * (max_norm / norm)
    return grad

# Register for all parameters
model = torch.nn.Linear(10, 5)
for name, param in model.named_parameters():
    param.register_hook(lambda g, m=max_norm: clip_grad_hook(g, m))

# Training
y = model(x)
loss = y.sum()
loss.backward()  # Gradients automatically clipped

Monitor Parameter Gradients

import torch

class GradientMonitor:
    def __init__(self, model):
        self.model = model
        self.grad_stats = {}
        self._register_hooks()

    def _register_hooks(self):
        """Register hooks on all parameters."""
        for name, param in self.model.named_parameters():
            param.register_hook(lambda g, n=name: self._record_grad(n, g))

    def _record_grad(self, name, grad):
        """Record gradient statistics."""
        self.grad_stats[name] = {
            'mean': grad.mean().item(),
            'std': grad.std().item(),
            'min': grad.min().item(),
            'max': grad.max().item(),
        }
        return grad

    def print_stats(self):
        """Print gradient statistics."""
        for name, stats in self.grad_stats.items():
            print(f"{name}: mean={stats['mean']:.6f}, std={stats['std']:.6f}")

# Usage
model = torch.nn.Sequential(
    torch.nn.Linear(10, 20),
    torch.nn.ReLU(),
    torch.nn.Linear(20, 5)
)

monitor = GradientMonitor(model)

# Training step
y = model(x)
loss = y.sum()
loss.backward()

monitor.print_stats()

Detaching and No-Grad

When to Detach

import torch

x = torch.randn(3, 4, requires_grad=True)

# Detach breaks gradient flow
y = x ** 2
z = y.detach()  # Stop tracking

w = z ** 2
loss = w.sum()
loss.backward()

print(x.grad)  # None - gradient didn't flow through detach
print(y.grad)  # None - y doesn't have grad_fn anymore

# Use case: Stop gradients for target network in RL
target_output = target_network(state).detach()

No-Grad for Inference

import torch

model = torch.nn.Linear(10, 5)

# Training
x = torch.randn(3, 10)
y = model(x)
loss = y.sum()
loss.backward()  # Computes gradients, builds graph

# Inference
with torch.no_grad():
    predictions = model(x)
    # No graph building, much faster, less memory

Gradient Checking

Numerical Gradient Verification

import torch

def numerical_gradient(func, x, eps=1e-5):
    """Compute numerical gradient using finite differences."""
    grad = torch.zeros_like(x)

    for i in range(x.numel()):
        x_plus = x.clone()
        x_plus.view(-1)[i] += eps

        x_minus = x.clone()
        x_minus.view(-1)[i] -= eps

        grad.view(-1)[i] = (func(x_plus) - func(x_minus)) / (2 * eps)

    return grad

# Test custom backward implementation
def my_function(x):
    return (x ** 2).sum()

x = torch.randn(3, requires_grad=True)

# Analytical gradient (via autograd)
y = my_function(x)
y.backward()
analytical_grad = x.grad.clone()

# Numerical gradient
x.grad.zero_()
numerical_grad = numerical_gradient(my_function, x)

# Compare
print(torch.allclose(analytical_grad, numerical_grad, atol=1e-4))

Practical: Training Loop with Autograd

import torch
import torch.nn.functional as F

class SimpleNet(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = torch.nn.Linear(10, 20)
        self.fc2 = torch.nn.Linear(20, 5)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        return self.fc2(x)

# Setup
model = SimpleNet()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
criterion = torch.nn.CrossEntropyLoss()

# Training loop
for epoch in range(10):
    for batch_x, batch_y in train_loader:
        # Forward pass: build computation graph
        predictions = model(batch_x)

        # Compute loss: creates loss node in graph
        loss = criterion(predictions, batch_y)

        # Backward pass: traverse graph, compute gradients
        optimizer.zero_grad()   # Clear old gradients
        loss.backward()          # Compute new gradients

        # Update: modify parameters using gradients
        optimizer.step()

# Evaluation (no gradient computation)
model.eval()
with torch.no_grad():
    for batch_x, batch_y in test_loader:
        predictions = model(batch_x)
        accuracy = (predictions.argmax(1) == batch_y).float().mean()

Summary: Autograd Flow

1. FORWARD PASS
   x → [Op1] → y → [Op2] → z → [Op3] → loss

   Builds computation graph, saving:
   - Operation type
   - Input tensors
   - grad_fn pointers

2. BACKWARD PASS (loss.backward())
   loss (grad = 1)
   ↓
   [Op3.backward] → grad_z
   ↓
   [Op2.backward] → grad_y
   ↓
   [Op1.backward] → grad_x
   ↑
   Leaf tensors (.grad attribute)

3. GRADIENT UPDATE
   param = param - lr * param.grad