Deep Learning Lab
Guide
Module 4 · Deep Learning Engineering Lesson 4.3

Memory, Batch Size and Precision

79 min · 22 code cells · 2 labs

In this lesson
Time
~79 min
Sections
10
Code cells
22
Labs
2
Figures
3

Before you start

Will this run fit in the memory you have? You count bytes: every number costs 4 bytes in fp32, gradients add one more copy of the model, and AdamW adds two, so training with AdamW needs about four times the model's own memory before a single activation. You estimate a 7-billion-parameter model three ways, read fp32, fp16 and bf16 as a trade between range and precision, and see why only fp16 needs a loss scaler. Activations are the one term that grows with the batch; speed stops improving past a batch size that belongs to the machine; gradient accumulation gets the big batch back in small memory. Autocast belongs around the forward pass. Finally, a recovery ladder for real out-of-memory errors and a playbook that tries the free fixes first.

Goal

be able to predict a run's memory before starting it, and to work an out-of-memory error methodically instead of guessing.

The four things that take up memory while training:

   weights          the model itself
      +
   gradients        one number per weight
      +
   optimizer state  AdamW keeps two more numbers per weight
      +
   activations      the intermediate results, which grow with batch size
Outcome

by the end you should be able to:

  • say roughly how much memory a model of a given size needs to train, before running it;
  • explain why training needs about four times what the model itself takes;
  • explain fp32, fp16 and bf16 in terms of range and precision, and say which needs a loss scaler;
  • measure how activation memory and speed respond to batch size;
  • use gradient accumulation to get a big effective batch in small memory;
  • put autocast in the right place, and recognise the damage when it is in the wrong one;
  • work through an out-of-memory error in a fixed order, cheapest fix first.
You will be able to
  • count the bytes in a tensor from its shape and its type;
  • explain what an optimizer keeps per weight, and why that triples the model's memory;
  • estimate the memory for a 7-billion-parameter model in three different situations;
  • read torch.finfo and say what a number format can and cannot hold;
  • measure activation memory with a forward hook;
  • explain why throughput stops improving past a certain batch size;
  • show that gradient accumulation matches a big batch, and name the one layer it breaks;
  • list the out-of-memory fixes in order of what they cost you.
Runtime
Section Approximate time
Sections 1–4 under 10 s
Section 5 — speed measurements ~6 s
Section 6 under 5 s
Section 7 — four short training runs ~40 s
Sections 8–10 seconds, or skipped

Under two minutes in total, plus the data download in Section 7 if you have not run 4.1 or 4.2 in this folder.

How to read this lesson

This lesson answers one question:

Will this run fit in the memory I have, and what do I do when it does not?

Every model in this course so far has been small enough that memory never came up. That ends in Module 8, when you adapt a pretrained model, and in Module 9, when you serve one. The first thing most people meet there is a wall of red text ending in out of memory.

The cure is not a bigger machine. It is being able to say, before you press run, roughly how many bytes the run needs and which setting moves that number.

Almost everything here is arithmetic that does not care what machine you are on. A number takes four bytes on a laptop and four bytes on an eighty-thousand-dollar GPU. So Sections 1 to 7 are measured on CPU, and every reader gets the same numbers. Section 8 needs an NVIDIA GPU and skips itself politely if there is not one. Section 9 is for Apple Silicon Macs.

Nothing here is imported from another notebook.

Setup — imports, versions and device detection used by the notebook (2 cells)
Setup 24 lines
# pip install torch==2.14.0 scikit-learn==1.8.0 numpy==2.4.4 pandas==3.0.2 matplotlib==3.10.8import gcimport ioimport osimport platformimport timeimport urllib.requestimport zipfileimport matplotlib.pyplot as pltimport numpy as npimport pandas as pdimport torchimport torch.nn as nnpd.set_option("display.width", 140)MB = 1024 ** 2          # a megabyte, in bytesGB = 1024 ** 3          # a gigabyte, in bytesprint("torch  ", torch.__version__)print("machine", platform.machine())print("CPU threads torch will use:", torch.get_num_threads())
Output
torch   2.14.0+cpu
machine x86_64
CPU threads torch will use: 1
Setup 11 lines
if torch.cuda.is_available():    DEVICE, ACCELERATOR = torch.device("cuda"), torch.cuda.get_device_name(0)elif torch.backends.mps.is_available():    DEVICE, ACCELERATOR = torch.device("mps"), "Apple Silicon GPU (MPS)"else:    DEVICE, ACCELERATOR = torch.device("cpu"), "none"print(f"accelerator found : {ACCELERATOR}")print(f"sections 1-7      : run on CPU, so everyone gets the same numbers")print(f"section 8 (NVIDIA): {'runs here' if DEVICE.type == 'cuda' else 'skipped on this machine'}")print(f"section 9 (Apple) : {'runs here' if DEVICE.type == 'mps' else 'skipped on this machine'}")
Output
accelerator found : none
sections 1-7      : run on CPU, so everyone gets the same numbers
section 8 (NVIDIA): skipped on this machine
section 9 (Apple) : skipped on this machine
1

Where the bytes go

Start with the smallest possible question: how much memory does one tensor take?

A tensor holds numbers of one type. The commonest type is float32, which uses 4 bytes per number. So the memory is simply "how many numbers" times "bytes per number". PyTorch will tell you both.

Python 7 lines
example = torch.zeros(1000, 50)                 # 1,000 rows of 50 numbersprint("shape                :", tuple(example.shape))print("how many numbers     :", example.numel())print("bytes per number     :", example.element_size())print("total bytes          :", example.numel() * example.element_size())print("in megabytes         :", round(example.numel() * example.element_size() / MB, 3))
Output
shape                : (1000, 50)
how many numbers     : 50000
bytes per number     : 4
total bytes          : 200000
in megabytes         : 0.191

That is the whole arithmetic. Everything else in this lesson is that multiplication applied to different things.

Now, what does a training run hold? Four kinds of tensor. Three of them scale with the size of the model and one scales with the size of the batch:

  • parameters — the weights themselves, the thing you are training;
  • gradients — one per parameter, saying how much that parameter contributed to the error. So, a second copy of the model;
  • optimizer state — extra numbers the optimizer keeps between steps. Plain SGD keeps none. SGD with momentum keeps one per parameter. Adam and AdamW keep two;
  • activations — the intermediate results of the forward pass, kept because the backward pass needs them.

Rather than take that on trust, measure it on the Module 3 network.

Predict before you run

The Module 3 network has 5,377 weights, which take 21,508 bytes in fp32. After one AdamW step, how many bytes do the weights, their gradients and AdamW's state take together?

Step by step

Where the bytes of a training run go

1. Parameters: 1× the model

The weights you are training. The Module 3 network: 5,377 numbers × 4 bytes = 21,508 bytes.

2. Gradients: another 1×

One gradient per parameter, the same shape as the weights: 21,508 bytes more.

3. Optimizer state: 0×, 1× or 2×

Plain SGD keeps nothing, momentum one copy, Adam and AdamW two (exp_avg, exp_avg_sq): 43,040 bytes. Total so far: 4× the model.

4. Activations: set by the batch

Every layer's output, kept for the backward pass. They grow in a straight line with batch size, which is why halving the batch is the first thing to try when a run won't fit.

