Add Unsloth backend for 2-5x faster training (Phase 5) — v0.4.3

- Add `backend: unsloth` config option (default: transformers)
- Create utils/unsloth.py with FastLanguageModel integration
- Update SFT/DPO/GRPO trainers with _setup_unsloth() path
- Auto-detect unsloth and suggest enabling in `soup train`
- Add `fast` extra: pip install 'soup-cli[fast]'
- Add unsloth to `soup doctor` dependency check
- Add `backend` sweep shortcut
- 33 new tests (404 total), ruff clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-03-23 16:55:44 +05:00
parent dd73d91356
commit 6de66fb28c
13 changed files with 849 additions and 160 deletions

View File

@ -43,7 +43,9 @@ soup train --config soup.yaml
**Data pipeline:** `data/loader.py` handles local files (JSONL/JSON/CSV/Parquet) and HuggingFace datasets. `data/formats.py` auto-detects and normalizes alpaca/sharegpt/chatml formats into a unified `{"messages": [...]}` structure. Also supports reverse conversion via `messages_to_format()`.
**Trainer:** `trainer/sft.py` (`SFTTrainerWrapper`), `trainer/dpo.py` (`DPOTrainerWrapper`), and `trainer/grpo.py` (`GRPOTrainerWrapper`) wrap HuggingFace's SFTTrainer/DPOTrainer/GRPOTrainer with auto quantization (BitsAndBytes), LoRA (PEFT), and batch size estimation. Heavy ML imports are lazy (inside methods) so CLI stays fast for non-training commands. All trainers enable Rich progress bars for HuggingFace Hub model downloads via `_enable_hf_transfer_progress()`.
**Trainer:** `trainer/sft.py` (`SFTTrainerWrapper`), `trainer/dpo.py` (`DPOTrainerWrapper`), and `trainer/grpo.py` (`GRPOTrainerWrapper`) wrap HuggingFace's SFTTrainer/DPOTrainer/GRPOTrainer with auto quantization (BitsAndBytes), LoRA (PEFT), and batch size estimation. Heavy ML imports are lazy (inside methods) so CLI stays fast for non-training commands. All trainers enable Rich progress bars for HuggingFace Hub model downloads via `_enable_hf_transfer_progress()`. All trainers support `backend: unsloth` — when enabled, model loading is delegated to `utils/unsloth.py` which uses `unsloth.FastLanguageModel` for 2-5x faster training.
**Unsloth backend:** `utils/unsloth.py` provides `is_unsloth_available()`, `get_unsloth_version()`, and `load_model_and_tokenizer()`. The latter uses `FastLanguageModel.from_pretrained()` + `FastLanguageModel.get_peft_model()` which handles quantization, LoRA patching, and kernel optimization internally. Config: `backend: unsloth` (default: `transformers`). Install: `pip install 'soup-cli[fast]'`. Auto-detection: if unsloth is installed but not enabled, `commands/train.py` shows a hint.
**GRPO (Group Relative Policy Optimization):** `trainer/grpo.py` implements reasoning model training (DeepSeek-R1 style). Generates multiple completions per prompt, scores them with reward functions, and optimizes using group-relative advantages. `trainer/rewards.py` provides built-in reward functions (`accuracy` — checks final answer, `format` — checks `<think>` blocks) and supports custom rewards via Python files. Config: `task: grpo`, `grpo_beta`, `num_generations`, `reward_fn`.
@ -164,3 +166,4 @@ Test suite lives in `tests/`:
| `test_quickstart.py` | `soup quickstart` demo, data/config creation, --dry-run |
| `test_grpo.py` | GRPO config, rewards, data prep, template, sweep shortcuts |
| `test_progress.py` | Rich download progress bar, `_enable_hf_transfer_progress` |
| `test_unsloth.py` | Unsloth backend config, detection, trainer integration, templates |

View File

