EVERYTHING AIAI engineering, made visual
0/12 complete
LESSON 03 · SETUP & TOOLING × AI · BUILD

8 hours on CPU.
10 minutes on GPU.

A matrix multiply is billions of independent little sums — so the machine that does them thousands at a time wins. This lesson sets up the three ways to get that machine (your own GPU, free Colab, or a rented one), benchmarks CPU against GPU honestly, and works out how much model actually fits in memory.

45 MIN · 6 CHAPTERS + CHECKPREREQ · PHASE 0 · LESSON 01
FIG. 03 / A LIVE RACE · CPU CORES VS GPU LANES (SIMULATED)
CPU core tiles GPU lanes pending
LESSON 03TYPE · BUILD~45 MINPREREQ · PHASE 0 · LESSON 01ORIGINAL LESSON ↗
THE 60-SECOND VERSIONSee the problem ↓
01 / ONE OPERATION, TWO MACHINES

A GPU is not faster. It is wider.

Training is dominated by matrix multiplication, and one n×n matmul costs 2n³ floating-point operations — for n = 5000 that is 2.5×10¹¹ of them, all independent. A central processing unit (CPU) runs a few very fast cores one step at a time; a graphics processing unit (GPU) runs thousands of slower lanes at once. That is the whole trade: latency versus throughput, and throughput is what training needs.

2n³ FLOPs · n = 5000 → 2.5×10¹¹ per matmul
02 / THREE WAYS TO GET ONE

Local, free cloud, or rented by the hour.

A local NVIDIA GPU costs nothing per hour if you already own it; Google Colab's free T4 needs no setup at all; cloud providers rent everything from a T4 to an A100 for roughly $0.20–2.00 per hour (the source's range — prices change). The right answer depends on how often you train: daily → local, occasionally → Colab, seriously → rent.

$0 local · $0 Colab · ≈ $0.20–2.00/hr rented (example)
03 / MEMORY DECIDES WHAT FITS

2 bytes per parameter is the inference rule.

Storing a 7B model in fp16 takes 14 GB. Training it is a different bill: fp32 gradients (28 GB) and Adam's two fp32 moments (56 GB) ride along, plus activations that grow with the batch. The 2-bytes-per-parameter rule gets you 12B parameters on a 24 GB card for inference — and about 1.7B for Adam training.

7B: 14 GB stored → ≈ 98 GB to train with Adam
MENTAL MODEL IN ONE SENTENCE

A GPU does not compute faster — it computes wider: thousands of small operations happen at once, which is exactly what matrix multiplication is; and the first wall you hit is not speed but memory, because training stores far more per parameter than inference does.

By the end you will be able to verify a GPU with nvidia-smi and torch.cuda, read the Apple Silicon MPS path and the no-GPU fallback, run the source’s 5000×5000 benchmark with torch.cuda.synchronize() and explain why the barrier matters, compute a training-memory budget (7B in fp16 is 14 GB, but Adam training is ≈ 98 GB), decide between a local card, Colab and a rental, and write one script that picks its device at runtime instead of assuming one.

WHY A GPU AT ALL

The same 2.5×10¹¹ operations.
A different shape of machine.

Phases 1–3 of this course run fine on a CPU. But training CNNs, transformers and LLMs is a wall of matrix multiplication, and the source’s framing says it plainly: a run that takes 8 hours on a CPU takes about 10 minutes on a GPU. Why? Not because a GPU is faster at any one thing — because it does thousands of things at once.

Every layer of a neural network is a matrix multiplication in disguise. The forward pass is y = xW + b; the backward pass is more of the same with transposed shapes; even attention is a few matmuls glued together. One multiply of an n×n matrix by another costs 2n³ floating-point operations — counting a multiply and an add separately, as the standard bookkeeping does. For n = 5000:

FLOPs = 2n³ = 2 × (5×10³)³ = 2 × 1.25×10¹¹ = 2.5×10¹¹ one operand = 5000 × 5000 = 25×10⁶ elements = 25×10⁶ × 4 bytes (fp32) = 100 MB so a single matmul is a quarter of a trillion operations over 300 MB of matrices — and a training run does thousands of these per step, for thousands of steps

Here is the part that makes hardware matter: every one of those 2.5×10¹¹ operations is independent. Each entry of the output is its own dot product; nothing waits for anything else. Work like this maps badly onto a CPU — a handful of very fast cores, each optimised for doing one complicated thing after another — and perfectly onto a GPU, which is thousands of small arithmetic lanes built to run the same instruction across a sea of data.

