n4nAI

QLoRA explained: fine-tuning in 4-bit precision

QLoRA combines 4-bit quantization with LoRA adapters to fine-tune large language models on consumer GPUs without quality loss.

n4n Team6 min read1,281 words

Audio narration

Coming soon — every post will get a voice note here.

QLoRA (Quantized Low-Rank Adaptation) is a parameter-efficient fine-tuning method that combines 4-bit quantization with low-rank adapters to train large language models on consumer-grade GPUs. It freezes a 4-bit quantized base model and trains only a small set of low-rank adapter weights in 16-bit precision, reducing memory requirements by up to 4× compared to standard LoRA while preserving full 16-bit fine-tuning performance. The technique makes it practical to fine-tune models like Llama-2-70B on a single 48 GB GPU.

How QLoRA works

QLoRA builds on two established techniques: LoRA (Low-Rank Adaptation) and quantization. The innovation lies in how they interact during training.

The quantization backbone

Standard quantization compresses model weights from 16-bit (FP16/BF16) to 4-bit integers, typically using a block-wise scheme where each block of 64 or 128 weights shares a quantization constant. This works well for inference but breaks during training: gradients computed through quantized weights are noisy, and the quantization constants themselves need updating.

QLoRA solves this with 4-bit NormalFloat (NF4), a data type designed for normally distributed weights. NF4 uses a fixed lookup table of 16 values optimized for a standard normal distribution, then scales each block independently:

# Conceptual NF4 quantization (simplified)
def quantize_nf4(weight_block: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
    """Quantize a weight block to NF4. Returns (quantized_weights, absmax_scale)."""
    absmax = weight_block.abs().max()
    normalized = weight_block / absmax  # Now roughly N(0, 1)
    quantized = torch.round(normalized * 7.5).clamp(-8, 7).to(torch.int8)  # 4-bit signed
    return quantized, absmax

The key insight: NF4’s lookup table values are fixed and derived from the quantiles of a standard normal distribution. This means the quantization error is predictable and, critically, differentiable with respect to the scale factor.

Double quantization

QLoRA adds a second quantization pass on the quantization constants themselves. Each block’s absmax scale (typically FP32) is quantized to 8-bit, saving another 0.5 bits per parameter. For a 70B model, this recovers ~3.5 GB of memory.

# Double quantization: quantize the scales
def double_quantize(weight: torch.Tensor, block_size: int = 64) -> tuple:
    # First pass: NF4 quantize weights per block
    n_blocks = weight.numel() // block_size
    weight_blocks = weight.view(n_blocks, block_size)
    
    quantized_blocks = []
    scales = []
    for block in weight_blocks:
        q_block, scale = quantize_nf4(block)
        quantized_blocks.append(q_block)
        scales.append(scale)
    
    # Second pass: 8-bit quantize the scales
    scales_tensor = torch.stack(scales)
    scale_absmax = scales_tensor.abs().max()
    quantized_scales = (scales_tensor / scale_absmax * 127).round().to(torch.int8)
    
    return torch.cat(quantized_blocks), quantized_scales, scale_absmax

The LoRA adapter path

During training, the 4-bit base weights stay frozen. Only the LoRA adapters — typically two low-rank matrices A (r × d) and B (d × r) per layer — receive gradients. These adapters live in 16-bit precision (BF16 or FP16).

The forward pass decompresses 4-bit weights to 16-bit on the fly, computes the base model output, then adds the LoRA contribution:

def qlora_forward(x: torch.Tensor, base_weight_4bit: torch.Tensor, 
                  lora_A: torch.Tensor, lora_B: torch.Tensor, 
                  scale: float, absmax: torch.Tensor) -> torch.Tensor:
    # Dequantize base weight to BF16 for matmul
    base_weight = dequantize_nf4(base_weight_4bit, absmax).to(torch.bfloat16)
    
    # Base model path
    base_out = x @ base_weight.t()
    
    # LoRA path (stays in BF16)
    lora_out = (x @ lora_A.t()) @ lora_B.t() * scale
    
    return base_out + lora_out

Crucially, gradients flow only through lora_A and lora_B. The dequantized base_weight is treated as a constant during backprop — no gradients propagate into the 4-bit storage.

Paged optimizers

The final memory trick: optimizer states (Adam moments) for the LoRA parameters can be offloaded to CPU memory via paged optimizers. When a parameter’s optimizer state isn’t needed for the current step, it lives on CPU; pages are swapped in/out asynchronously. This keeps GPU memory focused on model weights and activations.

Why QLoRA matters

Memory arithmetic

Component 16-bit LoRA (7B) QLoRA (7B) QLoRA (70B)
Base model (4-bit) 4.8 GB 48 GB
Base model (16-bit) 14 GB
LoRA adapters (r=64) ~0.2 GB ~0.2 GB ~1.8 GB
Gradients + optimizer states ~6 GB ~1.5 GB ~12 GB
Activations (batch=1, seq=4k) ~3 GB ~3 GB ~12 GB
Total (approx.) >24 GB ~10 GB ~75 GB

A 7B model fits on a 12 GB consumer card (RTX 3060/4060) with room for batch size >1. A 70B model fits on a single 80 GB A100 or dual 48 GB workstation GPUs — previously impossible without model parallelism.

No quality degradation

The original QLoRA paper (Dettmers et al., 2023) showed that 4-bit NF4 + LoRA matches 16-bit LoRA and full 16-bit fine-tuning on benchmarks like MMLU, GSM8K, and HumanEval. The quantization error introduced by NF4 is smaller than the variance from LoRA’s low-rank approximation itself.

This holds because:

  1. NF4 is information-theoretically optimal for normally distributed weights
  2. LoRA adapters absorb distribution shift — they learn the residual between the quantized base and the target task
  3. Gradients never see quantization noise — they flow through 16-bit adapters only

Training speed trade-off

QLoRA is slower per step than 16-bit LoRA due to on-the-fly dequantization. Expect 1.3–1.8× step time overhead depending on kernel implementation. However, the ability to run larger batch sizes (due to memory savings) often recovers wall-clock time. On a 24 GB GPU, you might run batch size 4 with QLoRA vs. batch size 1 with 16-bit LoRA — a net win.

Concrete example: Fine-tuning Llama-3-8B on a 24 GB GPU

Here’s a complete, runnable configuration using Hugging Face trl and bitsandbytes:

# Install dependencies
pip install trl peft bitsandbytes accelerate transformers datasets
# train_qlora.py
import torch
from datasets import load_dataset
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer

# 1. 4-bit quantization config
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",           # NF4, not FP4
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,      # Double quantization
)

