01

PyTorch 101

From Tensors to Training — everything you need to read or modify the tiny-LLM code, in 15 slides.

Tensors

The core data structure

Autograd

Automatic differentiation

nn.Module

Reusable layers

Layers & F.*

Linear, Embedding, ops

Tensor Ops

view, transpose, @, …

Training Loop

The 4-line pattern

Inference

no_grad, eval mode

Save / Load

Checkpoints & devices

02 / Tensors

Tensors — The Core Data Structure EVERYTHING IS A TENSOR

A tensor is a multi-dimensional array (like NumPy's ndarray) with two superpowers: it can run on a GPU, and it can track gradients automatically.

Creating Tensors

import torch

# From a Python list
x = torch.tensor([1, 2, 3])         # 1-D, shape (3,)
x = torch.tensor([[1, 2], [3, 4]])  # 2-D, shape (2, 2)

# Special constructors
torch.zeros(2, 3)                    # 2x3 of zeros
torch.ones(5)                        # length-5 of ones
torch.arange(10)                     # [0,1,…,9]
torch.randn(4, 8)                    # Gaussian noise, shape (4,8)
torch.randint(0, 100, (2, 5))        # random ints in [0,100)

Inspecting Tensors

x.shape         # torch.Size([2, 3])
x.dtype         # torch.float32
x.device        # device(type='cpu')
x.numel()       # total number of elements

Visualizing Shape

A tensor of shape (2, 3):

1 2 3 4 5 6 2 rows 3 cols

A 4-D tensor in deep learning is typically (batch, time, heads, dim). You'll see this constantly in attention.

The Two Superpowers

  • Device-aware: x.to("cuda") moves data to GPU. Operations on GPU tensors run on GPU.
  • Autograd-aware: if a tensor has requires_grad=True, every op on it is recorded so we can compute derivatives later.
03 / Autograd — Forward

The Computation Graph BUILT AS YOU GO

Every operation on a tensor with requires_grad=True creates a new tensor that remembers what op produced it and which tensors went in. The result is a directed graph from leaves (your params) to outputs (your loss).

A Tiny Example

# Two learnable scalars
w = torch.tensor([2.0], requires_grad=True)
b = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([3.0])              # input (no grad)

# Forward pass — every op gets recorded
a = w * x         # 6.0   grad_fn=<MulBackward>
z = a + b         # 7.0   grad_fn=<AddBackward>
y = z ** 2        # 49.0  grad_fn=<PowBackward>

print(y.grad_fn)
# <PowBackward0 object at 0x…>

The model never knows what y "is." It only knows how to undo the last op (grad_fn) and which tensors to ask next.

Three Kinds of Tensors in the Graph

KindExample
Leaf w/ gradw, b — params we'll update
Leaf w/o gradx — input data, frozen
Intermediatea, z, y — built by ops, hold a grad_fn

The Graph It Builds

w 2.0 leaf · requires_grad x 3.0 leaf · no grad b 1.0 leaf · requires_grad × MulBackward + AddBackward PowBackward a = 6 z = 7 y = 49 FORWARD: leaves → ops → output

Orange-rimmed = leaf with requires_grad. Purple = op node (knows how to compute its derivative). Green = output we'll backprop from.

04 / Autograd — Backward

The Backward Pass — Chain Rule, Mechanized loss.backward()

Calling y.backward() walks the graph in reverse, multiplying local derivatives along each edge. The result lands in .grad on every leaf with requires_grad.

The Chain Rule (5-Second Refresher)

If  y = f(g(x))   then   dy/dx = f′(g(x)) · g′(x)

For a longer chain y = f(g(h(x))):

dy/dx = f′(g(h)) · g′(h) · h′(x)

Each op contributes its own local derivative. Autograd's job is to multiply them together — in the right order, automatically, for every parameter.

Local Derivative for Each Op

Each op type knows its own forward formula and its own derivative. PyTorch ships these as built-in Backward classes.

Op Forward Local derivative Value here
Mul
MulBackward
a = w · x ∂a/∂w = x
∂a/∂x = w
∂a/∂w = 3
∂a/∂x = 2
Add
AddBackward
z = a + b ∂z/∂a = 1
∂z/∂b = 1
∂z/∂a = 1
∂z/∂b = 1
Pow
PowBackward
y = z² ∂y/∂z = 2z ∂y/∂z = 2·7 = 14

Chain Them at Each Leaf

# Seed: ∂y/∂y = 1

∂y/∂w = ∂y/∂z · ∂z/∂a · ∂a/∂w
      =   14   ·   1   ·   3     = 42

∂y/∂b = ∂y/∂z · ∂z/∂b
      =   14   ·   1            = 14

PyTorch does exactly this — but for millions of parameters at once, with matrix-valued local derivatives (Jacobians).

The Same Graph, Backward

w w.grad = 42 x no grad needed b b.grad = 14 × ·14·3 → 42 + pass-through 2z = 14 y seed: 1 ×3 ×1 ×1 ×1 ← BACKWARD: gradient × local derivative at each edge

The Code That Triggers All This

w = torch.tensor([2.0], requires_grad=True)
b = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([3.0])

y = (w * x + b) ** 2      # 49.0
y.backward()                # ← runs the whole reverse pass

print(w.grad)              # tensor([42.])
print(b.grad)              # tensor([14.])

Three Things That Surprise People

  • Gradients accumulate. Calling backward() twice adds to .grad. That's why we call optimizer.zero_grad() every step.
  • Only leaves get .grad. Intermediate tensors like a, z have a grad_fn but no .grad by default.
  • Backward is one-shot. The graph is freed after backward() to save memory. Pass retain_graph=True if you really need to do it twice.
05 / nn.Module

nn.Module — Reusable Computation Blocks THE BASE CLASS

Every layer, every sub-network, every full model in PyTorch inherits from nn.Module. You define two things: what's inside (__init__) and what to compute (forward).

The Pattern

import torch.nn as nn
import torch.nn.functional as F

class FeedForward(nn.Module):
    def __init__(self, d_model):
        super().__init__()
        # declare learnable sub-modules here
        self.fc1 = nn.Linear(d_model, 4 * d_model)
        self.fc2 = nn.Linear(4 * d_model, d_model)

    def forward(self, x):
        # describe the computation
        return self.fc2(F.gelu(self.fc1(x)))

# use it
ff = FeedForward(128)
y  = ff(x)        # calls forward() under the hood

What Module Does For You

  • Auto-registers sub-modules and parameters — anything assigned to self.X that's a Module or Parameter.
  • model.parameters() returns an iterator over all learnable tensors recursively. The optimizer uses this.
  • model.to(device) moves everything at once.
  • model.state_dict() dumps all weights for saving.
  • model.train() / model.eval() toggles modes (dropout/batchnorm care).

One Catch — Lists

A regular Python list of modules is invisible to parameters(). Use nn.ModuleList:

# ❌ Wrong — params hidden from optimizer
self.blocks = [Block(cfg) for _ in range(N)]

# ✅ Right
self.blocks = nn.ModuleList([Block(cfg) for _ in range(N)])
06 / Built-in Layers

The Layers You'll Use 95% of the Time nn.* GALLERY

PyTorch ships with hundreds of layers. For an LLM, you only need a handful.

LayerWhat it doesCode example
nn.Linear(in, out) Fully connected: y = x · W + b. The fundamental "linear neural network layer."
Params: in_features = input vector size · out_features = output vector size · bias=True = whether to add learnable bias b
fc = nn.Linear(128, 256)
y  = fc(x)  # x: (B, 128) → y: (B, 256)
nn.Embedding(V, d) Lookup table: integer ID → vector ∈ ℝᵈ. The embedding layer in slide 7 of the LLM talk.
Params: num_embeddings (V) = vocabulary size · embedding_dim (d) = dimension of each vector
emb = nn.Embedding(50257, 128)
v   = emb(token_ids)  # (B,T) → (B,T,128)
nn.LayerNorm(d) Normalize each token's vector to mean 0, variance 1 (per-sample, not per-batch).
Params: normalized_shape (d) = feature dim to normalize over · eps=1e-5 = small constant for numerical stability · also has learnable scale γ and shift β of size d
ln = nn.LayerNorm(128)
y  = ln(x)  # (B, T, 128) → same shape
nn.ModuleList A list container that does register its modules with the parent.
Params: modules = iterable of nn.Module instances to register · no learnable params of its own
self.blocks = nn.ModuleList([
    Block(cfg) for _ in range(N)
])
nn.Sequential Chain modules together — runs them in order.
Params: *args = modules to chain (or one OrderedDict) · output of each becomes input of next · no learnable params of its own
net = nn.Sequential(
    nn.Linear(128, 256),
    nn.GELU(),
    nn.Linear(256, 128),
)
nn.Dropout(p) Randomly zero out p fraction of activations during training. Regularization.
Params: p = drop probability per element (default 0.5) · inplace=False = whether to modify input in place · no learnable params
drop = nn.Dropout(0.1)
y    = drop(x)  # inactive in eval mode

The Pattern

Every nn.* module has the same shape: declare in __init__, call in forward. Compose them however you want — that's how transformers, ResNets, and everything else gets built.

07 / Functional API

torch.nn.functional (F) — Stateless Operations NO PARAMS, JUST MATH

If a "layer" has no learnable parameters, use F.* instead of nn.*. Cleaner code, no module declaration needed.

Side by Side

Two ways to apply GeLU. Both produce the same output:

As a module (nn.GELU)
class FFN(nn.Module):
    def __init__(self):
        super().__init__()
        self.act = nn.GELU()
        self.fc  = nn.Linear(128, 128)

    def forward(self, x):
        return self.fc(self.act(x))
As a function (F.gelu)
class FFN(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc = nn.Linear(128, 128)

    def forward(self, x):
        return self.fc(F.gelu(x))   # ← cleaner

The Functional Ops You'll Use

OpWhat it does & parameters
F.gelu(input, approximate) Smooth activation: x · Φ(x). Element-wise, no params changed.
Params: input = any tensor · approximate='none' = exact erf-based; 'tanh' = fast approximation
Use when: inside transformer FFN blocks · the modern default for LLMs (GPT, BERT, Llama). Smoother than ReLU → gradients flow even at slightly-negative values, no "dead neuron" problem.
F.relu(input, inplace) Element-wise max(0, x). Cuts off everything negative.
Params: input = any tensor · inplace=False = if True, modifies input directly to save memory
Use when: CNNs and older / simpler MLPs; cheap, fast on hardware. For transformers prefer GeLU. Beware dead ReLUs — neurons that always output 0 stop learning.
F.softmax(input, dim) Turn raw scores into a probability distribution along one dim (sums to 1).
Params: input = real-valued tensor · dim = axis to normalize over (must specify; -1 = last dim, the typical choice)
Use when: computing attention weights, sampling at generation time, or showing probabilities to the user. Don't use it before F.cross_entropy — that op wants raw logits.
F.cross_entropy(input, target, weight, ignore_index, reduction) Combines log-softmax + NLL loss. Standard classification / next-token loss.
Params: input = logits, shape (N, C) · target = class indices, shape (N,), ints in [0, C) · weight=None = per-class weights for imbalanced data · ignore_index=-100 = label value to skip in loss (used for SFT masking!) · reduction='mean' = aggregate via 'mean' / 'sum' / 'none'
Use when: any classification task — image labels, sentiment, and especially next-token prediction in LLMs. The ignore_index trick is exactly how SFT masks the user prompt.
F.mse_loss(input, target, reduction) Mean squared error: ((input − target)²).mean(). Regression default.
Params: input = predictions · target = ground truth (same shape) · reduction='mean' = how to aggregate
Use when: regression (continuous targets), reconstruction losses (VAEs, autoencoders), distillation, or fitting any continuous quantity. For heavy-tailed errors prefer F.smooth_l1_loss or F.huber_loss.
F.dropout(input, p, training) Zero out elements at random with probability p · scale survivors by 1/(1-p).
Params: input = tensor to drop from · p=0.5 = drop probability · training=True = if False, becomes a no-op (rarely used directly — prefer nn.Dropout which respects model.eval())
Use when: almost never directly. Prefer nn.Dropout as a sub-module — it auto-disables in model.eval(). Reach for the functional form only when you need conditional/dynamic dropout that nn.Dropout can't express.
F.scaled_dot_product_attention(q, k, v, attn_mask, dropout_p, is_causal) Fused attention: softmax(QKᵀ/√d) · V. Hardware-optimized (Flash Attention on CUDA).
Params: query, key, value = shape (..., L, E) · attn_mask=None = additive mask (use −inf for blocked positions) · dropout_p=0.0 = dropout on attention weights · is_causal=False = if True, applies causal mask automatically (no need to build it yourself)
Use when: any production attention. 2-10× faster than manual softmax(Q@K.T/√d) @ V on GPU thanks to Flash Attention kernels, and uses ~⅓ less memory. The hand-rolled version in tiny-LLM is for teaching only; real models swap it for this.

Rule of Thumb

  • Has learnable params (Linear, Embedding, LayerNorm) → use nn.*
  • Has stateful behavior (Dropout, BatchNorm — they care about train/eval mode) → use nn.*
  • Pure math (gelu, softmax, cross_entropy, matmul) → use F.*
08 / Tensor Ops — Shapes & Math

Shape Manipulation & Math WITH WORKED EXAMPLES

Most "deep learning code" is shape gymnastics. Each op below shows the input shape → output shape and a concrete value.

Shape Manipulation

view — reshape without copying memory
x = torch.arange(6)              # tensor([0,1,2,3,4,5])  shape (6,)
x.view(2, 3)
# tensor([[0, 1, 2],
#         [3, 4, 5]])    shape (2, 3)
transpose — swap two dims
x = torch.tensor([[1,2,3],[4,5,6]])   # (2, 3)
x.transpose(0, 1)
# tensor([[1, 4],
#         [2, 5],
#         [3, 6]])       shape (3, 2)
permute — reorder all dims at once
x = torch.randn(2, 3, 4)         # shape (2, 3, 4)
x.permute(2, 0, 1).shape       # torch.Size([4, 2, 3])
unsqueeze / squeeze — add or drop size-1 dims
x = torch.tensor([1, 2, 3])      # shape (3,)
x.unsqueeze(0).shape           # torch.Size([1, 3])
x.unsqueeze(1).shape           # torch.Size([3, 1])

y = torch.zeros(1, 3, 1)
y.squeeze().shape              # torch.Size([3])
chunk — split into N equal pieces
x = torch.arange(12).view(3, 4)
a, b = x.chunk(2, dim=1)        # split last dim in 2
# a, b each have shape (3, 2)
# Used in attention: q, k, v = qkv.chunk(3, dim=-1)
cat vs stack
a = torch.tensor([1, 2, 3])      # shape (3,)
b = torch.tensor([4, 5, 6])      # shape (3,)

torch.cat([a, b], dim=0)
# tensor([1,2,3,4,5,6])     shape (6,)   — joins along existing dim

torch.stack([a, b], dim=0)
# tensor([[1,2,3],
#         [4,5,6]])         shape (2,3)  — creates a NEW dim
contiguous — needed after transpose before view
x = torch.randn(2, 3).transpose(0, 1)
# transpose doesn't move data — just changes strides
x.view(6)                       # ❌ RuntimeError: view size not compatible
x.contiguous().view(6)          # ✅ now it's a flat copy

Math & Reductions

Element-wise math (broadcasts!)
a = torch.tensor([1, 2, 3])
b = torch.tensor([10, 20, 30])

a + b      # tensor([11, 22, 33])
a * b      # tensor([10, 40, 90])
b / a      # tensor([10., 10., 10.])
a + 100    # tensor([101, 102, 103])  — scalar broadcasts
@ / matmul — matrix multiply (last 2 dims)
A = torch.tensor([[1., 2.],
                  [3., 4.]])             # (2, 2)
B = torch.tensor([[5., 6.],
                  [7., 8.]])             # (2, 2)

A @ B
# tensor([[19., 22.],
#         [43., 50.]])      shape (2, 2)

# Batched: works for any leading dims
x = torch.randn(8, 4, 3, 5)         # (8, 4, 3, 5)
y = torch.randn(8, 4, 5, 7)         # (8, 4, 5, 7)
(x @ y).shape                    # torch.Size([8, 4, 3, 7])
sum / mean / max — reductions over a dim
x = torch.tensor([[1., 2., 3.],
                  [4., 5., 6.]])             # (2, 3)

x.sum()                       # tensor(21.)         scalar
x.sum(dim=0)                  # tensor([5., 7., 9.])  shape (3,)
x.sum(dim=1)                  # tensor([6., 15.])     shape (2,)
x.sum(dim=1, keepdim=True)    # shape (2, 1)  — useful for broadcasting

x.mean(dim=-1)                # tensor([2., 5.])
x.max()                       # tensor(6.)
argmax / topk — get indices of largest values
logits = torch.tensor([1.2, 3.4, 0.8, 5.6, 2.1])

logits.argmax()                # tensor(3)   — index of max

vals, idx = torch.topk(logits, k=2)
# vals = tensor([5.6, 3.4])
# idx  = tensor([3, 1])      ← used in top-k sampling
09 / Tensor Ops — Indexing & Broadcasting

Indexing, Sampling, & Broadcasting THE OTHER HALF

Selecting subsets, drawing random tensors, and the rule that lets (B, T, D) + (D,) just work.

Indexing & Masking

Basic slicing — same as NumPy
x = torch.arange(24).view(2, 3, 4)   # shape (2, 3, 4)

x[0]            # first batch:           shape (3, 4)
x[:, -1, :]     # last token, all batch: shape (2, 4)
x[..., 0]       # first elem of last dim: shape (2, 3)
x[:, :, :2]     # first 2 along last dim: shape (2, 3, 2)
Fancy indexing — index with another tensor
x = torch.tensor([10, 20, 30, 40, 50])
idx = torch.tensor([0, 2, 4])

x[idx]
# tensor([10, 30, 50])

# This is exactly how nn.Embedding works internally
masked_fill — replace by condition
x = torch.tensor([1., 2., 3., 4.])
mask = torch.tensor([True, False, True, False])

x.masked_fill(mask, float('-inf'))
# tensor([-inf, 2., -inf, 4.])

# Causal mask in attention:
mask = torch.triu(torch.ones(T,T), diagonal=1).bool()
scores.masked_fill(mask, float('-inf'))   # future → -inf → 0 after softmax

Sampling & Random

randint — random integers
torch.randint(0, 100, (2, 3))
# tensor([[42,  7, 91],
#         [ 3, 55, 28]])   shape (2, 3)  — used in get_batch()
randn — Gaussian noise
torch.randn(3)
# tensor([0.21, -1.45, 0.83])  — mean 0, std 1 (used to init weights)
multinomial — sample from a distribution
probs = torch.tensor([0.1, 0.7, 0.2])
torch.multinomial(probs, num_samples=1)
# tensor([1])    — most likely 1 (highest prob), occasionally 0 or 2
# Used in generate() to sample the next token
arange + tril — building masks
torch.arange(4)               # tensor([0, 1, 2, 3])  — position indices

torch.tril(torch.ones(4, 4))
# tensor([[1, 0, 0, 0],
#         [1, 1, 0, 0],
#         [1, 1, 1, 0],
#         [1, 1, 1, 1]])    ← causal mask in attention

Broadcasting — In Detail

When tensor shapes don't match, PyTorch virtually expands the smaller one to match the larger — no actual copy is made. This makes code dramatically cleaner and is used everywhere.

The Three Rules

  1. Align from the right. Pad missing leading dims with size-1.
  2. For each dim, sizes must match OR one must be 1. The size-1 one gets virtually replicated.
  3. Otherwise → error.
A.shape: 8 3 4 B.shape: (1) 1 4 ↓ broadcast result: 8 3 4 prepended & expanded ↑ expanded 1 → 3 ↑ match already ↑

Worked Examples

✓ Adding a bias vector to every row
x    = torch.randn(32, 128)        # (B, D)
bias = torch.randn(128)             # (D,)

x + bias
# bias is treated as (1, 128) → expanded to (32, 128)
# Same vector added to every row
✓ Outer-product style: column + row vector
col = torch.tensor([[1], [2], [3]])    # shape (3, 1)
row = torch.tensor([[10, 20, 30, 40]]) # shape (1, 4)

col + row
# tensor([[11, 21, 31, 41],
#         [12, 22, 32, 42],
#         [13, 23, 33, 43]])    shape (3, 4)
✓ Adding token + positional embedding (from the LLM!)
tok = self.tok_embed(idx)        # (B, T, D)
pos = self.pos_embed(torch.arange(T))   # (T, D)

x = tok + pos
# pos is treated as (1, T, D) → expanded to (B, T, D)
# Same position vectors added to every batch item
✗ Shapes that DON'T broadcast
a = torch.randn(3, 4)
b = torch.randn(3, 5)
a + b   # RuntimeError: 4 ≠ 5 and neither is 1
10 / Training Loop

The Training Loop FOUR LINES, INFINITE TIMES

Every PyTorch training script — from a 2-layer MLP to a 70B-parameter LLM — boils down to the same four lines repeated millions of times.

The Full Pseudo-Loop

# ─── ONE-TIME SETUP ─────────────────────────
model     = MyModel().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
model.train()                # enable dropout / batchnorm-train mode

# ─── (optional) OUTER LOOP: epochs ──────────
for epoch in range(NUM_EPOCHS):

    # ─── INNER LOOP: one mini-batch per step ───
    for x, y in dataloader:

        x, y = x.to(device), y.to(device)

        # ── 1. ZERO grads — BEFORE backward ────────
        #    grads accumulate by default; must clear
        #    each step or you'd add this step's grads
        #    to last step's leftover grads.
        optimizer.zero_grad()

        # ── 2. FORWARD — build the graph ───────────
        logits = model(x)
        loss   = loss_fn(logits, y)

        # ── 3. BACKWARD — fill .grad on every leaf ─
        #    needs the forward graph to exist!
        loss.backward()

        # ── 4. STEP — apply grads to weights ───────
        #    must come AFTER backward (it reads .grad)
        optimizer.step()

        # ── 5. (optional) log / eval / checkpoint ──
        if step % 100 == 0:
            print(f"step {step}: loss {loss.item():.4f}")

Common Optimizers — When to Use What

OptimizerTypical LRWhen to reach for it
SGD 0.01 – 0.1 Classic, low memory. Needs careful LR tuning. Rarely the best choice today, but cheapest in memory.
SGD(momentum=0.9) 0.01 – 0.1 Vision models (ResNets, CNNs). Still the default for ImageNet — often beats Adam on convolutional architectures.
Adam 1e-4 – 5e-4 The "just works" optimizer. Per-parameter adaptive LR via running estimates of mean & variance. Higher memory (stores 2 extra tensors per param).
AdamW 1e-4 – 5e-4 LLMs & transformers — the default. Same as Adam but decouples weight decay from the gradient update, which generalizes better. Used by GPT, BERT, Llama, Claude.
RMSprop 1e-3 Predecessor of Adam. Mostly historical now — pick Adam instead unless you're reproducing an old paper.
Adafactor 1e-3 Huge models / tight memory. Approximates Adam's variance with a row+column factorization → ~⅔ less optimizer state. Used in training T5 / PaLM.

Quick Code

torch.optim.SGD(model.parameters(), lr=0.05, momentum=0.9)
torch.optim.Adam(model.parameters(), lr=3e-4)
torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
                                          # LLM default

Rules of thumb: training a transformer → AdamW · training a CNN → SGD+momentum · unsure → AdamW with lr=3e-4 ("the Karpathy constant") · out of GPU memory → switch to Adafactor or 8-bit Adam (bitsandbytes).

Pair any of these with a learning-rate scheduler (cosine decay, warmup) — the schedule often matters more than the optimizer choice.

Order Matters — Why?

CallWhen & why
model(x)
forward
First. Builds the autograd graph by running every op. Loss is the scalar root we'll backprop from.
optimizer.zero_grad() Before backward. Sets every .grad to zero (or None). Without this, gradients accumulate across steps and you'd train on the sum of multiple batches.
loss.backward() After forward, before step. Walks the graph backwards. Needs the graph to exist (forward must have happened) and writes into .grad.
optimizer.step() Last. Reads .grad values and applies the update w ← w − lr · grad. Must come after backward filled them in.

Mnemonic: Z·F·B·SZero, Forward, Backward, Step.

Common Bugs (and what they look like)

MistakeSymptom
Forgot zero_grad() Loss goes wild after a few steps; effective batch size keeps growing.
step() before backward() Updates with stale grads from the previous step (or zeros — silently wrong).
backward() twice without retain_graph=True RuntimeError: Trying to backward through the graph a second time…
backward() on a non-scalar RuntimeError: grad can be implicitly created only for scalar outputs — call .sum() or .mean() first.
Forgot x.to(device) RuntimeError: Expected all tensors to be on the same device

Bonus: Gradient Accumulation

Want a bigger effective batch than fits in memory? Skip zero_grad and step for K steps:

for i, (x, y) in enumerate(dataloader):
    loss = model(x, y) / ACCUM_STEPS
    loss.backward()                # grads keep accumulating

    if (i + 1) % ACCUM_STEPS == 0:   # every K steps:
        optimizer.step()             #   apply combined grads
        optimizer.zero_grad()        #   then clear

Same math as a K× larger batch — but with K× less peak memory. The "accumulation" you usually fight is now your friend.

11 / Inference Mode

Inference — Turning Off Autograd FASTER & LIGHTER

During inference you don't need gradients. Telling PyTorch lets it skip building the graph — saving memory and speeding things up significantly.

Two Things to Toggle

1. Disable autograd
# As a context manager
with torch.no_grad():
    logits = model(x)
    preds  = logits.argmax(dim=-1)

# Or as a decorator (cleaner for whole functions)
@torch.no_grad()
def generate(model, idx, max_new_tokens):
    for _ in range(max_new_tokens):
        ...
        idx = torch.cat([idx, next_token], dim=1)
    return idx
2. Switch the model into eval mode
model.eval()      # dropout off, batchnorm uses running stats
# …generate / predict…
model.train()     # back to training mode

Why Bother?

  • Memory: autograd stores intermediate activations for the backward pass. no_grad skips this — often 2-4× less memory.
  • Speed: no graph construction, no .grad bookkeeping → typically 10-30% faster.
  • Correctness: Dropout layers behave wildly differently in train vs eval mode. Forgetting model.eval() is a classic bug.

Modern Alternative: torch.inference_mode()

with torch.inference_mode():
    logits = model(x)

Even more aggressive than no_grad — additional optimizations that assume the tensors won't ever need gradients again. Use it for pure inference workloads.

The Three-Step Inference Recipe

model.eval()
with torch.no_grad():
    output = model(x)

Run this anytime you're predicting/generating, not training.

12 / Checkpoints

Saving & Loading Models CHECKPOINTS

Save the weights, not the whole module. Rebuild the architecture from code, then load the weights into it. This is portable and version-safe.

Saving

# Get a dict of {param_name → tensor}
state = model.state_dict()

# Save it (plus anything else you'll need later)
torch.save({
    "model":     state,
    "optimizer": optimizer.state_dict(),
    "config":    config,
    "step":      step,
}, "checkpoints/model.pt")

Loading

ckpt = torch.load(
    "checkpoints/model.pt",
    map_location=device,        # load onto current device
    weights_only=False,        # allow non-tensor objects (config)
)

# Rebuild the architecture from code
model = GPT(ckpt["config"]).to(device)
model.load_state_dict(ckpt["model"])

What's a state_dict?

An ordered dict mapping every parameter (and buffer) to its tensor. For our tiny GPT it looks roughly like:

{
  "tok_embed.weight":        tensor(50257, 128),
  "pos_embed.weight":        tensor(128, 128),
  "blocks.0.ln1.weight":     tensor(128),
  "blocks.0.ln1.bias":       tensor(128),
  "blocks.0.attn.qkv.weight": tensor(384, 128),
  "blocks.0.attn.proj.weight": tensor(128, 128),
  "blocks.0.ffn.fc1.weight":  tensor(512, 128),
  "blocks.0.ffn.fc1.bias":    tensor(512),
  ...
}

Why Not Save the Whole Module?

torch.save(model, …) works but pickles the entire Python class. If you change model.py later (rename a layer, refactor), loading breaks. state_dict is just data — it survives architectural changes as long as parameter names match.

13 / Devices

Device Management — CPU, CUDA, MPS WHERE TENSORS LIVE

Every tensor lives on one device. Operations require all operands to be on the same device. The model and the inputs must agree.

Picking the Best Available Device

device = (
    "cuda"  if torch.cuda.is_available()
    else "mps"   if torch.backends.mps.is_available()
    else "cpu"
)
print(f"running on {device}")
DeviceWhen you'll see it
"cuda"NVIDIA GPU (Linux / Windows servers, gaming PCs)
"mps"Apple Silicon (M1/M2/M3/M4 Macs)
"cpu"Always available. Slow for big models.

Moving Things Around

# Move the whole model
model = model.to(device)

# Move tensors
x = x.to(device)
y = y.to(device)

# Or create directly on a device
x = torch.zeros(3, 4, device=device)

The Classic Bug

RuntimeError: Expected all tensors to be on
the same device, but found at least two
devices, cuda:0 and cpu!

You moved the model to GPU but forgot to move x. Or vice versa. Always .to(device) both.

Speed Comparison (rough)

DeviceTiny LLM (7M params) per step
CPU (M2 Pro)~800 ms
MPS (M2 Pro)~80 ms (10× speedup)
CUDA (RTX 4090)~5 ms (160× speedup)

For training large models, GPUs aren't a luxury — they're a necessity.

Bonus: map_location

ckpt = torch.load("model.pt", map_location=device)

Load a checkpoint that was saved on a CUDA machine onto a CPU/MPS machine. Without this you'll hit a "no CUDA" error.

14 / Deployment

From .pt to Production: GGUF & Ollama Modelfiles SHIP IT LOCALLY

A trained PyTorch model is a 28 MB .pt file. To deploy it — fast loading, low memory, single binary — you convert it to GGUF and serve it with Ollama.

What is GGUF?

GGUF = GPT-Generated Unified Format. A binary container designed for fast, memory-mapped inference. Built by Georgi Gerganov for llama.cpp; now the de-facto standard for local LLM deployment.

What's Inside

  • Model weights — usually quantized to 2–8 bits
  • Tokenizer — vocab + merges, embedded in the file
  • Architecture metadata — layer count, head count, context length
  • Chat template — how to wrap conversations

One file, no dependencies. Memory-mapped → loads instantly even for 70B models. Replaces the older GGML format.

Quantization Levels — Pick Your Trade-off

LevelBits/weightNotes
F3232Full precision. Training default. Huge.
F1616Half precision. Lossless for inference.
Q8_088-bit. Near-lossless quality, ½ memory of F16.
Q5_K_M~5.5Excellent quality, big size win.
Q4_K_M~4.5The sweet spot. ~¼ size, barely noticeable quality drop. Default for most users.
Q2_K~2.5Tiny but quality degrades visibly. Last resort.

A 7B Llama at Q4_K_M weighs ~4 GB and runs comfortably on a 16 GB laptop.

The Ollama Modelfile

Like a Dockerfile for AI models. Declares: base weights, system prompt, sampling defaults, chat template. Build it once → run it anywhere Ollama runs.

Basic Modelfile Syntax

FROM      <base>           # required: model to start from
SYSTEM    "..."            # default system prompt
PARAMETER temperature 0.7  # sampling knobs
PARAMETER top_p       0.9
PARAMETER num_ctx     8192
TEMPLATE  "..."            # optional: chat format override
LICENSE   "..."            # optional: license text

Example: A Custom Python Tutor

# Modelfile
FROM llama3.2:3b

SYSTEM """You are a patient Python tutor.
Always show runnable code examples and
explain edge cases."""

PARAMETER temperature 0.5
PARAMETER top_p       0.9
PARAMETER num_ctx     8192
PARAMETER stop        "<|eot_id|>"

Build & Run

1. Build from a Modelfile
$ ollama create py-tutor -f Modelfile
# transferring model data 100%
# creating model layer
# writing manifest
# success
2. Inspect & manage models
$ ollama list
# NAME              ID            SIZE     MODIFIED
# py-tutor:latest   a3b1c…        2.0 GB   3 minutes ago
# llama3.2:3b       7d8c9…        2.0 GB   2 days ago

$ ollama show py-tutor              # config, params, template
$ ollama show py-tutor --modelfile  # dump the Modelfile
$ ollama cp py-tutor py-tutor-v2    # clone
$ ollama rm py-tutor                # delete
$ ollama pull llama3.2:3b           # fetch from registry
$ ollama push user/py-tutor         # publish to ollama.com
3. Run interactively (REPL)
$ ollama run py-tutor
>>> Explain decorators with an example.
# …streaming response…
>>> /set system "Now answer in haikus."   # override at runtime
>>> /show modelfile                          # inspect
>>> /bye                                     # exit
4. Run one-shot (scripting / pipes)
$ ollama run py-tutor "Why is Python slow?"

$ echo "Summarize this:" | ollama run py-tutor
cat log.txt | ollama run py-tutor "Find errors:"
5. Serve as a REST API
$ ollama serve              # starts on http://localhost:11434

# Generate (single-turn)
$ curl http://localhost:11434/api/generate -d '{
    "model": "py-tutor",
    "prompt": "Explain async/await",
    "stream": false
  }'

# Chat (multi-turn, OpenAI-style messages)
$ curl http://localhost:11434/api/chat -d '{
    "model": "py-tutor",
    "messages": [
      {"role": "user", "content": "Hi"}
    ]
  }'