1 / 4
Python 51 lines
def build_mlp(n_inputs=50, hidden=(64, 32)):    """Build the Module 3 network: inputs, then ``Linear → ReLU`` per hidden layer, then one output.    Args:        n_inputs: Number of input features.        hidden: Width of each hidden layer, in order. ``(1024, 1024)`` gives the lesson's            "wide network" of 1,102,849 parameters.    Returns:        An ``nn.Sequential`` producing one logit per row, with PyTorch's default        initialisation (seed it first for repeatable weights).    """    layers, previous = [], n_inputs    for width in hidden:        layers += [nn.Linear(previous, width), nn.ReLU()]        previous = width    layers.append(nn.Linear(previous, 1))    return nn.Sequential(*layers)torch.manual_seed(0)model = build_mlp()optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)loss_function = nn.BCEWithLogitsLoss()batch_x = torch.randn(256, 50)batch_y = (torch.rand(256, 1) > 0.5).float()loss_function(model(batch_x), batch_y).backward()    # creates the gradientsoptimizer.step()                                     # creates the optimizer statedef total_bytes(tensors):    """Add up the memory held by a collection of tensors.    Args:        tensors: Any iterable, such as ``model.parameters()``, a list of gradients, or the            values of an optimizer's state. Items that are not tensors are skipped.    Returns:        The total size in bytes: the number of elements times the bytes per element,        summed over every tensor.    """    return sum(t.numel() * t.element_size() for t in tensors if torch.is_tensor(t))parameter_bytes = total_bytes(model.parameters())gradient_bytes = total_bytes([p.grad for p in model.parameters()])state_bytes = total_bytes([v for entry in optimizer.state.values() for v in entry.values()])print(f"parameters      : {sum(p.numel() for p in model.parameters()):>7,} numbers  {parameter_bytes:>8,} bytes")print(f"gradients       : {'':>7}  {gradient_bytes:>8,} bytes   ({gradient_bytes / parameter_bytes:.2f} x parameters)")print(f"AdamW state     : {'':>7}  {state_bytes:>8,} bytes   ({state_bytes / parameter_bytes:.2f} x parameters)")print(f"total so far    : {'':>7}  {parameter_bytes + gradient_bytes + state_bytes:>8,} bytes")
Counting the first three kinds of tensor
  1. 1

    .grad does not exist until the first backward pass; before it, every p.grad is None.

  2. 2

    AdamW creates exp_avg and exp_avg_sq lazily, on the first step. Before it, optimizer.state is empty.

  3. 3

    Section 1's multiplication, summed. torch.is_tensor skips anything that is not a tensor.

  4. 4

    step is a 4-byte tensor too, one per parameter tensor: six of them are the 24 bytes the Section 2 estimate misses.

Output
parameters      :   5,377 numbers    21,508 bytes
gradients       :            21,508 bytes   (1.00 x parameters)
AdamW state     :            43,040 bytes   (2.00 x parameters)
total so far    :            86,056 bytes

Gradients cost exactly what the parameters cost, as promised. AdamW costs twice the parameters. Look at what it is keeping:

Python 4 lines
for parameter, stored in optimizer.state.items():    print("per parameter tensor, AdamW keeps:",          {name: (tuple(value.shape) if torch.is_tensor(value) else value) for name, value in stored.items()})    break
Output
per parameter tensor, AdamW keeps: {'step': (), 'exp_avg': (64, 50), 'exp_avg_sq': (64, 50)}

exp_avg is a running average of recent gradients and exp_avg_sq is a running average of their squares. Both are the same shape as the weights, so both cost the same as the whole model.

That is the rule to remember: training with AdamW needs about four times the memory of the model itself, before a single activation is stored. One copy of weights, one of gradients, two of optimizer state.

That single factor of four explains a lot of Module 8. It is why fully fine-tuning a large model is out of reach on ordinary hardware, and why LoRA — which freezes almost all the weights, so they need no gradient and no optimizer state — changes what is possible rather than merely what is convenient.

Check your understandingQuestions to sit with
  1. You swap AdamW for plain SGD with no momentum. What does the "four times" become?
  2. Which of the four kinds of tensor does inference need, and which does it not?
Checkpoint Before you move on from §1
1

Training with AdamW in fp32 needs roughly how many times the model's own memory, before activations?

2

You switch from AdamW to plain SGD with no momentum. What does the 'four times' become?

Explain it back · two sentences, in your own words

Which of the four kinds of memory does inference need, and which does it not?

2

Counting before you run

The arithmetic above works for any model, so you can estimate a model you have never run, from its parameter count alone.

The function below does that. Its arguments:

  • n_parameters — how many weights the model has;
  • precision — how many bytes each number takes (Section 3 explains the choices);
  • optimizer — how many extra copies it keeps per parameter;
  • trainable — the share of parameters you are actually training. 1.0 means all of them; LoRA makes this tiny.

Activations are not included; Section 4 adds them.

Python 36 lines
BYTES_PER_NUMBER = {"fp32": 4, "fp16": 2, "bf16": 2, "int8": 1}OPTIMIZER_COPIES = {"none (inference)": 0, "sgd": 0, "sgd with momentum": 1, "adam or adamw": 2}def training_memory(n_parameters, precision="fp32", optimizer="adam or adamw", trainable=1.0):    """Estimate the bytes for weights, gradients and optimizer state, before running anything.    Activations are not included (Section 4). Every term uses the same number format, so    the fp16 rows describe pure 16-bit training; standard mixed precision keeps an fp32    master copy and fp32 optimizer state instead.    Args:        n_parameters: How many weights the model has.        precision: A key of ``BYTES_PER_NUMBER``: ``"fp32"``, ``"fp16"``, ``"bf16"`` or            ``"int8"``.        optimizer: A key of ``OPTIMIZER_COPIES``. ``"none (inference)"`` also forces            ``trainable`` to 0, since nothing is trained.        trainable: The share of weights being trained, from 0 to 1. Frozen weights still            count toward ``"weights"`` but need no gradient or optimizer state.    Returns:        A dict of bytes with keys ``"weights"``, ``"gradients"``, ``"optimizer state"`` and        ``"total"``.    """    size = BYTES_PER_NUMBER[precision]    if optimizer == "none (inference)":        trainable = 0.0                       # no backward pass, so no gradients at all    weights = n_parameters * size    gradients = n_parameters * trainable * size    state = n_parameters * trainable * OPTIMIZER_COPIES[optimizer] * size    return {"weights": weights, "gradients": gradients,            "optimizer state": state, "total": weights + gradients + state}# check it against what we measured by hand in Section 1estimate = training_memory(5377)print("estimated total bytes for the Day 3 network:", int(estimate["total"]))print("measured  total bytes in Section 1         :", parameter_bytes + gradient_bytes + state_bytes)
Estimating before running
  1. 1

    Inference overrides whatever share was passed: nothing is trained.

  2. 2

    The optimizer state is counted in the same format as the weights: exact for fp32, an assumption for the fp16 rows.

Output
estimated total bytes for the Day 3 network: 86032
measured  total bytes in Section 1         : 86056

The estimate is a few bytes under the measurement, because AdamW also keeps a tiny step counter per tensor. Close enough for planning.

Now use it on models you would not want to run by accident. A 7B model means seven billion parameters, the size of a small open-weights language model like the ones Module 8 fine-tunes.

Python 17 lines
rows = []for label, count, extra in [    ("Day 3 network (5,377)", 5_377, {}),    ("wide network (1.1M)", 1_102_849, {}),    ("BERT-base (110M)", 110_000_000, {}),    ("7B, fp32, AdamW", 7_000_000_000, {}),    ("7B, fp16 weights, AdamW", 7_000_000_000, {"precision": "fp16"}),    ("7B, inference only, fp16", 7_000_000_000, {"precision": "fp16", "optimizer": "none (inference)"}),    ("7B, LoRA: 0.2% trainable", 7_000_000_000, {"precision": "fp16", "trainable": 0.002}),]:    rows.append({"model": label, **training_memory(count, **extra)})table = pd.DataFrame(rows).set_index("model")table["total (MB)"] = (table["total"] / MB).round(2)for column in ["weights", "gradients", "optimizer state", "total"]:    table[column] = (table[column] / GB).round(3)print(table.rename(columns=lambda c: f"{c} (GB)" if c != "total (MB)" else c).to_string())
Output
                          weights (GB)  gradients (GB)  optimizer state (GB)  total (GB)  total (MB)
model                                                                                               
Day 3 network (5,377)            0.000           0.000                 0.000       0.000        0.08
wide network (1.1M)              0.004           0.004                 0.008       0.016       16.83
BERT-base (110M)                 0.410           0.410                 0.820       1.639     1678.47
7B, fp32, AdamW                 26.077          26.077                52.154     104.308   106811.52
7B, fp16 weights, AdamW         13.039          13.039                26.077      52.154    53405.76
7B, inference only, fp16        13.039           0.000                 0.000      13.039    13351.44
7B, LoRA: 0.2% trainable        13.039           0.026                 0.052      13.117    13431.55

Read those 7B rows against real hardware. A free Colab T4 has about 15 GB of GPU memory. A good consumer GPU has 24 GB. An A100 or H100 in a data centre has 80 GB.

  • Fully fine-tuning a 7B model in fp32 with AdamW: 104 GB, before any activations. That does not fit on any single GPU you are likely to touch.
  • Same, with fp16 weights: 52 GB. Still no.
  • Loading it just to make predictions, in fp16: 13 GB. That fits on a free Colab GPU, which is why running a 7B model is routine while training one is not.
  • LoRA, training 0.2% of the parameters: 13 GB plus 0.08 GB. The gradients and optimizer state almost vanish, and what is left is the frozen model itself. Quantise those frozen weights to 4 bits — that is QLoRA, in Module 8 — and even that shrinks.