Do the crude arithmetic: 8 CPU cores at ~4 GHz give about 8 × 4×10⁹ ≈ 3.2×10¹⁰ lane-cycles per second, while a modest data-center GPU with 2,560 lanes at ~1.6 GHz gives 2,560 × 1.6×10⁹ ≈ 4.1×10¹² — roughly 100× more parallel width. A GPU lane is individually slower and simpler, which is why a GPU loses on small one-off operations and wins by a lot on big ones. This is a teaching estimate, not a benchmark: real speedups depend on the card, the precision, memory bandwidth and how well the library parallelises your exact shapes.

One more source detail worth noticing: the lesson text benchmarks a 5000×5000 matmul, while the bundled gpu_check.py uses size = 4000. That is 2 × 4000³ = 1.28×10¹¹ FLOPs — about half the work, from a 20% smaller matrix, because the cost grows with the cube of the size. Doubling the matrix multiplies the FLOPs by eight.

Worked check — reading 2n³ like an engineer
n = 1000 2n³ = 2×10⁹ FLOPs two fp32 operands = 8 MB n = 2000 2n³ = 1.6×10¹⁰ operands = 32 MB n = 4000 2n³ = 1.28×10¹¹ operands = 128 MB n = 5000 2n³ = 2.5×10¹¹ operands = 200 MB n = 8000 2n³ = 1.02×10¹² operands = 512 MB n ×2 → FLOPs ×8, operand bytes ×4. Compute grows one exponent faster than the data it feeds on.

This single ratio explains the whole lesson. Small matmuls leave both machines idling, so the fixed costs — kernel launch, host-to-device transfer, Python overhead — dominate and the GPU can even lose. Large matmuls give the GPU enough independent work to fill every lane, and the fixed costs vanish into the noise. Training loops are the large case by construction: the same big weights are reused for every step, so the GPU never runs out of work.

The throughput race

The same matrix multiply, split across CPU cores and GPU lanes. Everything here is a teaching simulation — but the shape is the real story: small matrices can be slower on a GPU, and big ones win by a lot.

GPU parallel width2560 lanes
matrix 5000 × 5000 (2.5×10^11 FLOPs) output tiles 400 · one tile = 250×250 elements cpu 8 cores · 50 rounds · 5.00 s gpu 2560 lanes · 1 wave(s) + launch · 200 ms speedup 25.0× (simulated) data moved 0.3 GB — two fp32 operands + one output, 0.1 GB each At this size the simulated GPU wins. The bigger the matrix, the less the fixed launch cost matters — that is why training loops (which reuse the same weights millions of times) are the GPU's home turf.

This model charges the GPU one tick of launch overhead, so at the smallest sizes the win disappears entirely. Real hardware adds library warm-up, memory-bandwidth limits and kernel-launch queues on top — which is exactly why the source’s benchmark prints whatever ratio your machine actually produces.

Quick check

Why does splitting a 5000×5000 matrix multiply across thousands of GPU lanes work so well?

THREE WAYS TO GET ONE

Own it, borrow it for free, or rent it by the hour.

You do not need to buy anything to start training on a GPU. The source lays out three routes: a local NVIDIA card, Google Colab’s free tier, or a rented cloud instance. Each one trades money against setup and flexibility in a different way.

Route 1 — a local NVIDIA GPU. If the machine on your desk already has an NVIDIA card, the marginal cost is $0 and the loop is the fastest: no upload, no SSH, no session timeout. The setup is a driver plus the CUDA toolkit — CUDA (Compute Unified Device Architecture) is NVIDIA’s platform for running general-purpose code on its GPUs — and its deep-learning library, cuDNN (CUDA Deep Neural Network library); in practice a prebuilt PyTorch wheel bundles what it needs, so “install” usually means picking the right wheel. The catch is the hardware you already own: an 8 GB card caps you at small models, and driver-versus-toolkit mismatches are a classic first-day problem.

Route 2 — Google Colab, free tier. No setup at all: open a notebook, choose Runtime → Change runtime type → T4 GPU, and verify from inside the notebook. It is the right answer for “I have no GPU and I want to try this today”, and the lessons that need one link a Colab notebook. Free has limits: sessions are recycled, GPUs are shared and queued, and a long training job is not safe there. Treat Colab as a lab bench, not a server.

Route 3 — a rented cloud GPU. Lambda, RunPod and Vast.ai rent GPU instances by the hour, roughly $0.20–2.00/hour in the source’s range, priced by card class and on-demand versus spot. You get a real machine over SSH (Secure Shell), install what you need, and run as long as you pay. This is the route for serious training — and the one that can quietly cost real money, so checkpoints and a spending cap come first.

