Python LLM Fine-Tuning in Depth (2026): The Complete QLoRA Pipeline
Quick preview
Full-param fine-tuning a 7B model needs 4× A100 — impractical for most teams. QLoRA combines 4-bit quantization, low-rank adaptation, and paged optimizer to let a single consumer GPU fine-tune large models.
This article provides a reproducible end-to-end pipeline: from NF4 quantization theory, to PEFT configuration, to TRL training, to vLLM deployment. Every step includes code, data, and gotcha warnings.
Deep principles: what LoRA and QLoRA actually do
LoRA: freeze the base, train only side-paths
LoRA doesn't touch pre-trained weights W. It inserts two trainable low-rank matrices A and B beside every attention layer:
h = W·x + (B·A)·x
^^^^ ^^^^^^^^^
frozen trainable (r << d, typically 8–64)
During backpropagation, only A and B have non-zero gradients — W's gradient stays zero (frozen). This is why LoRA is memory-efficient: optimizer states (AdamW momentum + variance) only need to track A and B, not the entire W.
Initialization trick: A is initialized with Kaiming/Xavier normal, B as all zeros. This means BA=0 at training start, so the model's initial output is identical to the original pre-trained model — guaranteeing training stability.
For a 7B model, trainable params can be as low as 0.1%–0.5%.
QLoRA: compress the base too
QLoRA extends LoRA by quantizing frozen base weights W to 4-bit NormalFloat (NF4).
Why NF4 beats INT4: INT4 is uniform quantization (equal spacing), assuming weights are uniformly distributed. In reality, pre-trained weights follow an approximately zero-mean normal distribution — concentrated near the mean, sparse in the tails. NF4 is the information-theoretically optimal non-uniform quantization scheme, allocating more quantization levels where probability density is high and fewer in the tails. Experiments show NF4 loses roughly 1/2 to 1/3 as much information as INT4 at equal bit-width.
Double Quantization: quantization requires storing a scaling factor (FP32) for each parameter block. When blocks are small, scaling factor storage becomes non-trivial. Double Quantization quantizes the scaling factors themselves (typically to FP8), saving an extra ~0.4 bits/param.
Paged Optimizer: when GPU VRAM nears capacity, transient allocations can trigger CUDA OOM. The Paged Optimizer splits optimizer states into fixed-size pages and pages them between CPU and GPU — analogous to an OS virtual memory system.
VRAM math
Llama 3.1-8B, ~16 GB BF16 weights:
| Component | Full-param | QLoRA |
|---|---|---|
| Model weights | 16 GB (BF16) | ~3.8 GB (NF4) |
| Optimizer (AdamW) | 32 GB (m+v) | ~0.08 GB (LoRA only) |
| Gradients | 16 GB | ~0.08 GB |
| Activations (batch=4,seq=2048) | ~12 GB | ~12 GB |
| Total | ~76 GB ❌ | ~16 GB ✅ |
A 24 GB card is ample; even 16 GB cards (RTX 4060 Ti 16GB) can run with small batches.
Setup
pip install torch transformers accelerate peft bitsandbytes trl datasets tensorboard
Version gotchas: bitsandbytes must match CUDA version. trl 0.9+ uses SFTConfig. Windows users: use WSL2.
Load 4-bit quantized model
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-8B",
quantization_config=bnb_config, device_map="auto", torch_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
tokenizer.pad_token = tokenizer.eos_token
Configure LoRA
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
bias="none", task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable: ~42M / ~8B = 0.52%
Hyperparameter guide
| Param | Recommendation | Rationale |
|---|---|---|
r |
8–32 | r controls the rank. r=8 means expressing ΔW with 8 basis vectors — sufficient for most tasks. Returns diminish rapidly beyond 32. |
lora_alpha |
r × 1–2 | Effective learning rate ∝ alpha/r. With r=16, alpha=32 gives scaling 2.0. |
lora_dropout |
0.05 | Regularization. Small data (<500) → 0.1. Large data → 0. |
target_modules |
More is better | Llama: q/k/v/o + gate/up/down. Check state_dict for other architectures. |
Prepare dataset
from datasets import load_dataset
dataset = load_dataset("tatsu-lab/alpaca")
def format_instruction(example):
prompt = f"### Instruction:\n{example['instruction']}\n\n### Response:\n{example['output']}"
return {"text": prompt}
dataset = dataset.map(format_instruction)
💡 200–500 samples to validate → 2k–5k for visible improvement → 10k+ for competitive results. Quality >> quantity.
Training: TRL SFTTrainer
from trl import SFTTrainer, SFTConfig
training_args = SFTConfig(
output_dir="./qlora-llama3-chat",
per_device_train_batch_size=4, gradient_accumulation_steps=4,
num_train_epochs=3, max_seq_length=2048,
logging_steps=10, save_steps=200,
learning_rate=2e-4, lr_scheduler_type="cosine", warmup_ratio=0.03,
optim="paged_adamw_8bit", bf16=True, gradient_checkpointing=True,
report_to="tensorboard",
)
trainer = SFTTrainer(model=model, args=training_args,
train_dataset=dataset["train"], tokenizer=tokenizer)
trainer.train()
Evaluation
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-8B", torch_dtype=torch.bfloat16, device_map="auto"
)
base = PeftModel.from_pretrained(base, "./qlora-llama3-chat/checkpoint-600")
base = base.merge_and_unload()
def generate(prompt, max_new=256):
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
outputs = base.generate(**inputs, max_new_tokens=max_new, temperature=0.7)
return tokenizer.decode(outputs[0], skip_special_tokens=True)
Quantitative: GPT-4 as judge, score 1–5 on accuracy/completeness/helpfulness. Average > 4.0 is acceptable.
Save & deploy
trainer.save_model("./qlora-llama3-chat-final") # ~60 MB, adapter only
model = PeftModel.from_pretrained(base, "./qlora-llama3-chat-final")
model = model.merge_and_unload()
model.save_pretrained("./merged-model")
vLLM
python -m vllm.entrypoints.openai.api_server \
--model ./merged-model --dtype bfloat16 --max-model-len 4096 --port 8000
FAQ
Q1: QLoRA vs full fine-tune quality? — 96–99% on most domain-adaptation tasks. Only extreme tasks need full params.
Q2: r=8 vs r=64? — r=8 sufficient for medium datasets. Don't waste compute on r — ensure data quality first.
Q3: Multiple adapters? — model.set_adapter() switches in milliseconds without reloading the base.
Q4: Low loss, bad responses? — Overfitting to prompt template. Add diversity, reduce epochs, increase dropout.
Q5: CPU training? — Extremely slow. Use Colab free T4 / Kaggle free P100.
Conclusion
Recommended path: 200 samples validate → TensorBoard tune → manual test → add data (not r) → merge → vLLM deploy.