Fine-Tuning LLMs with LoRA & QLoRA: Complete Production Guide
Master parameter-efficient fine-tuning — from theory to production deployment with LoRA, QLoRA, and advanced techniques.
Introduction
Fine-tuning adapts pre-trained LLMs to specific tasks. Full fine-tuning updates all parameters — expensive, requires massive compute. Parameter-Efficient Fine-Tuning (PEFT) updates <1% of parameters — fast, cheap, nearly same performance.
This guide covers LoRA (Low-Rank Adaptation) and QLoRA (Quantized LoRA) with production-ready code.
LoRA Theory
Original weights: W ∈ ℝ^(d×k) LoRA adapters: A ∈ ℝ^(d×r), B ∈ ℝ^(r×k) where r << min(d,k) Forward pass: h = Wx + BAx = (W + BA)x Key insight: ΔW = BA has rank r, so we only train A and B.
# peft/lora_theory.py import torch import torch.nn as nn from typing import Optional class LoRALinear(nn.Module): """LoRA-adapted linear layer.""" def __init__( self, in_features: int, out_features: int, r: int = 16, alpha: int = 32, dropout: float = 0.1, bias: bool = False ): super().__init__() self.in_features = in_features self.out_features = out_features self.r = r self.alpha = alpha self.scaling = alpha / r # Original frozen weights self.weight = nn.Parameter(torch.empty(out_features, in_features)) self.weight.requires_grad = False # LoRA adapters self.lora_A = nn.Parameter(torch.empty(r, in_features)) self.lora_B = nn.Parameter(torch.empty(out_features, r)) self.dropout = nn.Dropout(dropout) if bias: self.bias = nn.Parameter(torch.empty(out_features)) else: self.register_parameter('bias', None) self.reset_parameters() def reset_parameters(self): nn.init.kaiming_uniform_(self.weight, a=5**0.5) nn.init.kaiming_uniform_(self.lora_A, a=5**0.5) nn.init.zeros_(self.lora_B) if self.bias is not None: nn.init.zeros_(self.bias) def forward(self, x: torch.Tensor) -> torch.Tensor: # Base model forward (frozen) base_output = nn.functional.linear(x, self.weight, self.bias) # LoRA forward (trainable) lora_output = (self.dropout(x) @ self.lora_A.T @ self.lora_B.T) * self.scaling return base_output + lora_output def merge_weights(self): """Merge LoRA into base weights for inference.""" if self.r > 0: delta_w = (self.lora_B @ self.lora_A) * self.scaling self.weight.data += delta_w self.lora_A.data.zero_() self.lora_B.data.zero_() def apply_lora_to_model( model: nn.Module, target_modules: list = None, r: int = 16, alpha: int = 32, dropout: float = 0.1 ) -> nn.Module: """Apply LoRA to target modules (e.g., q_proj, v_proj).""" if target_modules is None: target_modules = ["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"] for name, module in model.named_modules(): if any(target in name for target in target_modules) and isinstance(module, nn.Linear): parent_name = ".".join(name.split(".")[:-1]) child_name = name.split(".")[-1] parent = model.get_submodule(parent_name) if parent_name else model lora_layer = LoRALinear( module.in_features, module.out_features, r=r, alpha=alpha, dropout=dropout, bias=module.bias is not None ) lora_layer.weight.data = module.weight.data.clone() if module.bias is not None: lora_layer.bias.data = module.bias.data.clone() setattr(parent, child_name, lora_layer) return model Part 2: QLoRA — 4-bit Quantization + LoRA
QLoRA combines 4-bit NF4 quantization with LoRA for massive memory savings.
# peft/qlora.py import torch from transformers import ( AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig ) from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training def load_qlora_model( model_id: str, r: int = 64, alpha: int = 16, dropout: float = 0.1, target_modules: list = None, device_map: str = "auto" ): """Load model with 4-bit quantization + LoRA.""" # 4-bit NF4 quantization config bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, ) # Load base model model = AutoModelForCausalLM.from_pretrained( model_id, quantization_config=bnb_config, device_map=device_map, torch_dtype=torch.bfloat16, trust_remote_code=True, ) # Prepare for k-bit training model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True) # LoRA config if target_modules is None: target_modules = ["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"] lora_config = LoraConfig( r=r, lora_alpha=alpha, lora_dropout=dropout, target_modules=target_modules, bias="none", task_type="CAUSAL_LM", modules_to_save=None, ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() return model NF4 Quantization Details
NormalFloat4 (NF4) — optimal for normally distributed weights Standard int4: [-8, -7, ..., 7] uniform spacing NF4: 16 quantiles of N(0,1) — more precision near 0 Double Quantization: Quantize the quantization constants (saves 0.37 bits/param) # peft/nf4_details.py NF4_QUANTILES = torch.tensor([ -1.0, -0.6961928, -0.525073, -0.394917, -0.284441, -0.184773, -0.091050, 0.0, 0.079580, 0.160930, 0.246112, 0.337915, 0.440709, 0.562617, 0.722957, 1.0 ]) def quantize_nf4(tensor: torch.Tensor) -> tuple: """Quantize to NF4 with double quantization.""" # Block-wise quantization (block_size=64) block_size = 64 original_shape = tensor.shape tensor = tensor.view(-1, block_size) # Compute absmax per block absmax = tensor.abs().max(dim=1, keepdim=True).values absmax = absmax.clamp(min=1e-8) # Normalize normalized = tensor / absmax # Quantize to NF4 distances = (normalized.unsqueeze(-1) - NF4_QUANTILES.to(tensor.device)).abs() quantized = distances.argmin(dim=-1).to(torch.uint8) # Double quantization: quantize absmax absmax_quantized = quantize_absmax(absmax) return quantized, absmax_quantized, original_shape def dequantize_nf4(quantized, absmax_quantized, shape): """Dequantize NF4 back to float.""" # Implementation for inference pass Part 3: Training Pipeline
# peft/training.py from transformers import TrainingArguments, Trainer from peft import PeftModel from datasets import load_dataset import torch def create_training_args( output_dir: str, num_epochs: int = 3, batch_size: int = 4, gradient_accumulation: int = 4, learning_rate: float = 2e-4, warmup_ratio: float = 0.03, max_grad_norm: float = 0.3, logging_steps: int = 10, save_steps: int = 100, eval_steps: int = 100, fp16: bool = False, bf16: bool = True, gradient_checkpointing: bool = True, optim: str = "paged_adamw_32bit", lr_scheduler_type: str = "cosine", ) -> TrainingArguments: """Production-ready training arguments.""" return TrainingArguments( output_dir=output_dir, num_train_epochs=num_epochs, per_device_train_batch_size=batch_size, per_device_eval_batch_size=batch_size, gradient_accumulation_steps=gradient_accumulation, learning_rate=learning_rate, warmup_ratio=warmup_ratio, max_grad_norm=max_grad_norm, logging_steps=logging_steps, save_steps=save_steps, eval_steps=eval_steps, evaluation_strategy="steps", save_strategy="steps", load_best_model_at_end=True, metric_for_best_model="eval_loss", greater_is_better=False, fp16=fp16, bf16=bf16, gradient_checkpointing=gradient_checkpointing, optim=optim, lr_scheduler_type=lr_scheduler_type, report_to="wandb", run_name=f"lora-{model_id.split('/')[-1]}", ddp_find_unused_parameters=False, remove_unused_columns=False, ) def format_dataset(example, tokenizer, max_length=2048): """Format for instruction tuning.""" # Alpaca format prompt = f"""### Instruction: {example['instruction']} ### Input: {example.get('input', '')} ### Response: {example['output']}""" tokenized = tokenizer( prompt, truncation=True, max_length=max_length, padding=False, return_tensors=None, ) # Labels = input_ids for causal LM tokenized["labels"] = tokenized["input_ids"].copy() return tokenized def train_lora( model, tokenizer, train_dataset, eval_dataset, training_args: TrainingArguments, ): """Train LoRA adapters.""" trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset, tokenizer=tokenizer, data_collator=lambda x: { "input_ids": torch.nn.utils.rnn.pad_sequence( [torch.tensor(i["input_ids"]) for i in x], batch_first=True, padding_value=tokenizer.pad_token_id ), "attention_mask": torch.nn.utils.rnn.pad_sequence( [torch.tensor(i["attention_mask"]) for i in x], batch_first=True, padding_value=0 ), "labels": torch.nn.utils.rnn.pad_sequence( [torch.tensor(i["labels"]) for i in x], batch_first=True, padding_value=-100 ), }, ) trainer.train() return trainer # Usage if __name__ == "__main__": model_id = "meta-llama/Meta-Llama-3-8B" model = load_qlora_model(model_id) tokenizer = AutoTokenizer.from_pretrained(model_id) tokenizer.pad_token = tokenizer.eos_token dataset = load_dataset("tatsu-lab/alpaca", split="train") dataset = dataset.map(lambda x: format_dataset(x, tokenizer)) train_size = int(0.9 * len(dataset)) train_dataset = dataset.select(range(train_size)) eval_dataset = dataset.select(range(train_size, len(dataset))) args = create_training_args("./lora-llama3-8b-alpaca") trainer = train_lora(model, tokenizer, train_dataset, eval_dataset, args) # Save LoRA adapters only trainer.model.save_pretrained("./lora-adapters") tokenizer.save_pretrained("./lora-adapters") Part 4: Advanced LoRA Variants
AdaLoRA — Adaptive Rank Allocation
# peft/adalora.py from peft import AdaLoraConfig, get_peft_model def create_adalora_model(model, target_modules=None): """AdaLoRA: dynamically allocates rank per layer.""" config = AdaLoraConfig( init_r=12, target_r=8, beta1=0.85, beta2=0.85, tinit=200, tfinal=1000, deltaT=10, lora_alpha=32Source:https://dev.to/ipsphil_digital/fine-tuning-llms-with-lora-qlora-complete-production-guide-46l6PreviousWho am i ?NextProbabilistic Graph Neural Inference for planetary geology survey missions under real-time policy constraints
Related posts
I Launched My Company Website, Made Task Monitor Free, and Opened Custom Project Requests
0dev.to AI
How I turn Hacker News, GitHub and npm into product ideas every 3 hours
0dev.to AI
Lenovo's 'Encrypted' Fingerprint USB Drive Is an SD Card. How to Check Yours
0dev.to Linux

Email: OpenAI plans to merge ChatGPT's Chat and Work tabs on desktop, rolling out to select users before a wider release; Codex remains a separate experience (Ivan Mehta/TechCrunch)
0Techmeme

Memo: Alphabet is issuing €6B in euro-denominated debt across seven tranches, attracting €17B+ of demand; the company sold €9B of bonds earlier in 2026 (Reuters)
0Techmeme

S-1: Anthropic reports Q3 revenue up 12x YoY to nearly $17B, a net loss of $23.3B, up from $12.1B in Q3 2025, and operating expenses up 292% to $17.5B (Bailey Lipschultz/Bloomberg)
0Techmeme