Deep Implementation

GPTQ-style round-to-nearest with Hessian weighting

Naive quantization treats all weights equally. GPTQ is smarter: it uses second-order information to quantize weights in an order that minimizes the total output error, compensating each rounding decision by adjusting the weights that haven't been quantized yet.

In yesterday's INT8 post I implemented naive round-to-nearest quantization. It works well for INT8 because 256 levels give you enough precision that most rounding errors are harmless. But push to INT4 (16 levels) or INT3, and naive rounding causes real quality loss. GPTQ solves this by being smarter about how it rounds: it considers which weights matter most and compensates for each rounding error by adjusting the weights it hasn't quantized yet.

The key insight: not all weights are equal

When you quantize a weight matrix W in a linear layer y = Wx, the error in the output depends on two things: the rounding error in each weight, and how much that weight contributes to the output. A weight that multiplies a large, frequently activated input feature matters more than one that multiplies a near-zero input. The Hessian matrix captures this: it tells you how sensitive the layer's output is to changes in each weight.

For a linear layer with input X (the calibration data), the Hessian of the squared error with respect to the weights is:

H = 2 * X^T X

The diagonal of H tells you how sensitive the output is to each weight column. Large diagonal entries mean that column of weights is important: rounding errors there will cause large output errors. GPTQ uses this information to quantize weights in an order that minimizes cumulative error.

The OBQ/GPTQ algorithm

GPTQ is built on Optimal Brain Quantization (OBQ), which itself extends Optimal Brain Surgeon. The core algorithm processes one column of the weight matrix at a time:

import torch

