← Back to DevBytes

Fine-Tuning Small Language Models on Consumer Hardware

Fine-Tuning Small Language Models on Consumer Hardware

Fine-tuning large language models (LLMs) used to require expensive cloud infrastructure with multiple high-end GPUs. Today, thanks to advances in parameter-efficient fine-tuning (PEFT) techniques and the rise of capable small language models (SLMs), developers can fine-tune models on a single consumer GPU — or even a laptop. This tutorial walks you through the concepts, tools, and code you need to fine-tune a small language model on your own machine.

What Is Fine-Tuning a Small Language Model?

A small language model typically refers to a model with fewer than 8 billion parameters — examples include Llama 3.2 (1B and 3B), Microsoft Phi-3 Mini (3.8B), Qwen2.5 (0.5B–7B), and Gemma 2 (2B). Fine-tuning is the process of taking a pre-trained model and training it further on a smaller, task-specific dataset so it performs better on your particular use case. Instead of paying for API calls to a frontier model, you can adapt a compact model to your domain and run it locally.

The key breakthrough that makes this practical on consumer hardware is LoRA (Low-Rank Adaptation) and its successor QLoRA (Quantized LoRA). Instead of updating all of the model's billions of parameters, LoRA injects small trainable rank-decomposition matrices into each layer. Only these small matrices are updated during training, reducing memory usage dramatically while preserving performance.

Why It Matters

Prerequisites and Hardware Requirements

Before you start, make sure you have the following:

Install the required packages:

pip install torch transformers peft trl datasets bitsandbytes accelerate

Step 1: Prepare Your Dataset

Fine-tuning quality depends heavily on your dataset. For instruction tuning, you typically want a JSONL file with instruction, input (optional), and output fields. Here is a small example of a custom dataset for a customer support assistant:

[
  {
    "instruction": "How do I reset my password?",
    "input": "",
    "output": "To reset your password, click 'Forgot Password' on the login page, enter your email, and follow the link sent to your inbox."
  },
  {
    "instruction": "What is your return policy?",
    "input": "",
    "output": "We accept returns within 30 days of purchase. Items must be unused and in original packaging."
  }
]

Load and format the dataset using the Hugging Face datasets library:

from datasets import load_dataset, Dataset
import json

# Load a local JSONL file
with open("train_data.jsonl", "r") as f:
    data = [json.loads(line) for line in f]

dataset = Dataset.from_list(data)

# Format into a single text prompt for causal LM training
def format_prompt(example):
    text = f"### Instruction:\n{example['instruction']}\n\n### Response:\n{example['output']}"
    return {"text": text}

dataset = dataset.map(format_prompt)
print(dataset[0]["text"])

Step 2: Load the Model with 4-Bit Quantization

QLoRA loads the base model in 4-bit precision, which drastically reduces memory. A 7B model that would normally need ~14GB in float16 fits in roughly 4–5GB in 4-bit. Here is how to configure and load the model:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

model_id = "meta-llama/Llama-3.2-3B-Instruct"

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_use_double_quant=True,
)

tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
    torch_dtype=torch.float16,
)
model.config.use_cache = False

Step 3: Configure LoRA Adapters

Next, wrap the model with LoRA adapters. The r parameter controls the rank of the decomposition matrices — higher values mean more capacity but more memory. A value of 8 or 16 works well for most tasks.

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,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

You should see output like trainable params: 19,988,480 || all params: 3,115,512,320 || trainable%: 0.64. Only a fraction of a percent of parameters are actually trained — this is what makes the process feasible on consumer hardware.

Step 4: Set Up the Trainer

The trl library provides SFTTrainer (Supervised Fine-Tuning Trainer), which handles tokenization, padding, and the training loop for you:

from trl import SFTTrainer, SFTConfig

training_args = SFTConfig(
    output_dir="./results",
    num_train_epochs=3,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    logging_steps=10,
    save_strategy="epoch",
    fp16=True,
    max_seq_length=512,
    dataset_text_field="text",
    optim="paged_adamw_8bit",
)

trainer = SFTTrainer(
    model=model,
    train_dataset=dataset,
    args=training_args,
    processing_class=tokenizer,
)

trainer.train()

Key parameters explained:

Step 5: Save and Merge the Adapter

After training, save the LoRA adapter weights. These are tiny — usually 20–100MB — and can be loaded on top of the base model at inference time:

# Save just the adapter
trainer.save_model("./my-lora-adapter")
tokenizer.save_pretrained("./my-lora-adapter")

If you want a standalone model (for example, to run with llama.cpp or ollama), merge the adapter back into the base model and save it:

from peft import PeftModel
import torch

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

merged_model = PeftModel.from_pretrained(base_model, "./my-lora-adapter")
merged_model = merged_model.merge_and_unload()
merged_model.save_pretrained("./my-merged-model")
tokenizer.save_pretrained("./my-merged-model")

Step 6: Test the Fine-Tuned Model

Run inference to verify your model produces the expected outputs:

prompt = "### Instruction:\nHow do I reset my password?\n\n### Response:\n"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)

with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=128,
        temperature=0.7,
        do_sample=True,
        pad_token_id=tokenizer.eos_token_id,
    )

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

Best Practices

Fine-tuning is as much an art as a science. Follow these guidelines to get the best results:

Memory Budget Reference

Here is a rough guide to what you can fine-tune on common consumer GPUs using QLoRA with 4-bit quantization:

Conclusion

Fine-tuning small language models on consumer hardware is no longer a research experiment — it is a practical workflow that any developer can adopt. By combining QLoRA quantization, LoRA adapters, and the Hugging Face ecosystem, you can adapt a 1B–7B parameter model to your specific task using nothing more than a single consumer GPU and a few hundred training examples. The result is a private, cost-effective, and highly customized model that you fully control. Start small with a clean dataset, iterate on your prompt format and hyperparameters, and you will be surprised at how capable a fine-tuned SLM can be for your domain.

— Ad —

Google AdSense will appear here after approval

← Back to all articles