← Back to DevBytes

How to Merge LoRA Adapters with Base Models

How to Merge LoRA Adapters with Base Models

Low-Rank Adaptation (LoRA) has become one of the most popular techniques for fine-tuning large language models efficiently. Instead of updating all the parameters of a massive model, LoRA freezes the original weights and trains small, low-rank adapter matrices that capture task-specific behavior. This dramatically reduces memory usage and training time, making it feasible to fine-tune models like Llama, Mistral, or Qwen on consumer hardware.

However, LoRA adapters are not standalone models. They are deltas — small weight updates that must be applied on top of a base model at inference time. While you can keep the adapter separate and load it dynamically, there are many scenarios where you want to merge the adapter weights directly into the base model. This produces a single, self-contained model that no longer requires the adapter at runtime, simplifying deployment and often improving inference speed.

What Is LoRA Merging?

LoRA works by approximating the weight update of a linear layer as the product of two smaller matrices. For a weight matrix W of shape (d_out, d_in), LoRA introduces two matrices A of shape (r, d_in) and B of shape (d_out, r), where r is the rank (typically 8, 16, 32, or 64). The effective weight becomes:

W_effective = W + (B @ A) * scaling

Merging means permanently computing W + (B @ A) * scaling and storing the result as the new weight matrix. After merging, the adapter matrices are no longer needed, and the model behaves identically to a fully fine-tuned model with those combined weights.

Why Merging Matters

Prerequisites

To follow along with the code examples, you will need a Python environment with the following packages installed:

pip install transformers peft accelerate torch safetensors

You will also need a base model and a trained LoRA adapter. For this tutorial, we will use meta-llama/Llama-3.1-8B as the base model and assume you have a trained adapter stored locally in a directory called ./my-lora-adapter.

Merging a Single Adapter with PEFT

The peft library from Hugging Face provides a straightforward API for merging LoRA adapters. The core method is merge_and_unload(), which performs the weight arithmetic in place and returns a standard transformers model.

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

base_model_id = "meta-llama/Llama-3.1-8B"
adapter_path = "./my-lora-adapter"
output_dir = "./merged-model"

# Load the base model in float16 to save memory
base_model = AutoModelForCausalLM.from_pretrained(
    base_model_id,
    torch_dtype=torch.float16,
    device_map="auto",
)

# Load the tokenizer
tokenizer = AutoTokenizer.from_pretrained(base_model_id)

# Attach the LoRA adapter to the base model
model = PeftModel.from_pretrained(base_model, adapter_path)

# Merge adapter weights into the base model and unload the adapter
merged_model = model.merge_and_unload()

# Save the merged model and tokenizer
merged_model.save_pretrained(output_dir, safe_serialization=True)
tokenizer.save_pretrained(output_dir)

print(f"Merged model saved to {output_dir}")

After running this script, the ./merged-model directory will contain a complete model with merged weights in the safetensors format. You can load it with any standard transformers code path, just like the original base model.

Handling Memory Constraints

Merging large models can require significant memory because the full model weights must be loaded. If you are working on a GPU with limited VRAM, consider loading the model on CPU first, merging, and then moving to GPU for inference. Alternatively, use 8-bit or 4-bit loading for the base model, but note that merging into quantized weights is not always supported and may require dequantization first.

from transformers import BitsAndBytesConfig

bnb_config = BitsAndBytesConfig(load_in_4bit=True)

base_model = AutoModelForCausalLM.from_pretrained(
    base_model_id,
    quantization_config=bnb_config,
    device_map="auto",
)

# Note: merging into 4-bit weights is NOT directly supported.
# For merging, load in float16 or bfloat16 on CPU instead.
base_model = AutoModelForCausalLM.from_pretrained(
    base_model_id,
    torch_dtype=torch.float16,
    device_map="cpu",
)

model = PeftModel.from_pretrained(base_model, adapter_path)
merged_model = model.merge_and_unload()
merged_model.save_pretrained(output_dir, safe_serialization=True)

Merging Multiple Adapters

One of the powerful features of LoRA is the ability to combine multiple adapters. The peft library supports adding multiple adapters to a single base model and merging them with custom scaling weights. This is useful for blending capabilities, such as combining a coding adapter with a reasoning adapter.

from peft import PeftModel

base_model = AutoModelForCausalLM.from_pretrained(
    base_model_id,
    torch_dtype=torch.float16,
    device_map="auto",
)