def gptq_quantize_layer(weight, hessian, n_bits=4, group_size=128):
    """
    GPTQ quantization for one linear layer.

    weight: (out_features, in_features) float16
    hessian: (in_features, in_features) float32, H = X^T @ X / num_samples
    n_bits: quantization bit width
    group_size: number of columns sharing a scale factor
    """
    W = weight.clone().float()
    rows, cols = W.shape
    H = hessian.clone()

    # Add small diagonal dampening for numerical stability
    damp = 0.01 * torch.mean(torch.diag(H))
    H += damp * torch.eye(cols, device=H.device)

    # Cholesky decomposition of the Hessian
    # H_inv is used to compute the error compensation
    H_inv = torch.linalg.cholesky(H)
    H_inv = torch.cholesky_inverse(H_inv)
    H_inv_chol = torch.linalg.cholesky(H_inv, upper=True)

    quantized = torch.zeros_like(W, dtype=torch.int8)
    scales = torch.zeros(rows, cols // group_size, device=W.device)
    zeros = torch.zeros(rows, cols // group_size, device=W.device)

    # Process columns left to right
    for col_start in range(0, cols, group_size):
        col_end = min(col_start + group_size, cols)

        # Compute scale for this group
        w_group = W[:, col_start:col_end]
        w_min = w_group.min(dim=1).values
        w_max = w_group.max(dim=1).values
        qmin, qmax = 0, 2**n_bits - 1
        scale = (w_max - w_min) / (qmax - qmin)
        scale = torch.clamp(scale, min=1e-8)
        zero_point = torch.round(-w_min / scale).clamp(qmin, qmax)

        group_idx = col_start // group_size
        scales[:, group_idx] = scale
        zeros[:, group_idx] = zero_point

        for j in range(col_start, col_end):
            # Quantize column j
            w_col = W[:, j]
            q_col = torch.clamp(
                torch.round(w_col / scale + zero_point),
                qmin, qmax
            )

            # Dequantize to get the quantized value
            w_hat = (q_col - zero_point) * scale

            # Quantization error
            error = (w_col - w_hat) / H_inv_chol[j, j]

            # Store quantized values
            quantized[:, j] = q_col.to(torch.int8)

            # Compensate remaining columns
            if j < cols - 1:
                W[:, j+1:] -= error.unsqueeze(1) * H_inv_chol[j, j+1:].unsqueeze(0)

    return quantized, scales, zeros

The magic is in the error compensation step. When you round column j and introduce an error, you adjust columns j+1 through n to partially cancel out that error's effect on the output. The Hessian inverse tells you exactly how to distribute the compensation.

Computing the Hessian

The Hessian H = X^T X is computed from calibration data: you run a small set of representative inputs through the model and collect the activations at each layer's input.

def collect_hessian(model, layer_name, calibration_loader, num_samples=128):
    """Collect the Hessian (X^T @ X) for a specific linear layer."""
    H = None
    n_samples = 0

    def hook_fn(module, input, output):
        nonlocal H, n_samples
        x = input[0].detach().float()  # (batch, seq_len, in_features)
        x = x.reshape(-1, x.shape[-1])  # (batch*seq_len, in_features)

        if H is None:
            H = torch.zeros(x.shape[1], x.shape[1], device=x.device)
        H += x.T @ x
        n_samples += x.shape[0]

    # Register forward hook on the target layer
    target = dict(model.named_modules())[layer_name]
    handle = target.register_forward_hook(hook_fn)

    for batch in calibration_loader:
        if n_samples >= num_samples * 2048:  # enough tokens
            break
        with torch.no_grad():
            model(batch["input_ids"].to(model.device))

    handle.remove()
    return H / n_samples

The calibration dataset should be representative of real inference inputs. Most GPTQ implementations use 128 samples from C4, WikiText-2, or a similar corpus. The choice matters less than having enough samples: 128 sequences of 2048 tokens gives a stable Hessian estimate.

Why GPTQ works so well at INT4

At INT8 (256 levels), naive round-to-nearest is already very good because the quantization grid is fine enough that errors are small. The advantage of GPTQ over naive rounding is minimal at INT8.

At INT4 (16 levels), the story changes. With only 16 possible values per weight, rounding errors are large, and they compound across the thousands of weights in a column. GPTQ's error compensation prevents this compounding: each rounding error is actively counteracted by adjusting future weights. The result is that GPTQ at INT4 often achieves perplexity within 0.5 points of FP16, while naive INT4 quantization can degrade by 2 or more points.

Group quantization

GPTQ uses group quantization: instead of one scale factor per row (per-channel), it uses one scale per group of 128 columns. This adds more scale factors (and thus more metadata) but allows different parts of the weight row to use different scales. A group_size of 128 is the standard default. Smaller groups (64, 32) improve accuracy at the cost of more metadata; larger groups (256) save metadata but lose precision.

The practical pipeline

In practice, you don't implement GPTQ from scratch for production use. The AutoGPTQ library and Hugging Face's integration make it a few lines:

# Using AutoGPTQ (the practical way)
from transformers import AutoModelForCausalLM, AutoTokenizer
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig

model_id = "meta-llama/Llama-3.1-70B-Instruct"
quantize_config = BaseQuantizeConfig(
    bits=4,
    group_size=128,
    desc_act=True,    # Use activation-order (slower but more accurate)
    damp_percent=0.01,
)

model = AutoGPTQForCausalLM.from_pretrained(model_id, quantize_config)
tokenizer = AutoTokenizer.from_pretrained(model_id)

# Calibration
calibration_data = [tokenizer(text, return_tensors="pt") for text in calibration_texts]
model.quantize(calibration_data)

# Save quantized model
model.save_quantized("llama-3.1-70b-gptq-int4")

Quantization of a 70B model with 128 calibration samples takes roughly 3 to 4 hours on a single A100. The result is a model that uses roughly 35 GB (down from 140 GB in FP16) and runs significantly faster on memory-bound decode workloads.

GPTQ vs. AWQ vs. naive: when to use what

Understanding GPTQ's internals matters even if you use a library, because it tells you why calibration data matters, why group size affects quality, and why some models quantize better than others. A model with smooth, well-distributed weights quantizes easily. A model with outlier weights (large values concentrated in a few channels) fights the quantizer at every step.

Next: a quantization sweep measuring perplexity vs. compression across bit widths and methods, so we can see the tradeoffs in hard numbers.