How to Fine-Tune DeepSeek R1 Distill with Unsloth on Linux (Single GPU Guide)

Quick Start (TL;DR)

Fine-tuning DeepSeek R1 distilled reasoning models locally requires preserving the <think>...</think> internal monologue while fitting model weights into consumer VRAM. Run the following commands on Ubuntu 24.04 with an NVIDIA GPU (16GB or 24GB VRAM) to install Unsloth and start 4-bit QLoRA fine-tuning:

# 1. Create a dedicated virtual environment
conda create -n unsloth_env python=3.11 pytorch-cuda=12.4 pytorch cudatoolkit xformers -c pytorch -c nvidia -c xformers -y
conda activate unsloth_env

# 2. Install Unsloth and training dependencies
pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
pip install --no-deps trl peft accelerate bitsandbytes datasets

# 3. Launch training script
python train_r1.py

1. Hardware & VRAM Allocation Matrix

Unsloth uses manual Triton kernel rewrites to cut memory consumption by up to 70% compared to standard Hugging Face PEFT. This enables fine-tuning DeepSeek R1 models on a single workstation GPU:

Base Model Architecture Quantization Min VRAM Required Max Sequence Length Recommended GPU
DeepSeek-R1-Distill-Qwen-1.5B Qwen 2.5 4-bit QLoRA 5.5 GB 8,192 tokens RTX 3060 (6GB mobile / 12GB)
DeepSeek-R1-Distill-Qwen-7B Qwen 2.5 4-bit QLoRA 9.2 GB 4,096 tokens RTX 4070 (12GB) / RTX 3060 (12GB)
DeepSeek-R1-Distill-Llama-8B Llama 3.1 4-bit QLoRA 10.1 GB 4,096 tokens RTX 4070 Ti Super (16GB)
DeepSeek-R1-Distill-Qwen-14B Qwen 2.5 4-bit QLoRA 18.8 GB 4,096 tokens RTX 3090 / RTX 4090 (24GB)
DeepSeek-R1-Distill-Qwen-32B Qwen 2.5 4-bit QLoRA 36.5 GB 2,048 tokens 2x RTX 3090 / A6000 (48GB)

2. Dataset Formatting: Preserving <think> Reasoning Tags

Standard Supervised Fine-Tuning (SFT) breaks reasoning models if you replace the model’s intermediate thinking trace with direct answers. The dataset must format the reasoning block explicitly:

{
  "prompt": "Write a Python function to validate whether an IP address string is an IPv4 or IPv6 format.",
  "response": "<think>\nTo validate an IP address in Python, I should consider using the standard ipaddress module.\n1. Attempt ipaddress.ip_address(address).\n2. Check if isinstance(obj, IPv4Address) or isinstance(obj, IPv6Address).\n3. Handle ValueError exceptions cleanly.\n</think>\nimport ipaddress\n\ndef validate_ip(address: str) -> str:\n    try:\n        ip = ipaddress.ip_address(address)\n        return \"IPv4\" if ip.version == 4 else \"IPv6\"\n    except ValueError:\n        return \"Invalid\""
}

Format your input JSONL file with prompt and response columns matching this convention before loading.


3. The Complete Training Script (train_r1.py)

Save the following code as train_r1.py. This script initializes 4-bit loading, applies LoRA adapters targeting all attention projection matrices, and runs training:

import torch
from unsloth import FastLanguageModel
from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments

# Configuration
max_seq_length = 4096
dtype = None # Auto-detect (Float16 or Bfloat16)
load_in_4bit = True

# 1. Load model and tokenizer
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/DeepSeek-R1-Distill-Qwen-14B",
    max_seq_length=max_seq_length,
    dtype=dtype,
    load_in_4bit=load_in_4bit,
)

# 2. Add LoRA adapters
model = FastLanguageModel.get_peft_model(
    model,
    r=16, # Rank
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    lora_alpha=16,
    lora_dropout=0, # Unsloth optimizes dropout=0 for speed
    bias="none",
    use_gradient_checkpointing="unsloth",
    random_state=3407,
)

# 3. Load dataset
dataset = load_dataset("json", data_files="dataset.jsonl", split="train")

def format_prompts(examples):
    texts = []
    for prompt, response in zip(examples["prompt"], examples["response"]):
        text = f"<|User|>{prompt}<|Assistant|>{response}"
        texts.append(text)
    return {"text": texts}

dataset = dataset.map(format_prompts, batched=True)

# 4. Set training hyperparameters
trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field="text",
    max_seq_length=max_seq_length,
    dataset_num_proc=2,
    packing=False,
    args=TrainingArguments(
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,
        warmup_steps=10,
        max_steps=120,
        learning_rate=2e-4,
        fp16=not torch.cuda.is_bf16_supported(),
        bf16=torch.cuda.is_bf16_supported(),
        logging_steps=1,
        optim="adamw_8bit",
        weight_decay=0.01,
        lr_scheduler_type="linear",
        seed=3407,
        output_dir="outputs",
    ),
)

# 5. Execute training
trainer_stats = trainer.train()
print(f"Training complete. Peak memory: {torch.cuda.max_memory_allocated() / 1e9:.2f} GB")

4. Exporting to GGUF & Serving in Ollama

Once training finishes, export the merged weights directly into GGUF format for deployment on local workstations with Ollama or llama.cpp:

# Export 4-bit quantized GGUF
model.save_pretrained_gguf(
    "deepseek-r1-14b-custom",
    tokenizer,
    quantization_method="q4_k_m"
)

Create a Modelfile to register the custom model with Ollama:

FROM ./deepseek-r1-14b-custom-unsloth.Q4_K_M.gguf

PARAMETER temperature 0.6
PARAMETER top_p 0.95
PARAMETER num_ctx 8192

SYSTEM """You are a domain-specific reasoning assistant. Always formulate your inner monologue inside <think>...</think> before presenting your final answer."""

Register the model:

ollama create deepseek-r1:14b-custom -f Modelfile
ollama run deepseek-r1:14b-custom "Validate this IPv6 CIDR block"

The model produces structured reasoning steps followed by the formatted code response, retaining its analytical capabilities within your specialized problem domain.