6. Call from Python
# pip install ollama
from ollama import chat

response = chat(
    model="py-tutor",
    messages=[{"role": "user", "content": "Explain GIL"}],
)
print(response.message.content)

# Or stream tokens as they arrive
for chunk in chat(model="py-tutor", messages=[...], stream=True):
    print(chunk.message.content, end="", flush=True)

Loading Your Own GGUF

# Modelfile
FROM ./my-model.Q4_K_M.gguf
SYSTEM "You are my fine-tuned model."
PARAMETER temperature 0.3

Point FROM at any local .gguf file. Ollama wraps it with your config and the same create / list / run / serve commands above just work.

The Full Pipeline: PyTorch → GGUF → Ollama

PyTorch checkpoint model.pt · 28 MB · F32 convert Hugging Face format safetensors + config.json convert_hf_to_gguf.py GGUF (F16) model.gguf · ~14 MB quantize Q4_K_M GGUF (Q4_K_M) ~4 MB · ready to ship ollama create Ollama model ollama run my-model

Same pipeline used to ship Llama, Mistral, Qwen — just at much larger scale. llama.cpp provides the conversion + quantization tools; Ollama wraps it all for one-command deployment.

15 / Recap

The Whole API on One Page CHEATSHEET

The Core Concepts

Tensormulti-dim array, lives on a device
Autogradtracks ops, computes gradients via .backward()
nn.Modulereusable computation block (params + forward)
Optimizerreads .grad, updates weights via .step()
DeviceCPU / CUDA / MPS — all tensors must agree

The Universal Training Pattern

for step in range(MAX_ITERS):
    x, y = get_batch()
    logits, loss = model(x, y)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

If you understand these 5 lines, you understand 95% of every PyTorch script ever written.

What to Read Next

  • Official tutorials: pytorch.org/tutorials — the "Learn the Basics" track
  • Karpathy's nanoGPT: the canonical small GPT in PyTorch
  • The PyTorch source: reading nn/modules/linear.py teaches you how to write your own layer
  • This deck's companion code: tiny_llm/ — every concept here used in real working code

You Now Have Enough To…

  • ✓ Read the entire tiny_llm/model.py source
  • ✓ Modify the architecture (add layers, change dimensions)
  • ✓ Write your own training loop on a different dataset
  • ✓ Save and resume training
  • ✓ Move work to / from GPU
  • ✓ Debug shape and device errors

Questions?