From Tensors to Training — everything you need to read or modify the tiny-LLM code, in 15 slides.
The core data structure
Automatic differentiation
Reusable layers
Linear, Embedding, ops
view, transpose, @, …
The 4-line pattern
no_grad, eval mode
Checkpoints & devices
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.
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)
x.shape # torch.Size([2, 3]) x.dtype # torch.float32 x.device # device(type='cpu') x.numel() # total number of elements
A tensor of shape (2, 3):
A 4-D tensor in deep learning is typically (batch, time, heads, dim). You'll see this constantly in attention.
x.to("cuda") moves data to GPU. Operations on GPU tensors run on GPU.requires_grad=True, every op on it is recorded so we can compute derivatives later.
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).
# 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.
| Kind | Example |
|---|---|
| Leaf w/ grad | w, b — params we'll update |
| Leaf w/o grad | x — input data, frozen |
| Intermediate | a, z, y — built by ops, hold a grad_fn |
Orange-rimmed = leaf with requires_grad. Purple = op node (knows how to compute its derivative). Green = output we'll backprop from.
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.
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.
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 |
# 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).
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.])
backward() twice adds to .grad. That's why we call optimizer.zero_grad() every step..grad. Intermediate tensors like a, z have a grad_fn but no .grad by default.backward() to save memory. Pass retain_graph=True if you really need to do it twice.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).
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
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).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)])
PyTorch ships with hundreds of layers. For an LLM, you only need a handful.
| Layer | What it does | Code 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 |
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.
If a "layer" has no learnable parameters, use F.* instead of nn.*. Cleaner code, no module declaration needed.
Two ways to apply GeLU. Both produce the same output:
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))
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
| Op | What 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.
|
nn.*nn.*F.*Most "deep learning code" is shape gymnastics. Each op below shows the input shape → output shape and a concrete value.
view — reshape without copying memoryx = 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 dimsx = 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 oncex = 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 dimsx = 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 piecesx = 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 stacka = 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 viewx = 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
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 dimx = 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 valueslogits = 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
Selecting subsets, drawing random tensors, and the rule that lets (B, T, D) + (D,) just work.
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)
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 conditionx = 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
randint — random integerstorch.randint(0, 100, (2, 3)) # tensor([[42, 7, 91], # [ 3, 55, 28]]) shape (2, 3) — used in get_batch()
randn — Gaussian noisetorch.randn(3) # tensor([0.21, -1.45, 0.83]) — mean 0, std 1 (used to init weights)
multinomial — sample from a distributionprobs = 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 maskstorch.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
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.
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
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)
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
a = torch.randn(3, 4) b = torch.randn(3, 5) a + b # RuntimeError: 4 ≠ 5 and neither is 1
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.
# ─── 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}")
| Optimizer | Typical LR | When 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. |
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.
| Call | When & 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·S — Zero, Forward, Backward, Step.
| Mistake | Symptom |
|---|---|
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 |
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.
During inference you don't need gradients. Telling PyTorch lets it skip building the graph — saving memory and speeding things up significantly.
# 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
model.eval() # dropout off, batchnorm uses running stats # …generate / predict… model.train() # back to training mode
no_grad skips this — often 2-4× less memory.model.eval() is a classic bug.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.
model.eval() with torch.no_grad(): output = model(x)
Run this anytime you're predicting/generating, not training.
Save the weights, not the whole module. Rebuild the architecture from code, then load the weights into it. This is portable and version-safe.
# 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")
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"])
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),
...
}
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.
Every tensor lives on one device. Operations require all operands to be on the same device. The model and the inputs must agree.
device = (
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
print(f"running on {device}")
| Device | When 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. |
# 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)
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.
| Device | Tiny 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.
map_locationckpt = 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.
.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.
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.
One file, no dependencies. Memory-mapped → loads instantly even for 70B models. Replaces the older GGML format.
| Level | Bits/weight | Notes |
|---|---|---|
F32 | 32 | Full precision. Training default. Huge. |
F16 | 16 | Half precision. Lossless for inference. |
Q8_0 | 8 | 8-bit. Near-lossless quality, ½ memory of F16. |
Q5_K_M | ~5.5 | Excellent quality, big size win. |
| Q4_K_M | ~4.5 | The sweet spot. ~¼ size, barely noticeable quality drop. Default for most users. |
Q2_K | ~2.5 | Tiny but quality degrades visibly. Last resort. |
A 7B Llama at Q4_K_M weighs ~4 GB and runs comfortably on a 16 GB laptop.
Like a Dockerfile for AI models. Declares: base weights, system prompt, sampling defaults, chat template. Build it once → run it anywhere Ollama runs.
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
# 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|>"
$ ollama create py-tutor -f Modelfile # transferring model data 100% # creating model layer # writing manifest # success
$ 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
$ 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
$ ollama run py-tutor "Why is Python slow?" $ echo "Summarize this:" | ollama run py-tutor cat log.txt | ollama run py-tutor "Find errors:"
$ 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"} ] }'
# 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)
# 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.
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.
| Tensor | multi-dim array, lives on a device |
| Autograd | tracks ops, computes gradients via .backward() |
| nn.Module | reusable computation block (params + forward) |
| Optimizer | reads .grad, updates weights via .step() |
| Device | CPU / CUDA / MPS — all tensors must agree |
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.
pytorch.org/tutorials — the "Learn the Basics" tracknn/modules/linear.py teaches you how to write your own layertiny_llm/ — every concept here used in real working codetiny_llm/model.py sourceQuestions?