v9.1.0
Menu

Chapter 6: PyTorch Fundamentals

Learn PyTorch from tensors to training at scale: autograd, nn.Module, DataLoaders, the training loop, custom autograd, mixed precision and torch.compile.

Topics
8
Videos
8
Min read
111

Chapter Overview

Every chapter so far has handed you a piece of the machinery behind modern AI: Python to express ideas, NumPy to compute on whole arrays at once, calculus to know which way is downhill, probability to define what a good model is, and linear algebra to understand what a layer actually does to its input. What none of them gave you is a way to put those pieces together at scale: to define a model with millions of parameters, compute the gradient of a loss with respect to every one of them without deriving a single derivative by hand, and run it all on a GPU.

That is the job of PyTorch. Its tensors look and feel like NumPy arrays, but they can live on a GPU and they can record every operation applied to them, so that a single call to loss.backward() applies the chain rule through the whole computation. On top of that sit a small number of abstractions (nn.Module, Dataset, DataLoader, optimizers) that every research codebase and production training job is built from.

The chapter climbs from beginner to advanced:

  • Tensors and Tensor Operations: the data structure, its dtypes, devices and memory layout, then indexing, broadcasting, views and einsum
  • Autograd: how the computational graph is recorded and how reverse-mode differentiation fills in .grad
  • nn.Module and Datasets & DataLoaders: packaging parameters into models and feeding them batches
  • The Training Loop: losses, optimizers and schedulers, and why every line of the canonical loop exists
  • Custom Autograd Functions & Hooks: extending PyTorch when the built-ins are not enough
  • Performance at Scale: where time and memory go, mixed precision, gradient accumulation and torch.compile

Every topic has an explainer video, runnable code, and links to the PyTorch problems on PixelBank so you can practise each idea right after reading it.

Chapter Roadmap

Click any topic to jump in

Explainer video
1
Tensors

Shape, dtype, device and strides: the n-dimensional array PyTorch computes on.

Creating Tensors: Copy or ShareShape, ndim and numelDtypes, Promotion and CastingDevices: Where the Data LivesStorage, Strides and Contiguity
2
Tensor Operations

Indexing, broadcasting, views vs copies, matmul and einsum on whole tensors.

Element-wise Operations and ReductionsIndexing, Boolean Masks and gatherBroadcasting in PyTorchViews: reshape, view, permute and In-place OpsMatrix Products: @, bmm and einsum
Differentiable tensors

Gradients and the layers that own them

3
Autograd

The recorded graph and reverse-mode differentiation that fill in every .grad.

requires_grad and the Dynamic Graphbackward(): Reverse Mode as Vector-Jacobian ProductsLeaf Tensors, .grad and Gradient AccumulationSwitching Recording Off: no_grad, inference_mode and detachretain_graph and create_graph: Higher-Order Gradients
4
nn.Module

Parameters, submodules, train/eval modes and state_dict: how models are packaged.

nn.Module: __init__, forward and Auto-Registrationnn.Linear: y = xW^T + bContainers and Shape Helpers: Sequential, ModuleList, ModuleDict, Flattentrain() vs eval(): Dropout, BatchNorm and BuffersWeight Initialization: Xavier and Kaiming
From model to training

Batches in, updates out

5
Data Loading

Dataset, DataLoader, collate and transforms: turning raw data into batches.

Map-Style Datasets, TensorDataset & Random SplitsTransforms: ToTensor, Normalize & ComposeDataLoader Batching, Shuffling & the Last BatchCollate Functions & Padding Variable-Length SequencesSamplers & WeightedRandomSampler for Class Imbalance
6
Training Loop

zero_grad, forward, loss, backward, step: losses, optimizers and schedulers.

The Canonical Training StepLoss Functions: Logits In, Scalar OutOptimizers: SGD, Momentum, Adam and AdamWLearning-Rate Schedules and WarmupClipping, Validation and Checkpoints
Beyond the built-ins

Custom gradients and running fast at scale

7
Custom Autograd & Hooks

autograd.Function and hooks: custom gradients and looking inside a model.

Anatomy of a torch.autograd.FunctionCustom Gradients: Straight-Through and Stable BackwardVerifying Gradients with gradcheckForward Hooks and Pre-Hooks on ModulesBackward Hooks: Inspecting and Rewriting Gradients
8
Performance at Scale

Memory budgets, mixed precision, accumulation, checkpointing and torch.compile.

Where the Memory Goes: Bytes per ParameterGPU Asynchrony and Correct TimingMixed Precision: autocast and GradScalerTrading Compute for Memory: Gradient Accumulation and Checkpointingtorch.compile: Graph Capture and Kernel Fusion

