Mixed precision: formats, loss scaling and accumulation
Before this lesson: lesson 1 (tensor cores) and lesson 2 (memory-bound and compute-bound ops); floating-point numbers, and the unit roundoff from linear algebra 13; backpropagation (deep learning 3) and Adam (optimization 6). Time: about 60 minutes, plus the notebook.
Why this matters #
Fewer bits per number buy two things.
- Arithmetic. The dense peaks of an H100 SXM (2022) are about 67 TFLOP/s in fp32 (CUDA cores), 495 in tf32, 989 in bf16 and 1,979 in fp8 (tensor cores). NVIDIA's product page lists twice these numbers "with sparsity"; we always use the dense numbers.
- Bytes. A 16-bit tensor is half the bytes of an fp32 tensor. Memory-bound ops (lesson 2) run about twice as fast, and stored activations take half the memory.
The price is precision and range. Every large model today is trained in mixed precision: bf16 with fp32 master weights is the default on A100 and H100 GPUs, and some models (DeepSeek-V3, 2024) run their matmuls in fp8. The same failures (long sums, small updates, tiny values) appear in scientific code that moves to GPUs.
1. Predict first #
2. The picture #
A float is a grid that gets coarser. A binary floating-point number is . It has significant bits (the leading 1 is not stored) and an exponent . The exponent bits set the range (how large and how small a number can be). The mantissa bits set the precision (how fine the grid is). Linear algebra 13 introduced these ideas. Here is the short review we need:
- machine epsilon is the gap between 1 and the next float;
- the unit roundoff bounds the relative error of one rounding: with (if there is no overflow or underflow);
- round to nearest, ties to even: a value exactly halfway between two floats goes to the one whose last bit is 0.
A toy format. Take . Between 1 and 2 the format has 1, 1.25, 1.5, 1.75. Between 2 and 4 it has 2, 2.5, 3, 3.5. Between 8 and 16 it has 8, 10, 12, 14. Each interval holds evenly spaced numbers, so the gap doubles at every power of two.
Now count with this format: start at 0 and add 1 again and again. Up to 8 every integer is representable. At 8 the gap is 2, so lies exactly halfway between 8 and 10. Ties go to the even neighbour: ends in 0, and ends in 1. So . The counter is stuck at . Every later addition of 1 is lost.
Pick a format and type a value. The top row shows the bits of the stored value: sign (grey), exponent and mantissa. The middle line shows the representable numbers next to ; the number under a gap is its size, the ulp. The bottom strip shows the range of the format on a log scale, with the underflow and overflow zones. Try in bf16 (a tie: it rounds to 1), then 70000 in fp16 (overflow) and in fp16 and in bf16. Notice that bf16 has the range of fp32 but a much coarser grid.
Two different failures. So a float can fail in two ways.
- Range. A number that is too large overflows to inf. A number that is too small underflows to 0.
- Precision. A number added to a much larger one can vanish, because it is smaller than half the gap at the larger number.
A training step meets both failures in three places: tiny gradients (range), weight updates that are tiny compared with the weights (precision), and long sums in matmuls, norms and losses (precision). The mixed-precision recipe has one fix for each.
Add the same term again and again. The magenta curve is the computed sum, the dashed line the exact sum. With a bf16 accumulator and the naive method, the sum stops at 32. Switch the accumulator to fp32: the curve follows the exact line. Then go back to bf16 and try pairwise and Kahan: they stay close to the exact line even with a bf16 accumulator. Try the term 1: in bf16 the sum stops at exactly , like the toy counter at .
An fp16 accumulator () starts at 0 and adds the bf16 value of 0.1 (that is 0.10009765625) 10,000 times, rounding after every addition. Where does the sum stop?
The sum stops at the first value where the term is below half the gap, . In fp16, for . In the gap is 0.125, and every addition rounds up to ; at 256 the half-gap 0.125 exceeds 0.1001, so the sum is stuck. fp16's extra bits move the stopping point from 32 to 256, but 10,000 terms are still far too many.
3. Definitions and main results #
The formats #
| format | bits s/e/m | largest | smallest normal | typical use | ||
|---|---|---|---|---|---|---|
| fp32 | 1/8/23 | 24 | master weights, optimizer states, accumulators, softmax, norms, loss | |||
| tf32 | 1/8/10 | 11 | tensor-core input mode for "fp32" matmuls (A100 and later) | |||
| bf16 | 1/8/7 | 8 | weights, activations, gradients in training | |||
| fp16 | 1/5/10 | 11 | 65504 | inference; training only with loss scaling | ||
| fp8 E4M3 | 1/4/3 | 4 | 448 (no inf) | FP8 weights and activations | ||
| fp8 E5M2 | 1/5/2 | 3 | 57344 | FP8 gradients |
"s/e/m" counts the sign, exponent and mantissa bits, and = mantissa bits + 1. In decimals: is about (fp32), (tf32, fp16), (bf16), 0.125 (E4M3) and 0.25 (E5M2). The smallest normal number is about for the formats with 8 exponent bits, for fp16 and E5M2, and 0.016 for E4M3.
Read the table in two directions:
- Same exponent bits, same range. fp32, tf32 and bf16 have 8 exponent bits, so they have the same range. bf16 is exactly the top 16 bits of an fp32 number: the same range, but only 8 significant bits (2–3 decimal digits).
- Same mantissa bits, same precision. fp16 and tf32 have 10 mantissa bits (). fp16 is more precise than bf16, but its range ends at 65504.
Below the smallest normal number come subnormal numbers, with fewer significant bits. The smallest positive fp16 number is . A value of at most rounds to 0 in fp16.
tf32 is not a storage format. With tf32 enabled, the tensor cores round fp32 inputs to 10 mantissa bits, multiply, and accumulate in fp32. The tensors in memory stay fp32.
Theorem (stagnation of a low-precision sum) #
Let the format have significant bits and round to nearest. Let be a normal float with , and let be the gap above (the unit in the last place). If , then .
Why this is true, in one sentence. The exact sum lies closer to than to the next float, so rounding returns .
Proof.
- The floats in are for . They are evenly spaced with gap .
- So the next float above is .
- : the exact sum is closer to than to .
- Round to nearest returns , and is lost.
At a tie, , the rounding keeps the neighbour whose last bit is 0. So is also lost when has an even last bit (as in above).
Consequence: where a sum of equal terms stops. For we have . Take terms of size :
- the sum cannot stop before , that is before ;
- it must stop once , that is at about .
So a running sum of terms of size stops between about and , after about terms: 256 terms in bf16, 2048 in fp16, in fp32.
A bf16 accumulator holds . Which addition changes it?
In , bf16 has . A term below is lost, so 0.9 is lost. The term 1 is a tie: 257 is halfway between 256 and 258, and ties go to the even neighbour, 256. Only a term above 1 moves the sum: rounds to 258. bf16 represents numbers up to ; the problem is the gap, not the range.
Three fixes for long sums #
- fp32 accumulation. A tensor core computes with in 16 (or 8) bits and in fp32. The product of two bf16 significands (8 bits each) has at most 16 bits, so it is exact in fp32. Only the additions round, at fp32 precision (), and the output is rounded to bf16 once. The accumulator is a register, so this costs nothing. A bf16 matmul with inner dimension 4096 therefore does not stagnate: stagnation in fp32 needs about terms.
- Pairwise summation. Sum each half recursively, then add the two results. Each input passes through only additions instead of up to . GPU reductions are trees anyway, so they are close to pairwise.
- Kahan summation. Keep a second variable for the lost part. For each : , , , . Here is the part of that really entered the sum, so is minus the part that was lost, and the next step adds it back. It costs 4 operations instead of 1. Use it when the accumulator must stay in low precision (for example, bf16 weights without an fp32 copy).
Error bounds for the three methods
With the unit roundoff of the accumulator and to first order in (Higham, Accuracy and Stability of Numerical Algorithms, chapter 4), the error of a computed sum of is at most
- for the left-to-right (naive) sum;
- for pairwise summation;
- for Kahan summation.
With bf16's , the naive bound exceeds the sum itself once , which matches the stagnation after about terms. The problem returns one level down: DeepSeek-V3 (2024) found that the FP8 matmuls of the H800 GPU keep only about 14 bits in the accumulator. Their kernels therefore copy the partial sums to fp32 registers every 128 products.
The mixed-precision recipe #
The recipe of Micikevicius et al. (2018), as used by PyTorch's automatic mixed precision (AMP) and by Megatron-LM, has five rules.
- fp32 master weights and optimizer states. Apply the theorem with and : an update is lost when is below , that is, when is below about . This is about in bf16 and in fp16. Adam's relative updates are often to , so many of them would be lost.
- 16-bit weights for the forward and backward pass. Matmuls and convolutions run on tensor cores with fp32 accumulation.
- Range-sensitive ops and long sums in fp32. PyTorch's CUDA autocast runs, among others,
softmax,log_softmax,layer_norm,group_norm,cross_entropy,mse_loss,exp,log,pow,sum,cumsumandnormin fp32. The reasons: overflows fp16 for , and norm statistics, softmax denominators and losses are long sums. Elementwise ops (add, GELU) stay in 16 bits: they are memory-bound, and each element is rounded only once. - Gradients. Activation gradients are 16-bit. Weight gradients are 16-bit when the step uses
16-bit weights (Megatron-LM); with PyTorch autocast and fp32 parameters they are fp32. The sum
over micro-batches and the sum across GPUs often run in fp32 (Megatron's fp32 "main grad",
FSDP's
reduce_dtype). - The update runs in fp32 on the master weights. The next 16-bit copy is cast from them.
One training step, coloured by format: fp32, 16-bit, fp8 and loss-scaling steps. Click a box to read why it has its format. Then switch to fp16 + loss scaling: two magenta steps appear, "× S" after the loss and "÷ S, inf check" before the optimizer. In FP8 matmuls, only the matmul inputs change; the master weights, the optimizer and the reductions stay in fp32.
Loss scaling for fp16 #
fp16 has 3 more mantissa bits than bf16, but only 5 exponent bits: its range ends near . Micikevicius et al. (section 3.2 and figure 3) show a detector network in which many activation gradients are below and would become 0 in fp16, while the top of the fp16 range stays unused.
The fix: multiply the loss by .
- Compute the loss in fp32 and multiply it by . A power of 2 only changes the exponent, so this is exact.
- Run the backward pass on . By the chain rule, : every gradient is multiplied by , so the small ones move up into the fp16 range.
- Right after backward, unscale in fp32: . Do this before gradient clipping, weight decay or the update, because all of them expect the true gradient.
Dynamic loss scaling. A fixed is hard to choose: too small and gradients underflow, too
large and some overflow to inf. PyTorch's torch.amp.GradScaler adapts :
- start at ;
- if any gradient is inf or NaN: skip the step (no update) and halve ;
- after 2000 clean steps in a row: double .
So stays near the largest safe value. A skipped step costs one batch, which is cheap.
Move the loss scale. The bars are the magnitudes of the gradients of a synthetic network, on a log₂ scale, after multiplication by . The magenta zones are lost: values at or below become 0, values from 65520 up become inf. At about 42% of the gradients are flushed to 0. Raise : from on, less than 0.1% are flushed; from on, the largest gradients overflow and GradScaler would skip the step. Here only loses nothing at all. Then switch to bf16: its range is so wide that no scaling is needed.
Why bf16 needs no loss scaling. bf16 has the 8 exponent bits of fp32, so gradients do not underflow: is stored as about . Its weakness is precision, and rules 1–5 handle precision. (The PyTorch documentation warns about the other direction: many models pretrained in bf16 produce values above 65504, so they overflow in fp16.)
A model trains with fp16 and GradScaler at . In this step one gradient becomes inf. What happens?
An inf or NaN means the scaled gradients overflowed, so they are wrong. GradScaler skips the update (the weights stay as they were) and halves . If the next steps are clean, stays at ; after 2000 clean steps in a row it doubles again. Clipping inf to 65504 would give a wrong gradient direction.
A colleague says: "bf16 has the same range as fp32, so with bf16 we can drop the fp32 master weights and store only bf16 weights." What is wrong?
The master weights fix a precision problem, not a range problem. In bf16, is between and , about 0.2–0.4% of the weight. Adam's updates are often much smaller, so they would be rounded away, and training would quietly stall, most of all when the learning rate decays.
FP8 training: scale factors #
E4M3 covers only about to 448, and E5M2 about to 57344. Real tensors do not fit these ranges by chance, so every FP8 tensor gets a scale factor. A tensor is stored as with, for example, , where . A matmul of two scaled tensors accumulates in higher precision and divides the result by .
- The 2022 recipe (Micikevicius et al., "FP8 Formats for Deep Learning"): E4M3 for weights and activations (they need precision: 3 mantissa bits), E5M2 for gradients (they spread over many orders of magnitude: they need range), one scale per tensor.
- Delayed vs current scaling. Computing amax needs a pass over the tensor before the cast. Delayed scaling (NVIDIA Transformer Engine) takes the scale from the amax history of recent steps instead; current scaling computes it from the tensor itself.
- Fine-grained scales. DeepSeek-V3 (2024) uses E4M3 for all tensors, with one scale per tile of activations and per block of weights.
- No inf in E4M3. Its top codes are used for numbers and NaN. Depending on the library, an overflow gives NaN or saturates to 448 (PyTorch's cast saturates). Either way, you will not see an inf.
The master weights, the optimizer and the reductions stay in fp32, as in bf16 training.
In the 2022 FP8 recipe, which format stores the gradients, and why?
E4M3 has 3 mantissa bits and a small range (about to 448); E5M2 trades one mantissa bit for one exponent bit and reaches 57344. Weights and activations are well scaled by one factor per tensor and profit from precision. Gradients of one tensor can differ by many powers of two, so range matters more. (DeepSeek-V3 later used E4M3 everywhere, but with small blocks that each have their own scale.)
Answers to section 1. (1) About 0.003. The bf16 sum freezes at 32 (section 4), and . (2) Only fp16. In fp16, is below and rounds to 0. bf16 stores it as .
4. Worked example #
(a) The mean of 10,000 values of 0.1 in bf16. First, 0.1 itself is rounded: the stored input is .
- In , . The exact sum lies between and , closer to . So almost every addition contributes 0.125: 25% too much.
- In , . Again each addition contributes 0.125.
- At 32, and . By the theorem, every later addition is lost. The sum freezes at 32 after 276 additions.
- Mean: .
For comparison: with an fp32 accumulator the sum is 1000.98 and the mean 0.1001 (the 0.1% error comes only from storing 0.1 in bf16). Pairwise summation in bf16 gives 1000. Kahan in bf16 gives 1004: the result itself must be a bf16 number, and the bf16 gap at 1000 is 4.
(b) An update that vanishes. A weight is stored in bf16 as . It lies in , so , and .
- An update of is below : it is lost.
- An update of is above it, so it rounds to one full gap, : 22% too large.
- In fp32, : both updates are kept.
(c) Loss scaling and back-off in fp16. Take .
- A gradient becomes after scaling: a normal fp16 number. Unscaled in fp32 it is , a relative error of .
- Another gradient is 2.0. Scaled, it is : inf. GradScaler skips the step and sets .
- In the next step the same gradient gives , still inf (everything from 65520 up is inf). Skip again; .
- Now it gives , which fits. Training goes on after two skipped steps.
5. In code #
NumPy has fp16 but no bf16. A bf16 value is the top 16 bits of an fp32 value, so we can round to bf16 with integer operations on the bits. The notebook asks you to write this function yourself.
import numpy as np
def bf16(x):
"""Round to bf16 (nearest, ties to even); the result is a float32 holding a bf16 value."""
u = np.asarray(x, dtype=np.float32).view(np.uint32).astype(np.uint64)
u = (u + 0x7FFF + ((u >> 16) & 1)) & 0xFFFF0000 # keep the top 16 bits, rounded
return u.astype(np.uint32).view(np.float32)
# 1. Stored values: 0.1 and 0.02 in bf16; 4e-9 in bf16 and in fp16
print(bf16(0.1), bf16(0.02), bf16(4e-9), np.float16(4e-9))
# 0.100097656 0.020019531 3.9872248e-09 0.0
# 2. Stagnation: a bf16 accumulator, rounded after every addition
x, S = bf16(0.1), np.float32(0)
for _ in range(10_000):
S = bf16(S + x)
print(S, S / 10_000) # 32.0 0.0032
print(np.sum(np.full(10_000, x), dtype=np.float32)) # 1000.97656 (fp32 accumulator)
# 3. An update below ulp(w)/2 is lost in bf16
w = bf16(0.02)
print(bf16(w + 3e-5) == w, bf16(w + 1e-4) - w) # True 0.00012207031
# 4. fp16: a sum of ones stops at 2^11 = 2048
print(float(np.cumsum(np.ones(5000, np.float16), dtype=np.float16)[-1])) # 2048.0
In PyTorch, autocast chooses the format of each op and GradScaler runs the dynamic loss scale.
This CPU example shows both:
import torch
# GradScaler: skip the step and halve S on inf; double S after growth_interval clean steps
p = torch.nn.Parameter(torch.ones(3))
opt = torch.optim.SGD([p], lr=0.1)
scaler = torch.amp.GradScaler("cpu", init_scale=2.0**16, growth_interval=2)
for c in [1.0, float("inf"), 1.0, 1.0]:
opt.zero_grad()
scaler.scale((c * p).sum()).backward() # every gradient is multiplied by S
scaler.step(opt) # unscales; skips the step if a gradient is inf/nan
scaler.update()
print(c, scaler.get_scale(), round(p[0].item(), 4))
# 1.0 65536.0 0.9
# inf 32768.0 0.9 <- step skipped, S halved
# 1.0 32768.0 0.8
# 1.0 65536.0 0.7 <- two clean steps: S doubled
# autocast picks the format per op: the matmul in bf16, the loss in fp32
lin = torch.nn.Linear(16, 4)
with torch.autocast("cpu", dtype=torch.bfloat16):
y = lin(torch.randn(8, 16))
loss = torch.nn.functional.cross_entropy(y, torch.zeros(8, dtype=torch.long))
print(lin.weight.dtype, y.dtype, loss.dtype) # torch.float32 torch.bfloat16 torch.float32
The CPU autocast lists differ from the CUDA lists (on the CPU, softmax stays in bf16). Section 3
gives the CUDA list. In real training, growth_interval is 2000; we use 2 to see the doubling.
6. Where it shows up #
- LLM training. bf16 autocast with fp32 master weights is the default on A100 and H100 GPUs.
FSDP exposes the recipe as
param_dtype(the compute format) andreduce_dtype(the format of the gradient reduction). FP8 training adds the scale factors of section 3. Inference formats come back in lesson 11. - Memory. Mixed-precision Adam keeps 16 bytes per parameter in model states (2 for the 16-bit weights, 2 for the gradients, 4 + 4 + 4 for the fp32 master weights, and ). Lesson 6 counts every byte.
- TF32.
torch.set_float32_matmul_precision("high")lets "fp32" matmuls use tf32 (10 mantissa bits), and cuDNN convolutions use tf32 by default. Check this setting when scientific code gives slightly different results on an A100 or H100. - ML for science. Long sums: global means and conservation checks over millions of grid points belong in fp32. Small updates: an Euler step of a diffusion or flow sampler with and is below in bf16 and is lost, so keep the sampler state in fp32. Cancellation: PDE residuals and finite differences subtract nearly equal numbers and need fp32 or fp64.
7. Common mistakes #
- "bf16 is more precise than fp16." The opposite: bf16 has range, fp16 has precision (8 against 11 significant bits).
- Clipping before unscaling. The clipping threshold is then compared with , not . Call
scaler.unscale_(optimizer)beforeclip_grad_norm_. - bf16 weights without an fp32 master copy. Small updates vanish (section 4b), and training quietly stalls when the learning rate decays.
- Reductions in bf16. A loss, a metric or a variance summed naively in bf16 stops growing after about 256 terms.
- Taking as the smallest number. is the gap after 1. The smallest positive fp16 number is .
- Expecting the loss scaler to fix forward overflow. It protects only the gradients. Activations above 65504 in the forward pass are inf in fp16 whatever is.
- Waiting for an inf from E4M3. It has no inf; an overflow saturates or becomes NaN.
8. Check yourself #
- Give , and the largest value of bf16 and fp16. Which needs loss scaling, and why?
Answer
bf16: , , largest about . fp16: , , largest 65504. fp16 needs loss scaling: its range ends near and many gradients are smaller. bf16 has the range of fp32. - A bf16 accumulator starts at 0 and adds 0.5 again and again. Where does it stop?
Answer
At 128. In the gap is , so is a tie, and round-to-even keeps . This is . - A weight gets an update of . Is it kept in bf16? In fp32?
Answer
bf16: , and half of it, , is larger than : the update is lost. fp32: : it is kept. - Why does a bf16 tensor-core matmul with inner dimension not stagnate?
Answer
The products are exact in fp32 and the additions round to fp32 (). Stagnation needs about terms, and the error bound (relative to ) is small. The output is rounded to bf16 only once. - In fp16 with , a gradient of 100 appears. What does GradScaler do? What would happen with bf16?
Answer
, so it is inf: the step is skipped and becomes ; then fits. With bf16 there is no loss scaling and 100 is far inside the range. - Why does FP8 training need a scale factor per tensor (or per block), while bf16 training does not?
Answer
E4M3 covers only about to 448. Without a scale, large values overflow and small ones lose their bits. Multiplying by moves the tensor into the range. bf16 has the range of fp32, so tensors fit without a scale.
What is the machine epsilon of a format with significant bits, and its value for bf16?
, the gap between 1 and the next float. bf16 has , so .
Give the sign/exponent/mantissa bits of bf16 and fp16.
bf16: 1/8/7 (fp32 range, 8 significant bits). fp16: 1/5/10 (11 significant bits, largest value 65504).
What is the largest finite fp16 number?
- Everything from 65520 up rounds to inf.
State the stagnation condition for adding to a float .
If , then : the addition is lost (at a tie, round-to-even may also keep ).
Where does a bf16 accumulator that adds 1 again and again stop?
At : there the gap is 2, so is a tie and rounds to the even neighbour 256.
Why does mixed-precision training keep fp32 master weights?
An update smaller than , about in bf16, is lost in a 16-bit format. Updates are applied to the fp32 copy.
What is the dynamic loss-scaling rule of GradScaler?
On an inf or NaN gradient, skip the step and halve . After 2000 clean steps in a row, double .
Why does multiplying the loss by scale every gradient by ?
By the chain rule, for every parameter and every intermediate tensor.
In FP8 training, which format holds the gradients, and which the weights and activations?
Gradients: E5M2 (range, up to 57344). Weights and activations: E4M3 (precision, 3 mantissa bits, up to 448).
What does tf32 mean on an A100 or H100?
A tensor-core mode: fp32 inputs are rounded to 10 mantissa bits, multiplied, and accumulated in fp32. Tensors in memory stay fp32.
9. Interview angle #
- "bf16 or fp16 for training?" bf16: it has the range of fp32 and needs no loss scaling. fp16 has 3 more mantissa bits but needs loss scaling and overflows above 65504.
- "Why fp32 master weights if the matmuls run in bf16?" An update below about is lost in bf16; Adam's updates are often smaller.
- "What does a tensor core compute?" on small tiles, with 16- or 8-bit inputs and fp32 accumulation.
- "You get NaNs after switching from bf16 to fp16. Why?" Most likely an overflow above 65504 in the forward pass (activations, attention logits), or softmax or norms not in fp32. The loss scaler protects only the gradients; it cannot fix a forward overflow.
- "Why is a metric computed in bf16 wrong?" A naive bf16 sum stops growing after about 256 terms. Accumulate in fp32.
10. Practice #
The notebook for this lesson runs in Colab, on a CPU, with NumPy only. Each exercise has a check cell and a folded solution.
- Round to bf16 with bit operations (round to nearest, ties to even), and check it against known values.
- Simulate a low-precision accumulator, find where a bf16 and an fp16 sum stop, and compare with the theorem.
- Implement pairwise and Kahan summation in bf16 and compare their errors as grows.
- Write a dynamic loss scaler and run it on a stream of synthetic gradients with rare overflows.
- Count how many of 1000 updates of size change a bf16 weight of 1.0, and compare with an fp32 master weight.
11. Watch and read #
- Micikevicius et al., Mixed Precision Training (ICLR 2018): section 3.1 (fp32 master copy of the weights), 3.2 (loss scaling, figure 3), 3.3 (arithmetic precision).
- Micikevicius et al., FP8 Formats for Deep Learning (2022): section 3 and table 1 (E4M3, E5M2), and the scaling factors.
- PyTorch, Automatic Mixed Precision package - torch.amp: "Gradient Scaling" and the "Autocast Op Reference" (the CUDA ops that run in float32).
- DeepSeek-AI, DeepSeek-V3 Technical Report (2024), section 3.3: FP8 training with fine-grained scales and fp32 accumulation every 128 products.
- David Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic (1991): rounding, ulps and relative error.
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.