The source’s three options, plus the Apple Silicon path many learners actually have. Costs are examples.
routecostsetupbest forwatch out
Local NVIDIA GPU$0/hr (you own it)Driver + CUDA toolkit (cuDNN via wheel)Regular use, large local datasetsCapped by your card; version mismatches
Google Colab, free$0None — pick T4 GPU at runtimeQuick experiments, no GPU at homeSessions time out; no guarantee of a GPU
Rented cloud GPU≈ $0.20–2.00/hrSSH + installSerious training, large modelsThe meter runs; preemption on spot
Apple Silicon (MPS)$0/hr (you own it)Nothing beyond PyTorchLearning, small models, quiet local devNot CUDA; op coverage varies; shared memory
Colab: three lines to a GPUpython
# Runtime → Change runtime type → T4 GPU, then in a cell:
!nvidia-smi
import torch
print(torch.cuda.get_device_name(0), torch.cuda.get_device_properties(0).total_memory / 1e9)
The T4 in the free tier has 16 GB of VRAM — enough to serve a 7B fp16 model's weights (14 GB) but not to train one.
A rented instance: SSH in, install, verifybash
ssh user@your-gpu-instance

pip install torch torchvision torchaudio
python -c "import torch; print(torch.cuda.get_device_name(0))"

# then: clone your code or upload it, and start with a smoke test
# before launching the multi-hour run you are paying for
Adapted from the source's Option 3. Providers differ in images and pricing; the pattern is always log in, install, verify, then train.

The cloud cost calculator

GPU time is a meter running. Pick a card class, set the hours and the number of runs, and see what a training sweep costs. Every rate here is an example for teaching, not a quote.

EXAMPLE RECEIPT · SOURCE RANGE $0.20–2.00/HR
A10 / L4-class · 24 GB · 24 GB

fine-tunes small models; serves a 7B model with room to spare

rate $0.60/hour (on-demand, example) per run 10 h × $0.60/h = $6.00 runs × 1 TOTAL $6.00 on-demand total $6.00 same sweep on spot $3.00 (saves $3.00, 50%) your wall-clock ≈ 10.0 h if the runs are serial
Example rates per hour — teaching numbers inside the source’s $0.20–2.00/hr range.
classVRAMon-demandspot
T4-class · 16 GB16 GB$0.30/h$0.15/h
A10 / L4-class · 24 GB24 GB$0.60/h$0.30/h
A100 80 GB-class80 GB$1.60/h$0.80/h
tier A10 / L4-class · 24 GB rate $0.60/h (on-demand example) hours per run 10 h runs 1 per run $6.00 sweep total $6.00 on-demand total $6.00 · spot would cost $3.00 the source's framing: a training run that takes 8 hours on CPU takes about 10 minutes on a GPU. At an example $0.30/h that 10-minute run costs $0.05 — about the price of a coffee for a whole experiment. prices change: re-check the provider's page before renting.

Spot instances are cheaper because they can be taken away mid-run — which is why checkpointing is not optional on spot. On-demand costs more and still needs a spending cap: a forgotten instance bills by the hour, not by the idea.

VERIFY YOUR HARDWARE

Before you trust a GPU, ask it to introduce itself.

Two minutes of checking saves an afternoon of debugging. The source’s first move is nvidia-smi, then a short PyTorch report — and both have exact outputs you can read like a receipt.

nvidia-smi is the NVIDIA System Management Interface: a command-line tool that talks to the driver, not to Python. It answers the question “is there a GPU here at all, and what is it doing?” before any framework gets involved.

the first command on any NVIDIA machinebash
nvidia-smi

# the output is a receipt for your hardware:
#   driver version    the software that talks to the card
#   CUDA version      the highest CUDA API this driver supports
#   GPU name          e.g. NVIDIA GeForce RTX 4090
#   memory            used / total VRAM
#   utilization       % of the last second the GPU was busy
#   processes         which PIDs are using the card right now
nvidia-smiis there a GPU + driver?torch.cuda.is_available()can PyTorch see it?torch.version.cudawhich CUDA was the build made for?get_device_properties(0)how much VRAM?
Four probes, four different questions. A missing GPU shows up at step one; a broken PyTorch install shows up at step two.

Once the driver is fine, ask PyTorch the same questions in code — this is the source’s device report, extended with the memory line:

the PyTorch device reportpython
import torch

