Implement LoRA in LLMs from Scratch: Modifying Llama 3 Architecture with PyTorch
Large Language Models (LLMs) have scaled to billions of parameters, making full fine-tuning computationally prohibitive for downstream task adaptation. Low-Rank Adaptation (LoRA) solves this bottleneck by freezing the base model's pre-trained weights and injecting small, trainable low-rank decomposition matrices into its linear projection layers. This guide provides a complete architectural modification walkthrough using PyTorch, converting a standard Meta Llama 3.2 1B model into a parameter-efficient fine-tuning setup that achieves a ~98.9% reduction in trainable parameters.
1. The Fine-Tuning Bottleneck & The LoRA Solution
Standard fine-tuning updates all parameter weights () across an entire network. For massive model architectures (such as GPT-3 with 175B parameters or Llama 3.2 with 1B parameters), full fine-tuning poses severe infrastructure constraints:
- Memory Overhead: Storing full weight gradients and optimizer states (e.g., Adam tracking first and second momentum states) requires up to to the memory footprint of the model itself.
- Deployment Costs: Deploying distinct downstream variants requires saving and hosting full independent copies of the multi-gigabyte/terabyte model weights for every target domain (e.g., text-to-SQL, medical QA, code synthesis).
The LoRA Hypothesis
LoRA operates on the principle that the weight update matrix added to a pre-trained weight matrix during domain adaptation has a low intrinsic rank . Instead of optimizing the full parameter space , LoRA decomposes into the product of two low-rank matrices:
where and , with rank .
2. Structural Architecture Transformation
In a standard transformer block (such as Llama 3), self-attention projection layers (Query , Key , Value , Output ) and Multi-Layer Perceptron (MLP) layers (Gate, Up, Down projections) are defined as standard linear layers (nn.Linear).
To adapt an existing pre-trained model architecture to support LoRA:
- Freeze the Base Weights: All parameters of the original pre-trained model are set to
requires_grad = False. - Inject LoRA Modules: Every targeted
nn.Linearlayer is wrapped alongside a dynamic, parallel low-rank matrix adapter (LinearWithLoRA).
3. Mathematical Formulation & Matrix Dimensions
When an input sequence tensor passes through a linear layer, the traditional forward pass computes:
where .
With LoRA added, the modified forward calculation becomes:
Where:
- : Input activation tensor of dimension .
- : Down-projection matrix initialized with parameter dimension .
- : Up-projection matrix initialized with parameter dimension .
- : Intrinsic rank hyperparameter (e.g., ).
- : Fixed scaling hyperparameter (e.g., ).
- : Constant scaling multiplier that stabilizes training across varying choices of rank .
The Zero-State Preserving Initialization
To ensure that adding the LoRA layers does not corrupt or disrupt the original pre-trained model capabilities prior to training:
- Matrix is initialized using Kaiming (He) Uniform Initialization:
- Matrix is initialized strictly to Zeros:
Note: Because at step , the matrix product . Consequently, . The modified model output is mathematically identical to the unadapted base model before fine-tuning begins.
4. PyTorch Implementation of the Core LoRA Layer
The underlying LoRALayer manages the low-rank trainable weight tensors and , their initialization routines, and scaling execution.
import torch
import torch.nn as nn
import torch.nn.functional as F
class LoRALayer(nn.Module):
"""
Low-Rank Adaptation (LoRA) Layer Module.
Encapsulates down-projection matrix A, up-projection matrix B, and scaling.
"""
def __init__(self, in_dim: int, out_dim: int, rank: int = 16, alpha: float = 32.0):
super().__init__()
self.rank = rank
self.alpha = alpha
self.scaling = alpha / rank
# Matrix A: Down-projecting from in_dim to rank (r)
self.A = nn.Parameter(torch.empty(rank, in_dim))
# Matrix B: Up-projecting from rank (r) to out_dim
self.B = nn.Parameter(torch.zeros(out_dim, rank))
# Initialize Matrix A with Kaiming Uniform, and Matrix B to zero
nn.init.kaiming_uniform_(self.A, a=5**0.5)
nn.init.zeros_(self.B)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass for LoRA block:
Computation: (x @ A^T @ B^T) * scaling
"""
# Intermediate down-projection: [B, T, in_dim] @ [in_dim, r] -> [B, T, r]
x_down = F.linear(x, self.A)
# Up-projection: [B, T, r] @ [r, out_dim] -> [B, T, out_dim]
x_up = F.linear(x_down, self.B)
# Apply scaling factor (alpha / r)
return x_up * self.scaling
5. Combining Base Linear Layers with LoRA (LinearWithLoRA)
To cleanly integrate LoRA into existing networks without disrupting base forward passes, we implement a custom composite wrapper module: LinearWithLoRA.
class LinearWithLoRA(nn.Module):
"""
Wrapper module combining a frozen linear layer with a trainable LoRALayer.
"""
def __init__(self, linear_module: nn.Linear, rank: int = 16, alpha: float = 32.0):
super().__init__()
# Retain original linear module (pre-trained weight & bias)
self.linear = linear_module
# Instantiate corresponding LoRA module using dimensions of base linear layer
in_dim = linear_module.in_features
out_dim = linear_module.out_features
self.lora = LoRALayer(in_dim=in_dim, out_dim=out_dim, rank=rank, alpha=alpha)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Computes parallel addition: Frozen Linear Pass + Trainable LoRA Pass
"""
# Base representation from original model parameters
base_output = self.linear(x)
# Adaptation delta computed by low-rank path
lora_output = self.lora(x)
# Combined activation output
return base_output + lora_output
6. Recursive Network Traversal & Layer Injection
Real-world transformer models (like Llama 3) contain deeply nested module sub-trees (model.model.layers[i].self_attn.q_proj). Manually re-assigning each individual projection layer is error-prone.
To solve this, we construct a recursive tree traversal function that inspects PyTorch's nn.Module tree depth-first. If an instance of nn.Linear is detected, it is dynamically replaced with a LinearWithLoRA module.
def replace_linear_with_lora(model: nn.Module, rank: int = 16, alpha: float = 32.0):
"""
Recursively iterates through all sub-modules of a PyTorch network,
swapping every instance of nn.Linear with a LinearWithLoRA module.
"""
for name, child in model.named_children():
if isinstance(child, nn.Linear):
# Replace single nn.Linear module with wrapper module
setattr(model, name, LinearWithLoRA(child, rank=rank, alpha=alpha))
else:
# Recurse down nested child modules
replace_linear_with_lora(child, rank=rank, alpha=alpha)
7. Step-by-Step Execution: Modifying Llama 3.2 Architecture
Below is the complete sequence for loading a base Meta-Llama-3.2-1B model, freezing its pre-trained parameter weights, injecting LoRA modules recursively, and validating the resulting parameter structures.
import torch
from transformers import AutoModelForCausalLM
# 1. Hardware device assignment
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Executing on device: {device}")
# 2. Load Base Large Language Model (Meta Llama 3.2 1B)
model_id = "meta-llama/Llama-3.2-1B"
model = AutoModelForCausalLM.from_pretrained(model_id)
model.to(device)
# 3. Freeze ALL Base Model Parameters
for param in model.parameters():
param.requires_grad = False
# 4. Inject LoRA Layers into Model Architecture
replace_linear_with_lora(model, rank=16, alpha=32.0)
model.to(device)
8. Empirical Parameter Reduction Analysis
To analyze the reduction in trainable parameters, we quantify parameters before and after LoRA modification.
Mathematical Parameter Breakdown
Consider an MLP expansion projection inside Llama 3.2:
- Standard
nn.LinearParameters:
LoRALayerParameters ():
Model-Wide Parameter Audit
We can verify trainable parameters across the entire Llama 3.2 1B architecture:
def print_trainable_parameters(model: nn.Module):
"""
Calculates total vs. trainable parameters across the PyTorch model.
"""
total_params = 0
trainable_params = 0
for param in model.parameters():
num_params = param.numel()
total_params += num_params
if param.requires_grad:
trainable_params += num_params
reduction_pct = 100 * (1 - (trainable_params / total_params))
print(f"Total Base Parameters : {total_params:,}")
print(f"Trainable LoRA Parameters: {trainable_params:,}")
print(f"Parameter Reduction : {reduction_pct:.2f}%")
print_trainable_parameters(model)
Measured Architectural Output
Total Base Parameters : 1,249,150,976
Trainable LoRA Parameters: 13,500,416
Parameter Reduction : 98.92%
Adapting Llama 3.2 1B via LoRA leaves only ~13.5 million trainable parameters out of 1.25 billion total parameters, reducing computational overhead by 98.92%.
9. End-to-End Data Transformation Flow
The following workflow demonstrates the internal tensor shape transformations as activations propagate through a modified LinearWithLoRA module during a forward pass.
10. Architectural Comparison: Fine-Tuning Paradigms
Post-Training Deployment Tip: Because matrix multiplication is associative, once fine-tuning completes, the adapter weights can be permanently fused back into the main base weight matrix:
This completely eliminates additional latency during production inference!
Summary and Key Takeaways
- Parameter Efficiency: LoRA achieves parameter reductions exceeding 98.9% on Llama 3.2 1B (reducing trainable weights from ~1.25B down to ~13.5M).
- Mathematical Matrix Decomposition: Instead of updating full matrix , LoRA factors updates into two low-rank matrices and , where .
- Initialization Guarantee: Matrix is initialized via Kaiming Uniform, while matrix is initialized to zeros. This guarantees at step 0, fully preserving the pre-trained model's output prior to fine-tuning.
- Scaling Multiplier (): Applying a constant scaling factor ensures that tuning the rank hyperparameter does not require re-tuning learning rate schedules.
- Recursive Layer Replacement: Standard PyTorch models are converted by freezing base parameters (
param.requires_grad = False) and recursively replacingnn.Linearmodules withLinearWithLoRAwrappers. - Zero Added Inference Latency: Post fine-tuning, adapter weights can be directly added into frozen base weights , resulting in standard inference latency with no additional computational overhead.