Topics

8 topics in this chapter

Suppose you have a batch of 32 RGB images at 224 by 224 pixels and a network with millions of weights. You need to push every pixel through that network thousands of times, on a GPU, while also recording how each output depends on each weight. The NumPy array you mastered in NumPy & Data Wrangling gets you halfway: it stores numbers densely and vectorizes arithmetic, but it lives only in CPU memory and forgets how it was computed. PyTorch's answer is the tensor, an n-dimensional array that behaves almost exactly like a NumPy array but can live on a GPU and, as later topics show, can track gradients. The vectors and matrices of Linear Algebra are simply tensors with one and two dimensions.

This topic builds the data structure in the order you meet it in practice. We start by creating tensors and the copy-versus-share distinction with NumPy, then read a tensor's shape, rank and element count. Next come dtypes, where precision trades against memory, and devices, where the data physically lives. We close with the layer underneath every tensor, a flat storage plus strides, which explains why some reshapes are free and others copy.

Definition

A PyTorch tensor is an n-dimensional array whose elements all share one type. It is described by four pieces of metadata: a shape (the size of each dimension), a dtype (the element type, torch.float32 by default for floats), a device (where its memory lives, such as cpu or cuda:0), and strides that map each multi-index onto an offset in a flat block of memory called the storage.

In this topic

  1. 1Creating Tensors: Copy or Share
  2. 2Shape, ndim and numel
  3. 3Dtypes, Promotion and Casting
  4. 4Devices: Where the Data Lives
  5. 5Storage, Strides and Contiguity
1 of 5
Creating Tensors: Copy or Share

torch.tensor(a)→new copy,torch.from_numpy(a)→shared memory\texttt{torch.tensor}(a) \rightarrow \text{new copy}, \qquad \texttt{torch.from\_numpy}(a) \rightarrow \text{shared memory}

Every computation starts with data in a tensor, and the first trap is not knowing whether you just copied it. torch.tensor(data) reads a Python list or NumPy array aa and always allocates a fresh copy, inferring the dtype: Python integers become int64, Python floats float32. Factories build tensors from a shape alone: torch.zeros, torch.ones, torch.arange(start, end, step), torch.linspace(start, end, steps), torch.rand (uniform on [0,1)[0, 1)) and torch.randn (standard normal), while torch.zeros_like(x) copies another tensor's shape, dtype and device. torch.from_numpy(a) instead wraps the array's existing buffer, so writes through either name appear in both, exactly like the views from NumPy & Data Wrangling, and it keeps NumPy's float64.

Mathematical Intuition

Sharing costs O(1)O(1): from_numpy writes a small header (data pointer, shape, strides, dtype) and touches none of the nn elements, while torch.tensor costs O(n)O(n) time and doubles peak memory. For a 1 GB float32 array that is the difference between microseconds and copying a full gigabyte. Random factories are deterministic given a seed: after torch.manual_seed(0), torch.rand(2, 2) always starts with 0.4963, which is what makes experiments reproducible across runs and machines with the same PyTorch build.

Example:

a = np.arange(4, dtype=np.float32). You make t = torch.from_numpy(a) and c = torch.tensor(a), then run t *= 2. What do a and c hold?

2 of 5
Shape, ndim and numel

numel=∏k=0ndim−1dk,bytes=numel×element size\text{numel} = \prod_{k=0}^{\text{ndim}-1} d_k, \qquad \text{bytes} = \text{numel} \times \text{element size}

Most PyTorch bugs are shape bugs, so reading a tensor's geometry is the first debugging reflex. x.shape (a torch.Size, which is a tuple) lists the size dkd_k of each dimension kk. x.ndim counts the dimensions: a scalar has 0, a vector 1, a matrix 2, matching the objects of Linear Algebra. x.numel() is the total element count, the product of every dkd_k, and x.element_size() gives bytes per element, so their product is the memory the data occupies. Conventions matter: vision models expect (N, C, H, W) with the batch first, and swapping two equal-sized dimensions raises no error at all, it silently computes garbage.

Mathematical Intuition

Memory grows multiplicatively with every dimension, so doubling the image side quadruples numel: at 448×448448 \times 448 the same float32 batch needs about 77 MB. Tensor ndim is not the matrix rank from Linear Algebra: ndim counts the indices needed to address one element, while matrix rank counts linearly independent columns. A 3×33 \times 3 matrix of all ones has ndim 2 but matrix rank 1. numel is also the cost model for any element-wise operation, which touches each element exactly once, so it predicts time as well as memory.