This table is also how you answer "will this fit?" in a code review, without booking a GPU to find out.

A calculated stacked bar chart compares 7B-parameter static memory: fp16 inference 13.0 GiB, fp32 inference 26.1, fp32 SGD training 52.2, and fp32 AdamW training 104.3. Components are weights, gradients and two optimizer moments.
Figure 4.3.1. Memory is computed using 2³⁰ bytes per GiB, as in the notebook. Counts omit activations and other overhead; the four-times comparison uses the same fp32 weight precision.
Why training costs about four times inferenceone 7-billion-parameter model, before a single activation is stored020406080100GB13.013 GBinferencefp1626.126 GBinferencefp3226.126.152 GBtrainingfp32 + SGD26.126.126.126.1104 GBtrainingfp32 + AdamWfree Colab T4, ~15 GBconsumer GPU, 24 GBA100 / H100, 80 GBweightsgradientsaverage 1exp_avgaverage 2exp_avg_sq

The four bars for one 7B model, from the lesson's function. Only fp16 inference fits under the free Colab line; full fp32 training with AdamW needs more than one 80 GB card before a single activation.

Interactive

Memory planner: will it fit?

The lesson's arithmetic, extended to activations. Start on 7B · AdamW · fp32, then switch to mixed: the total does not fall. Then lower the share of weights trained to 0.2% (the LoRA row) and try 4-bit frozen. For the two small networks, activations use the lesson's measured bytes per row; for BERT-base and the 7B model they are a standard estimate for GPT-style blocks (Korthikanti et al., 2022), so treat them as rough.

Loading interactive…
Check your understandingQuestions to sit with
  1. Using the same logic, estimate the memory needed to fully fine-tune BERT-base with AdamW in fp32.
  2. In the LoRA row, why does the total barely fall if you make the adapters even smaller?
Code exercise

Will it fit? Estimate before you run

Python · runs in your browser

Return the memory (in GiB, \(2^{30}\) bytes) for weights, gradients and optimizer state. Every parameter needs a weight; only the trainable share needs a gradient and optimizer state. Activations are left out, as in §2.

Checkpoint Before you move on from §2
1

A model has 1 million weights. Roughly how much memory do weights, gradients and AdamW state take in fp32?

2

Why can you run a 7B model on a free Colab GPU but not fully fine-tune it?

Explain it back · two sentences, in your own words

In the LoRA row, why does the total barely fall if you make the adapters even smaller?

3

fp32, fp16 and bf16

Halving the bytes per number halves three of the four memory terms at once, so the number format is a memory decision before it is a speed decision.

Every one of these formats spends its bits on two different jobs:

  • range — how big and how small a number it can hold at all;
  • precision — how finely it can tell nearby numbers apart.

torch.finfo reports both.

Where the bits go: range from the exponent, precision from the fractionfp324 bytes8 exponent bits23 fraction bitslargest 3.4e38 · smallest normal 1.2e-38 · step near 1.0: 1.2e-7fp162 bytes5 exponent bits10 fraction bitslargest 65,504 · smallest normal 6.1e-5 · step near 1.0: 9.8e-4bf162 bytes8 exponent bits7 fraction bitslargest 3.4e38 · smallest normal 1.2e-38 · step near 1.0: 7.8e-3signexponent: how big or small (range)fraction: how fine (precision)bf16 is fp32 with the last 16 fraction bits cut off: the same range, far less precision

Each format splits its bits between an exponent (range) and a fraction (precision). bf16 keeps fp32's eight exponent bits, so it keeps fp32's range.

Python 4 lines
for number_type in [torch.float32, torch.float16, torch.bfloat16]:    info = torch.finfo(number_type)    print(f"{str(number_type):<16} bytes {info.bits // 8}   largest {info.max:>12.3e}   "          f"smallest normal {info.tiny:.3e}   step near 1.0 {info.eps:.1e}")
Output
torch.float32    bytes 4   largest    3.403e+38   smallest normal 1.175e-38   step near 1.0 1.2e-07
torch.float16    bytes 2   largest    6.550e+04   smallest normal 6.104e-05   step near 1.0 9.8e-04
torch.bfloat16   bytes 2   largest    3.390e+38   smallest normal 1.175e-38   step near 1.0 7.8e-03

In words:

  • fp32 (float32) — 4 bytes. Big range, fine precision. The default everywhere.
  • fp16 (half) — 2 bytes. Precision is decent, but the range is narrow: nothing above 65,504, and nothing usefully small below about 0.00006.
  • bf16 (bfloat16) — 2 bytes. The same range as fp32, bought by giving up precision.

Watch both failures happen.

Interactive

Precision lab: what each format keeps, and loss scaling

Real IEEE rounding in your browser. Store 70000 and 1e-8 in each format, then switch to Loss scaling and find the scale at which no fp16 gradient vanishes.

Loading interactive…
ChallengeNothing lost, nothing overflowed

In Loss scaling with fp16, find a loss scale at which no gradient becomes 0 and none overflows.

Python 13 lines
print("too big for fp16:")print("   70000 in fp32 :", torch.tensor(70000.0).item())print("   70000 in fp16 :", torch.tensor(70000.0, dtype=torch.float16).item())print("   70000 in bf16 :", torch.tensor(70000.0, dtype=torch.bfloat16).item())print()print("too small for fp16:")print("   0.00000001 in fp16 :", torch.tensor(1e-8, dtype=torch.float16).item())print("   0.00000001 in bf16 :", torch.tensor(1e-8, dtype=torch.bfloat16).item())print()print("precision, telling 1.0001 from 1:")print("   1.0001 in fp32 :", torch.tensor(1.0001).item())print("   1.0001 in fp16 :", torch.tensor(1.0001, dtype=torch.float16).item())print("   1.0001 in bf16 :", torch.tensor(1.0001, dtype=torch.bfloat16).item())
Output
too big for fp16:
   70000 in fp32 : 70000.0
   70000 in fp16 : inf
   70000 in bf16 : 70144.0

too small for fp16:
   0.00000001 in fp16 : 0.0
   0.00000001 in bf16 : 1.0011717677116394e-08

precision, telling 1.0001 from 1:
   1.0001 in fp32 : 1.000100016593933
   1.0001 in fp16 : 1.0
   1.0001 in bf16 : 1.0

inf means the number was too big to represent, so it became infinity. 0.0 means it was too small and vanished. And bf16 cannot tell 1.0001 from 1 at all.

Now the consequence that matters for training. Gradients are small numbers. In fp16 many of them fall below that floor and become exactly zero, and a weight whose gradient is zero stops learning. Nothing crashes; the model just quietly trains worse.

The fix is loss scaling: multiply the loss by a big constant before the backward pass, so every gradient is multiplied by that constant too and lands inside fp16's range; then divide the gradients by the same constant before the optimizer uses them. torch.amp.GradScaler does this and adjusts the constant automatically.

Python 8 lines
tiny_gradient = 1e-8scale = 65536.0print(f"gradient {tiny_gradient} in fp16              :", torch.tensor(tiny_gradient, dtype=torch.float16).item())print(f"multiplied by {scale:,.0f} first, in fp16 :",      torch.tensor(tiny_gradient * scale, dtype=torch.float16).item())print("...then divided back down afterwards:",      torch.tensor(tiny_gradient * scale, dtype=torch.float16).item() / scale)
Output
gradient 1e-08 in fp16              : 0.0
multiplied by 65,536 first, in fp16 : 0.0006551742553710938
...then divided back down afterwards: 9.997165761888027e-09

Survived. bf16 needs no scaler, because its range is fp32's range; that is exactly what it bought with its precision. This is why bf16 is preferred on hardware that supports it.

Which you get depends on the chip. A free Colab T4 has fast fp16 but no hardware bf16. A100, H100 and recent Apple Silicon support bf16. Section 8 checks at runtime instead of assuming.

Check your understandingQuestions to sit with
  1. Why does fp16 need a loss scaler when bf16 does not?
  2. bf16 cannot tell 1.0001 from 1.0. Why is that usually harmless while training?
Checkpoint Before you move on from §3
1

Why does fp16 training need a loss scaler while bf16 does not?

2

70,000 stored in fp16 becomes…

Explain it back · two sentences, in your own words

Explain the trade between range and precision for fp16 and bf16 in two sentences.

4

Activations and batch size

Parameters, gradients and optimizer state do not care about batch size. Activations do, and that is what makes batch size a memory control.

An activation is any intermediate result the forward pass produces and the backward pass will need. Every layer's output is one.