print(f"CUDA available: {torch.cuda.is_available()}")
print(f"CUDA version:   {torch.version.cuda}")
if torch.cuda.is_available():
    print(f"GPU:            {torch.cuda.get_device_name(0)}")
    props = torch.cuda.get_device_properties(0)
    print(f"Memory:         {props.total_memory / 1e9:.1f} GB")
    print(f"Compute capability: {props.major}.{props.minor}")

# Apple Silicon has its own backend — Metal Performance Shaders (MPS)
print(f"MPS available:  {torch.backends.mps.is_available()}")
print(f"MPS built:      {torch.backends.mps.is_built()}")
Adapted from the source's gpu_check.py and the course's device report. total_memory is in bytes; divide by 1e9 for the decimal GB that vendors quote.
What the probes print on three machines. Example values — your own numbers will differ.
probeNVIDIA workstationApple Silicon MacCPU-only laptop
nvidia-smidriver + CUDA 12.4 + card + VRAMcommand not foundcommand not found
torch.cuda.is_available()TrueFalseFalse
torch.version.cuda“12.4”NoneNone
torch.cuda.get_device_name(0)NVIDIA GeForce RTX 4090— (no CUDA device)— (no CUDA device)
total_memory / 1e925.8 GB (a 24 GiB card)
torch.backends.mps.is_available()FalseTrueFalse
torch.device(…)cudacpu (MPS is opt-in)cpu

The no-GPU path is a first-class path. The source is explicit: most lessons work on CPU, and the ones that need a GPU say so and include Colab links. If you are on an Apple Silicon Mac, PyTorch ships a Metal-backed backend (MPS — Metal Performance Shaders) that accelerates a useful subset of operations on macOS 12.3+, but it is not CUDA and it is not picked up automatically.

the no-GPU fallback, exactly as the source writes itpython
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using: {device}")

# on a Mac this prints "cpu" — MPS is not part of the cuda-or-cpu idiom.
# The next chapter shows the three-way version.
Quick check

Your friend has an Apple Silicon Mac with an M2 chip and a recent PyTorch. What do these two lines print?

The device-dispatch simulator

One script, three machines. Step through it line by line and watch the same code land on CUDA, MPS or CPU — including the errors you get when you ask for a backend that is not there.

WHAT THIS MACHINE ADVERTISES · ERROR WORDING VARIES BY VERSIONNVIDIA workstationCUDA✓ availabledriver present, PyTorch built with CUDAMPS✗ unavailableno Apple GPU on this machineCPU✓ availablealways there — slower, never absent
  1. nvidia-smi
  2. print(torch.cuda.is_available())
  3. print(torch.version.cuda, torch.cuda.get_device_name(0))
  4. print(torch.backends.mps.is_available())
  5. print(round(torch.cuda.get_device_properties(0).total_memory / 1e9, 1))
  6. print(torch.device("cuda" if torch.cuda.is_available() else "cpu"))
  7. t = torch.randn(3, 3).to("cuda"); print(t.device)
  8. torch.cuda.synchronize(); print("kernel finished")

Linux box with an RTX 4090 (24 GiB) and the NVIDIA driver installed.

nothing run yet — press “run next line”
machine NVIDIA workstation probes cuda True · mps False chosen cuda:0 lines run 0 / 8 what changes per machine cuda ✓ driver present, PyTorch built with CUDA mps ✗ no Apple GPU on this machine cpu ✓ always available the device-agnostic rule ask, don't assume: select the backend at runtime, move the model and every batch to that device, and branch the timing code (cuda vs mps vs nothing).

The errors here are the real ones you will meet, though the exact wording shifts between PyTorch versions. Notice the Mac: the usual cuda if available else cpu line never touches MPS — Apple’s backend is opt-in.

THE 5000×5000 BENCHMARK

Time it honestly, or the GPU will lie to you.

The source’s benchmark looks like a beginner’s five-liner, and one line in it is doing something subtle: torch.cuda.synchronize(). Delete it and the GPU appears a thousand times faster — because the stopwatch never waited for the work.

GPU operations are asynchronous. When Python says c_gpu = a_gpu @ b_gpu, it enqueues a kernel on the device’s work queue and returns immediately; the multiply happens later, on the GPU. That is a feature — the host can prepare the next batch while the device chews — but it breaks naive timing.

The source’s fix brackets the measured region with a barrier on both sides: synchronize before starting the timer (so pending copies are done), and synchronize before stopping it (so the multiply is done). The measured interval is then work, not enqueue.

the source's CPU-vs-GPU benchmark, with its two barrierspython
import torch
import time

size = 5000

a_cpu = torch.randn(size, size)
b_cpu = torch.randn(size, size)