Example:

A batch of 32 RGB images at 224 by 224 pixels, stored as float32. What are ndim, numel() and the memory in bytes?

3 of 5
Dtypes, Promotion and Casting

bool≺integer≺floating≺complex\text{bool} \prec \text{integer} \prec \text{floating} \prec \text{complex}

The dtype fixes both precision and memory. Floats default to float32 (4 bytes), integers to int64 (8 bytes, used for labels and indices), and bool holds masks. float16 and bfloat16 use 2 bytes: float16 keeps more precision but overflows above 65504, while bfloat16 keeps float32's range with only about 3 significant digits. When an operation mixes dtypes, PyTorch promotes along the category order in the formula, where ≺\prec means ranks below, so int64 divided by 2 gives float32, never truncation. Within one category, a zero-dimensional tensor or Python scalar never raises a dimensioned tensor's precision. Cast explicitly with .to(dtype), .float() or .long(); float-to-int truncates toward zero.

Mathematical Intuition

A float stores a sign, an exponent and a mantissa. Exponent bits set the range, mantissa bits the precision: machine epsilon, the gap just above 1.0, is 2−10≈0.000982^{-10} \approx 0.00098 for float16, 2−7≈0.00782^{-7} \approx 0.0078 for bfloat16 and 2−23≈1.2×10−72^{-23} \approx 1.2 \times 10^{-7} for float32. So 1.001 survives in float16 as 1.00098 but rounds to 1.0 in bfloat16, while 70000 overflows float16 to inf yet becomes 70144 in bfloat16. Training cares more about range than precision, since gradients span many orders of magnitude, which is why bfloat16 became the usual low-precision type for large models.

Example:

i = torch.tensor([1, 2, 3]) and h = torch.ones(3, dtype=torch.float16). What dtypes do i / 2, i // 2, h + torch.tensor(2.0) and h + torch.tensor([2.0]) have?

4 of 5
Devices: Where the Data Lives

op(x(cuda:0),  y(cpu))→RuntimeError\text{op}\big(x^{(\text{cuda:0})},\; y^{(\text{cpu})}\big) \rightarrow \text{RuntimeError}

A GPU only accelerates data in its own memory, so every tensor carries a device: cpu, cuda:0 for the first NVIDIA GPU, or mps on Apple silicon. In the formula xx and yy are operands and each superscript says where it lives; an operation across devices raises a RuntimeError instead of copying silently (a zero-dimensional CPU tensor is the one exception, treated like a scalar). x.to(device) returns a copy on the target device, or x itself if it is already there, and .cpu() brings data back, which .numpy() requires because NumPy only sees host memory. The portable idiom picks the device once: device = "cuda" if torch.cuda.is_available() else "cpu".

Mathematical Intuition

Device transfers cross the PCIe bus, whose 4.0 x16 generation peaks near 32 GB/s, while a high-end GPU reads its own HBM memory at 2 to 3 TB/s, roughly 100 times faster. Moving a 19 MB image batch therefore costs on the order of a millisecond, longer than many layers take to run. The rule is to move data once per batch and keep weights, activations and gradients resident on the GPU. Calls like .item() or .cpu() inside a loop also force the CPU to wait for the GPU, quietly serializing the pipeline.

Example:

Your weights w are on cuda:0 but the input x = torch.ones(3) is still on the CPU. What does w + x do, and how does .to behave?

5 of 5
Storage, Strides and Contiguity

offset(i0,…,in−1)=o+∑k=0n−1ik sk\text{offset}(i_0, \dots, i_{n-1}) = o + \sum_{k=0}^{n-1} i_k \, s_k

Why can transposing a huge tensor take microseconds while .view sometimes refuses to run? Because a tensor is a window onto a flat, one-dimensional storage. The formula locates element (i0,…,in−1)(i_0, \dots, i_{n-1}): start at the storage offset oo, then step sks_k elements for each unit of index iki_k, where sks_k is the stride of dimension kk. A fresh 3×43 \times 4 tensor has strides (4,1)(4, 1), row-major and contiguous. Transposing, slicing and permute only rewrite shape, strides and offset, returning views that share storage, like NumPy views. The catch: .view needs compatible strides and raises a RuntimeError on a transposed tensor, whereas .reshape or .contiguous() falls back to copying.

Mathematical Intuition

