Index Ai Academy
Phase 6 · Modern LLM engineering · Inference

Quantization

Store weights in fewer bits to run models cheaply.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Fewer bits per weight
  2. The recipe: scale, round, rebuild
  3. One giant value spoils the group
  4. Worked example: one group at four precisions
  5. A real model at each precision
  6. What it buys
  7. 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.)

Quick check
A model takes 140 GB at 16 bits per weight. How much at 8 bits?
print(140 * 8 / 16)
  1. 70.0
  2. 140.0
  3. 280.0
  4. 35.0
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.

  1. Pick the scale so the biggest weight lands on the top level. For 4 bits, that's 7.
  2. Divide every weight by the scale and round to the nearest whole number. Store those numbers.
  3. To use a weight again, multiply its whole number by the scale.

Here is the same recipe as a formula. First, the symbols:

  • ww is one original weight.
  • bb is the number of bits, for example 4.
  • ss is the scale shared by the group.
  • qq is the whole number you store.
  • w^\hat{w} (say "w-hat") is the rebuilt weight. It's close to ww but not exact.
q=round(ws),s=max⁡∣w∣2b−1−1,w^=q⋅sq = \text{round}\left(\frac{w}{s}\right), \qquad s = \frac{\max |w|}{2^{b-1} - 1}, \qquad \hat{w} = q \cdot s

The bars in ∣w∣|w| mean "ignore the minus sign". So max⁡∣w∣\max |w| 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 runs right here in your browser, with nothing to install.

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].

Quick check
How many levels above zero does round-to-nearest use at 3 bits? Predict what this prints.
bits = 3
print(2 ** (bits - 1) - 1)
  1. 3
  2. 4
  3. 7
  4. 8
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.

Quick check
This group of four weights has one outlier. Predict what the 4-bit stored numbers are.
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])
  1. [7, 0, 0, 0]
  2. [7, 3, -2, 1]
  3. [70, 3, -2, 1]
  4. [1, 0, 0, 0]
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.
Quick check
Why do quantization methods use a separate scale for each small group of weights?
  1. So a rare large value only stretches the scale of its own group, not the whole matrix
  2. To add more parameters
  3. Because groups make the model smaller than per-tensor scales
  4. Because GPUs can't multiply by one scale
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

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}")
In the lesson you can change this and run it.

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.

Quick check
Weights are stored in 4 bits, in groups of 32, with one 16-bit scale per group. What is the real cost per weight? Predict what this prints.
bits, group, scale_bits = 4, 32, 16
print(bits + scale_bits / group)
  1. 4.0
  2. 5.0
  3. 20.0
  4. 4.5
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.
Try it now

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

Create a free account

Try a quiz question

Why does a 4-bit model generate text faster than the same model in 16-bit?

  1. Each token requires reading all the weights, and there are a quarter as many bytes to read
  2. 4-bit arithmetic is always more accurate, so the model needs fewer steps per token to settle
  3. Quantized models skip layers, because the smallest weights get rounded to zero and removed
  4. It uses a smaller vocabulary, because rare tokens can't be stored with so few bits per weight

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson