Quantization#

QLoRA in Action: a Fine-Tuning Tutorial

Quantization is a compression trade-off: use fewer bits to store model numbers so the model fits on available hardware. It makes a model cheaper to load; it does not make the model smaller in judgement, safer, or better at your task.

⏱ ~12 min read Β· ~25 min hands-on πŸ”— needs: Fine-Tuning Techniques Β· basic Python

From precision to practical memory#

Model weights are large arrays of numbers. Full precision uses many bits for each number; quantization represents them with fewer bits plus scales/metadata that approximately reconstruct the original values.

FP32  β†’ 32 bits per weight  β†’ high precision, large memory
FP16/BF16 β†’ 16 bits         β†’ common training/inference precision
INT8  β†’ 8 bits              β†’ roughly half the weight memory of FP16
4-bit β†’ 4 bits              β†’ roughly quarter the weight memory of FP16

The word roughly matters. The model weights are only one part of memory usage. Tokenizer, framework overhead, temporary tensors, attention KV cache, batch size, and context length all consume RAM/VRAM too.

GoalFirst choiceWhy
Train a model from scratchBF16/FP16 on suitable acceleratorsgradients/optimizer state need precision and lots of memory
Run a model locallya trusted pre-quantized 8-bit/4-bit releasemakes inference fit on smaller hardware
Adapt an LLM on one modest GPUQLoRA4-bit frozen base + small trainable adapter
Need best possible benchmark qualityevaluate FP16/BF16 vs quantized candidatescompression can change quality
Need a phone/CPU runtimea runtime-specific format such as GGUF/ONNX, tested on targetthe serving engine matters as much as bit-width

The calculation you should do before downloading#

This estimates weight storage only. It is a useful reality check, not a hardware promise.

# save as estimate_memory.py
def weight_memory_gib(parameters_billions: float, bits_per_weight: int) -> float:
    bytes_used = parameters_billions * 1_000_000_000 * bits_per_weight / 8
    return bytes_used / 1024**3


for params in [1, 4, 8, 27]:
    print(f"\n{params}B-parameter model (weights only)")
    for bits in [32, 16, 8, 4]:
        print(f"  {bits:>2}-bit: ~{weight_memory_gib(params, bits):5.1f} GiB")
uv run estimate_memory.py

An 8B model at 4-bit has roughly 3.7 GiB of raw weight data. It may still fail on a 4 GiB GPU once runtime overhead and a useful context window are included. Start with a much smaller model than the maximum your device might theoretically hold.

TermMeaningCommon beginner confusion
Quantizationrepresent numbers with fewer bitsnot the same as training fewer parameters
8-bit / 4-bit inferenceload weights compactly to generate textcan be a pre-quantized release or dynamic load option
LoRAtrain a small adapter while base weights are frozendoes not require 4-bit by itself
QLoRAload frozen base in 4-bit and train LoRA adapteradapter is still trained in a usable compute precision

With QLoRA, save and publish the adapter separately from the base model unless you have explicit rights and a reason to merge. Anyone using it needs the exact compatible base model and chat template.

Try it β€” load a small model in 4-bit only if you have a compatible GPU#

This is an optional local experiment for NVIDIA/Intel-compatible hardware. Check the current bitsandbytes hardware requirements first. If it does not match your machine, use the guided cloud notebook in the next lesson; do not spend hours fighting drivers.

mkdir quantization-demo && cd quantization-demo
uv init
uv add transformers accelerate bitsandbytes torch
# save as load_4bit.py
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

# Substitute a model you have permission to access and that fits your hardware.
model_id = "Qwen/Qwen2.5-0.5B-Instruct"

quantization = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
)

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    device_map="auto",
    quantization_config=quantization,
)

messages = [{"role": "user", "content": "Reply with exactly three colours."}]
inputs = tokenizer.apply_chat_template(
    messages, add_generation_prompt=True, return_tensors="pt"
).to(model.device)
output = model.generate(inputs, max_new_tokens=20, do_sample=False)
print(tokenizer.decode(output[0][inputs.shape[-1]:], skip_special_tokens=True))
uv run load_4bit.py

nf4 is a 4-bit representation commonly used for QLoRA-style training. bfloat16 is the compute dtype where supported. If the script errors, do not β€œfix” it by randomly changing dtypes: read the error, verify GPU/driver/library compatibility, or use a notebook that configures the environment for you.

Compare quality, not just whether it starts#

Quantization is successful only if it meets your use case. For the same prompt set, record:

CheckWhy it matters
Task score / human rubricDoes it still make the right decision/output?
JSON/schema validitySmall errors can break application integration.
Latency and tokens/secondSmaller weights do not always mean faster generation on every device.
Peak RAM/VRAMDetermines whether it is deployable.
Safety and edge casesCompression can change borderline behaviour.

For a custom support-triage adapter, test the same held-out tickets against base FP16/BF16 (if available), 8-bit, and 4-bit/QLoRA. Keep the smallest version that passes the release threshold; do not choose from one impressive prompt.

Quantization choices in practice#

OptionUse forAdvantagesWatch for
FP16/BF16baseline or well-resourced servingstrong default quality/compatibilityhigh VRAM/RAM
8-bitquality-sensitive constrained inferencesmaller memory impact, usually easier quality trade-offstill may not fit small devices
4-bit NF4 + LoRAbeginner adapter trainingenables QLoRA on modest GPUsdepends on runtime/hardware; test quality
GGUF/GPTQ/AWQ etc.specific local inference runtimepractical distribution for target engineformats are not interchangeable; document exact runtime/quantization

Never say merely β€œ4-bit model.” Include model revision, base model, quantization method, runtime, context length tested, and evaluation result. Those details decide whether another person can reproduce the result.

When it fails#

SymptomLikely causeFix
CUDA out of memory even with 4-bitcontext/KV cache or temporary tensors exceed VRAMreduce context, batch, and model size; monitor peak memory.
bitsandbytes import/backend errorincompatible OS, GPU, CUDA/driver, or package buildcheck the current compatibility table; use a supported notebook/runner instead of guessing.
Output quality falls sharplybit-width/method is too aggressive for task/modelcompare 8-bit or BF16; validate with held-out evals.
Adapter will not loadbase revision, tokenizer/template, or PEFT config differspin and document every dependency; load the exact base model.
Model runs but is painfully slowCPU offloading or unsupported kernelsuse a smaller model/shorter context or a runtime designed for your hardware.

Your turn (β‰ˆ25 min)#

  1. Run estimate_memory.py for the smallest and largest model you are considering.
  2. Write down your actual available RAM/VRAM and choose a conservative model size.
  3. If your machine is supported, run the optional 4-bit loading example. Otherwise open the next lesson’s Unsloth notebook route.
  4. Create five fixed prompts for your custom task and a scorecard: correctness, format validity, latency, and one safety check.
  5. State the exact quantization/runtime you would publishβ€”not just β€œquantized.”

Checklist#

  • I know weight-memory estimates exclude context, cache, and runtime overhead.
  • I can distinguish quantization, LoRA, and QLoRA.
  • I know QLoRA trains an adapter on a frozen 4-bit base model.
  • I verify hardware compatibility before debugging an installation.
  • I select bit-width using held-out task quality as well as memory.
  • I document the base model, revision, method, runtime, and tested context.

Go deeper#