To measure them we use a forward hook: a small function PyTorch calls every time a layer produces an output. Ours just adds up the bytes.

Python 37 lines
def activation_bytes(model, batch_size, n_inputs=50):    """Measure the bytes of every layer output in one forward pass.    Registers a forward hook on each layer, runs one random batch under    ``torch.no_grad()``, then removes the hooks. The count covers every layer's output,    which is an upper bound on what autograd keeps for the backward pass.    Args:        model: An ``nn.Sequential`` (its layers are hooked one by one).        batch_size: Rows in the random batch.        n_inputs: Features per row.    Returns:        The total bytes of all layer outputs for that batch.    """    running_total = []    def record(module, module_input, module_output):        """Forward hook: add the size of one layer's output to the running total.        Args:            module: The layer that just ran (unused).            module_input: The layer's inputs (unused).            module_output: The layer's output tensor, whose bytes are recorded.        """        running_total.append(module_output.numel() * module_output.element_size())    handles = [layer.register_forward_hook(record) for layer in model]    with torch.no_grad():        model(torch.randn(batch_size, n_inputs))    for handle in handles:        handle.remove()                 # always remove hooks, or they fire forever    return sum(running_total)small_model = build_mlp()print("activations for one row through the Day 3 network:", activation_bytes(small_model, 1), "bytes")print("for 256 rows                                     :", activation_bytes(small_model, 256), "bytes")
Measuring activations with hooks
  1. 1

    Called after each layer's forward pass with its input and output; only the output's size is recorded.

  2. 2

    Iterating an nn.Sequential gives its layers in order. Each registration returns a handle for removing it later.

  3. 3

    Only the sizes are needed, so no graph is built. The count covers every layer output, more than autograd keeps.

  4. 4

    A forgotten hook runs on every later forward pass and keeps growing running_total through its closure.

Output
activations for one row through the Day 3 network: 772 bytes
for 256 rows                                     : 197632 bytes

Exactly 256 times as much. Activation memory is linear in batch size: each row costs the same, and you choose how many rows.

On the tiny Module 3 network nobody notices. Use a wider one, where each row costs more, and the picture changes.

Predict before you run

The wide network's weights take 4.2 MB. At batch 16,384, its activations are about…

Python 13 lines
wide_model = build_mlp(hidden=(1024, 1024))wide_parameters = sum(p.numel() for p in wide_model.parameters())rows = []for batch_size in [32, 256, 2048, 16384]:    measured = activation_bytes(wide_model, batch_size)    rows.append({"batch size": batch_size,                 "activations (MB)": round(measured / MB, 2),                 "bytes per row": measured // batch_size,                 "times the model's weights": round(measured / (wide_parameters * 4), 2)})print(f"wide network: {wide_parameters:,} parameters = {wide_parameters * 4 / MB:.2f} MB of weights")print(pd.DataFrame(rows).to_string(index=False))
Output
wide network: 1,102,849 parameters = 4.21 MB of weights
 batch size  activations (MB)  bytes per row  times the model's weights
         32              0.50          16388                       0.12
        256              4.00          16388                       0.95
       2048             32.01          16388                       7.61
      16384            256.06          16388                      60.87

At batch 32 the activations are a rounding error next to the weights. At batch 16,384 they are 61 times the weights.

That is the trade in one table. Model memory is fixed once you choose an architecture. Activation memory is yours to set, one batch at a time, which is why "halve the batch size" is the first thing to try when a run will not fit.

Two things this simple counting leaves out:

  • Real architectures store more per row. Attention in a Transformer keeps intermediate results that grow with the square of the sequence length, which is why Module 6's talk of context windows is also a conversation about memory.
  • Gradient checkpointing trades this memory for time: throw activations away during the forward pass and recompute them during the backward pass. It typically saves a large share of activation memory for roughly 30% more compute.
Deep diveWhat the hook counts, and what autograd actually keeps

The hook adds up every layer's output: for the wide network, 1,024 + 1,024 + 1,024 + 1,024 + 1 numbers per row, which is the 16,388 bytes in the table. Autograd keeps less. Each Linear saves its input and each ReLU saves its output, and those are the same tensor, while the Linear outputs before each ReLU are freed as soon as the ReLU has used them. Counting what autograd saves, with torch.autograd.graph.saved_tensors_hooks, gives:

wide network, per row fp32 under bf16 autocast
the hook's count 16,388 bytes
kept for the backward pass 8,400 bytes (the 50 inputs, two ReLU outputs, the logit and the label) about 4,200 bytes
16-bit copies of the weights kept for the backward pass none about 2.1 MB, whatever the batch

Three consequences. The hook's figure is an upper bound on what is stored, here about twice the truth, though the freed tensors still count briefly toward the peak. The rule that matters survives: stored activations are still exactly linear in batch size. And autocast's saving is not free: at batch 256 its weight copies (2.1 MB) outweigh the activations it saved (1.1 MB), so for this network mixed precision only saves memory above a batch of roughly 500.

Check your understandingQuestions to sit with
  1. Your model fits at batch 64 and fails at batch 128. Which memory term changed, and which did not?
  2. In a Transformer, sequence length behaves like batch size for activation memory, only worse. Why?
Checkpoint Before you move on from §4
1

A model fits at batch 64 and not at batch 128. Which memory term changed?

2

Why is sequence length even worse than batch size for a Transformer's activation memory?

5

Speed and batch size

If small batches save memory, why not always use them? Because every step carries fixed overhead — the Python loop, the optimizer update, the cost of launching work on the hardware — and a tiny batch pays that overhead for very little work.

Throughput means rows processed per second. The cell measures it at several batch sizes by running as many steps as fit in one second.

The three warm-up steps matter: the first steps of any timing include one-off costs, so they are run and discarded.

These numbers belong to the machine that runs the cell. The shape of the curve is what transfers, not the values.

Python 53 lines
def throughput(model, batch_size, n_inputs=50, seconds=1.0):    """Measure training throughput, in rows per second, for one batch size on this machine.    Runs three warm-up steps, then as many AdamW steps as fit in ``seconds`` on one fixed    random batch. Creates a fresh optimizer but trains the model passed in, so its weights    change.    Args:        model: The network to time; its first layer must take ``n_inputs`` features.        batch_size: Rows per step.        n_inputs: Features per row.        seconds: How long to time for.    Returns:        Rows processed per second (the variable ``steps`` counts rows).    """    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)    inputs = torch.randn(batch_size, n_inputs)    targets = (torch.rand(batch_size, 1) > 0.5).float()    def one_step():        """Take one full training step: zero the gradients, forward, backward, update."""        optimizer.zero_grad()        loss_function(model(inputs), targets).backward()        optimizer.step()    for _ in range(3):        one_step()                                  # warm up, then discard    steps, started = 0, time.perf_counter()    while time.perf_counter() - started < seconds:        one_step()        steps += batch_size    return steps / (time.perf_counter() - started)torch.manual_seed(0)bench_model = build_mlp(hidden=(1024, 1024))batch_sizes = [16, 64, 256, 1024, 4096]rates = [throughput(bench_model, size) for size in batch_sizes]print(f"{'batch':>6}  {'rows/second':>13}  {'versus batch 16':>16}")for size, rate in zip(batch_sizes, rates):    print(f"{size:>6}  {rate:>13,.0f}  {rate / rates[0]:>15.1f}x")fig, ax = plt.subplots(figsize=(7.5, 3.6))ax.plot(batch_sizes, rates, "o-", color="#1b6ca8", lw=2)ax.set_xscale("log", base=2)ax.set_xlabel("batch size")ax.set_ylabel("rows per second")ax.set_title("Throughput rises with batch size, then flattens (this machine)")ax.grid(alpha=0.3)plt.tight_layout()plt.show()
Measuring throughput
  1. 1

    Warm-up: the first steps pay one-off costs (memory allocation, choosing kernels) and would drag the average down.

  2. 2

    A fixed time budget rather than a fixed number of steps, so small and large batches are timed equally long.

  3. 3

    Despite its name, steps counts rows: the function returns rows per second.

Output
 batch    rows/second   versus batch 16
    16          2,802              1.0x
    64          8,832              3.2x
   256         16,154              5.8x
  1024         17,629              6.3x
  4096         18,729              6.7x
Plot output
Plot produced by the code above

The curve climbs steeply and then flattens. Read your own output: on the machine that built this notebook, moving from batch 16 to batch 256 multiplied throughput several times over, moving from 256 to 1,024 added only a little more, and the largest batch was no faster than the one before it.

Past the flat part, a bigger batch buys no speed and costs memory in a straight line. That bend is the useful operating point, and it belongs to the machine rather than the model, so it is worth measuring once on the hardware you really use.

One warning. Batch size is not only an engineering knob. Module 3 showed it changes the training itself: bigger batches give a steadier gradient at each step and usually want a larger learning rate. Shrinking the batch to fit memory and leaving the learning rate alone is a common way to make a model worse for reasons that look mysterious afterwards.

Check your understandingQuestions to sit with
  1. Why does the curve flatten instead of rising forever?
  2. You halve the batch size to fit a GPU. What should you consider doing to the learning rate, and why?
Checkpoint Before you move on from §5

Why does throughput (rows per second) flatten at large batch sizes?

Explain it back · two sentences, in your own words

You halve the batch size to fit a GPU. What should you consider doing to the learning rate, and why?

6

Gradient accumulation

Sometimes the batch size you want will not fit at all. Gradient accumulation gets it anyway: run several small batches, add their gradients together, and take one optimizer step. Memory follows the small batch. The weight update follows the big one.

There is one subtlety, and it is the usual bug. The loss is an average over the rows in the batch, so each small batch's gradient has to be divided by the number of small batches before it is added, or the total comes out too large.

Prove the two are the same.

Python 19 lines
torch.manual_seed(1)one_big_batch = build_mlp()accumulated = build_mlp()accumulated.load_state_dict(one_big_batch.state_dict())      # identical starting weightsinputs = torch.randn(256, 50)targets = (torch.rand(256, 1) > 0.5).float()loss_function(one_big_batch(inputs), targets).backward()      # one batch of 256for small_x, small_y in zip(inputs.chunk(4), targets.chunk(4)):    # four batches of 64    (loss_function(accumulated(small_x), small_y) / 4).backward()biggest_difference = max((a.grad - b.grad).abs().max().item()                         for a, b in zip(one_big_batch.parameters(), accumulated.parameters()))print(f"largest difference between the two sets of gradients: {biggest_difference:.2e}")print()print("activation memory, batch 256:", f"{activation_bytes(build_mlp(), 256):,} bytes")print("activation memory, batch  64:", f"{activation_bytes(build_mlp(), 64):,} bytes")
Proving accumulation matches the big batch
  1. 1

    Two separate models with the same starting weights, so any gradient difference comes from the batching alone.

  2. 2

    .backward() adds into .grad and nothing zeroes it between the four calls. Dividing by 4 turns four averages into one.

Output
largest difference between the two sets of gradients: 7.45e-09

activation memory, batch 256: 197,632 bytes
activation memory, batch  64: 49,408 bytes

The gradients agree to about 7 in the ninth decimal place, which is ordinary float32 rounding, not a difference in behaviour. And the accumulated version held a quarter of the activations.

Where the equivalence breaks:

  • BatchNorm. It computes statistics over whatever is in the current small batch, so four batches of 64 normalise differently from one batch of 256. LayerNorm, which Module 6's Transformer block uses, works one row at a time and is unaffected.
  • Forgetting to divide. Gradients come out four times too big, which behaves exactly like a four-times learning rate. Lesson 3.4 Case 1 shows what that looks like.
  • Anything else you average inside the loop, such as a logged metric.

in Module 8 this is a routine setting. Fine-tuning recipes commonly pair a batch of 1 or 2 with 8 to 16 accumulation steps, to get an effective batch of 16 or 32 on one consumer GPU.

Check your understandingQuestions to sit with
  1. You accumulate over 8 small batches and forget to divide. What does the loss curve do?
  2. Does gradient accumulation change training time? Memory? The final result?
Checkpoint Before you move on from §6
1

You accumulate over 8 small batches with AdamW and forget to divide each loss by 8. What happens?

2

Where does gradient accumulation stop being exactly equivalent to one big batch?

7

Mixed precision, and where to put it

Mixed precision means running some operations in a smaller number format while keeping the weights themselves in fp32. PyTorch does this with torch.autocast, which picks a sensible format per operation: matrix multiplications in bf16 or fp16, and precision-sensitive things like losses in fp32.

On CPU, autocast supports bf16. Time it — remembering that the speed is a property of this machine.

Python 40 lines
def timed_steps(use_autocast, seconds=2.0, batch_size=1024):    """Measure training steps per second on the wide network, with or without bf16 autocast.    Builds a fresh seeded ``(1024, 1024)`` network and puts ``torch.autocast`` around the    forward pass only, which is the recommended placement.    Args:        use_autocast: Run the forward pass in bf16 under ``torch.autocast`` on the CPU.        seconds: How long to time for, after three warm-up steps.        batch_size: Rows per step.    Returns:        Steps per second on this machine.    """    torch.manual_seed(0)    model = build_mlp(hidden=(1024, 1024))    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)    inputs = torch.randn(batch_size, 50)    targets = (torch.rand(batch_size, 1) > 0.5).float()    def one_step():        """Take one training step, with the forward pass inside the (possibly disabled) autocast block."""        optimizer.zero_grad()        with torch.autocast("cpu", dtype=torch.bfloat16, enabled=use_autocast):            output = model(inputs)        loss_function(output.float(), targets).backward()        optimizer.step()    for _ in range(3):        one_step()    steps, started = 0, time.perf_counter()    while time.perf_counter() - started < seconds:        one_step()        steps += 1    return steps / (time.perf_counter() - started)fp32_rate = timed_steps(False)bf16_rate = timed_steps(True)print(f"fp32           : {fp32_rate:6.1f} steps per second")print(f"autocast bf16  : {bf16_rate:6.1f} steps per second   ({bf16_rate / fp32_rate:.2f}x)")
Timing mixed precision
  1. 1

    One code path for both runs: enabled=False makes the block do nothing.

  2. 2

    Loss and backward pass outside the block, as PyTorch recommends. .float() brings the bf16 output back to fp32.

  3. 3

    Here steps really counts steps, so the result is steps per second at a fixed batch of 1,024.