@ -104,6 +104,7 @@ soup export --model ./output --format gguf --quant q4_k_m
```yaml
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/train.jsonl
@ -122,6 +123,39 @@ training:
output: ./output
```
## Unsloth Backend (2-5x Faster Training)
Use the [Unsloth](https://github.com/unslothai/unsloth) backend for significantly faster training and up to 80% less VRAM:
```bash
# Install unsloth support
pip install 'soup-cli[fast]'
```
Then add one line to your config:
```yaml
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
backend: unsloth # 2-5x faster, -80% VRAM
data:
train: ./data/train.jsonl
format: alpaca
training:
epochs: 3
lr: 2e-5
quantization: 4bit
lora:
r: 64
alpha: 16
```
Works with all training tasks: SFT, DPO, and GRPO. If unsloth is installed but not enabled, Soup will suggest it automatically.
> **Tip:** Soup auto-detects unsloth. When installed, you'll see a hint during `soup train` if you haven't enabled it yet.
## DPO Training
Train with preference data using Direct Preference Optimization:
@ -545,6 +579,7 @@ soup --verbose <command> Full traceback on errors
| Extra | Install | What it adds |
|---|---|---|
| `fast` | `pip install 'soup-cli[fast]'` | Unsloth backend (2-5x faster, -80% VRAM) |
| `serve` | `pip install 'soup-cli[serve]'` | Inference server (FastAPI + uvicorn) |
| `data` | `pip install 'soup-cli[data]'` | Deduplication (MinHash via datasketch) |
| `eval` | `pip install 'soup-cli[eval]'` | Benchmark evaluation (lm-evaluation-harness) |

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.4.2"
version = "0.4.3"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "MIT"
@ -46,6 +46,7 @@ ui = ["fastapi>=0.104.0", "uvicorn>=0.24.0"]
serve = ["fastapi>=0.104.0", "uvicorn>=0.24.0"]
generate = ["httpx>=0.24.0"]
deepspeed = ["deepspeed>=0.12.0"]
fast = ["unsloth>=2024.8"]
[project.scripts]
soup = "soup_cli.cli:run"

View File

@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune LLMs in one command."""
__version__ = "0.4.2"
__version__ = "0.4.3"

View File

@ -31,6 +31,7 @@ DEPS = [
("wandb", "wandb", "0.15.0", False),
("deepspeed", "deepspeed", "0.12.0", False),
("httpx", "httpx", "0.24.0", False),
("unsloth", "unsloth", "2024.8", False),
]

View File

@ -290,6 +290,7 @@ def _set_nested_param(config_dict: dict, key: str, value) -> dict:
"grpo_beta": "training.grpo_beta",
"num_generations": "training.num_generations",
"reward_fn": "training.reward_fn",
"backend": "backend",
}
full_key = shortcuts.get(key, key)

View File

@ -100,18 +100,34 @@ def train(
# Detect hardware
device, device_name = detect_device()
gpu_info = get_gpu_info()
backend_label = cfg.backend
if cfg.backend == "unsloth":
backend_label = "unsloth [green](fast mode)[/]"
console.print(
Panel(
f"Device: [bold]{device_name}[/]\n"
f"Memory: [bold]{gpu_info['memory_total']}[/]\n"
f"Model: [bold]{cfg.base}[/]\n"
f"Task: [bold]{cfg.task}[/]\n"
f"LoRA: [bold]r={cfg.training.lora.r}, alpha={cfg.training.lora.alpha}[/]\n"
f"Quant: [bold]{cfg.training.quantization}[/]",
f"Device: [bold]{device_name}[/]\n"
f"Memory: [bold]{gpu_info['memory_total']}[/]\n"
f"Model: [bold]{cfg.base}[/]\n"
f"Task: [bold]{cfg.task}[/]\n"
f"Backend: [bold]{backend_label}[/]\n"
f"LoRA: [bold]r={cfg.training.lora.r}, alpha={cfg.training.lora.alpha}[/]\n"
f"Quant: [bold]{cfg.training.quantization}[/]",
title="Training Setup",
)
)
# Suggest unsloth if available but not being used
if cfg.backend == "transformers":
from soup_cli.utils.unsloth import is_unsloth_available
if is_unsloth_available():
console.print(
"[dim]Tip: unsloth is installed. Add [bold]backend: unsloth[/dim]"
"[dim] to soup.yaml for 2-5x faster training.[/]"
)
if not dry_run and not yes:
if not typer.confirm("Start training?", default=True):
console.print("[yellow]Cancelled.[/]")

View File

@ -67,6 +67,10 @@ class SoupConfig(BaseModel):
base: str = Field(..., description="Base model name or path (HF model ID)")
task: Literal["sft", "dpo", "grpo"] = Field(default="sft", description="Training task type")
backend: Literal["transformers", "unsloth"] = Field(
default="transformers",
description="Training backend: transformers (default) or unsloth (2-5x faster)",
)
data: DataConfig
training: TrainingConfig = Field(default_factory=TrainingConfig)
output: str = Field(default="./output", description="Output directory for trained model")
@ -81,6 +85,7 @@ TEMPLATES: dict[str, str] = {
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/train.jsonl
@ -105,6 +110,7 @@ output: ./output
base: codellama/CodeLlama-7b-Instruct-hf
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/code_train.jsonl
@ -129,6 +135,7 @@ output: ./output
base: meta-llama/Llama-3.1-8B-Instruct
task: grpo
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/reasoning_train.jsonl
@ -157,6 +164,7 @@ output: ./output
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/medical_train.jsonl

View File

@ -40,12 +40,6 @@ class DPOTrainerWrapper:
def setup(self, dataset: dict):
"""Load model, tokenizer, apply LoRA, create DPO trainer."""
from datasets import Dataset
from peft import LoraConfig, TaskType, get_peft_model, prepare_model_for_kbit_training
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
)
from trl import DPOConfig, DPOTrainer
# Enable Rich progress bar for HuggingFace downloads
@ -55,52 +49,13 @@ class DPOTrainerWrapper:
cfg = self.config
tcfg = cfg.training
use_unsloth = cfg.backend == "unsloth"
# --- Tokenizer ---
console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
self.tokenizer = AutoTokenizer.from_pretrained(cfg.base, trust_remote_code=True)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
if use_unsloth:
self._setup_unsloth(cfg, tcfg)
else:
self._setup_transformers(cfg, tcfg)
# --- Quantization ---
bnb_config = None
if tcfg.quantization == "4bit":
import torch
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":
bnb_config = BitsAndBytesConfig(load_in_8bit=True)
# --- Model ---
console.print(f"[dim]Loading model: {cfg.base}[/]")
model_kwargs = {"trust_remote_code": True, "device_map": "auto"}
if bnb_config:
model_kwargs["quantization_config"] = bnb_config
self.model = AutoModelForCausalLM.from_pretrained(cfg.base, **model_kwargs)
if tcfg.quantization in ("4bit", "8bit"):
self.model = prepare_model_for_kbit_training(self.model)
# --- LoRA ---
target_modules = tcfg.lora.target_modules
if target_modules == "auto":
target_modules = None # peft will auto-detect
lora_config = LoraConfig(
r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=target_modules,
task_type=TaskType.CAUSAL_LM,
bias="none",
)
self.model = get_peft_model(self.model, lora_config)
trainable, total = self.model.get_nb_trainable_parameters()
pct = 100 * trainable / total
console.print(
@ -183,6 +138,70 @@ class DPOTrainerWrapper:
self._output_dir = str(output_dir)
def _setup_transformers(self, cfg, tcfg):
"""Load model via standard transformers + peft pipeline."""
from peft import LoraConfig, TaskType, get_peft_model, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
self.tokenizer = AutoTokenizer.from_pretrained(cfg.base, trust_remote_code=True)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
bnb_config = None
if tcfg.quantization == "4bit":
import torch
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":
bnb_config = BitsAndBytesConfig(load_in_8bit=True)
console.print(f"[dim]Loading model: {cfg.base}[/]")
model_kwargs = {"trust_remote_code": True, "device_map": "auto"}
if bnb_config:
model_kwargs["quantization_config"] = bnb_config
self.model = AutoModelForCausalLM.from_pretrained(cfg.base, **model_kwargs)
if tcfg.quantization in ("4bit", "8bit"):
self.model = prepare_model_for_kbit_training(self.model)
target_modules = tcfg.lora.target_modules
if target_modules == "auto":
target_modules = None
lora_config = LoraConfig(
r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=target_modules,
task_type=TaskType.CAUSAL_LM,
bias="none",
)
self.model = get_peft_model(self.model, lora_config)
def _setup_unsloth(self, cfg, tcfg):
"""Load model via unsloth FastLanguageModel (2-5x faster)."""
from soup_cli.utils.unsloth import load_model_and_tokenizer
console.print(f"[dim]Loading model via [bold]unsloth[/]: {cfg.base}[/]")
self.model, self.tokenizer = load_model_and_tokenizer(
model_name=cfg.base,
max_seq_length=cfg.data.max_length,
quantization=tcfg.quantization,
lora_r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=tcfg.lora.target_modules,
)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
def train(
self,
display: Optional[object] = None,

View File

@ -41,12 +41,6 @@ class GRPOTrainerWrapper:
def setup(self, dataset: dict):
"""Load model, tokenizer, apply LoRA, create GRPO trainer."""
from datasets import Dataset
from peft import LoraConfig, TaskType, get_peft_model, prepare_model_for_kbit_training
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
)
from trl import GRPOConfig, GRPOTrainer
# Enable Rich progress bar for HuggingFace downloads
@ -56,57 +50,18 @@ class GRPOTrainerWrapper:
cfg = self.config
tcfg = cfg.training
use_unsloth = cfg.backend == "unsloth"
# --- Load reward function ---
from soup_cli.trainer.rewards import load_reward_fn
reward_fn = load_reward_fn(tcfg.reward_fn)
# --- Tokenizer ---
console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
self.tokenizer = AutoTokenizer.from_pretrained(cfg.base, trust_remote_code=True)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
if use_unsloth:
self._setup_unsloth(cfg, tcfg)
else:
self._setup_transformers(cfg, tcfg)
# --- Quantization ---
bnb_config = None
if tcfg.quantization == "4bit":
import torch
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":
bnb_config = BitsAndBytesConfig(load_in_8bit=True)
# --- Model ---
console.print(f"[dim]Loading model: {cfg.base}[/]")
model_kwargs = {"trust_remote_code": True, "device_map": "auto"}
if bnb_config:
model_kwargs["quantization_config"] = bnb_config
self.model = AutoModelForCausalLM.from_pretrained(cfg.base, **model_kwargs)
if tcfg.quantization in ("4bit", "8bit"):
self.model = prepare_model_for_kbit_training(self.model)
# --- LoRA ---
target_modules = tcfg.lora.target_modules
if target_modules == "auto":
target_modules = None # peft will auto-detect
lora_config = LoraConfig(
r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=target_modules,
task_type=TaskType.CAUSAL_LM,
bias="none",
)
self.model = get_peft_model(self.model, lora_config)
trainable, total = self.model.get_nb_trainable_parameters()
pct = 100 * trainable / total
console.print(
@ -192,6 +147,70 @@ class GRPOTrainerWrapper:
self._output_dir = str(output_dir)
def _setup_transformers(self, cfg, tcfg):
"""Load model via standard transformers + peft pipeline."""
from peft import LoraConfig, TaskType, get_peft_model, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
self.tokenizer = AutoTokenizer.from_pretrained(cfg.base, trust_remote_code=True)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
bnb_config = None
if tcfg.quantization == "4bit":
import torch
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":
bnb_config = BitsAndBytesConfig(load_in_8bit=True)
console.print(f"[dim]Loading model: {cfg.base}[/]")
model_kwargs = {"trust_remote_code": True, "device_map": "auto"}
if bnb_config:
model_kwargs["quantization_config"] = bnb_config
self.model = AutoModelForCausalLM.from_pretrained(cfg.base, **model_kwargs)
if tcfg.quantization in ("4bit", "8bit"):
self.model = prepare_model_for_kbit_training(self.model)
target_modules = tcfg.lora.target_modules
if target_modules == "auto":
target_modules = None
lora_config = LoraConfig(
r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=target_modules,
task_type=TaskType.CAUSAL_LM,
bias="none",
)
self.model = get_peft_model(self.model, lora_config)
def _setup_unsloth(self, cfg, tcfg):
"""Load model via unsloth FastLanguageModel (2-5x faster)."""
from soup_cli.utils.unsloth import load_model_and_tokenizer
console.print(f"[dim]Loading model via [bold]unsloth[/]: {cfg.base}[/]")
self.model, self.tokenizer = load_model_and_tokenizer(
model_name=cfg.base,
max_seq_length=cfg.data.max_length,
quantization=tcfg.quantization,
lora_r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=tcfg.lora.target_modules,
)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
def train(
self,
display: Optional[object] = None,

View File

@ -33,13 +33,7 @@ class SFTTrainerWrapper:
def setup(self, dataset: dict):
"""Load model, tokenizer, apply LoRA, create trainer."""
from datasets import Dataset
from peft import LoraConfig, TaskType, get_peft_model, prepare_model_for_kbit_training
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments,
)
from transformers import TrainingArguments
from trl import SFTTrainer
# Enable Rich progress bar for HuggingFace downloads
@ -47,52 +41,13 @@ class SFTTrainerWrapper:
cfg = self.config
tcfg = cfg.training
use_unsloth = cfg.backend == "unsloth"
# --- Tokenizer ---
console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
self.tokenizer = AutoTokenizer.from_pretrained(cfg.base, trust_remote_code=True)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
if use_unsloth:
self._setup_unsloth(cfg, tcfg)
else:
self._setup_transformers(cfg, tcfg)
# --- Quantization ---
bnb_config = None
if tcfg.quantization == "4bit":
import torch
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":
bnb_config = BitsAndBytesConfig(load_in_8bit=True)
# --- Model ---
console.print(f"[dim]Loading model: {cfg.base}[/]")
model_kwargs = {"trust_remote_code": True, "device_map": "auto"}
if bnb_config:
model_kwargs["quantization_config"] = bnb_config
self.model = AutoModelForCausalLM.from_pretrained(cfg.base, **model_kwargs)
if tcfg.quantization in ("4bit", "8bit"):
self.model = prepare_model_for_kbit_training(self.model)
# --- LoRA ---
target_modules = tcfg.lora.target_modules
if target_modules == "auto":
target_modules = None # peft will auto-detect
lora_config = LoraConfig(
r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=target_modules,
task_type=TaskType.CAUSAL_LM,
bias="none",
)
self.model = get_peft_model(self.model, lora_config)
trainable, total = self.model.get_nb_trainable_parameters()
pct = 100 * trainable / total
console.print(
@ -188,6 +143,72 @@ class SFTTrainerWrapper:
self._output_dir = str(output_dir)
def _setup_transformers(self, cfg, tcfg):
"""Load model via standard transformers + peft pipeline."""
from peft import LoraConfig, TaskType, get_peft_model, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
self.tokenizer = AutoTokenizer.from_pretrained(cfg.base, trust_remote_code=True)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
# Quantization
bnb_config = None
if tcfg.quantization == "4bit":
import torch
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
elif tcfg.quantization == "8bit":
bnb_config = BitsAndBytesConfig(load_in_8bit=True)
console.print(f"[dim]Loading model: {cfg.base}[/]")
model_kwargs = {"trust_remote_code": True, "device_map": "auto"}
if bnb_config:
model_kwargs["quantization_config"] = bnb_config
self.model = AutoModelForCausalLM.from_pretrained(cfg.base, **model_kwargs)
if tcfg.quantization in ("4bit", "8bit"):
self.model = prepare_model_for_kbit_training(self.model)
# LoRA
target_modules = tcfg.lora.target_modules
if target_modules == "auto":
target_modules = None
lora_config = LoraConfig(
r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=target_modules,
task_type=TaskType.CAUSAL_LM,
bias="none",
)
self.model = get_peft_model(self.model, lora_config)
def _setup_unsloth(self, cfg, tcfg):
"""Load model via unsloth FastLanguageModel (2-5x faster)."""
from soup_cli.utils.unsloth import load_model_and_tokenizer
console.print(f"[dim]Loading model via [bold]unsloth[/]: {cfg.base}[/]")
self.model, self.tokenizer = load_model_and_tokenizer(
model_name=cfg.base,
max_seq_length=cfg.data.max_length,
quantization=tcfg.quantization,
lora_r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=tcfg.lora.target_modules,
)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
def train(
self,
display: Optional[object] = None,

74
soup_cli/utils/unsloth.py Normal file
View File

@ -0,0 +1,74 @@
"""Unsloth backend utilities — detection, model loading, LoRA patching."""
from __future__ import annotations
def is_unsloth_available() -> bool:
"""Check if unsloth is installed and importable."""
try:
import unsloth # noqa: F401
return True
except ImportError:
return False
def get_unsloth_version() -> str | None:
"""Return unsloth version string, or None if not installed."""
try:
import unsloth
return getattr(unsloth, "__version__", "unknown")
except ImportError:
return None
def load_model_and_tokenizer(
model_name: str,
max_seq_length: int,
quantization: str = "4bit",
lora_r: int = 64,
lora_alpha: int = 16,
lora_dropout: float = 0.05,
target_modules: str | list[str] | None = "auto",
):
"""Load model + tokenizer via unsloth FastLanguageModel with LoRA already applied.
Returns (model, tokenizer) model already has LoRA adapters attached.
Unsloth handles quantization, LoRA patching, and kernel optimization internally.
"""
from unsloth import FastLanguageModel
load_in_4bit = quantization == "4bit"
# Unsloth's FastLanguageModel.from_pretrained handles quantization internally
dtype = None # auto-detect
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=model_name,
max_seq_length=max_seq_length,
dtype=dtype,
load_in_4bit=load_in_4bit,
)
# Resolve target_modules for LoRA
if target_modules == "auto" or target_modules is None:
# Unsloth default: all linear layers for maximum performance
target_modules = [
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
]
elif isinstance(target_modules, str):
target_modules = [target_modules]
# Apply LoRA via unsloth's optimized path
model = FastLanguageModel.get_peft_model(
model,
r=lora_r,
lora_alpha=lora_alpha,
lora_dropout=lora_dropout,
target_modules=target_modules,
bias="none",
use_gradient_checkpointing="unsloth", # 2x longer context for free
)
return model, tokenizer

491
tests/test_unsloth.py Normal file
View File

@ -0,0 +1,491 @@
"""Tests for Unsloth backend — config, detection, trainer integration, templates."""
from unittest.mock import MagicMock, patch
import pytest
from soup_cli.config.schema import TEMPLATES, SoupConfig
# ─── Config Tests ───────────────────────────────────────────────────────────
class TestUnslothConfig:
"""Test backend config field validation."""
def test_backend_default_is_transformers(self):
"""Default backend should be 'transformers'."""
cfg = SoupConfig(
base="some-model",
data={"train": "./data.jsonl"},
)
assert cfg.backend == "transformers"
def test_backend_unsloth_accepted(self):
"""backend: unsloth should be valid."""
cfg = SoupConfig(
base="some-model",
backend="unsloth",
data={"train": "./data.jsonl"},
)
assert cfg.backend == "unsloth"
def test_backend_transformers_accepted(self):
"""backend: transformers should be valid."""
cfg = SoupConfig(
base="some-model",
backend="transformers",
data={"train": "./data.jsonl"},
)
assert cfg.backend == "transformers"
def test_backend_invalid_rejected(self):
"""Invalid backend should raise validation error."""
with pytest.raises(Exception):
SoupConfig(
base="some-model",
backend="invalid",
data={"train": "./data.jsonl"},
)
def test_backend_with_sft(self):
"""Unsloth backend should work with SFT task."""
cfg = SoupConfig(
base="some-model",
task="sft",
backend="unsloth",
data={"train": "./data.jsonl"},
)
assert cfg.task == "sft"
assert cfg.backend == "unsloth"
def test_backend_with_dpo(self):
"""Unsloth backend should work with DPO task."""
cfg = SoupConfig(
base="some-model",
task="dpo",
backend="unsloth",
data={"train": "./data.jsonl"},
)
assert cfg.task == "dpo"
assert cfg.backend == "unsloth"
def test_backend_with_grpo(self):
"""Unsloth backend should work with GRPO task."""
cfg = SoupConfig(
base="some-model",
task="grpo",
backend="unsloth",
data={"train": "./data.jsonl"},
)
assert cfg.task == "grpo"
assert cfg.backend == "unsloth"
def test_backend_in_model_dump(self):
"""backend field should appear in model_dump output."""
cfg = SoupConfig(
base="some-model",
backend="unsloth",
data={"train": "./data.jsonl"},
)
dump = cfg.model_dump()
assert dump["backend"] == "unsloth"
def test_full_unsloth_config(self):
"""Full config with unsloth backend should validate."""
cfg = SoupConfig(
base="meta-llama/Llama-3.1-8B-Instruct",
task="sft",
backend="unsloth",
data={"train": "./data.jsonl", "format": "alpaca", "max_length": 4096},
training={
"epochs": 3,
"lr": 2e-5,
"quantization": "4bit",
"lora": {"r": 64, "alpha": 16},
},
)
assert cfg.backend == "unsloth"
assert cfg.training.quantization == "4bit"
assert cfg.data.max_length == 4096
# ─── Detection Tests ────────────────────────────────────────────────────────
class TestUnslothDetection:
"""Test unsloth availability detection."""
def test_is_unsloth_available_when_installed(self):
"""Should return True when unsloth is importable."""
mock_module = MagicMock()
with patch.dict("sys.modules", {"unsloth": mock_module}):
# Need to reimport to avoid cached result
import importlib
import soup_cli.utils.unsloth
importlib.reload(soup_cli.utils.unsloth)
assert soup_cli.utils.unsloth.is_unsloth_available() is True
def test_is_unsloth_available_when_not_installed(self):
"""Should return False when unsloth is not importable."""
from soup_cli.utils.unsloth import is_unsloth_available
# Default environment doesn't have unsloth
# This test works because unsloth isn't installed in test env
result = is_unsloth_available()
assert isinstance(result, bool)
def test_get_unsloth_version_when_not_installed(self):
"""Should return None when unsloth is not installed."""
from soup_cli.utils.unsloth import get_unsloth_version
result = get_unsloth_version()
# In test env, unsloth is not installed
assert result is None or isinstance(result, str)
def test_get_unsloth_version_when_installed(self):
"""Should return version string when unsloth is installed."""
mock_module = MagicMock()
mock_module.__version__ = "2024.11.0"
with patch.dict("sys.modules", {"unsloth": mock_module}):
import importlib
import soup_cli.utils.unsloth
importlib.reload(soup_cli.utils.unsloth)
result = soup_cli.utils.unsloth.get_unsloth_version()
assert result == "2024.11.0"
# ─── Trainer Integration Tests ──────────────────────────────────────────────
class TestSFTUnslothIntegration:
"""Test SFT trainer with unsloth backend."""
def test_sft_wrapper_init_with_unsloth(self):
"""SFTTrainerWrapper should accept unsloth backend config."""
from soup_cli.trainer.sft import SFTTrainerWrapper
cfg = SoupConfig(
base="some-model",
backend="unsloth",
data={"train": "./data.jsonl"},
)
wrapper = SFTTrainerWrapper(cfg, device="cuda")
assert wrapper.config.backend == "unsloth"
def test_sft_wrapper_init_with_transformers(self):
"""SFTTrainerWrapper should work with default transformers backend."""
from soup_cli.trainer.sft import SFTTrainerWrapper
cfg = SoupConfig(
base="some-model",
data={"train": "./data.jsonl"},
)
wrapper = SFTTrainerWrapper(cfg, device="cuda")
assert wrapper.config.backend == "transformers"
def test_sft_setup_unsloth_calls_load(self):
"""_setup_unsloth should call utils.unsloth.load_model_and_tokenizer."""
from soup_cli.trainer.sft import SFTTrainerWrapper
cfg = SoupConfig(
base="some-model",
backend="unsloth",
data={"train": "./data.jsonl", "max_length": 2048},
training={"lora": {"r": 64, "alpha": 16, "dropout": 0.05}},
)
wrapper = SFTTrainerWrapper(cfg, device="cuda")
mock_model = MagicMock()
mock_model.get_nb_trainable_parameters.return_value = (1000, 100000)
mock_tokenizer = MagicMock()
mock_tokenizer.pad_token = "pad"
with patch(
"soup_cli.utils.unsloth.load_model_and_tokenizer",
return_value=(mock_model, mock_tokenizer),
) as mock_load:
wrapper._setup_unsloth(cfg, cfg.training)
mock_load.assert_called_once_with(
model_name="some-model",
max_seq_length=2048,
quantization="4bit",
lora_r=64,
lora_alpha=16,
lora_dropout=0.05,
target_modules="auto",
)
assert wrapper.model is mock_model
assert wrapper.tokenizer is mock_tokenizer
class TestDPOUnslothIntegration:
"""Test DPO trainer with unsloth backend."""
def test_dpo_wrapper_init_with_unsloth(self):
"""DPOTrainerWrapper should accept unsloth backend config."""
from soup_cli.trainer.dpo import DPOTrainerWrapper
cfg = SoupConfig(
base="some-model",
task="dpo",
backend="unsloth",
data={"train": "./data.jsonl"},
)
wrapper = DPOTrainerWrapper(cfg, device="cuda")
assert wrapper.config.backend == "unsloth"
def test_dpo_setup_unsloth_calls_load(self):
"""_setup_unsloth should call utils.unsloth.load_model_and_tokenizer."""
from soup_cli.trainer.dpo import DPOTrainerWrapper
cfg = SoupConfig(
base="some-model",
task="dpo",
backend="unsloth",
data={"train": "./data.jsonl", "max_length": 2048},
)
wrapper = DPOTrainerWrapper(cfg, device="cuda")
mock_model = MagicMock()
mock_tokenizer = MagicMock()
mock_tokenizer.pad_token = "pad"
with patch(
"soup_cli.utils.unsloth.load_model_and_tokenizer",
return_value=(mock_model, mock_tokenizer),
) as mock_load:
wrapper._setup_unsloth(cfg, cfg.training)
mock_load.assert_called_once()
assert wrapper.model is mock_model
class TestGRPOUnslothIntegration:
"""Test GRPO trainer with unsloth backend."""
def test_grpo_wrapper_init_with_unsloth(self):
"""GRPOTrainerWrapper should accept unsloth backend config."""
from soup_cli.trainer.grpo import GRPOTrainerWrapper
cfg = SoupConfig(
base="some-model",
task="grpo",
backend="unsloth",
data={"train": "./data.jsonl"},
)
wrapper = GRPOTrainerWrapper(cfg, device="cuda")
assert wrapper.config.backend == "unsloth"
def test_grpo_setup_unsloth_calls_load(self):
"""_setup_unsloth should call utils.unsloth.load_model_and_tokenizer."""
from soup_cli.trainer.grpo import GRPOTrainerWrapper
cfg = SoupConfig(
base="some-model",
task="grpo",
backend="unsloth",
data={"train": "./data.jsonl", "max_length": 4096},
)
wrapper = GRPOTrainerWrapper(cfg, device="cuda")
mock_model = MagicMock()
mock_tokenizer = MagicMock()
mock_tokenizer.pad_token = "pad"
with patch(
"soup_cli.utils.unsloth.load_model_and_tokenizer",
return_value=(mock_model, mock_tokenizer),
) as mock_load:
wrapper._setup_unsloth(cfg, cfg.training)
mock_load.assert_called_once()
assert wrapper.model is mock_model
# ─── Template Tests ─────────────────────────────────────────────────────────
class TestTemplatesHaveUnslothHint:
"""Test that templates mention unsloth backend as an option."""
def test_chat_template_mentions_unsloth(self):
assert "unsloth" in TEMPLATES["chat"]
def test_code_template_mentions_unsloth(self):
assert "unsloth" in TEMPLATES["code"]
def test_reasoning_template_mentions_unsloth(self):
assert "unsloth" in TEMPLATES["reasoning"]
def test_medical_template_mentions_unsloth(self):
assert "unsloth" in TEMPLATES["medical"]
def test_templates_default_backend_commented(self):
"""Templates should have unsloth commented out (not active by default)."""
for name, template in TEMPLATES.items():
assert "# backend: unsloth" in template, f"{name} template missing unsloth hint"
# ─── Sweep Shortcut Tests ──────────────────────────────────────────────────
class TestBackendSweepParam:
"""Test backend parameter in sweep shortcuts."""
def test_backend_shortcut(self):
from soup_cli.commands.sweep import _set_nested_param
config = {"backend": "transformers"}
_set_nested_param(config, "backend", "unsloth")
assert config["backend"] == "unsloth"
# ─── Doctor Tests ───────────────────────────────────────────────────────────
class TestDoctorUnsloth:
"""Test that doctor checks for unsloth."""
def test_unsloth_in_deps_list(self):
from soup_cli.commands.doctor import DEPS
pkg_names = [pkg_name for _, pkg_name, _, _ in DEPS]
assert "unsloth" in pkg_names
def test_unsloth_is_optional(self):
from soup_cli.commands.doctor import DEPS
for import_name, pkg_name, _, required in DEPS:
if pkg_name == "unsloth":
assert required is False
break
else:
pytest.fail("unsloth not found in DEPS")
# ─── Load Function Tests ───────────────────────────────────────────────────
class TestLoadModelAndTokenizer:
"""Test the load_model_and_tokenizer function with mocked unsloth."""
def test_load_with_4bit(self):
"""Should pass load_in_4bit=True for 4bit quantization."""
mock_flm = MagicMock()
mock_model = MagicMock()
mock_tokenizer = MagicMock()
mock_flm.from_pretrained.return_value = (mock_model, mock_tokenizer)
mock_flm.get_peft_model.return_value = mock_model
with patch.dict("sys.modules", {"unsloth": MagicMock(FastLanguageModel=mock_flm)}):
import importlib
import soup_cli.utils.unsloth
importlib.reload(soup_cli.utils.unsloth)
model, tokenizer = soup_cli.utils.unsloth.load_model_and_tokenizer(
model_name="test-model",
max_seq_length=2048,
quantization="4bit",
)
mock_flm.from_pretrained.assert_called_once()
call_kwargs = mock_flm.from_pretrained.call_args
assert call_kwargs[1]["load_in_4bit"] is True
def test_load_with_none_quantization(self):
"""Should pass load_in_4bit=False for 'none' quantization."""
mock_flm = MagicMock()
mock_model = MagicMock()
mock_tokenizer = MagicMock()
mock_flm.from_pretrained.return_value = (mock_model, mock_tokenizer)
mock_flm.get_peft_model.return_value = mock_model
with patch.dict("sys.modules", {"unsloth": MagicMock(FastLanguageModel=mock_flm)}):
import importlib
import soup_cli.utils.unsloth
importlib.reload(soup_cli.utils.unsloth)
soup_cli.utils.unsloth.load_model_and_tokenizer(
model_name="test-model",
max_seq_length=2048,
quantization="none",
)
call_kwargs = mock_flm.from_pretrained.call_args
assert call_kwargs[1]["load_in_4bit"] is False
def test_load_auto_target_modules(self):
"""auto target_modules should expand to default linear layers."""
mock_flm = MagicMock()
mock_model = MagicMock()
mock_tokenizer = MagicMock()
mock_flm.from_pretrained.return_value = (mock_model, mock_tokenizer)
mock_flm.get_peft_model.return_value = mock_model
with patch.dict("sys.modules", {"unsloth": MagicMock(FastLanguageModel=mock_flm)}):
import importlib
import soup_cli.utils.unsloth
importlib.reload(soup_cli.utils.unsloth)
soup_cli.utils.unsloth.load_model_and_tokenizer(
model_name="test-model",
max_seq_length=2048,
target_modules="auto",
)
peft_call_kwargs = mock_flm.get_peft_model.call_args
target = peft_call_kwargs[1]["target_modules"]
assert "q_proj" in target
assert "v_proj" in target
def test_load_custom_target_modules(self):
"""Custom target_modules list should be passed through."""
mock_flm = MagicMock()
mock_model = MagicMock()
mock_tokenizer = MagicMock()
mock_flm.from_pretrained.return_value = (mock_model, mock_tokenizer)
mock_flm.get_peft_model.return_value = mock_model
with patch.dict("sys.modules", {"unsloth": MagicMock(FastLanguageModel=mock_flm)}):
import importlib
import soup_cli.utils.unsloth
importlib.reload(soup_cli.utils.unsloth)
custom_modules = ["q_proj", "k_proj"]
soup_cli.utils.unsloth.load_model_and_tokenizer(
model_name="test-model",
max_seq_length=2048,
target_modules=custom_modules,
)
peft_call_kwargs = mock_flm.get_peft_model.call_args
assert peft_call_kwargs[1]["target_modules"] == custom_modules
def test_load_lora_params_passed(self):
"""LoRA params should be forwarded to get_peft_model."""
mock_flm = MagicMock()
mock_model = MagicMock()
mock_tokenizer = MagicMock()
mock_flm.from_pretrained.return_value = (mock_model, mock_tokenizer)
mock_flm.get_peft_model.return_value = mock_model
with patch.dict("sys.modules", {"unsloth": MagicMock(FastLanguageModel=mock_flm)}):
import importlib
import soup_cli.utils.unsloth
importlib.reload(soup_cli.utils.unsloth)
soup_cli.utils.unsloth.load_model_and_tokenizer(
model_name="test-model",
max_seq_length=2048,
lora_r=128,
lora_alpha=32,
lora_dropout=0.1,
)
peft_kwargs = mock_flm.get_peft_model.call_args[1]
assert peft_kwargs["r"] == 128
assert peft_kwargs["lora_alpha"] == 32
assert peft_kwargs["lora_dropout"] == 0.1