start = time.time()
c_cpu = a_cpu @ b_cpu
cpu_time = time.time() - start
print(f"CPU: {cpu_time:.3f}s")

if torch.cuda.is_available():
    a_gpu = a_cpu.to("cuda")
    b_gpu = b_cpu.to("cuda")

    torch.cuda.synchronize()          # wait for the copies to land
    start = time.time()
    c_gpu = a_gpu @ b_gpu
    torch.cuda.synchronize()          # wait for the multiply to finish
    gpu_time = time.time() - start
    print(f"GPU: {gpu_time:.3f}s")
    print(f"Speedup: {cpu_time / gpu_time:.0f}x")
Verbatim in structure from the source. On a Mac the GPU branch never runs (cuda.is_available() is False) — the CPU line still prints, which is exactly why most lessons run anywhere.
Worked check — the async illusion, with numbers

Teaching estimates for one 5000×5000 fp32 matmul, chosen so every figure below can be checked by hand. They are example rates, not a measurement of your machine:

work = 2.5×10¹¹ FLOPs CPU at ≈ 50 GFLOP/s (5×10¹⁰ FLOPs per second) 2.5×10¹¹ / 5×10¹⁰ ≈ 5.00 s GPU at ≈ 1.25 TFLOP/s (1.25×10¹² FLOPs per second) 2.5×10¹¹ / 1.25×10¹² ≈ 0.20 s launch / transfer overhead ≈ 0.005 s WITHOUT synchronize the timer stops as soon as Python returns: measured = 0.005 s "speedup" = 5.00 / 0.005 = 1000× ← an illusion WITH synchronize the timer stops when the kernel actually ends: measured = 0.005 + 0.20 = 0.205 s speedup = 5.00 / 0.205 ≈ 24× ← the honest number the stopwatch was 205 / 5 = 41× too optimistic

The illusion is not small — it is the difference between “the GPU is magic” and “the GPU is about 24× faster here”. And the honest number is the one you can act on: it tells you that a 10-hour CPU run becomes ~25 minutes, not 36 seconds, which is the difference between a coffee break and a nap.

Two footnotes that make real timings even messier. First, the first GPU call is slow: libraries initialise, kernels get compiled and tuned, so benchmarks warm up with dummy iterations before recording. Second, for precise timing use torch.cuda.Event rather than time.time(); events are recorded on the GPU timeline itself. The barrier is still the point: without it, you are timing Python.

The timing pitfall

A GPU call returns before the work is done. Toggle synchronize() and watch the stopwatch: without it the timer stops while the kernel is still running.

current run · synchronize() ON stopwatch stops 205 ms kernel really ends 205 ms reported speedup 24.4× ← the honest number honest speedup 24.4× both runs of the same script without sync 5 ms measured → 1000× claimed with sync 205 ms measured → 24.4× real the stopwatch was 41.0× too optimistic why: Python enqueues the kernel and returns. Only synchronize() (or reading a GPU value back to the CPU) waits for the device — and only then does the stopwatch measure the work instead of the launch.

The same pitfall hides everywhere: timing with time.time() and no barrier, comparing an async run to a sync one, or benchmarking the first call while the GPU compiles kernels. Warm up, synchronize, then measure.

Quick check

You time a GPU matmul and get 0.006 s, then print a 900× speedup over the CPU. What most likely went wrong?

THE VRAM BUDGET

2 bytes per parameter sounds small. Then you count everything else.

VRAM — video RAM, the memory on the GPU, separate from your system’s RAM — is the wall. The source gives the standard rule of thumb: 2 bytes per parameter for fp16. It is a good rule and a dangerous one, because it answers the inference question, while training asks a much more expensive one.

The source’s arithmetic is simple enough to do in your head. A model with 7 billion parameters in fp16 (16-bit floating point — half the storage of its 32-bit sibling fp32, with minimal accuracy loss for inference) uses:

7×10⁹ parameters × 2 bytes = 14×10⁹ bytes = 14 GB and the rule read backwards: 24 GB of VRAM ÷ 2 bytes per parameter = 12×10⁹ parameters which is why the quiz answer for "how many parameters fit in 24 GB of VRAM?" is 12 billion.

Now the trap this lesson exists to disarm: that 14 GB figure is weights only. During training, every parameter also carries a gradient, optimizers keep their own state, and the forward pass leaves activations behind for the backward pass to use. The moment you add those, the picture changes by an order of magnitude.