Output
fp32           :   20.0 steps per second
autocast bf16  :   68.2 steps per second   (3.41x)
Python 6 lines
demo = build_mlp(hidden=(1024, 1024))with torch.autocast("cpu", dtype=torch.bfloat16):    output = demo(torch.randn(8, 50))print("inside autocast, the layer output is:", output.dtype)print("but the weights are still           :", next(demo.parameters()).dtype)
Output
inside autocast, the layer output is: torch.bfloat16
but the weights are still           : torch.float32

Note what autocast does not do. The weights stay fp32. Only the operations run in the smaller format, and the results come back smaller. So the memory saving is in the activations, not in the model.

On the machine that built this notebook, bf16 ran roughly three times as many steps per second. On yours it may be slower, especially on a CPU without bf16 instructions. Speed from mixed precision is a hardware property, not a free gift. Measure it on the device you will really train on. The memory saving, by contrast, is arithmetic, and is always there.

Does it change the answer?

Check against a result you already know: the Bank Marketing model from Lessons 4.1 and 4.2. First the data, in three compressed cells.

Python 52 lines
BANK_URL = "https://archive.ics.uci.edu/static/public/222/bank+marketing.zip"BANK_CSV = "data/bank-full.csv"def load_bank_marketing(path=BANK_CSV):    """Download Bank Marketing if needed and return its inputs and label.    ``duration`` is dropped because a call's length is only known after the call.    Args:        path: Where the CSV is (or will be) stored.    Returns:        A tuple ``(X, y)``: the 15-column input DataFrame and a float32 label array        (1 = subscribed).    Raises:        urllib.error.URLError: If the file is missing and UCI cannot be reached.    """    if not os.path.exists(path):        raw_bytes = urllib.request.urlopen(BANK_URL, timeout=120).read()        outer = zipfile.ZipFile(io.BytesIO(raw_bytes))        inner = zipfile.ZipFile(io.BytesIO(outer.read("bank.zip")))        os.makedirs(os.path.dirname(path), exist_ok=True)        with open(path, "wb") as f:            f.write(inner.read("bank-full.csv"))    df = pd.read_csv(path, sep=";")    y = (df["y"] == "yes").astype(np.float32).to_numpy()    return df.drop(columns=["y", "duration"]), yfrom sklearn.compose import ColumnTransformerfrom sklearn.metrics import roc_auc_scorefrom sklearn.model_selection import train_test_splitfrom sklearn.preprocessing import OneHotEncoder, StandardScalerX, y = load_bank_marketing()rows = np.arange(len(y))train_rows, rest = train_test_split(rows, train_size=0.8, random_state=42, stratify=y)val_rows, test_rows = train_test_split(rest, test_size=0.5, random_state=42, stratify=y[rest])numeric = X.select_dtypes(include="number").columns.tolist()categorical = [c for c in X.columns if c not in numeric]preprocessor = ColumnTransformer([    ("scale", StandardScaler(), numeric),    ("one_hot", OneHotEncoder(handle_unknown="ignore", sparse_output=False), categorical),]).fit(X.iloc[train_rows])train_inputs = torch.tensor(preprocessor.transform(X.iloc[train_rows]).astype(np.float32))train_targets = torch.tensor(y[train_rows]).unsqueeze(1)val_inputs = torch.tensor(preprocessor.transform(X.iloc[val_rows]).astype(np.float32))val_targets = y[val_rows]print("training rows:", train_inputs.shape, "| validation rows:", val_inputs.shape)
Output
training rows: torch.Size([36168, 50]) | validation rows: torch.Size([4521, 50])

