Tensors, GPUs and Precision: Why Matrix Multiplication Is Everything
By the end of this lesson you will be able to read a tensor’s shape and say what it costs in
bytes; count the operations in a matrix multiplication and predict when a chip is waiting on
memory rather than computing; put a number on every level of memory between a model’s weights on
disk and the arithmetic unit that uses them; and read a format name such as BF16, FP8 or Q4 and
say what it keeps, what it loses, and why a scale factor travels with every weight of eight bits
or fewer. Every figure in the tables is arithmetic from stated inputs, not a measurement: the
config.json of Qwen3-8B (Apache-2.0), the file sizes in the model reference,
and the vendor bandwidth figures in the hardware reference. The two timed
snippets are one run on one CPU and are labelled so; your times will differ. The snippets need
numpy and PyTorch, which this part’s lab installs; read them now and run them once that
environment exists.
Tensors: numbers in shapes
Section titled “Tensors: numbers in shapes”A tensor is an n-dimensional array of numbers of one type, described by a shape, the tuple
of its sizes along each dimension, and a dtype, the format of each element. A vector has one
dimension, a matrix two, a batch of matrices three. The shape is metadata over a single flat buffer
laid out row after row, so bytes = elements × bytes per element is the first piece of arithmetic
in this course: a matrix of shape (4096, 12288) in 32-bit floating point is 50,331,648 elements in
201,326,592 bytes.
Every quantity in a model is a tensor, and the shapes come straight from the configuration file.
The input to a layer is (batch, inputs), one row per token. A layer’s weights are (outputs, inputs)
as PyTorch stores an nn.Linear, and its output is (batch, outputs). Read the config.json of
Qwen3-8B and every matrix in the model follows:
| Tensor (one layer, unless noted) | Shape as stored, (outputs, inputs) | From config.json |
Elements | Bytes at BF16 |
|---|---|---|---|---|
embed_tokens (once) |
(151936, 4096) | vocab_size, hidden_size | 622,329,856 | 1,244,659,712 |
q_proj |
(4096, 4096) | num_attention_heads × head_dim, hidden_size | 16,777,216 | 33,554,432 |
k_proj and v_proj (each) |
(1024, 4096) | num_key_value_heads × head_dim, hidden_size | 4,194,304 | 8,388,608 |
o_proj |
(4096, 4096) | hidden_size, num_attention_heads × head_dim | 16,777,216 | 33,554,432 |
gate_proj and up_proj (each) |
(12288, 4096) | intermediate_size, hidden_size | 50,331,648 | 100,663,296 |
down_proj |
(4096, 12288) | hidden_size, intermediate_size | 50,331,648 | 100,663,296 |
lm_head (once; tie_word_embeddings is false) |
(151936, 4096) | vocab_size, hidden_size | 622,329,856 | 1,244,659,712 |
| One layer’s seven matrices | 192,937,984 | 385,875,968 | ||
| 36 layers plus the two vocabulary matrices | 8,190,427,136 | 16,380,854,272 |
The last row is the 8.2 billion parameters in the model’s name and the 16.4 GB of its BF16 checkpoint in the model reference, less the small normalisation vectors this table omits. Part 2’s lab has you list the real tensors and reconcile that count against the file. Here is the same arithmetic done by the library, and the error you get when shapes disagree:
RunnableAll tracks
"""Tensors have a shape, a dtype and a size in bytes; matrix multiplication checks the shapes."""import numpy as np
x = np.zeros((32, 4096), dtype=np.float32) # a batch of 32 token vectorsw = np.zeros((4096, 12288), dtype=np.float32) # one weight matrix as (inputs, outputs), which x @ w needs; # PyTorch stores the transpose and multiplies by w.Tfor name, t in (("x", x), ("w", w)): print(f"{name}: shape={t.shape} ndim={t.ndim} dtype={t.dtype} " f"elements={t.size:,} bytes={t.nbytes:,}")y = x @ wprint(f"x @ w: shape={y.shape} the inner sizes agree: {x.shape[1]} == {w.shape[0]}")try: w @ x # inner sizes 12288 and 32 do not agreeexcept ValueError as e: print("w @ x fails:", e)Output — what you should see
x: shape=(32, 4096) ndim=2 dtype=float32 elements=131,072 bytes=524,288w: shape=(4096, 12288) ndim=2 dtype=float32 elements=50,331,648 bytes=201,326,592x @ w: shape=(32, 12288) the inner sizes agree: 4096 == 4096w @ x fails: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 32 is different from 12288)Operations are defined on shapes. x @ w needs the inner sizes to agree, (32, 4096) times
(4096, 12288) gives (32, 12288), and the message names the operand, the dimension and the two
sizes that differ. When the mismatch is a matrix stored the other way round, the fix is a
transpose, w.T, which changes the shape by changing how the buffer is read. A reshape to the
same target shape also runs, but it scrambles which value sits where and produces wrong numbers
while raising nothing. Reading shapes is the first debugging skill in this field.
One operation, repeated
Section titled “One operation, repeated”Here is the sentence about accelerators. Almost all of the work in training or running a model is matrix multiplication, and a GPU is a machine for doing matrix multiplication in parallel.
C = A @ B A is (m, k), B is (k, n), C is (m, n)C[i, j] = sum over p of A[i, p] × B[p, j] for every i below m and j below nmultiply-adds = m × n × k FLOPs = 2 × m × n × kEach of the m × n outputs is a dot product of k terms, and no output depends on any other, so all of them can be computed at once. That independence is the whole trick. A CPU with a dozen cores does a dozen at a time; a GPU with thousands of simpler cores does thousands; and the units NVIDIA calls tensor cores multiply a small block of the matrix as one instruction, which is why its mixed-precision guide asks that “on FP16 inputs, all three dimensions (M, N, K) must be multiples of 8”. A processor does not run a model faster because it is faster in general. It runs it faster because the model’s work is one parallel operation and the processor is built for that operation.
The unit of this work is the FLOP, one floating-point operation, and every weight in a matrix costs one multiply and one add per row of input. For one token, m is 1 and the cost is two operations per parameter:
| Job | Formula | Qwen3-8B, 8.2 billion parameters |
|---|---|---|
| One token through the model (decode) | 2 × parameters | 16.4 GFLOP |
| A 2,048-token prompt (prefill) | 2 × parameters × tokens | 33.6 TFLOP |
| One training token, forward and backward | 6 × parameters | 49.2 GFLOP |
| Pretraining on one billion tokens | 6 × parameters × tokens | 4.9 × 10^19 FLOP |
The six comes from the backward pass costing twice the forward; nanochat’s source puts it in one line, “Each matmul weight parameter contributes 2 FLOPs (multiply *, accumulate +) in forward, and 2X that in backward => 2+4=6”, and Part 12’s scaling-laws lesson turns that line into a training budget. The difference between doing this work one operation at a time and doing it as one operation is visible on any machine:
RunnableAll tracks
"""Count the multiply-adds in one matrix multiplication, then time Python loops against numpy."""import osos.environ["OPENBLAS_NUM_THREADS"] = "1" # one BLAS thread, so both sides use one coreimport timeimport timeitimport numpy as np
m, k, n = 128, 128, 128rng = np.random.default_rng(0)a = rng.standard_normal((m, k), dtype=np.float32)b = rng.standard_normal((k, n), dtype=np.float32)flops = 2 * m * n * k # one multiply and one add for every (i, j, p)print(f"({m} x {k}) @ ({k} x {n}): {m * n * k:,} multiply-adds = {flops:,} FLOPs")
t0 = time.perf_counter()c = np.zeros((m, n), dtype=np.float32)for i in range(m): for j in range(n): s = 0.0 for p in range(k): s += a[i, p] * b[p, j] c[i, j] = st_loops = time.perf_counter() - t0t_numpy = min(timeit.repeat(lambda: a @ b, number=100, repeat=5)) / 100 # best of five runs, per callprint(f"python loops: {t_loops:9.4f} s per call {flops / t_loops / 1e9:8.3f} GFLOP/s")print(f"numpy a @ b : {t_numpy:9.6f} s per call {flops / t_numpy / 1e9:8.3f} GFLOP/s")print(f"same answer: max |difference| = {np.abs(c - a @ b).max():.1e}")Output — what you should see
(128 x 128) @ (128 x 128): 2,097,152 multiply-adds = 4,194,304 FLOPspython loops: 0.3056 s per call 0.014 GFLOP/snumpy a @ b : 0.000018 s per call 237.917 GFLOP/ssame answer: max |difference| = 1.1e-05Same 4,194,304 operations, same answer, seventeen thousand times faster on the same core: the loops ask the interpreter for each multiply, the library hands blocks of the matrix to vectorised code, and a GPU does the same thing with thousands of cores. Peak FLOPS, the trillions per second a vendor advertises, is this library number scaled up. It is also, for most of what this course does, not the number that matters.
Bytes moved versus operations done
Section titled “Bytes moved versus operations done”Every multiply needs its operands, and the operands live in memory. A chip can only pull so many bytes per second, its memory bandwidth, so every job has two clocks running, and the slower one sets the time:
time_compute = FLOPs / peak_FLOP_per_secondtime_memory = bytes_moved / bandwidth_bytes_per_secondtime ≈ the larger of the two
arithmetic intensity = FLOPs / bytes_moved (FLOP per byte)ridge point = peak_FLOP_per_second / bandwidth (FLOP per byte)intensity below the ridge: bandwidth-bound. Above it: compute-bound.For a language model the bytes moved are dominated by the weights, and the FLOPs by the same weights, so the intensity of a job is easy to write down:
| Job, Qwen3-8B | FLOPs | Bytes of weights read | Intensity, FLOP per byte |
|---|---|---|---|
| Decode, batch 1, BF16 (16.4 GB) | 16.4 G | 16.4 G | 1.0 |
| Decode, batch 1, Q4_K_M (5.0 GB file) | 16.4 G | 5.0 G | 3.3 |
| Decode, batch 8, BF16 | 131 G | 16.4 G | 8 |
| Prefill, 2,048 tokens, BF16 | 33.6 T | 16.4 G | 2,048 |
Decode at batch 1 does one multiply-add per weight and then discards the weight: one FLOP per byte at BF16, three at four-bit. No accelerator has a ridge point that low; every one of them sits far above one FLOP per byte between its peak arithmetic and its bandwidth, so decode sits far below the ridge and the chip spends its time waiting for bytes. That is what bandwidth-bound means. Prefill fetches the same weights once and uses each of them for every token of the prompt, so its intensity is the prompt length, and it climbs above the ridge: compute-bound. Training, with big batches, lives there too. The same shift is visible on a CPU with one weight matrix and four batch sizes:
RunnableAll tracks
"""The same weight matrix, read once per call: batch 1 waits on bytes, batch 256 does arithmetic."""import timeitimport torch
torch.manual_seed(0)inputs, outputs = 4096, 12288 # the shape of Qwen3-8B's gate_proj weightw = torch.randn(inputs, outputs, dtype=torch.float32)weight_bytes = w.numel() * w.element_size() # 4096 * 12288 * 4 = 201 MB
print(f"weights: {weight_bytes / 1e6:.0f} MB, read once per call; {torch.get_num_threads()} CPU threads")print(f"{'batch':>6} {'ms per call':>12} {'GFLOP/s':>9} {'weight GB/s':>12} {'FLOP per byte':>14}")for batch in (1, 8, 64, 256): x = torch.randn(batch, inputs, dtype=torch.float32) dt = min(timeit.repeat(lambda: x @ w, number=1, repeat=20)) # best of 20 calls flops = 2 * batch * inputs * outputs print(f"{batch:>6} {dt * 1e3:>12.2f} {flops / dt / 1e9:>9.1f} " f"{weight_bytes / dt / 1e9:>12.1f} {flops / weight_bytes:>14.1f}")Output — what you should see
weights: 201 MB, read once per call; 12 CPU threads batch ms per call GFLOP/s weight GB/s FLOP per byte 1 5.85 17.2 34.4 0.5 8 18.19 44.3 11.1 4.0 64 25.78 249.9 7.8 32.0 256 58.58 439.9 3.4 128.0The snippet’s weights are FP32, four bytes each, so its batch-1 intensity is 0.5 rather than the table’s 1.0 at BF16; the ratio between the rows is what matters. At batch 1 the call streamed the weights at this machine’s memory speed and got a handful of GFLOP/s for it: bandwidth-bound. At batch 256 it fetched exactly the same bytes, did two hundred and fifty-six times the arithmetic on them, and ran at twenty-five times the rate: compute-bound. On a GPU the gap is larger, not smaller. Part 5’s measurement lab has you measure both peaks for your own machine, and their quotient is your ridge point.
Rearranging the memory clock for decode gives the formula the course leans on hardest, worked here for one model on the four tracks from the vendor bandwidth figures in the hardware reference:
tokens_per_second (decode, batch 1) ≤ memory_bandwidth_bytes_per_second / bytes_of_weights_read_per_token| Track, machine | Vendor bandwidth (GB/s) | Qwen3-8B at BF16, 16.4 GB (tokens/s) | Qwen3-8B at Q4_K_M, 5.0 GB (tokens/s) |
|---|---|---|---|
| S, DGX Spark | 273 | 16.6 | 54.6 |
| X, Ryzen AI Max+ 395 | 256 | 15.6 | 51.2 |
| M, Mac Studio with M4 Max | 546 | 33.3 | 109.2 |
| N, desktop with RTX 5090 | 1,792 | 109.3 | 358.4 |
These are ceilings, not predictions: nothing else has been allowed any time. Part 3’s inference lesson states the formula formally and extends it to mixture-of-experts models, Part 5 measures the bandwidth, and Part 6 measures the achieved speed, which lands below these numbers by a gap the rest of the course explains. What the table already settles is which number to read on a specification sheet:
| What you are doing | Which clock dominates | The figure that predicts it |
|---|---|---|
| Generating text for one request | memory | bandwidth |
| Reading a long prompt; time to first token | compute | matrix-multiply throughput at your format |
| Serving many concurrent requests (Part 9) | moves from memory towards compute as the batch grows | both |
| Training and fine-tuning (Parts 11 to 15) | compute, once the batch is large | throughput at BF16 |
Where the memory is
Section titled “Where the memory is”Memory is not one thing. It is a hierarchy, fast and small next to the arithmetic and slow and large far from it, and the CUDA programming guide names the levels on the GPU side: registers “located on the SM”, shared memory, of which it says “the bandwidth is higher and the latency is lower than accessing global memory”, an L1 that “is physically located on each SM and is the same physical space used by shared memory”, an L2 that “is located on the device and is shared among all the SMs”, and global memory, the device’s own DRAM, which works “similar to RAM in a CPU system”. Below the device come the PCIe link, the host’s memory, storage and the network.
| Level | Where it is | Size | Bandwidth (vendor figure, or a link ceiling) | Source |
|---|---|---|---|---|
| Registers | on each streaming multiprocessor | 64 K 32-bit registers per SM, 256 KB (compute capability 12.x) | not published as a bandwidth | CUDA programming guide |
| Shared memory and L1 | on each SM | 100 KB per SM at compute capability 12.x (the RTX 50 series and the DGX Spark’s GB10, per NVIDIA’s CUDA GPUs list); 228 KB at 9.0 and 10.0; 164 KB at 8.0 | “the bandwidth is higher and the latency is lower than accessing global memory” | CUDA programming guide |
| L2 cache | on the device, shared by all SMs | not in the guide’s table | — | CUDA programming guide |
| Device memory, HBM3 | on the package, data-centre parts | — | 3,350 GB/s (H100 SXM) | NVIDIA H100 page |
| Device memory, GDDR7 | on the card, Track N | 32 GB (RTX 5090) | 1,792 GB/s | hardware reference |
| Unified memory, LPDDR5x | on the board, Tracks S, X and M | 128 GB | 273 (DGX Spark), 256 (Ryzen AI Max+ 395), 546 (M4 Max) GB/s | hardware reference |
| PCIe x16 | between the card and the host | — | 16 GB/s for an x16 Gen3 link (CUDA best-practices guide); 128 GB/s for the H100’s Gen5 x16 link (H100 page), which is the two directions summed: NVIDIA’s Hopper architecture post gives Gen5 x16 as “64 GB/sec in each direction” and Gen4 x16 as “32GB/sec in each direction” | NVIDIA |
| System memory, DDR5 | on the motherboard; from the GPU, behind PCIe | up to hundreds of GB | 89.6 GB/s for two channels of DDR5-5600: 2 × 5,600 million transfers × 8 bytes, a ceiling from the module’s name | arithmetic |
| NVMe storage | on four PCIe lanes | terabytes | a quarter of the per-direction x16 figures in the row above, four lanes against sixteen: 32 ÷ 4 = 8 GB/s at Gen4, 64 ÷ 4 = 16 GB/s at Gen5, as link ceilings; drives quote sequential reads below them | arithmetic from the NVIDIA per-direction figures |
| Network | between machines | — | 1.25 GB/s for 10 GbE; 25 GB/s for the Spark’s 200 Gb/s ConnectX-7 port (bits ÷ 8) | hardware reference |
The memory hierarchy, fastest at the top
- Registers, shared memory, L1On each streaming multiprocessor. Hundreds of kilobytes. Where the multiply-adds happen.
- L2 cacheOn the chip, shared by every SM. Megabytes.
- Accelerator memoryHBM on data-centre parts, GDDR7 on a Track N card, LPDDR5x shared with the CPU on the DGX Spark, the Ryzen AI Max+ 395 and Apple silicon. Tens to hundreds of gigabytes. The level a model must fit in.
- PCIe linkOn a discrete-memory machine, the only path between the card and everything below. A few per cent of what the memory on the card delivers.discrete only
- System memoryDDR5 on the motherboard. Large and cheap; at full speed for the CPU, on the far side of PCIe for a discrete GPU.
- Storage and networkNVMe, then Ethernet or RDMA to other machines. Where models live between runs, and how clusters share them (Parts 18 to 21).
What the hierarchy costs is easiest to see by reading one model’s weights once from each level. Qwen3-8B at BF16 is 16.4 GB; decode reads all of it once per token:
| Level feeding the decode | Bandwidth used (GB/s) | Time to read 16.4 GB once (ms) | Ceiling (tokens/s) |
|---|---|---|---|
| HBM3, H100 SXM | 3,350 | 4.9 | 204 |
| GDDR7, RTX 5090 | 1,792 | 9.2 | 109 |
| LPDDR5x, M4 Max | 546 | 30.0 | 33 |
| LPDDR5x, DGX Spark | 273 | 60.1 | 17 |
| PCIe Gen5 x16, one direction | 64 | 256 | 3.9 |
| PCIe Gen3 x16, one direction | 16 | 1,025 | 1.0 |
| NVMe on four Gen4 lanes | 8 | 2,050 | 0.5 |
| 10 GbE | 1.25 | 13,120 | 0.08 |
Two things to read off that table. Each step down is a factor of four to ten, not a few per cent, so the level a model sits in decides its speed class before any tuning. And the lower rows are per-token times only if the weights are re-read from that level on every token, which is exactly what happens when a model does not fit the level above: an engine that maps a file larger than memory reads it from disk for every token, and a model split between a card and system memory crosses PCIe for every token. The split case is worth doing with numbers, because the collapse is larger than intuition suggests:
| Where the 16.4 GB sits | Bytes | Bandwidth (GB/s) | Time per token (ms) |
|---|---|---|---|
| VRAM of an RTX 5090 | 12.0 GB | 1,792 | 6.7 |
| System memory, reached over PCIe Gen5 | 4.4 GB | 64 | 68.8 |
| Total | 16.4 GB | 75.4, a ceiling of 13 tokens/s against 109 with everything in VRAM |
A quarter of the weights in the wrong place takes nine tenths of the time. That is what “the speed collapses” means when Part 5’s NVIDIA lesson measures it.
The four tracks differ in which level is the full-speed one and how large it is. A desktop or laptop with an NVIDIA card has discrete memory: the GPU’s own VRAM, fast, fixed at purchase, joined to the rest of the machine by PCIe, of which the CUDA guide says that memory reached across that link has “higher latency and lower bandwidth compared to accessing device memory”. The DGX Spark, the Ryzen AI Max+ machines and Apple silicon have unified memory: one pool that both processors address. MLX’s documentation puts it in two sentences: “Arrays in MLX live in shared memory. Operations on MLX arrays can be performed on any of the supported device types without performing data copies.”
| Track | Design | The accelerator’s full-speed memory | Vendor bandwidth (GB/s) | The trade |
|---|---|---|---|---|
| N, NVIDIA desktop or laptop | discrete | 8 to 32 GB of GDDR on the card; 96 GB on the RTX PRO 6000 | 936 to 1,792 by card | fastest decode, smallest ceiling on what fits |
| S, DGX Spark | unified | 128 GB of LPDDR5x shared with the CPU | 273 | large models fit; each decodes more slowly |
| X, Ryzen AI Max+ 395 | unified | 64 or 128 GB shared, less a GPU-visible cap | 256 | the same, at a lower price |
| M, Apple silicon | unified | 24 to 512 GB shared | 120 (M4) to 1,200 (M5 Ultra) by chip | the widest range on both axes; the chip decides the bandwidth |
Unified memory is what lets a model of a hundred gigabytes fit on a desk; its cost is that the pool is system memory with system-memory bandwidth, several times below a card’s, so the same model that fits comfortably decodes more slowly. Capacity against bandwidth is the central fact about the four tracks. Part 5’s memory lesson makes it concrete per track, including the software ceilings that stop a unified machine’s GPU from taking the whole pool.
Precision: what each format gives up
Section titled “Precision: what each format gives up”A number format is a way of splitting a fixed number of bits between a sign, an exponent,
which sets the range, and a mantissa, which sets the precision. The layouts below are the ones
PyTorch documents for its dtypes; the numerical columns are what torch.finfo reports for each:
| Format | Bits | Sign, exponent, mantissa | Bytes per parameter | Step above 1.0 (eps) | Largest value | Smallest normal | What it gives up |
|---|---|---|---|---|---|---|---|
| FP32 | 32 | 1, 8, 23 | 4 | 1.19 × 10^-7 | 3.4 × 10^38 | 1.18 × 10^-38 | nothing you will notice; twice the bytes of anything else |
| BF16 | 16 | 1, 8, 7 | 2 | 7.81 × 10^-3 | 3.4 × 10^38 | 1.18 × 10^-38 | precision: about three significant digits |
| FP16 | 16 | 1, 5, 10 | 2 | 9.77 × 10^-4 | 65,504 | 6.10 × 10^-5 | range: above 65,504 becomes inf, tiny values become 0 |
| FP8 E4M3 | 8 | 1, 4, 3 | 1 | 0.125 | 448 | 1.56 × 10^-2 | both; unusable without a scale |
| FP8 E5M2 | 8 | 1, 5, 2 | 1 | 0.25 | 57,344 | 6.10 × 10^-5 | precision: about one significant digit (the step above 1.0 is 0.25); the gradient format |
| E2M1, the element of MXFP4 and NVFP4 | 4 | 1, 2, 1 | 0.5 plus scales | — | 6 | — | a grid of 0, 0.5, 1, 1.5, 2, 3, 4, 6 and their negatives |
| INT8 | 8 | integer | 1 plus scales | — | 127 × scale | — | a grid of 256 steps per block |
| INT4 | 4 | integer | 0.5 plus scales | — | 7 × scale | — | a grid of 16 steps per block |
Bytes per parameter is the column you will use most: it is the divisor in the previous section’s decode ceiling. The rest of the table says what you pay for a smaller number in it, and the snippet shows each loss happening:
RunnableAll tracks
"""What each floating-point format can represent, from torch.finfo, and what rounding does to values."""import torch
def name(dt): return str(dt).replace("torch.", "")
print(f"{'dtype':<14}{'bits':>5}{'eps':>11}{'max':>12}{'smallest normal':>17}")for dt in (torch.float32, torch.bfloat16, torch.float16, torch.float8_e4m3fn, torch.float8_e5m2): f = torch.finfo(dt) print(f"{name(dt):<14}{f.bits:>5}{f.eps:>11.2e}{f.max:>12.5g}{f.smallest_normal:>17.2e}")
def cast(value, dt): return torch.tensor(value, dtype=torch.float32).to(dt).float().item()
print()for label, v in (("1.001", 1.001), ("1e-8, a small gradient", 1e-8), ("70000, a large activation", 70000.0)): cells = " ".join(f"{name(dt)}={cast(v, dt):<10.7g}" for dt in (torch.float32, torch.bfloat16, torch.float16)) print(f"{label:<27} {cells}")
print()w = torch.tensor(0.5, dtype=torch.bfloat16)print(f"bf16: 0.5 + 0.0001 = {(w + 0.0001).item()} fp32: 0.5 + 0.0001 = {(torch.tensor(0.5) + 0.0001).item()}")
print()torch.manual_seed(0)weights = torch.randn(1_000_000) * 0.02 # weights of the size initializer_range=0.02 impliesdef report(label, back): rel = ((back - weights).abs() / weights.abs()).mean().item() print(f"{label:<28} mean relative rounding error {rel:.4f} values that became 0: {(back == 0).sum().item()}")for dt in (torch.bfloat16, torch.float16, torch.float8_e4m3fn, torch.float8_e5m2): report(name(dt), weights.to(dt).float())scale = 64.0 # one scale for the tensor: lift 0.02-sized values into E4M3's rangereport("float8_e4m3fn, scaled x64", (weights * scale).to(torch.float8_e4m3fn).float() / scale)Output — what you should see
dtype bits eps max smallest normalfloat32 32 1.19e-07 3.4028e+38 1.18e-38bfloat16 16 7.81e-03 3.3895e+38 1.18e-38float16 16 9.77e-04 65504 6.10e-05float8_e4m3fn 8 1.25e-01 448 1.56e-02float8_e5m2 8 2.50e-01 57344 6.10e-05
1.001 float32=1.001 bfloat16=1 float16=1.0009771e-8, a small gradient float32=1e-08 bfloat16=1.001172e-08 float16=070000, a large activation float32=70000 bfloat16=70144 float16=inf
bf16: 0.5 + 0.0001 = 0.5 fp32: 0.5 + 0.0001 = 0.5001000165939331
bfloat16 mean relative rounding error 0.0014 values that became 0: 0float16 mean relative rounding error 0.0002 values that became 0: 1float8_e4m3fn mean relative rounding error 0.1017 values that became 0: 38990float8_e5m2 mean relative rounding error 0.0454 values that became 0: 317float8_e4m3fn, scaled x64 mean relative rounding error 0.0238 values that became 0: 624The PyTorch documentation lists torch.finfo for the 32- and 16-bit types; it also answers for the
two float8 dtypes in 2.14.0 on the CPU, which is how the last two rows of the first block were
produced. Line by line:
BF16 against FP16. BF16 cannot hold 1.001, because its step above 1.0 is 0.0078; it keeps about three significant digits, where FP16 keeps 1.000977. FP16 in turn turns a gradient of 1e-8 into 0 and an activation of 70,000 into inf; BF16 keeps both, with error. NVIDIA’s mixed-precision guide gives the reason in two numbers: “Half precision dynamic range, including denormals, is 40 powers of 2. For comparison, single precision dynamic range including denormals is 264 powers of 2.” BF16 has FP32’s exponent and therefore FP32’s range. Loss scaling is the FP16 workaround, “You can scale the loss values computed in the forward pass, before starting backpropagation. By the chain rule, backpropagation ensures that all the gradient values of the same amount are scaled”, and PyTorch’s automatic mixed precision package implements it; hardware that computes in BF16 made it optional, which is why BF16 became the training format.
The lost update. 0.5 + 0.0001 is 0.5 in BF16. An update smaller than half the format’s step
at the weight’s magnitude changes nothing, and a learning rate of 1e-4 times a gradient of 1 is
such an update. This is why training keeps the weights in FP32 as well, “Maintain a primary copy of
weights in FP32” in NVIDIA’s guide, and does the multiplications in BF16. Part 11’s
memory arithmetic counts the bytes
that copy costs.
Why eight bits always come with a scale. Cast weights of the size a model’s initializer_range
of 0.02 implies straight into E4M3 and the mean rounding error is ten per cent, with four per cent
of the weights collapsing to zero, because E4M3’s smallest normal value is 0.0156 and most weights
sit below it, where the format falls back to subnormals down to about 0.002 with fewer bits of
precision: values below about 0.001 round to zero and the rest are rounded coarsely. Multiply by
64 first, cast, divide back, and the error falls to 2.4 per cent with almost none lost: the scale
moves the values into the part of the range where the format has its
precision. Every format of eight bits or fewer therefore ships with a scale per tensor, per channel
or per block, and the bits that scale occupies are part of the file.
INT8 and INT4 make the scale the whole design. A block of consecutive weights shares one scale and each weight becomes a small integer code:
scale = max|x| / qmax qmax = 127 for 8-bit, 7 for 4-bitq = round(x / scale) an integer in [-qmax - 1, qmax]x' = q × scale what the engine multiplies withRunnableAll tracks
"""Quantise one block of 32 weights to 8-bit and 4-bit integers with one shared scale; count the bits."""import numpy as np
rng = np.random.default_rng(0)block = (rng.standard_normal(32) * 0.02).astype(np.float32) # 32 consecutive weights of one tensorrms = float(np.sqrt(np.mean(block ** 2)))
def quantise(x, bits): qmax = 2 ** (bits - 1) - 1 # 127 for 8-bit, 7 for 4-bit: a symmetric integer grid scale = np.float16(np.abs(x).max() / qmax) # one 16-bit scale for the whole block q = np.clip(np.round(x / np.float32(scale)), -qmax - 1, qmax).astype(np.int8) return q, scale, q * np.float32(scale) # codes, scale, and the values they map back to
print("original :", np.round(block[:6], 4).tolist(), f" rms of block = {rms:.4f}")for bits in (8, 4): q, scale, back = quantise(block, bits) err = np.abs(back - block) print(f"{bits}-bit codes : {q[:6].tolist()} scale = {float(scale):.2e}") print(f" restored : {np.round(back[:6], 4).tolist()}") print(f" bits per weight = (32 x {bits} + 16) / 32 = {(32 * bits + 16) / 32:.2f} " f"mean |error| = {err.mean():.1e} = {err.mean() / rms:.1%} of the block's rms " f"codes equal to 0: {(q == 0).sum()} of 32")Output — what you should see
original : [0.0024999999441206455, -0.0026000000070780516, 0.012799999676644802, 0.002099999925121665, -0.010700000450015068, 0.007199999876320362] rms of block = 0.01638-bit codes : [7, -7, 35, 6, -29, 20] scale = 3.66e-04 restored : [0.0026000000070780516, -0.0026000000070780516, 0.012799999676644802, 0.002199999988079071, -0.010599999688565731, 0.007300000172108412] bits per weight = (32 x 8 + 16) / 32 = 8.50 mean |error| = 7.3e-05 = 0.5% of the block's rms codes equal to 0: 0 of 324-bit codes : [0, 0, 2, 0, -2, 1] scale = 6.64e-03 restored : [0.0, 0.0, 0.013299999758601189, 0.0, -0.013299999758601189, 0.006599999964237213] bits per weight = (32 x 4 + 16) / 32 = 4.50 mean |error| = 1.4e-03 = 8.6% of the block's rms codes equal to 0: 6 of 32The bits-per-weight lines are exact, not approximate: ggml’s block_q8_0 is one 16-bit scale plus
32 one-byte codes, 34 bytes per 32 weights, 8.5 bits per weight, and block_q4_0 is one 16-bit
scale plus 16 bytes of nibbles, 18 bytes per 32 weights, 4.5 bits per weight. Those are the two
oldest GGUF types, and the snippet is their algorithm. The K-quants that Part 6’s
GGUF lesson teaches spend the bits
differently: two FP16 super-block scales per 256 weights, one for the block scales and one for the
block minimums, plus a 6-bit scale and a 6-bit minimum per block of 32, which also comes to the
4.5 bits per weight the Hub’s GGUF table lists for Q4_K (block_q4_K is 2 + 2 + 12 + 128 = 144
bytes per 256 weights), and the _M mixes keep some tensors at a wider type, which is why
Qwen3-8B’s Q4_K_M file is 5.0 GB, 0.61 bytes or 4.9 bits per parameter, rather than 0.5. Part 4’s
memory-budget lesson
carries the exact bytes per parameter for every format. The error line is the price: at 4 bits,
six of the thirty-two weights became exactly zero and the mean error is nine per cent of the
block’s typical size, against half a per cent at 8 bits. What that does to the model’s output is a
measurement, and Part 16’s
measurement lesson makes it.
The four-bit floating-point formats are the same idea with a floating-point element. MXFP4 stores E2M1 elements in blocks of 32 with one power-of-two scale per block; NVFP4 keeps the E2M1 element, shrinks the block to 16, gives each block a fractional E4M3 scale and adds “a second-level FP32 scalar applied per tensor”. Blackwell tensor cores multiply with them natively, and gpt-oss-20b (Apache-2.0) is the course’s example of a model shipped that way: its card says the models “were post-trained with MXFP4 quantization of the MoE weights”. Part 16’s Blackwell-formats lesson takes them apart bit by bit; here they are two more rows of bytes per parameter.
| Format | Use it for | Needs | Where the course uses it |
|---|---|---|---|
| FP32 | the primary copy of the weights and the optimiser’s state in training; reference numerics | nothing special | Part 11 |
| BF16 | training compute; unquantised inference; the baseline every quantisation is measured against | BF16 support in the framework build, which the PyTorch and MLX builds this part’s lab installs provide | Parts 2, 11 to 15 |
| FP16 | inference on older GPUs without BF16; training only with loss scaling | a check that activations stay below 65,504 | avoided where BF16 exists |
| FP8 (E4M3 weights, E5M2 gradients) | serving throughput on Hopper and Blackwell; a half-size checkpoint | tensor cores with FP8 support | Parts 9 and 16 |
| INT8, Q8_0 | local inference at about half the bytes of BF16; the first quantised format to compare against BF16 | any engine | Parts 6 and 16 |
| INT4, Q4_K_M and relatives | memory-limited local inference; the default for the 8 GB tier | any engine, and a measurement of what was lost | Parts 6 and 16 |
| MXFP4, NVFP4 | models shipped in the format; native four-bit arithmetic on Blackwell | Blackwell tensor cores for native arithmetic; elsewhere the engine converts the blocks as it reads them | Part 16 |
What precision costs, and where it does not
Section titled “What precision costs, and where it does not”Lower precision is cheaper than intuition suggests, and the snippets say why. A rounding error on one weight is a small number of either sign, independent of the errors on its neighbours. A layer’s output is a dot product over thousands of such weights, so the errors partly cancel rather than add up, and the output moves by far less than the per-weight error. The network was trained by a noisy procedure on noisy data and learned to be robust to perturbations of about this kind. That is why a four-bit model with a nine-per-cent per-weight error can be indistinguishable from the original on most tasks.
Usually is not always. The cancellation argument is weakest where a few weights carry most of the
signal, which is why the embeddings, the output head and some attention matrices are the ones a
_M mixture keeps wider, and why the importance-weighted methods of Part 16’s
post-training quantisation lesson
scale the sensitive channels up before rounding. Small models suffer more than large ones, because
they have fewer parameters to absorb the noise, and the damage shows on hard tasks before easy
ones. The right response is the one this course takes everywhere: measure it, on your task,
against the unquantised model, with the perplexity and divergence tools of Part 16’s
measurement lesson, and
keep the format the measurement supports.
Training is less tolerant than inference for a reason the 0.5 + 0.0001 line makes exact: a
gradient step is a small change to a large number, and it has to survive the addition. That is
why the training-memory arithmetic in Part 11 counts sixteen bytes per parameter where the
inference arithmetic of Part 4 counts two.
Follow shapes through a language-model operation
Section titled “Follow shapes through a language-model operation”Take a batch of two sequences, each with five token positions, and an embedding width of four. Token IDs have shape [2, 5]; looking up their vectors produces [2, 5, 4]. A projection with input width four and output width three produces [2, 5, 3]. The projection applies at every position; it does not combine the five positions. Attention is a separate operation that mixes information across positions.
Write these shapes beside the code before calculating memory. A tensor’s element count is the product of its dimensions, and its storage estimate is that count times bytes per element. A view may share storage with another tensor, whereas a copy creates another allocation. Summing the apparent sizes of all views can therefore double-count memory.
For diagnosis, distinguish three questions: is the shape correct, is the tensor on the intended device, and is the arithmetic using the intended dtype? A successful import answers none of them. A GPU allocation also does not prove every operation remains on the GPU; profile the actual forward and backward path when the measured runtime contradicts the estimate.
Everything in a model is a tensor with a shape, and the shape times the bytes per element is the memory. Almost all of the work is matrix multiplication, two operations per weight per token, and a GPU is a machine for doing that one operation in parallel. Whether a job is limited by arithmetic or by bytes is its intensity against the chip’s ridge point: decode at batch 1 is one FLOP per byte and waits on memory, so its ceiling is bandwidth divided by the bytes of weights per token; prefill and training reuse each weight thousands of times and are limited by compute. Memory is a hierarchy in which each level is several times slower than the one above, and a model must fit in the level its accelerator reads at full speed. Formats set the bytes per parameter, two at BF16, one at FP8 and Q8, about 0.6 at Q4, and each step down is a rounding error that inference mostly cancels and training mostly cannot.
Check your understanding
Sources for this lesson
19 verified · checked 2026-09-12
- 01NVIDIA Deep Learning Performance — Train With Mixed Precision§ Half Precision Format; Loss Scaling To Preserve Small Gradient Magnitudes; Satisfying Tensor Core Shape Constraintsdocs.nvidia.com/deeplearning/performance/mixed-precision-training/index.html2026-09-12
- 02PyTorch documentation — Automatic Mixed Precision package, torch.ampdocs.pytorch.org/docs/2.14/amp.html2026-09-08
- 03PyTorch 2.14 documentation — Type Info, torch.finfo§ torch.finfo attributesdocs.pytorch.org/docs/2.14/type_info.html2026-09-12
- 04PyTorch 2.14 documentation — Tensor Attributes, torch.dtype§ dtype table (sign-exponent-mantissa layouts); float8 limitationsdocs.pytorch.org/docs/2.14/tensor_attributes.html2026-09-12
- 05MLX documentation — index (unified memory model)ml-explore.github.io/mlx/build/html/index.html2026-09-12
- 06CUDA C++ Programming Guide — Compute Capabilities, technical specifications per compute capability§ Table 31, Memory Information per Compute Capability (registers per SM; maximum shared memory per SM); Table 32, Shared Memory Capacity per Compute Capabilitydocs.nvidia.com/cuda/cuda-programming-guide/05-appendices/compute-capabilities.html2026-09-12
- 07CUDA C++ Programming Guide — Writing SIMT Kernels (memory spaces)§ registers, shared memory, L1 and L2 cache, global memorydocs.nvidia.com/cuda/cuda-programming-guide/02-basics/writing-cuda-kernels.html2026-09-12
- 08CUDA C++ Programming Guide — Unified and System Memory§ mapped memory across the CPU-GPU interconnectdocs.nvidia.com/cuda/cuda-programming-guide/02-basics/understanding-memory.html2026-09-12
- 09CUDA C++ Best Practices Guide — Data Transfer Between Host and Device§ PCIe x16 Gen3 bandwidth; device memory bandwidth comparisondocs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html2026-09-12
- 10NVIDIA H100 Tensor Core GPU — specifications§ memory bandwidth; interconnect (PCIe Gen5)nvidia.com/en-us/data-center/h1002026-09-12
- 11NVIDIA Technical Blog — NVIDIA Hopper Architecture In-Depth§ PCIe Gen 5 x16 total and per-direction bandwidth, against Gen 4developer.nvidia.com/blog/nvidia-hopper-architecture-in-depth2026-09-12
- 12NVIDIA Developer — CUDA GPUs, compute capability by product§ GeForce RTX 50 series (12.0); NVIDIA GB10, DGX Spark (12.1)developer.nvidia.com/cuda-gpus2026-09-12
- 13Hugging Face Hub documentation — GGUF, quantization types§ Quantization Types tablehuggingface.co/docs/hub/gguf2026-09-12
- 14ggml — ggml-common.h at llama.cpp build b10867 (block_q4_0, block_q8_0, block_q4_K)raw.githubusercontent.com/ggml-org/llama.cpp/b10867/ggml/src/ggml-common.h2026-09-12
- 15FP8 Formats for Deep Learning (Micikevicius et al., arXiv:2209.05433)§ Abstractarxiv.org/abs/2209.054332026-09-12
- 16Introducing NVFP4 for Efficient and Accurate Low-Precision Inference§ E2M1 values; NVFP4 and MXFP4 block sizes and scale formatsdeveloper.nvidia.com/blog/introducing-nvfp4-for-efficient-and-accurate-low-precision-inference2026-09-12
- 17openai/gpt-oss-20b model card§ MXFP4 quantisation of the MoE weights; licence; parameter countshuggingface.co/openai/gpt-oss-20b2026-09-12
- 18Qwen/Qwen3-8B — config.json§ hidden_size, intermediate_size, num_hidden_layers, num_attention_heads, num_key_value_heads, head_dim, vocab_size, tie_word_embeddings, initializer_rangehuggingface.co/Qwen/Qwen3-8B/raw/main/config.json2026-09-12
- 19nanochat — nanochat/gpt.py§ estimate_flops docstringraw.githubusercontent.com/karpathy/nanochat/master/nanochat/gpt.py2026-09-12
Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.