QLoRA Explained: Fit a 13B Model on One L4 GPU

QLoRA Explained: Fit a 13B Model on One L4 GPU

What QLoRA Actually Does (and Why the Memory Math Matters)

QLoRA is the reason you can fine-tune a 13B LLM on a single L4 GPU without renting a cluster. Three weeks ago in our LLM Fine-Tuning lab, a student pulled up a memory error trying to load Llama 3.1 13B in bfloat16 on an L4. The model alone needed 26 GB. The L4 has 24 GB. Classic off-by-two-gigabytes failure. We switched to QLoRA, the model loaded at 8.7 GB, and we were training inside ten minutes.

To understand why that works, you need two pieces: quantization and LoRA. They're independent ideas that Tim Dettmers and colleagues combined in the 2023 QLoRA paper, and the combination is what makes fitting a 13B model on single-GPU hardware realistic.

Why LLM Quantization Saves More Memory Than You'd Expect

LLM quantization stores model weights in a lower-precision format, typically 4-bit integers instead of 16-bit floats. A 13B parameter model in bfloat16 uses roughly 2 bytes per parameter: 13B × 2 = 26 GB. In 4-bit, that's 0.5 bytes per parameter, so around 6.5 GB for weights alone. The rest of your memory budget goes to activations, optimizer states, and the LoRA adapter.

The specific format QLoRA uses is called NF4 (NormalFloat4). Not vanilla INT4. NF4 is designed for the statistical distribution of neural network weights, which cluster near zero rather than spread uniformly across the value range. By mapping all 16 quantization levels to where weights actually live, NF4 preserves more information per bit than standard integer quantization does.

bitsandbytes 0.43 handles this transparently. Pass load_in_4bit=True and bnb_4bit_quant_type="nf4" and it just works. There's also a bnb_4bit_use_double_quant=True flag that quantizes the quantization constants themselves, saving another 0.3-0.4 GB. Keep it on. Always.

How LoRA Trains 0.1% of the Parameters and Still Works

LoRA (Low-Rank Adaptation) freezes the entire pretrained model and injects small trainable matrices into the attention layers. The idea is that the weight updates needed for fine-tuning live in a low-dimensional subspace, so you don't need to touch most of the original weights at all. Instead of updating a full W matrix of shape (4096, 4096), you learn two small matrices: A at (4096 × r) and B at (r × 4096), where r is the rank, typically 16 or 32. At inference, you add A×B back to W.

The math is worth spelling out. A rank-16 LoRA on a (4096, 4096) weight matrix trains 4096×16 + 16×4096 = 131,072 parameters instead of 16,777,216. That's about 0.8% of the original. Across all attention layers in a 13B model you're typically training somewhere between 30 and 100 million parameters total while the other 13 billion stay frozen.

Frozen weights don't need gradients. No gradients means no gradient storage. That's the second place memory disappears.

The two hyperparameters you actually tune are r (rank) and lola_alpha (a scaling factor; most people set it to 2× rank). Higher rank means a more expressive adapter and more memory. For a 13B model on an L4, rank 16 or 32 is fine for most instruction-following tasks. I've seen students burn time chasing rank 64 for marginal gains that weren't there.

Running QLoRA on a Single L4: The Actual Code

Below is a minimal working setup using Hugging Face PEFT 0.10 and bitsandbytes 0.43. This is exactly what we run in our LLM Fine-Tuning (A100) and LLM Inference (L4) lab environments, though the L4 version is the interesting one for the memory story.

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, TaskType

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

model_id = "meta-llama/Llama-3.1-13B"

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

# Prepare model for kbit training (enables gradient checkpointing safely)
from peft import prepare_model_for_kbit_training
model = prepare_model_for_kbit_training(model)

# LoRA config
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type=TaskType.CAUSAL_LM
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Output: trainable params: 39,976,960 || all params: 13,054,828,544 || trainable%: 0.3063

With gradient_checkpointing=True in your TrainingArguments, peak memory on the L4 sits around 19-21 GB depending on batch size and sequence length. Tight, but it fits. A sequence length above 1024 with batch size 4 will OOM. Drop to batch size 2 or cut sequences to 512 first, not both at once, or you'll lose track of which change actually helped.

When QLoRA Is the Wrong Tool

QLoRA solves the memory problem. It does not solve the quality problem when your task genuinely needs the full expressiveness of a fine-tuned model.

If you're adapting a model to a narrow domain with very specific output formats, say structured JSON extraction with dozens of nested fields, and production latency is non-negotiable, the quantization overhead and limited adapter expressiveness will cost you. I've seen this come up twice in capstone reviews: teams that committed to QLoRA early because of hardware constraints and then spent weeks fighting output reliability that full fine-tuning on a multi-GPU setup resolved in a day. In those situations, the right answer is full fine-tuning on better hardware, and our Machine Learning Engineering: Build, Optimize & Deploy Intelligent Models course covers how to make that call before you've already sunk time into the wrong approach.

For most instruction-following, summarization, and classification tasks though? QLoRA gets you within 1-2% of full fine-tune quality at a fraction of the cost. That's a trade worth making almost every time.

The real skill is reading the memory budget before you start, not after the CUDA OOM hits at 2 AM.

Frequently asked questions

What is QLoRA and how is it different from regular LoRA?+

QLoRA is LoRA applied on top of a 4-bit quantized base model. Standard LoRA keeps the base model in full (16-bit) precision; QLoRA quantizes it to 4-bit NF4 first, which cuts the base model's memory footprint roughly in half. The adapter weights themselves still train in bfloat16.

Can I fine-tune a 13B model on a single GPU with QLoRA?+

Yes. On a 24 GB L4, a Llama 3.1 13B model loaded with 4-bit quantization via bitsandbytes sits around 8-9 GB. Add LoRA adapters, optimizer states, and activations and you'll land near 18-20 GB total, well within the L4's limit. Gradient checkpointing drops that further if you need headroom.

Does quantization hurt model quality?+

On most instruction-tuning tasks, 4-bit NF4 quantization with double quantization loses less than 2 percent on standard benchmarks compared to bfloat16. For tasks that require very precise numerical reasoning the gap widens, but for dialogue, summarization, and classification it's rarely detectable in production.

How do I merge QLoRA adapter weights back into the base model?+

Load the base model in 16-bit (not 4-bit) and call model.merge_and_unload() from the PEFT library. The merged model can then be saved with model.save_pretrained() and served like any normal checkpoint. Merging in 4-bit mode is lossy, so always dequantize first.

Ready to learn AI seriously?

Browse our 13 live, instructor-led programs.

Explore Courses