Now one training function with three settings, so we can put autocast in different places and compare:

  • "fp32" — no autocast at all;
  • "forward" — autocast around the forward pass only, which is how it is meant to be used;
  • "whole" — autocast wrapped around the entire training loop, which looks tidier and is the mistake.
Predict before you run

Best validation AUC is 0.7847 in fp32 and with autocast around the forward pass. With autocast around the whole training loop it is…

Python 63 lines
def train_bank(mode="fp32", epochs=10, cache_enabled=True, seed=42):    """Train the Module 3 network on Bank Marketing with autocast in one of three places.    Uses the global ``train_inputs``, ``train_targets``, ``val_inputs`` and ``val_targets``,    AdamW at 1e-3 and shuffled batches of 256.    Args:        mode: ``"fp32"`` (no autocast), ``"forward"`` (bf16 autocast around each forward            pass, the recommended placement) or ``"whole"`` (one autocast block around the            whole loop, the mistake). In ``"whole"`` mode the training forward pass runs in            fp32 and only the validation scoring runs under autocast.        epochs: Passes over the training rows.        cache_enabled: Autocast's weight cache, used by ``"whole"`` mode only.        seed: Seeds the weights and the shuffling.    Returns:        The best validation ROC-AUC seen after any epoch.    """    torch.manual_seed(seed)    model = build_mlp(n_inputs=train_inputs.shape[1])    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)    generator = torch.Generator().manual_seed(seed)    loader = torch.utils.data.DataLoader(        torch.utils.data.TensorDataset(train_inputs, train_targets),        batch_size=256, shuffle=True, generator=generator)    def one_epoch():        """Train for one pass over the training rows."""        model.train()        for batch_x, batch_y in loader:            optimizer.zero_grad()            with torch.autocast("cpu", dtype=torch.bfloat16, enabled=(mode == "forward")):                output = model(batch_x)            loss_function(output.float(), batch_y).backward()            optimizer.step()    def validation_auc():        """Score the current model on the validation rows.        Returns:            Validation ROC-AUC, computed from fp32 probabilities.        """        model.eval()        with torch.no_grad():            probabilities = torch.sigmoid(model(val_inputs).float()).numpy().ravel()        return roc_auc_score(val_targets, probabilities)    best = 0.0    if mode == "whole":        with torch.autocast("cpu", dtype=torch.bfloat16, cache_enabled=cache_enabled):            for _ in range(epochs):                one_epoch()                best = max(best, validation_auc())    else:        for _ in range(epochs):            one_epoch()            best = max(best, validation_auc())    return bestprint(f"fp32, no autocast                        : best validation AUC {train_bank('fp32'):.4f}")print(f"autocast around the forward pass         : best validation AUC {train_bank('forward'):.4f}")print(f"autocast around the whole training loop  : best validation AUC {train_bank('whole'):.4f}")print(f"the same, with cache_enabled=False       : best validation AUC {train_bank('whole', cache_enabled=False):.4f}")
Three places to put autocast
  1. 1

    In "whole" mode this is enabled=False, which switches autocast off here even inside the outer block: training runs in fp32.

  2. 2

    In "whole" mode, the only forward pass under autocast: the first call fills the weight cache and every later call reuses it.

  3. 3

    Everything inside shares one weight cache, emptied only when the block exits after the last epoch.

Output
fp32, no autocast                        : best validation AUC 0.7847
autocast around the forward pass         : best validation AUC 0.7847
autocast around the whole training loop  : best validation AUC 0.7584
the same, with cache_enabled=False       : best validation AUC 0.7848

Read those four lines carefully, because the third one is the lesson.

The first two are identical to four decimal places: 0.7847. Running the forward pass in bf16 cost this model nothing.

The third dropped to 0.7584. The fourth, differing only by cache_enabled=False, came back to 0.7848.

So this is not a precision problem at all. It is a caching problem. Inside an autocast block, PyTorch caches the reduced-precision copies it makes of your fp32 weights, so that a weight used by several operations is converted once rather than repeatedly. That cache is designed for a block covering one forward pass. Stretch the block across many optimizer steps and the model keeps reusing copies made from older weights, so part of the network is training on stale values. Nothing errors. The loss curve looks plausible.

Put autocast around the forward pass, not around the loop. In an earlier draft of this lesson the block wrapped an entire 30-epoch run including the scoring, and validation AUC fell to 0.4079 — worse than guessing, a collapse that looks exactly like Lesson 3.4 Case 1 but has a completely different cause.

One more consequence, following from Lesson 4.2: mixed precision costs you bit-for-bit repeatability, because the arithmetic itself has changed. The run is still reproducible at Level 2, as long as the record says which precision was used. Make it a setting in your config rather than a switch you flip by hand.

Check your understandingQuestions to sit with
  1. Autocast leaves the weights in fp32. Which of the four memory terms does it actually reduce?
  2. Why should you not expect a bf16 run to reproduce an fp32 run to the last decimal?
Checkpoint Before you move on from §7
1

Autocast leaves the weights in fp32. Which memory term does it actually reduce?

2

Wrapping autocast around the whole training loop dropped validation AUC from 0.7847 to 0.7584. Why?

8

On an NVIDIA GPU: VRAM, running out, and recovering

Everything so far was arithmetic that holds anywhere. Three things are specific to an NVIDIA GPU, and these cells run only if one is present: reading how much GPU memory is actually in use, meeting a real out-of-memory error, and training in fp16 with a loss scaler.

VRAM is the GPU's own memory, separate from your computer's RAM. It is usually much smaller: 15 GB on a free Colab T4, against 16 GB or more of ordinary RAM on a laptop.

If you are on a laptop, run this section on a free Colab or Kaggle GPU. It is the same code; the cells below detect the machine and skip. Module 8's QLoRA work and Module 9's serving work need an NVIDIA GPU too, so it is worth doing once now, while the code is short enough to read.

Start with the recovery ladder, which is not GPU-specific logic at all and so can be tested anywhere: try the batch size you want; if it fails with an out-of-memory error, clean up and halve it.

Python 54 lines
OUT_OF_MEMORY = (torch.OutOfMemoryError, torch.cuda.OutOfMemoryError)def largest_batch_that_fits(attempt, start, smallest=1, errors=OUT_OF_MEMORY):    """Find the largest batch that runs without an out-of-memory error, halving from ``start``.    After each failure it collects garbage and, on an NVIDIA GPU, empties PyTorch's cache    before retrying.    Args:        attempt: A function taking a batch size; it raises one of ``errors`` when the batch            does not fit.        start: The first (largest) batch size to try.        smallest: Give up below this size.        errors: The exception types that mean "out of memory". Anything else propagates.    Returns:        A tuple ``(size, log)``: the first size that fitted (or ``None``) and a list of        ``(size, outcome)`` pairs, one per attempt.    """    size, log = start, []    while size >= smallest:        try:            attempt(size)            log.append((size, "fits"))            return size, log        except errors:            log.append((size, "out of memory"))            gc.collect()                                  # let Python release the tensors            if torch.cuda.is_available():                torch.cuda.empty_cache()                  # hand the memory back to the driver            size //= 2    return None, logclass PretendOutOfMemory(Exception):    """Stands in for a real out-of-memory error, so the ladder can be tested without a GPU."""    passdef pretend_gpu(batch_size, limit=512):    """Pretend to run a training step on a GPU that fits ``limit`` rows.    Args:        batch_size: The batch size being attempted.        limit: The largest batch this pretend GPU can hold.    Raises:        PretendOutOfMemory: If ``batch_size`` is larger than ``limit``.    """    if batch_size > limit:        raise PretendOutOfMemory(f"pretend out of memory at batch {batch_size}")found, log = largest_batch_that_fits(pretend_gpu, start=4096, errors=(PretendOutOfMemory,))for size, outcome in log:    print(f"  batch {size:>5} -> {outcome}")print(f"largest batch that fits: {found}")
The recovery ladder
  1. 1

    In current PyTorch these two names are the same class; listing both does no harm.

  2. 2

    Any other exception still propagates: only running out of memory is worth a smaller batch.

  3. 3

    Runs while the traceback is still alive; see the note above for moving it outside the handler.

  4. 4

    Halve and retry: step 5 of the playbook in Section 10.