Strides turn layout changes into O(1)O(1) metadata edits: transposing an nn-element tensor rewrites a few integers instead of moving nn values. The price is paid later, in memory access, because walking a transposed tensor jumps s0s_0 elements per step and wastes the 64-byte cache lines the CPU fetches, so kernels often call .contiguous() once and pay O(n)O(n) up front. A stride of 0 even lets one stored value appear many times, which is how expand builds a (4,3)(4, 3) view of a 3-element vector without copying.

Example:

x = torch.arange(12).reshape(3, 4). Which storage slot holds x[2, 1]? What are the strides of x.t() and of the slice x[:, 1:3]?

Code Examples

Copy versus share, and what each dtype costs

import numpy as np
import torch

a = np.array([1.0, 2.0, 3.0])          # NumPy defaults to float64
shared = torch.from_numpy(a)           # same memory, no copy
copied = torch.tensor(a)               # fresh copy
a[0] = 100.0
print("shared:", shared.tolist(), shared.dtype)
print("copied:", copied.tolist())

for dt in [torch.float32, torch.float16, torch.bfloat16, torch.int64, torch.bool]:
    x = torch.zeros(1000, 1000, dtype=dt)
    print(f"{str(dt):15s} {x.element_size()} bytes/elem  {x.numel() * x.element_size() / 1e6:.1f} MB")

big = torch.tensor(70000.0)
print("70000 as float16:", big.half().item(), " as bfloat16:", big.bfloat16().item())
print("1.001 as float16:", torch.tensor(1.001).half().item(), " as bfloat16:", torch.tensor(1.001).bfloat16().item())

After editing the NumPy array, the tensor built with torch.from_numpy prints [100.0, 2.0, 3.0] with dtype torch.float64, because it shares the buffer and keeps NumPy's default dtype, while the torch.tensor copy still prints [1.0, 2.0, 3.0]. A million elements cost 4.0 MB in float32, 2.0 MB in float16 and bfloat16, 8.0 MB in int64 and 1.0 MB in bool. Casting 70000 gives inf in float16 but 70144.0 in bfloat16, and casting 1.001 gives 1.0009765625 in float16 but 1.0 in bfloat16: float16 has the finer precision, bfloat16 the wider range.

Strides, views and contiguous copies

import torch

x = torch.arange(12).reshape(3, 4)
print("x shape", tuple(x.shape), "stride", x.stride(), "contiguous", x.is_contiguous())

t = x.t()                                   # transpose: swap strides, no copy
print("t shape", tuple(t.shape), "stride", t.stride(), "contiguous", t.is_contiguous())
print("same storage:", t.data_ptr() == x.data_ptr())

x[0, 1] = 99                                # edit through x ...
print("t[1, 0] =", t[1, 0].item())          # ... visible through t

try:
    t.view(12)
except RuntimeError as e:
    print("view failed:", str(e)[:27])

c = t.contiguous()                          # real copy, row-major again
print("c stride", c.stride(), "same storage:", c.data_ptr() == x.data_ptr())
print("flattened:", c.view(12).tolist())

The 3 by 4 tensor has stride (4, 1) and is contiguous. Its transpose has shape (4, 3), stride (1, 4), is not contiguous, and reports the same storage pointer, so writing 99 into x[0, 1] makes t[1, 0] print 99. Calling view(12) on the transpose fails with view size is not compatible, while contiguous() copies into new storage with stride (3, 1), which flattens to [0, 4, 8, 99, 5, 9, 2, 6, 10, 3, 7, 11], the column-by-column reading of the original.

Theory Exercise

Problem:

A freshly created float32 tensor x has shape (2, 3, 4) and is contiguous. (a) What are its strides? (b) At which storage element, and which byte offset, does x[1, 2, 3] live? (c) What shape and strides does x.permute(2, 0, 1) have, is it contiguous, and can you call .view(24) on it?

Hints:
  • In a contiguous row-major tensor the last dimension has stride 1, and each earlier stride is the product of all later sizes.
  • Use offset = o + sum of index times stride, with o = 0 for a fresh tensor, then multiply by the element size for bytes.
  • permute reorders shape and strides together without moving data.

Coding Exercise

Problem:

Build a tensor from the nested list [[1, 2, 3], [4, 5, 6]] and print its shape, ndim, numel, dtype and device. Cast it to float32 and print its mean, move it to the best available device, then transpose it and report the strides and contiguity before and after calling .contiguous().

Hints:
  • torch.tensor infers int64 from Python integers; .float() (or .to(torch.float32)) casts, and .mean() needs a floating dtype.
  • Pick the device with torch.cuda.is_available() so the same code runs with or without a GPU, then call .to(device).
  • .t() swaps the strides without copying, so the result is not contiguous; .contiguous() copies into row-major order.