# Load the first adapter as the default
model = PeftModel.from_pretrained(base_model, "./adapter-coding", adapter_name="coding")

# Load additional adapters
model.load_adapter("./adapter-reasoning", adapter_name="reasoning")
model.load_adapter("./adapter-chat", adapter_name="chat")

# Set active adapters with custom weights
# The weights determine the contribution of each adapter to the final merged weights
model.set_adapter(["coding", "reasoning", "chat"])

# Merge with weighted combination
# Use add_weighted_adapter for explicit control
from peft import add_weighted_adapter

add_weighted_adapter(
    model,
    adapters=["coding", "reasoning", "chat"],
    weights=[0.5, 0.3, 0.2],
    adapter_name="merged_combo",
    combination_type="linear",
)

model.set_adapter("merged_combo")
merged_model = model.merge_and_unload()
merged_model.save_pretrained("./merged-combo-model", safe_serialization=True)

The combination_type parameter supports several strategies, including "linear" (weighted sum) and "cat" (concatenation along the rank dimension). Linear combination is the most common and predictable approach.

Verifying the Merged Model

After merging, it is critical to verify that the merged model produces the same outputs as the base model with the adapter loaded separately. A simple sanity check is to run the same prompt through both configurations and compare the generated tokens.

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

# Load the merged model
merged_model = AutoModelForCausalLM.from_pretrained(
    "./merged-model",
    torch_dtype=torch.float16,
    device_map="auto",
)
tokenizer = AutoTokenizer.from_pretrained("./merged-model")

prompt = "Explain the concept of gradient descent in simple terms."
inputs = tokenizer(prompt, return_tensors="pt").to(merged_model.device)

with torch.no_grad():
    output = merged_model.generate(
        **inputs,
        max_new_tokens=100,
        do_sample=False,
        temperature=1.0,
    )

print(tokenizer.decode(output[0], skip_special_tokens=True))

Compare this output with the result from running the base model plus adapter using PeftModel.from_pretrained() without merging. The outputs should be identical or nearly identical, confirming that the merge was performed correctly.

Best Practices

Advanced: Merging with Custom Scripts

For maximum control, you can merge LoRA weights manually without relying on the peft library. This is useful when you need to integrate merging into a custom pipeline or when working with frameworks outside the Hugging Face ecosystem. The core operation is straightforward matrix arithmetic.

import torch
from safetensors.torch import load_file, save_file

# Load base model weights (simplified example for a single layer)
base_weights = load_file("base_model/model.safetensors")

# Load LoRA adapter weights
adapter_weights = load_file("my-lora-adapter/adapter_model.safetensors")

# Example: merge a single attention layer
# LoRA stores A and B matrices for each adapted linear layer
# The naming convention is typically: base_model.model.layers.{i}.self_attn.q_proj.lora_A.weight
# and base_model.model.layers.{i}.self_attn.q_proj.lora_B.weight

scaling = 16.0  # This value is stored in adapter_config.json

for key in list(adapter_weights.keys()):
    if key.endswith("lora_A.weight"):
        # Find the corresponding B matrix and base weight
        base_key = key.replace(".lora_A.weight", ".weight")
        b_key = key.replace("lora_A", "lora_B")

        A = adapter_weights[key]   # shape: (r, d_in)
        B = adapter_weights[b_key] # shape: (d_out, r)

        # Compute the delta: B @ A * scaling
        delta = (B @ A) * scaling

        # Add delta to the base weight
        base_weights[base_key] = base_weights[base_key] + delta.to(base_weights[base_key].dtype)

# Save the merged weights
save_file(base_weights, "merged_model/model.safetensors")

This manual approach gives you full visibility into the merging process but requires careful handling of key naming conventions, data types, and scaling factors. Always cross-reference the adapter_config.json file to confirm the correct lora_alpha and r values used during training.

Conclusion

Merging LoRA adapters with base models is a practical and often necessary step in the model deployment lifecycle. It transforms a modular training artifact into a unified, deployable model that integrates seamlessly with standard inference pipelines. Whether you use the high-level peft API for convenience or implement the merge manually for fine-grained control, understanding the underlying matrix arithmetic ensures you can debug issues, combine multiple adapters, and produce reliable production models. By following the best practices of preserving original adapters, verifying outputs, and documenting provenance, you can confidently ship merged models that retain the full capability of your fine-tuning work while simplifying the operational complexity of your serving infrastructure.

— Ad —

Google AdSense will appear here after approval

← Back to all articles