Output
  batch  4096 -> out of memory
  batch  2048 -> out of memory
  batch  1024 -> out of memory
  batch   512 -> fits
largest batch that fits: 512

On a GPU you pass the real training step and the real error type instead of the pretend ones. That is what the next cells do.

Python 11 lines
if DEVICE.type != "cuda":    print("No NVIDIA GPU here, so this cell is skipped.")    print("Run this notebook on Colab or Kaggle with a GPU runtime to see the numbers.")else:    properties = torch.cuda.get_device_properties(0)    free, total = torch.cuda.mem_get_info()    print(f"GPU                        : {properties.name}")    print(f"total VRAM                 : {total / GB:.2f} GB")    print(f"free right now             : {free / GB:.2f} GB")    print(f"bf16 supported in hardware : {torch.cuda.is_bf16_supported()}")    print(f"currently held by torch    : {torch.cuda.memory_allocated() / MB:.1f} MB")
Output
No NVIDIA GPU here, so this cell is skipped.
Run this notebook on Colab or Kaggle with a GPU runtime to see the numbers.
Python 36 lines
if DEVICE.type != "cuda":    print("Skipped: needs an NVIDIA GPU.")else:    gpu_model = build_mlp(hidden=(4096, 4096)).cuda()    gpu_optimizer = torch.optim.AdamW(gpu_model.parameters(), lr=1e-3)    def one_gpu_step(batch_size):        """Take one AdamW step on the GPU model with a random batch of the given size.        Args:            batch_size: Rows in the random batch.        Raises:            torch.OutOfMemoryError: If the step does not fit in GPU memory.        """        inputs = torch.randn(batch_size, 50, device="cuda")        targets = (torch.rand(batch_size, 1, device="cuda") > 0.5).float()        gpu_optimizer.zero_grad()        loss_function(gpu_model(inputs), targets).backward()        gpu_optimizer.step()    rows = []    for batch_size in [256, 1024, 4096, 16384]:        torch.cuda.reset_peak_memory_stats()          # start the high-water mark again        try:            one_gpu_step(batch_size)            rows.append({"batch size": batch_size,                         "peak VRAM (MB)": round(torch.cuda.max_memory_allocated() / MB, 1),                         "result": "fits"})        except OUT_OF_MEMORY:            rows.append({"batch size": batch_size, "peak VRAM (MB)": None, "result": "out of memory"})            gc.collect()            torch.cuda.empty_cache()    print(pd.DataFrame(rows).to_string(index=False))    print("\nthe weights alone:",          round(sum(p.numel() for p in gpu_model.parameters()) * 4 / MB, 1), "MB")
Output
Skipped: needs an NVIDIA GPU.
Python 19 lines
if DEVICE.type != "cuda":    print("Skipped: needs an NVIDIA GPU. On a GPU this fills the memory on purpose and recovers.")else:    blocks = []    free_at_start, _ = torch.cuda.mem_get_info()    try:        while True:                                   # grab 512 MB at a time until it fails            blocks.append(torch.empty(int(512 * MB // 4), dtype=torch.float32, device="cuda"))    except OUT_OF_MEMORY as error:        print("caught:", str(error).split("\n")[0][:110])    print(f"blocks allocated before it failed : {len(blocks)} ({len(blocks) * 512 / 1024:.1f} GB)")    print(f"free VRAM now                     : {torch.cuda.mem_get_info()[0] / MB:.0f} MB")    del blocks                                        # the recovery, in order    gc.collect()    torch.cuda.empty_cache()    print(f"free VRAM after cleaning up       : {torch.cuda.mem_get_info()[0] / MB:.0f} MB "          f"(it started at {free_at_start / MB:.0f} MB)")
Running out on purpose
  1. 1

    512 MB of float32 is 512 × 2²⁰ / 4 numbers. torch.empty reserves memory without writing to it.

  2. 2

    Outside the except, so nothing else still refers to the blocks: del, collect, then hand the cache back.

Output
Skipped: needs an NVIDIA GPU. On a GPU this fills the memory on purpose and recovers.

Three details in that recovery are worth remembering.

del on its own is not enough. Python releases the tensor, but PyTorch keeps the memory in its own pool to reuse later, so a tool like nvidia-smi still shows it as used. torch.cuda.empty_cache() gives it back to the driver. That matters when another process needs it; inside your own process the pool is a feature, not a leak.

A caught error can keep your memory alive. An exception carries a traceback, and the traceback holds every local variable in every function it passed through — including the huge tensors that caused the failure. In a notebook the variables from the failed cell survive too. Restarting the kernel after a bad out-of-memory error is not superstition.

nvidia-smi and torch.cuda.memory_allocated() measure different things. The first shows what the process has reserved from the driver; the second shows what your tensors are actually using. A large gap between them is the pool, not a leak.

Finally, fp16 training with the loss scaler from Section 3.

Python 20 lines
if DEVICE.type != "cuda":    print("Skipped: needs an NVIDIA GPU. The pattern below is the one to reuse on Colab or Kaggle.")else:    amp_model = build_mlp(hidden=(1024, 1024)).cuda()    amp_optimizer = torch.optim.AdamW(amp_model.parameters(), lr=1e-3)    scaler = torch.amp.GradScaler("cuda")    inputs = torch.randn(4096, 50, device="cuda")    targets = (torch.rand(4096, 1, device="cuda") > 0.5).float()    for step in range(5):        amp_optimizer.zero_grad()        with torch.autocast("cuda", dtype=torch.float16):     # forward pass only            loss = loss_function(amp_model(inputs), targets)        scaler.scale(loss).backward()      # multiply the loss up, then go backward        scaler.step(amp_optimizer)         # divide the gradients back down, then step        scaler.update()                    # adjust the multiplier for next time    print(f"five fp16 steps done. final loss {loss.item():.4f}, "          f"loss multiplier now {scaler.get_scale():,.0f}")    print("on bf16-capable hardware the same loop with dtype=torch.bfloat16 needs no scaler")
fp16 with a loss scaler
  1. 1

    The backward pass runs outside the autocast block, on the scaled loss.

  2. 2

    Unscales the gradients and skips the step if any is inf or NaN.

  3. 3

    Halves the scale after an overflow and slowly raises it again after a run of clean steps.

Output
Skipped: needs an NVIDIA GPU. The pattern below is the one to reuse on Colab or Kaggle.

scaler.step() sometimes skips the optimizer step entirely. That happens when a gradient overflowed to infinity, in which case the step would be meaningless, so it is thrown away and the multiplier is lowered. Skipping a step occasionally is the correct behaviour, not a bug.

Check your understandingQuestions to sit with
  1. Why does nvidia-smi still show memory in use after del tensor?
  2. You get an out-of-memory error at batch 64 that did not happen yesterday with the same code. Name three things that could have changed.
Checkpoint Before you move on from §8

del big_tensor runs, but nvidia-smi still shows the memory in use. Most likely?

Explain it back · two sentences, in your own words

You hit an out-of-memory error at batch 64 that didn't happen yesterday with the same code. Name three things that could have changed.

9

On an Apple Silicon Mac

MPS is PyTorch's backend for the graphics processor built into Apple Silicon chips (M1 through M5). Two differences from an NVIDIA GPU matter here.

Memory is unified. The graphics processor shares the machine's RAM rather than having its own separate VRAM. So there is no separate memory figure to read, and no clean wall to hit. An oversized job tends to push the whole machine into swapping and slow everything down, rather than raising a tidy error. That is why this lesson does not include a deliberate out-of-memory demonstration for MPS. Raise batch sizes gradually and watch memory pressure in Activity Monitor.

Coverage is good but not complete. An operation with no MPS implementation raises an error, unless you set PYTORCH_ENABLE_MPS_FALLBACK=1, which quietly runs it on the CPU instead. That is correct but sometimes much slower than staying on the CPU for the whole job.

