Quantization
Store weights in fewer bits to run models cheaply.
Create a free account Try part of it here
Why it matters
A 70-billion-parameter model stored in 16-bit numbers needs 140 GB of memory. That's more than any single consumer GPU holds. Stored in 4 bits, the same model needs about 35 to 40 GB. It also runs faster, and it loses surprisingly little quality. This trick is called quantization. It's how open models run on laptops and how providers serve models cheaply. It's also one reason the same model comes in several sizes on a download page.
What you'll be able to do
- Quantize weights to 8, 4, 3 and 2 bits with a scale per group, and measure the error
- Explain why outlier values make per-tensor scales fail, and how groups fix it
- Measure a real model's quality and size at each precision
- Estimate memory and speed gains, and know the common formats (int8, 4-bit, GGUF, FP8)
What it covers
- Fewer bits per weight
- The recipe: scale, round, rebuild
- One giant value spoils the group
- Worked example: one group at four precisions
- A real model at each precision
- What it buys
- Things to know
Fewer bits per weight
A model is a huge list of numbers called weights. During training, each weight is usually stored as a 16- or 32-bit floating-point number, a format that can hold decimals. A bit is a single 0 or 1, and 8 bits make a byte. So a 16-bit weight takes 2 bytes. A model with 70 billion weights needs 140 billion bytes, which is 140 GB.
Think about prices in a shop. A price like $4.37 is exact. Round every price to the nearest dollar and each one takes fewer digits to write. You lose a little detail, and most of the time nobody minds.
Quantization does the same to weights. It stores each weight as a small whole number plus a shared scale. The scale is one number that turns those whole numbers back into weights. With 4 bits, each weight becomes one of 15 whole numbers, from −7 to 7. Multiply that whole number by the scale and you get back something close to the original weight.
(4 bits can hold 16 different patterns. This method uses 15 of them, so the levels are balanced around zero.)
print(140 * 8 / 16)
Show the answer
70.0
140 × 8 / 16 prints as 70.0. Half the bits, half the memory. At 4 bits it would be 35 GB.
The recipe: scale, round, rebuild
The simplest method is called round-to-nearest. It works on a group of weights in three steps.
- Pick the scale so the biggest weight lands on the top level. For 4 bits, that's 7.
- Divide every weight by the scale and round to the nearest whole number. Store those numbers.
- To use a weight again, multiply its whole number by the scale.
Here is the same recipe as a formula. First, the symbols:
- is one original weight.
- is the number of bits, for example 4.
- is the scale shared by the group.
- is the whole number you store.
- (say "w-hat") is the rebuilt weight. It's close to but not exact.
The bars in mean "ignore the minus sign". So is the biggest weight in the group by size. The bottom of the scale's fraction is the number of levels above zero: 7 for 4 bits.
In plain words: the scale is the biggest weight divided by the number of levels above zero. Divide each weight by the scale and round it: that whole number is what you store. Multiply it by the scale to get an approximate weight back.
Try it on four weights: 0.7, −0.32, 0.18 and 0.04, at 4 bits.
- Levels above zero: (2 × 2 × 2) − 1 = 7.
- Scale: the biggest weight is 0.7, and 0.7 ÷ 7 = 0.1.
- Divide by the scale: 7, −3.2, 1.8 and 0.4.
- Round: 7, −3, 2 and 0. These are what you store.
- Rebuild: 0.7, −0.3, 0.2 and 0.
Each rebuilt weight is off by at most 0.04. The weight 0.04 became 0, because it was smaller than half a step. Here are the same steps in Python:
weights = [0.7, -0.32, 0.18, 0.04] bits = 4 levels = 2 ** (bits - 1) - 1 # 7 steps each side of zero scale = max(abs(w) for w in weights) / levels # the biggest weight lands on 7 q = [round(w / scale) for w in weights] # the small whole numbers you store rebuilt = [round(n * scale, 2) for n in q] # whole number times scale print(levels, round(scale, 2)) print(q) print(rebuilt)
It prints 7 and 0.1, then the stored numbers [7, -3, 2, 0], then the rebuilt weights [0.7, -0.3, 0.2, 0.0].
bits = 3 print(2 ** (bits - 1) - 1)
Show the answer
3
2 × 2 − 1 = 3. The stored whole numbers run from −3 to 3: seven levels in all.
One giant value spoils the group
Imagine measuring a box of pencils and one flagpole with the same ruler. The ruler has only 15 marks, and it must stretch to reach the top of the flagpole. Now the marks are far apart. Every pencil is shorter than half a mark, so every pencil measures zero.
Weights have flagpoles too. Neural network weights include rare outliers: values far larger than the rest. One outlier sets a huge scale for its whole group. Then the ordinary weights round to zero.
weights = [7.0, 0.3, -0.2, 0.1] scale = max(abs(w) for w in weights) / 7 print([round(w / scale) for w in weights])
Show the answer
[7, 0, 0, 0]
The scale is 7.0 ÷ 7 = 1.0. The weights 0.3, −0.2 and 0.1 are each less than half a step, so all three round to 0. The small weights are lost.
So the choice that matters most is how many weights share a scale. The code below builds a 64 × 64 grid (a matrix) of small, typical weights: 4,096 in all. Then it plants one outlier, 50 times larger than usual. It quantizes the matrix to 4 bits in three ways:
- one scale for the whole matrix;
- one scale per row of 64 weights;
- one scale per group of 16 weights.
For each, it prints the average error on the ordinary weights, as a share of a typical weight's size.
import numpy as np def quantize(w, bits, group): """Round-to-nearest with one scale per group of consecutive weights.""" levels = 2 ** (bits - 1) - 1 g = w.reshape(-1, group) scale = np.abs(g).max(axis=1, keepdims=True) / levels scale[scale == 0] = 1 return (np.clip(np.round(g / scale), -levels, levels) * scale).reshape(w.shape) rng = np.random.default_rng(0) w = rng.normal(0, 0.02, (64, 64)) # typical small weights... w[3, 7] = 1.0 # ...and one outlier, 50x larger for name, group in [("one scale for the matrix", 64 * 64), ("one scale per row", 64), ("one scale per 16 weights", 16)]: error = np.abs(quantize(w, 4, group) - w) error[3, 7] = 0 # judge the ordinary weights print(f"4-bit, {name:<26} average error {error.mean() / np.abs(w).mean():.0%} of a typical weight")
With one scale for the whole matrix, the error is 98% of a typical weight. The ordinary weights lose almost everything. One scale per row cuts the error to 13%, because only the outlier's own row is badly hit. Groups of 16 bring it down to 9%, since each small group's scale fits its own values more closely.
In practice, groups of 32, 64 or 128 weights are common. Smaller groups are more accurate. But every group stores its own scale, and those scales take space too.
Go deeper Smarter rounding: GPTQ, AWQ and LLM.int8()
Round-to-nearest treats every weight on its own. Methods used in practice go further.
- GPTQ and AWQ choose the rounding to keep each layer's outputs close to the original, measured on sample data. They care about what the layer computes, not about each weight by itself.
- LLM.int8() keeps the few outlier features in 16-bit and stores the rest in 8 bits.
Show the answer
So a rare large value only stretches the scale of its own group, not the whole matrix
An outlier would otherwise force a huge scale on every weight, and the ordinary weights would round to zero.
Worked example: one group at four precisions
Here are six weights from one group. The code quantizes them at 8, 4, 3 and 2 bits, rebuilds them, and prints the rebuilt weights and the average error. Before you run it, guess where the error jumps.
weights = [0.7, -0.32, 0.18, 0.04, -0.51, 0.26] for bits in [8, 4, 3, 2]: levels = 2 ** (bits - 1) - 1 scale = max(abs(w) for w in weights) / levels rebuilt = [round(w / scale) * scale for w in weights] error = sum(abs(r - w) for r, w in zip(rebuilt, weights)) / len(weights) print(f"{bits}-bit: levels -{levels} to {levels}, rebuilt {[round(r, 2) for r in rebuilt]}, average error {error:.3f}")
At 8 bits there are 127 levels on each side. The rebuilt weights match the originals to two decimal places, and the average error is about 0.001. At 4 bits the error is 0.022, and the tiny weight 0.04 becomes 0. At 3 bits it roughly doubles to 0.042. At 2 bits only three levels are left: −1, 0 and 1. Every weight becomes −0.7, 0 or 0.7, and the error jumps to 0.165. Each bit you remove roughly halves the levels (7, then 3, then 1). So each step gets more than twice as wide: 0.1, then about 0.23, then 0.7.
A real model at each precision
Now try it on a real model: the course's tiny GPTs. The code quantizes every weight matrix in groups of 16, at 8, 4, 3 and 2 bits. It compares each with the original 32-bit model and prints three things:
- Size in kilobytes (KB), counting the 16-bit scales too.
- Quality: the base model's loss on catalog text. Lower is better.
- Writing: the start of the chat model's answer to a product question.
(torch here is the course's in-browser PyTorch subset.)
import numpy as np import requests import torch import torch.nn.functional as F from tinygpt import load_pretrained def quantize(w, bits, group=16): levels = 2 ** (bits - 1) - 1 g = w.reshape(-1, group) scale = np.abs(g).max(axis=1, keepdims=True) / levels scale[scale == 0] = 1 return (np.clip(np.round(g / scale), -levels, levels) * scale).reshape(w.shape) def quantized(weights, bits): return {k: torch.tensor(quantize(v.numpy(), bits)) if v.ndim == 2 else v for k, v in weights.items()} base, tok = load_pretrained("tiny-gpt") chat, _ = load_pretrained("tiny-gpt-chat") base_w, chat_w = base.state_dict(), chat.state_dict() data = torch.tensor(tok.encode(requests.get("https://api.gradient.test/v1/corpus").json()["text"])) torch.manual_seed(0) starts = torch.randint(0, len(data) - 49, (64,)).tolist() x = torch.stack([data[i:i + 48] for i in starts]) y = torch.stack([data[i + 1:i + 49] for i in starts]) n = sum(v.numel() for v in base_w.values()) prompt = torch.tensor([tok.encode("<user> Describe the Cove camp mug . <assistant>")]) for bits in (32, 8, 4, 3, 2): if bits < 32: base.load_state_dict(quantized(base_w, bits)) chat.load_state_dict(quantized(chat_w, bits)) with torch.no_grad(): loss = F.cross_entropy(base(x).reshape(-1, len(tok)), y.reshape(-1)).item() torch.manual_seed(0) reply = tok.decode(chat.generate(prompt, 40, temperature=0.7, stop_id=tok.token_id("<end>"))[0, prompt.shape[1]:].tolist()) size_kb = (n * bits / 8 + (n / 16 * 2 if bits < 32 else 0)) / 1024 print(f"{bits:>2}-bit {size_kb:6.1f} KB loss {loss:.3f} | {reply[:95]}")
The pattern is typical of real models.
- 8-bit is essentially free. The loss is unchanged, at a little over a quarter of the 32-bit size.
- 4-bit costs a little. The loss rises slightly, and the writing is still fine.
- 3-bit starts to hurt.
- 2-bit breaks the model. It writes a jumble of catalog words.
Larger models handle quantization better than this tiny one, because each weight matters less.
Watch out: the scales take space. Each group of 16 weights also stores one 16-bit scale. That's 16 ÷ 16 = 1 extra bit per weight, so "4-bit" here really costs 5 bits per weight. That's one reason real formats use larger groups.
bits, group, scale_bits = 4, 32, 16 print(bits + scale_bits / group)
Show the answer
4.5
Each group's 16-bit scale is shared by 32 weights, which adds 16 ÷ 32 = 0.5 bits to each. So 4 + 0.5 = 4.5 bits per weight.
What it buys
The code below works out the memory and a top speed for two real open models. Llama 3 8B has 8 billion weights, and Llama 3 70B has 70.6 billion. Each is shown at three precisions.
models = {"Llama 3 8B": 8.0e9, "Llama 3 70B": 70.6e9}
formats = {"16-bit": 16, "8-bit": 8, "4-bit (about 4.5 bits with scales)": 4.5}
for name, params in models.items():
print(name)
for fmt, bits in formats.items():
gb = params * bits / 8 / 1e9
print(f" {fmt:<36} {gb:6.1f} GB of weights decode limit at 1 TB/s: ~{1e12 / (params * bits / 8):.0f} tokens/s")
Both gains come from moving fewer bytes.
- Memory. The 70B model drops from about 141 GB to about 40 GB. That takes it from two or more datacenter GPUs down to one. The 8B model drops to about 4.5 GB, which fits comfortably on a laptop. Freed memory can also hold more KV cache, so a server can handle more users at once.
- Speed. As Phase 5 showed, producing each token means reading every weight from memory. So the speed of writing text is limited by the bytes read per token. Half the bytes gives roughly twice the tokens per second: about 62, then 125, then 222 for the 8B model. These are rough upper limits. They assume memory that moves 1 TB (a trillion bytes) per second, and they ignore everything else.
A 70-billion-weight model goes from 140 GB at 16 bits to 35 GB at 4 bits. The fewer the levels, the larger the rounding error.
Things to know
- Formats. You'll meet round-to-nearest int8 (8-bit integers) and GPTQ and AWQ (4-bit, for GPUs). There's also GGUF, the file format of llama.cpp and Ollama. Its quantization levels have names like Q4_K_M and Q8_0. Recent GPUs also compute directly with FP8 and FP4, small floating-point formats. Some labs now train in 8-bit. Some also ship "quantization-aware" checkpoints: saved models trained to survive 4-bit.
- What gets quantized. Usually the big weight matrices. Embeddings, normalization layers and sometimes the output layer stay in higher precision. The KV cache can be quantized too.
- Evaluate after quantizing. An average loss can hide damage to specific skills, such as math, rare languages or long documents. Before you deploy a 3- or 4-bit model, compare it on your own task.
For marketers: when a provider offers a cheaper "quantized" or "turbo" version of a model, treat it as a different model. Run your own evaluation set on it before you switch high-stakes work.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why does a 4-bit model generate text faster than the same model in 16-bit?