Worked check — the 7B training budget, line by line
7×10⁹ parameters · fp16 weights · Adam · batch 1 weights 7×10⁹ × 2 B = 14.0 GB (what inference needs) gradients 7×10⁹ × 4 B = 28.0 GB (one fp32 per parameter) Adam moments 7×10⁹ × 8 B = 56.0 GB (two fp32 numbers: m and v) activations ≈ 0.5 GB (scales with batch) ─────────────────────────────────────────────── training total ≈ 98.5 GB so: 2 bytes to serve it, 14 to train it with lessons-level bookkeeping. The industry mixed-precision rule adds an fp32 master copy of the weights (+4 B/param) and lands on ≈16 bytes per parameter — 7×10⁹ × 16 B ≈ 112 GB. Both rules say the same thing: one 16 GB card is not a training machine for a 7B model.

A second example at a size people actually fine-tune on a single consumer card — 1.5B parameters, fp16, batch 1:

weights 1.5×10⁹ × 2 B = 3.0 GB gradients 1.5×10⁹ × 4 B = 6.0 GB SGD state 0.0 GB activations ≈ 0.11 GB ──────────────────────────────────────── with SGD total ≈ 9.1 GB → fits 12 GB, not 8 with Adam: + 12.0 GB ≈ 21.1 GB → fits 24 GB, not 16 or 12 same model, same weights, three different answers — because the optimizer is part of the memory budget.
Teaching estimates with fp16 weights and no activations; subtract room for activations, the CUDA context and fragmentation before you trust a card to the last gigabyte. SGD (stochastic gradient descent) keeps no extra per-parameter state; Adam (Adaptive Moment Estimation) keeps two fp32 moments.
cardfp16 inference (2 B/param)train with SGD (6 B/param)train with Adam (14 B/param)
8 GB4 B params1.3 B params0.6 B params
12 GB6 B params2.0 B params0.9 B params
16 GB8 B params2.7 B params1.1 B params
24 GB12 B params4.0 B params1.7 B params
80 GB40 B params13.3 B params5.7 B params

When the budget does not fit, engineers change the budget. Gradient checkpointing stores a few activations and recomputes the rest during the backward pass, trading compute for memory (the planner’s ÷8 is a teaching estimate). Parameter-efficient fine-tuning — LoRA (Low-Rank Adaptation) and its quantized cousin QLoRA — freezes the base weights and trains small adapter matrices instead: a 7B base in 4-bit form plus adapters is a well-trodden 24 GB recipe, because the frozen base costs about 1 byte per weight and far fewer parameters carry optimizer state. Sharding — FSDP (PyTorch’s Fully Sharded Data Parallel) or ZeRO (DeepSpeed’s Zero Redundancy Optimizer) — splits the 98 GB across several cards. And gradient accumulation gives you the gradient of a large batch while only ever holding the activations of a small one.

The VRAM budget planner

Weights are only the first line of the bill. Move the sliders and watch gradients, optimizer states and activations stack up — and find out which of the five card sizes can actually hold the run.

precisionfp16 · 2 bytes/param
optimizerAdam · 2 fp32 moments
model 7B params · fp16 · adam · batch 1 bytes/param 14.0 B (weights 2 B + 4 B gradients + 8 B Adam moments) weights 14.0 GB gradients 28.0 GB optimizer 56.0 GB (two fp32 moments: m and v) activations 0.5 GB (no checkpointing) TOTAL TRAIN 98.5 GB INFERENCE 14.0 GB (weights only) card verdicts 8 GB ✗ overflow 90.5 GB too big 12 GB ✗ overflow 86.5 GB too big 16 GB ✗ overflow 82.5 GB too big 24 GB ✗ overflow 74.5 GB too big 80 GB ✗ overflow 18.5 GB too big the source's rule of thumb, both readings fp16 inference on 24 GB ≈ 12.0B params (24 ÷ 2 B) Adam training on 24 GB ≈ 1.7B params (24 ÷ 14 B)

The rule of thumb answers “will it run?”. Training asks a harder question — every parameter also carries a gradient, Adam carries two more numbers, and activations grow with the batch. That is how a 14 GB model becomes a 98 GB training job.

Quick check

A 7B model in fp16 is 14 GB, so it loads on a 16 GB T4. Can you train it there with Adam?

STAY DEVICE-AGNOSTIC

Write one script. Let the machine pick the device.

The source’s device line — torch.device(“cuda” if torch.cuda.is_available() else “cpu”) — is the smallest habit with the biggest payoff. It is what lets the same notebook run on a T4, a Mac, and the laptop you are reading this on.

Hard-coding .cuda() is the classic mistake: it works on exactly one kind of machine and fails everywhere else with an error that says nothing about the fix. The pattern that survives every environment is ask, then use: probe the backends at runtime, pick one, and move both the model and every batch onto it.