Worth planning around: bitsandbytes, vLLM and flash-attention are NVIDIA-only. Module 8's QLoRA work and Module 9's serving work go to Colab or Kaggle. Module 8 has a LoRA-only path that does run on a Mac.

Python 27 lines
if DEVICE.type != "mps":    print("No Apple Silicon GPU here, so this cell is skipped.")    print("On a Mac it reports unified memory use and checks which autocast types work.")else:    print(f"memory torch is allowed to use : {torch.mps.recommended_max_memory() / GB:.2f} GB")    print(f"currently held by torch        : {torch.mps.current_allocated_memory() / MB:.1f} MB")    print(f"reserved from the system       : {torch.mps.driver_allocated_memory() / MB:.1f} MB")    mps_model = build_mlp(hidden=(1024, 1024)).to("mps")    mps_optimizer = torch.optim.AdamW(mps_model.parameters(), lr=1e-3)    inputs = torch.randn(4096, 50, device="mps")    targets = (torch.rand(4096, 1, device="mps") > 0.5).float()    for _ in range(3):        mps_optimizer.zero_grad()        loss_function(mps_model(inputs), targets).backward()        mps_optimizer.step()    torch.mps.synchronize()                 # wait for the GPU to actually finish    print(f"after three steps, held by torch: {torch.mps.current_allocated_memory() / MB:.1f} MB")    for number_type in (torch.float16, torch.bfloat16):        try:            with torch.autocast("mps", dtype=number_type):                output = mps_model(inputs[:8])            print(f"autocast {str(number_type):<18}: works, output {output.dtype}")        except (RuntimeError, TypeError) as error:            print(f"autocast {str(number_type):<18}: not supported here ({str(error)[:60]})")    torch.mps.empty_cache()
Apple Silicon
  1. 1

    MPS work is queued asynchronously: without waiting, memory figures and timings describe work not yet done.

Output
No Apple Silicon GPU here, so this cell is skipped.
On a Mac it reports unified memory use and checks which autocast types work.
Checkpoint Before you move on from §9

On an Apple Silicon Mac, why doesn't the lesson demonstrate a deliberate out-of-memory error?

10

The out-of-memory playbook

Lesson 3.4 ordered its checks by cost, cheapest first. The same discipline applies here, because these fixes differ enormously in what they cost you.

Tier 0 — free, and changes nothing about the model

  1. Restart the kernel or the process. Clears dead tensors held alive by tracebacks and by old notebook state.
  2. Check you are not keeping tensors alive by accident. Appending loss to a list instead of loss.item() keeps the entire computation graph for that batch, epoch after epoch. It looks like a slow leak and it is the commonest cause.
  3. Wrap scoring in torch.no_grad(). Without it, evaluation stores activations for a backward pass that never comes.
  4. Free what you no longer need: del, then gc.collect(), then empty_cache().

Tier 1 — cheap, changes the run but not the model

  1. Halve the batch size, and use the ladder from Section 8 to find the largest that fits.
  2. Add gradient accumulation to get the effective batch back, as in Section 6.
  3. Turn on mixed precision. Halves the activations, and may help speed if the hardware supports it.
  4. Shorten the sequence length if you are working with text. The cost grows faster than linearly.

Tier 2 — changes the computation or the model

  1. Gradient checkpointing: recompute activations instead of storing them, at roughly 30% more time.
  2. Freeze most of the model, training only a head or small adapters. This is where LoRA belongs, and it removes gradients and optimizer state for everything frozen (Module 8).
  3. Quantise the frozen weights, which is QLoRA in Module 8, or quantise for serving in Module 9.
  4. A smaller model, or a bigger GPU. Last, not first, because both cost money or accuracy.

Work down the list. Most people start at 12, or at 5 with a guess. The first four cost nothing and fix a surprising share of real failures.

Three descending tiers list free cleanup and no-grad checks; smaller batches, accumulation, mixed precision and sequence length; then activation checkpointing, freezing and quantisation. Model size and hardware come last.
Figure 4.3.2. The lesson’s troubleshooting order: eliminate needless retained state, change the run, then change computation or capacity. Savings are workload-dependent.
The out-of-memory ladder: work top to bottomTier 0 · free · changes nothing about the modelcosts you: nothing1Restart the kernel or the process2Append loss.item(), never loss3Score inside torch.no_grad()4del, then gc.collect(), then empty_cache()Tier 1 · cheap · changes the run, not the modelcosts you: some speed, and maybe a re-tuned learning rate5Halve the batch size (the ladder)6Gradient accumulation to get it back7Mixed precision: halves the activations8Shorter sequences, for textTier 2 · changes the computation or the modelcosts you: time, accuracy or money9Gradient checkpointing (about 30% slower)10Freeze most of the model: LoRA11Quantise the frozen weights: QLoRA12 A smaller model, or a bigger GPU: last, not firstcost to you risesstop at the first fix that works

Stop at the first rung that works. The four free fixes solve more real failures than their position suggests.

What to take away

Training with AdamW needs about four times the model's own memory, before a single activation: weights, gradients, and two running averages. That factor is the reason Module 8 exists.

You can predict memory before running anything. Parameter count, times bytes per number, times the optimizer's copies. The 7B rows in Section 2 show why running such a model and training it sit on opposite sides of a hardware boundary.

Precision is a trade between range and fineness. bf16 keeps fp32's range and needs no loss scaler; fp16 keeps more fineness but its narrow range means gradients vanish without one.

Activations are the batch-dependent term, linear in batch size and constant per row. So batch size is the fastest memory control you have, and gradient accumulation gives back the effective batch you gave up.

Speed from mixed precision is a hardware property; the memory saving is arithmetic. Measure the first, count on the second.

Put autocast around the forward pass. Wrapping the whole loop cost 0.026 AUC here, silently, because of a weight cache that was never meant to live that long.

An out-of-memory error has a playbook. Restart, look for retained graphs, use no_grad, then batch size and accumulation, then checkpointing and freezing. Hardware last.

Transition to Lesson 4.4

You now have three of the four pieces of a defensible experiment: data you can trust (4.1), a run that records itself and can be repeated (4.2), and a clear idea of what a run costs in memory (4.3).

Lesson 4.4 spends them. A gradient-boosting model and the Module 3 neural network compete on Bank Marketing, on identical splits, with the tuning budget written down in advance and every claim checked against the noise you measured in 4.2. The blueprint's instruction for that contest is explicit: the conclusion is not allowed to be "neural networks win". It has to be a decision you could defend to somebody who disagrees with you.

Checkpoint Before you move on from §10

According to the playbook, what should you try first after an out-of-memory error?

Wrap-up

Key takeaways
  1. Training with AdamW needs about four times the model's memory before activations: weights, gradients and two running averages, 16 bytes per weight in fp32.
  2. Estimate before running: parameters × bytes per number × copies. Running a 7B model (13 GB in fp16) and training it (104 GB) sit on opposite sides of a hardware line.
  3. Mixed precision keeps an fp32 master copy, so it does not shrink weights, gradients or optimizer state. It shrinks activations, and on the right hardware it is faster.
  4. Precision trades range against fineness. bf16 keeps fp32's range and needs no loss scaler; fp16's narrow range makes small gradients vanish without one.
  5. Activations are the batch-dependent term, linear in batch size. Halve the batch first, and use gradient accumulation (dividing each loss) to get the effective batch back.
  6. Put autocast around the forward pass only. Around the whole loop, its weight cache froze every validation score at epoch 1 while the model kept training, and nothing warned.
  7. Work an out-of-memory error from the cheap end: restart, stop keeping graphs alive, no_grad for scoring, then batch size and accumulation, then checkpointing and freezing. Clean up outside the except.
Knowledge check

Quick check

1

A model has 1 million weights. Roughly how much memory do weights, gradients and AdamW state take in fp32?

2

You switch a 7B AdamW fine-tune from fp32 to standard mixed precision. What happens to weights + gradients + optimizer state?

3

Why does fp16 training need a loss scaler while bf16 does not?

4

A model fits at batch 64 and not at batch 128. Which memory term changed?

5

You accumulate over 8 small batches with AdamW and forget to divide each loss by 8. What happens?

6

In the lesson's "whole" mode, every validation score after epoch 1 was identical. Why?

7

del big_tensor runs, but nvidia-smi still shows the memory in use. Most likely?

Questions and discussion

Ask about this lesson, share what you found in a lab, or help someone else. Posts are public on the course's GitHub Discussions: anyone can read them, and you need a GitHub account to post.

Opening it loads giscus from giscus.app.