# 2. Load model in 4-bit
model_id = "meta-llama/Meta-Llama-3-8B"
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
    torch_dtype=torch.bfloat16,
    attn_implementation="flash_attention_2",  # Requires Ampere+ GPU
)

# 3. Prepare for k-bit training (adds gradient checkpointing, etc.)
model = prepare_model_for_kbit_training(model)

# 4. LoRA config
lora_config = LoraConfig(
    r=64,
    lora_alpha=16,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", 
                    "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Typical output: trainable params: 4,194,304 || all params: 8,030,203,904 || trainable%: 0.052%

# 5. Data
dataset = load_dataset("tatsu-lab/alpaca", split="train")
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token

def format_example(example):
    return {"text": f"### Instruction:\n{example['instruction']}\n\n### Response:\n{example['output']}"}

dataset = dataset.map(format_example)

# 6. Training args — tuned for 24 GB VRAM
training_args = TrainingArguments(
    output_dir="./llama3-8b-qlora-alpaca",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,   # Effective batch = 8
    num_train_epochs=3,
    learning_rate=2e-4,
    bf16=True,
    tf32=True,
    logging_steps=10,
    save_strategy="epoch",
    optim="paged_adamw_8bit",        # Paged optimizer
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    max_grad_norm=0.3,
    report_to="none",
)

# 7. Trainer
trainer = SFTTrainer(
    model=model,
    train_dataset=dataset,
    dataset_text_field="text",
    max_seq_length=2048,
    tokenizer=tokenizer,
    args=training_args,
    packing=True,                    # Pack sequences for efficiency
)

trainer.train()
trainer.save_model("./llama3-8b-qlora-alpaca-final")

Memory profile on RTX 3090 (24 GB):

  • Model (4-bit NF4): ~5.2 GB
  • LoRA adapters + gradients: ~1.1 GB
  • Paged AdamW 8-bit states (on CPU): ~0.8 GB GPU / ~3.2 GB CPU
  • Activations (batch 2, seq 2048): ~4.5 GB
  • Peak GPU: ~16 GB — leaves headroom for larger context or batch

Merging and deploying

After training, merge adapters into a standalone 16-bit model for inference:

from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained(
    model_id, torch_dtype=torch.bfloat16, device_map="auto"
)
merged = PeftModel.from_pretrained(base, "./llama3-8b-qlora-alpaca-final")
merged = merged.merge_and_unload()  # Fuses LoRA into base weights
merged.save_pretrained("./llama3-8b-qlora-merged")
tokenizer.save_pretrained("./llama3-8b-qlora-merged")

The merged model runs at native 16-bit speed with no QLoRA overhead. You can also re-quantize the merged model to 4-bit GPTQ/AWQ for deployment.

Common misconceptions

“QLoRA trains the quantized weights”

False. The 4-bit base weights are frozen throughout training. Only the 16-bit LoRA adapters receive gradients. The quantization is a storage and compute optimization for the frozen backbone, not a training-time approximation.

“NF4 is just another 4-bit format like INT4 or FP4”

False. INT4 uses uniform spacing; FP4 uses IEEE-style exponent/mantissa. NF4 uses a fixed, non-uniform lookup table derived from the quantiles of a standard normal distribution. This matches the actual weight distribution of pretrained transformers, yielding lower quantization error for the same bit budget.

# NF4 lookup table (values from Dettmers et al.)
NF4_VALUES = torch.tensor([
    -1.0, -0.6961928009986877, -0.5250730514526367, -0.39491748809814453,
    -0.28444138169288635, -0.18477343022823334, -0.09105003625154495,
    0.0, 0.07958029955625534, 0.16093020141124725, 0.24611230194568634,
    0.33791524171829224, 0.44070982933044434, 0.5626170039176941,
    0.7229568362236023, 1.0
])

“QLoRA works equally well at all ranks”

False. Low ranks (r=8–16) show larger gaps vs. 16-bit LoRA because the adapter capacity is too small to compensate for quantization-induced distribution shift. At r=64–128, the gap closes. For 70B models, r=128 or 256 is common.

“You can QLoRA-train any model off the shelf”

Mostly true, but watch for:

  • Architecture support: bitsandbytes kernels cover standard attention/MLP layouts. Custom architectures (e.g., MoE with unusual routing, RWKV, Mamba) may need kernel work.
  • Layer norm placement: Pre-LN vs. post-LN affects gradient flow. QLoRA was validated on pre-LN (Llama, GPT). Post-LN models may need learning rate tuning.
  • Embedding size: Vocabulary embeddings are often kept in 16-bit (they’re small). If your model has tied embeddings, ensure the output head isn’t accidentally quantized.

“QLoRA eliminates the need for full fine-tuning”

False. QLoRA is parameter-efficient fine-tuning (PEFT). It adapts a model to a new domain or style with ~0.1% trainable parameters. It does not recover the full representational capacity of updating all weights. For continued pretraining, massive domain shifts, or learning entirely new languages, full fine-tuning (or LoRA with very high rank) still wins.

“QLoRA and GPTQ/AWQ are the same thing”

False. GPTQ and AWQ are post-training quantization (PTQ) methods for inference. They compress a finished model to 4-bit with calibration data. QLoRA is a training-time method that uses 4-bit storage for the frozen backbone while learning 16-bit adapters. You can apply GPTQ/AWQ after QLoRA training to the merged model for deployment.

When to choose QLoRA over alternatives

Scenario Recommended approach
Consumer GPU (8–24 GB), instruction tuning QLoRA (r=64–128)
Single 48–80 GB GPU, 70B+ model QLoRA (r=128–256) or FSDP + LoRA
Multi-GPU cluster, full fine-tuning needed FSDP / DeepSpeed ZeRO-3 + BF16
Inference-only, no training GPTQ / AWQ / EXL2 (4-bit PTQ)
Rapid iteration, multiple tasks LoRA (16-bit) + adapter swapping
Continued pretraining Full fine-tuning or LoRA (r=256+)

The bottom line

QLoRA is the reason you can fine-tune a 70B parameter model on hardware you already own. It combines three mature ideas — NF4 quantization, double quantization, and paged optimizers — into a training pipeline that preserves 16-bit quality at 4-bit memory cost. The technique is mature, supported in every major training framework, and has become the default starting point for open-weight model adaptation.

If you’re building a fine-tuning pipeline today, start with QLoRA. Only escalate to full fine-tuning or higher-rank LoRA when you have evidence that the adapter capacity is the bottleneck.

Tagsqloraloraquantizationfine-tuning

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All lora, qlora & parameter-efficient fine-tuning posts →