QLoRA › Double Quantization

Michael BrenndoerferDecember 4, 20255 min read
Language AI HandbookAll chapters
Part 35 of 67Parameter-Efficient Fine-tuning
Chapter 259QLoRA › Double Quantization
Reading section 5 of 15 · Double Quantization · 5 minSection 5 of 15 · 5 min

Book contents

Section 5 of 15 · 5 min read

QLoRA › Double QuantizationOne section · Sections in this chapter5 / 15 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Block-wise quantization requires storing a scaling factor for each block. These scaling factors enable each block to adapt to its local value distribution, but they come with a storage cost that is easy to underestimate. Consider that for a 70B parameter model with 64-element blocks, we need approximately 1.09 billion scaling factors. Each factor is a 32-bit float, so the scales alone require 4.4 GB of memory. This is a significant fraction of the memory we're trying to save through quantization, and it means our effective bit-rate for storage is higher than 4 bits per weight. With 64-element blocks and FP32 scales, the overhead per parameter is:

Os=SbitsBblock=3264=0.5 bits/element\begin{aligned} O_s &= \frac{S_{\text{bits}}}{B_{\text{block}}} \\ &= \frac{32}{64} \\ &= 0.5 \text{ bits/element} \end{aligned}

where:

  • OsO_s: the memory overhead per parameter due to storing scales
  • SbitsS_{\text{bits}}: the size of a single scaling factor in bits (32 for FP32)
  • BblockB_{\text{block}}: the number of weights per quantization block (typically 64)

This formula captures the basic trade-off in block-wise quantization. Each 64-element block requires one 32-bit scaling factor, so the overhead per element is simply the scale size divided by the block size. For a 70B parameter model, this overhead alone requires 4.4 GB of memory, which is a significant fraction of the memory we're trying to save through quantization.

QLoRA's second innovation is double quantization: quantizing the quantization constants themselves. The key insight is that the scaling factors, while necessary for accurate reconstruction, are themselves just numbers that can be compressed. If the scaling factors have their own predictable distribution, we can quantize them too, at the cost of introducing a small additional layer of approximation. The question is whether the memory savings from compressing the scales justify the tiny accuracy loss from less precise scale reconstruction. The answer, it turns out, is yes by a comfortable margin.

The Approach

The scale factors across blocks form their own distribution. Since each scale represents the range of values in a block of normally distributed weights, these scales tend to be positive numbers clustered around a characteristic value determined by the layer's weight statistics. We can apply a second round of quantization to these scales:

  1. First quantization: Quantize weight blocks using NF4 with FP32 scales
  2. Second quantization: Quantize the FP32 scales to 8-bit with a single global scale

The scales are typically positive values (since we normalize weights to have unit variance before quantization), so 8-bit unsigned integers work well. Using 256 levels for the scales provides enough precision to avoid meaningful accuracy loss while reducing storage by 4 times. The global scale needed to reconstruct the 8-bit scales is just a single FP32 number per tensor, contributing negligibly to total memory.

Think of double quantization as a Russian nesting doll of compression. The first doll represents the main weights, compressed from 16 bits to 4 bits using NF4. The second doll represents the scaling constants for the first compression, themselves compressed from 32 bits to 8 bits. The third doll is just a single number, the global constant needed to reconstruct the 8-bit scales. The outer dolls protect the precision of the inner dolls, and each layer of nesting adds only a tiny amount of storage overhead.

In[14]:
Code
def double_quantize_scales(scales, n_bits=8):
    """Quantize FP32 scales to 8-bit."""
    n_levels = 2**n_bits

    # Scales are positive, quantize with single global scale
    global_max = scales.max()
    quantized = np.round(scales / global_max * (n_levels - 1)).astype(np.uint8)

    # Dequantize
    dequantized = quantized.astype(np.float32) / (n_levels - 1) * global_max

    return quantized, global_max, dequantized


# Simulate block scales from a 1B parameter model with 64-element blocks
n_blocks = 1_000_000_000 // 64
simulated_scales = np.abs(np.random.normal(0.5, 0.1, n_blocks)).astype(
    np.float32
)

# Double quantization
quantized_scales, global_scale, reconstructed_scales = double_quantize_scales(
    simulated_scales
)

scale_error = np.mean((simulated_scales - reconstructed_scales) ** 2)
original_storage_gb = n_blocks * 4 / 1e9
quantized_storage_gb = n_blocks * 1 / 1e9
Out[15]:
Console
Number of block scales: 15,625,000
Original scale storage: 0.06 GB (FP32)
Quantized scale storage: 0.02 GB (INT8 + global)
Scale reconstruction MSE: 0.00000134

The minimal Mean Squared Error (MSE) confirms that 8-bit quantization preserves the scale information with high accuracy. This suggests we can safely compress the scales without degrading model performance. The error is so small because the scales vary smoothly across blocks, making them well-suited to quantization with many levels.

Memory Savings from Double Quantization

Let's calculate the total memory reduction. The overhead calculation changes because we now store 8-bit scales instead of 32-bit scales, with one additional 32-bit global scale per tensor:

In[16]:
Code
def calculate_memory_overhead(n_params, block_size=64):
    """Calculate quantization overhead with and without double quantization."""
    n_blocks = n_params // block_size

    # Without double quantization: FP32 scales
    single_quant_overhead_bits = 32 / block_size  # bits per parameter

    # With double quantization: INT8 scales + one FP32 global scale
    double_quant_overhead_bits = (8 / block_size) + (
        32 / n_params
    )  # bits per parameter

    return single_quant_overhead_bits, double_quant_overhead_bits


single, double = calculate_memory_overhead(7_000_000_000)
reduction_pct = (1 - double / single) * 100
Out[17]:
Console
Scale overhead (single quantization): 0.500 bits/param
Scale overhead (double quantization): 0.125 bits/param
Overhead reduction: 75%

Double quantization reduces the scale storage overhead by 75%, from 0.5 bits per parameter to 0.125 bits per parameter. The quantization error introduced to the scales is negligible since we're using 256 levels and the scales vary smoothly. This reduction might seem small in percentage terms, but for a 70B model, it saves over 3 GB of memory, which can make the difference between fitting on a GPU and not fitting. More importantly, it means the total memory per parameter for a 4-bit quantized model is approximately 4+0.125=4.1254 + 0.125 = 4.125 bits rather than 4+0.5=4.54 + 0.5 = 4.5 bits, a meaningful improvement to the effective compression ratio.