pick_device(): ask, then usetorch.cuda.is_available()truedevice = "cuda"NVIDIA GPU with driver + CUDA buildtorch.backends.mps.is_available()truedevice = "mps"Apple Silicon · opt-in, not automaticelsedevice = "cpu"every machine — most lessons run here
The three-way dispatch. The dashed arrows are the “false” path — the standard two-way idiom stops at the second box and ignores MPS entirely.
the three-way device picker (source line, extended for MPS)python
import torch

def pick_device() -> torch.device:
    if torch.cuda.is_available():          # NVIDIA
        return torch.device("cuda")
    if torch.backends.mps.is_available():  # Apple Silicon (opt-in)
        return torch.device("mps")
    return torch.device("cpu")

device = pick_device()
print(f"Using: {device}")

model = model.to(device)          # move the model once
for x, y in loader:               # move every batch to the same place
    x, y = x.to(device), y.to(device)
    ...

# checkpoints: load them wherever you are
state = torch.load("checkpoint.pt", map_location=device)
The source's one-liner covers CUDA and CPU; the MPS branch is an original addition for readers on a Mac. Moving the model and the batches is not optional — a model on cuda and a tensor on cpu raise a device mismatch.

Three habits make the pattern stick. First, never call .cuda() outside a probe — use .to(device) everywhere. Second, branch the timing code too: torch.cuda.synchronize() for NVIDIA, torch.mps.synchronize() for Apple, nothing on CPU — which is why the benchmark from the last chapter wraps its barrier in an if torch.cuda.is_available(). Third, let environment variables steer, not code: the same script should run in CI (continuous integration) on CPU, in Colab on a T4, and on your Mac without a single edit.

CHECK YOURSELF

Five questions.
Then the terms worth keeping.

Answer before you look. The synchronize question and the VRAM question are the two that separate “I read the lesson” from “I can budget a training run”.

0 / 5 answered · 0 correct

01Why is a GPU faster than a CPU for training neural networks?

02What does VRAM refer to?

03What command verifies that your NVIDIA GPU is detected and shows its current status?

04When benchmarking GPU vs CPU matrix multiplication, why must you call torch.cuda.synchronize() before measuring GPU time?

05Using the fp16 rule of thumb, approximately how many parameters can fit in 24 GB of VRAM?

Key terms, demystified

Click a card to swap the lazy description for what it actually means.

Exercises from the lesson

Three problems with exact numbers — run the benchmark and read the ratio, take it to Colab if you have no GPU, and turn your card’s memory into a parameter budget. Try first; a worked answer is one click away.

  1. Run the source's CPU-vs-GPU benchmark on your own machine (or the closest thing you have) and compare the two times. Report the CPU time, the GPU time and the speedup, and say what would change if you doubled the matrix size.
    Show one worked answer

    Expect these shapes, not exact numbers — every machine differs. The benchmark builds two size×size fp32 matrices, times a_cpu @ b_cpu with time.time(), then (if CUDA exists) copies both to the GPU, calls torch.cuda.synchronize() before starting the timer, multiplies, synchronizes again and stops the timer. Read the output in three parts. (1) The CPU number: on a laptop, a 5000×5000 matmul is usually several seconds (the lesson's teaching estimate is 5.0 s at ≈50 GFLOP/s — a quarter of a trillion FLOPs at 5×10¹⁰ per second). (2) The GPU number: a fraction of a second on a modern card (the teaching estimate is 0.20 s), and it should include the 5 ms launch overhead rather than hide it. (3) The ratio: expect double digits, e.g. 5.00 / 0.205 ≈ 24×, and be suspicious of three- or four-digit ratios — that is the missing-synchronize signature. If you double size to 10,000, the FLOPs go ×8 (2n³) while the operand bytes only ×4, so the GPU's advantage grows: more independent work per launch. Two caveats to check before believing your own numbers: warm up the GPU first (the first kernel pays library initialisation), and make sure both synchronize() calls are present. If the GPU branch never runs (no CUDA), the script prints the CPU line only — that is the designed fallback, not a failure.

  2. If you don't have a GPU, run the benchmark on Google Colab and compare the results with your local CPU.
    Show one worked answer

    In Colab: File → New notebook, then Runtime → Change runtime type → T4 GPU, then paste the benchmark and run it with !nvidia-smi first to confirm the hardware. The free T4 has 16 GB of VRAM and its fp32 throughput is a fraction of a data-centre card's — the teaching estimate of 1.25 TFLOP/s effective gives 2.5×10¹¹ / 1.25×10¹² ≈ 0.20 s, but a realistic free-tier run can be slower because the CPU runtime it pairs with is also fast and because the shared GPU may be busy. Compare like with like: same size, same precision, same synchronize() placement. Two Colab-specific traps: the runtime is recycled when you are idle, so re-run the checks rather than trusting a stale state; and the 'CPU' you compare against is Colab's host, not your laptop — to compare your laptop, export the CPU time from your local run and compare against the notebook's GPU number. The lesson's point is not the exact ratio but the order of magnitude: minutes instead of hours, at $0.

  3. Print your GPU's memory and estimate the largest model you can fit — first for inference, then for training with SGD, then for training with Adam. Show the arithmetic.
    Show one worked answer

    Run torch.cuda.get_device_properties(0).total_memory / 1e9. A free Colab T4 reports about 15.8–16.0 GB; a 24 GiB RTX 4090 reports about 25.8 GB (decimal GB, because 1 GiB = 1.0737×10⁹ bytes). Then three divisions. Inference: capacity ÷ 2 bytes per parameter — 16 GB → 8B parameters, 24 GB → 12B, 80 GB → 40B. Training with SGD: capacity ÷ (2 + 4) bytes per parameter = capacity ÷ 6 — 16 GB → 2.7B, 24 GB → 4.0B, 80 GB → 13.3B. Training with Adam: capacity ÷ (2 + 4 + 8) = capacity ÷ 14 — 16 GB → 1.1B, 24 GB → 1.7B, 80 GB → 5.7B. Subtract room for activations (roughly 0.5 GB at 7B params and batch 1, scaling with batch) plus the CUDA context, so leave a gigabyte or two of headroom. Cross-check one line with the full budget: a 7B model is 14 GB of fp16 weights, 28 GB of fp32 gradients and 56 GB of Adam moments ≈ 98.5 GB — which is why the honest answer for a 16 GB T4 is 'serve 7B, train about 1B'. When the budget does not fit, change the budget: gradient checkpointing shrinks activations, LoRA/QLoRA trains adapters instead of all parameters, and FSDP/ZeRO shards the 98 GB across cards.

Terms this lesson borrows from later lessons (or outside)

You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

  • matrix multiplicationThe operation that makes GPUs matter: one n×n by n×n multiply is 2n³ FLOPs of independent dot products, and a 5000×5000 result holds 25×10⁶ entries. Taught properly in Phase 1, Lesson 02 (Vectors, Matrices & Operations).
  • floating-point precisionWhat fp32 / fp16 / int8 actually store, and why a training run mixes them: cheap weights, careful gradients. The numerical-stability treatment, including mixed precision, is Phase 1, Lesson 13.
  • backpropagationThe reverse sweep that produces one gradient per parameter — the 4-bytes-per-parameter line in every training-memory budget. Taught properly in Phase 3, Lesson 03.
  • optimizerThe rule that turns gradients into updates. SGD keeps no extra state; Adam keeps two fp32 moments per parameter (8 bytes), which is the largest single line in a 7B training budget. Taught properly in Phase 3, Lesson 06 (Optimizers).
  • parameterA learned weight of the model — the unit every memory estimate here is written per. The 'B' in 7B means 7×10⁹ of them. Taught properly in Phase 3, Lesson 01 (The Perceptron).
KEEP GOING

A picture is a start.
Practice is the rest.

This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

Lesson text, quiz and the benchmark code are adapted from AI Engineering from Scratch (Phase 00, Lesson 03) and the Math Foundations Notebook reference build. The five labs (the simulated throughput race, the VRAM budget planner, the cloud cost calculator, the device-dispatch simulator and the timing-pitfall lab) are original to this page, as are the matmul arithmetic (2n³, 2.5×10¹¹ FLOPs for 5000², 100 MB per fp32 operand), the rough lane-width comparison, the source's 5000-versus-4000 size check, the async-illusion numbers (CPU 5.0 s, kernel 0.20 s, launch 5 ms → “1000×” untimed versus ≈24× honest, a 41× illusion), the full 7B training budget (14 GB weights + 28 GB fp32 gradients + 56 GB Adam moments + ≈0.5 GB activations ≈ 98.5 GB) with the 1.5B second example and the 8/12/16/24/80 GB capacity table, the ≈16-bytes-per-parameter mixed-precision cross-check, the Apple Silicon MPS path, the three-way device dispatch, the CUDA-version triage, the LoRA/QLoRA/checkpointing/sharding escape hatches, and the memory hooks. Every hourly rate and every simulated timing is labelled as a teaching estimate, and the labs compute their numbers live.