高価なNVIDIA GPUなしでLLMをFine-tuningする:MLflow公式QLoRAチュートリアルをM5 Max / Apple Siliconで動かす
はじめに
LLMのFine-tuningを試してみたいと思ったとき、最初に立ちはだかるのがGPUです。
特にQLoRAやLoRAのチュートリアルを見ると、
- NVIDIA GPU
- CUDA
- 20GB以上のVRAM
- A10 / A100 / H100
といった環境を前提にしているものが多くあります。
今回ベースにした MLflow公式の「Fine-Tuning Open-Source LLM using QLoRA with MLflow and PEFT」 も、1枚のNVIDIA A10G 24GB GPUで検証されたチュートリアルです。公式では20GB以上のGPU VRAMが必要とされています。
しかし、個人の学習やモデル開発のためだけに高価なNVIDIA GPUを購入するのは、なかなかハードルが高いです。
そこで本記事では、
MLflow公式QLoRAチュートリアルをApple M5 Max上で動かす
ことを目標にします。
単にMistral 7Bを動かすだけではありません。
公式チュートリアルと同じように、
- データセットを準備する
- Mistral 7Bを4-bit量子化する
- LoRA Adapterを追加する
- QLoRAでFine-tuningする
- MLflowでLossやHyperparameterを管理する
- 学習済みLoRA AdapterをMLflowへ保存する
- MLflowからモデルを読み戻して推論する
ところまで行います。
Apple SiliconはCPUとGPUが同じUnified Memoryを共有する構造を持ち、PyTorch / Transformersからはmps backendを使ってGPUを利用できます。
また、2026年のbitsandbytes 0.50.0ではApple Silicon向けMPS backendが大きく改善され、4-bitおよびLLM.int8構成がMPS上で利用できるようになりました。
つまり、
NVIDIA GPUを購入しなくても、Apple Siliconを使ってLoRA / QLoRAを学び、モデル開発の一連のサイクルを経験できる
環境がかなり整ってきています。
ただし、本記事は「M5 MaxがNVIDIA GPUより高速」という話ではありません。
目的は、
手元のApple Siliconで、実際のLLM Fine-tuningとMLflowによるモデル開発サイクルを学べること
を示すことです。
この記事で分かること
この記事を最後まで実行すると、次の構成を自分のMac上に作れます。
最終的には、
Mistral-7B
+
4-bit Quantization
+
LoRA
+
Apple M5 Max / MPS
+
MLflow
という構成になります。
まず知っておきたい用語
いきなりコードに入る前に、今回登場する技術を簡単に整理します。
Fine-tuningとは
LLMは最初から大量の知識を持っています。
しかし、
一般的なLLM
に、
SQL生成が得意になってほしい
と要求しても、必ずしも期待通りにはなりません。
そこで特定用途のデータを追加学習させます。
これがFine-tuningです。
LoRAとは
通常のFine-tuningでは、モデルの大量のParameterを更新します。
7Bモデルなら約70億Parameterです。
これは非常に重い処理です。
LoRAではBase ModelのParameterを基本的に固定し、小さな追加行列だけを学習します。
つまり、
巨大なBase Model
↓
変更しない
小さなLoRA Adapter
↓
ここだけ学習
という考え方です。
Quantizationとは
Mistral 7BのParameterを、そのまま高精度の数値で保持すると大量のメモリを消費します。
そこでParameterを4-bitなどの小さな表現に変換します。
高精度Weight
↓
4-bit
↓
必要メモリ削減
これをQuantization(量子化)と呼びます。
QLoRAとは
QLoRAは非常に単純化すると、
Quantization
+
LoRA
です。
MLflow公式チュートリアルでも、4-bit量子化したMistral 7BにPEFTでLoRAを追加する構成になっています。
PEFTとは
PEFTはHugging Faceが提供する、
Parameter-Efficient Fine-Tuning
のためのライブラリです。
今回のLoRA Adapter生成を担当します。
MLflowとは
ここは誤解しやすいポイントです。
MLflow自身がLoRAを学習するわけではありません。
役割分担は次のようになります。
| ライブラリ | 主な役割 |
|---|---|
| PyTorch | Deep Learning基盤 |
| Transformers | Mistral、Tokenizer、Trainer |
| bitsandbytes | 4-bit量子化 |
| PEFT | LoRA Adapter |
| Datasets | 学習Dataset |
| MLflow | 実験・Metric・Model管理 |
MLflowにはTransformers / PEFT向けのネイティブ統合があり、LoRA / QLoRAを含むPEFTモデルを記録・ロードできます。
NVIDIA版からM5 Max版へ何を変更するのか
今回の重要ポイントです。
MLflow公式TutorialをそのままMacへコピーしても動きません。
主な変更点は次のとおりです。
| MLflow公式 | M5 Max版 |
|---|---|
| NVIDIA A10G 24GB | Apple M5 Max |
| CUDA | MPS / Metal |
nvidia-smi |
torch.backends.mps |
| bitsandbytes CUDA | bitsandbytes MPS |
paged_adamw_8bit |
adamw_torch |
| batch size 2 | batch size 1 |
| gradient accumulation 4 | 8 |
| effective batch 8 | 8 |
| Python環境 | uv + Python 3.13 |
| MLflow | 同じ |
| Mistral-7B | 同じ |
| Dataset | 同じ |
| LoRA設定 | 原則同じ |
bitsandbytes 0.50.0ではMPS上で4-bit構成が動作しますが、Apple Siliconで8-bit optimizerを使う構成はNVIDIA/CUDA版と同じにはできません。そこでTraining時のOptimizerだけPyTorch標準のadamw_torchへ変更します。
全体のSTEP
MLflow公式チュートリアルの構成は次の7 STEPです。
1. Environment Set up
2. Dataset Preparation
3. Load the Base Model
4. Define a PEFT Model
5. Kick-off a Training Job
6. Save the PEFT Model to MLflow
7. Load the Saved PEFT Model
今回はその前に、
STEP 0
Python 3.13 + uv環境
を追加します。
STEP 0:uv + Python 3.13環境を作る
今回はPython管理にuvを使います。
venvでも構いませんが、uvなら、
Python本体
仮想環境
Package
Lock file
をまとめて管理できます。
uvをインストール
curl -LsSf https://astral.sh/uv/install.sh | sh
shellを読み直します。
source ~/.zshrc
確認します。
uv --version
Python 3.13をインストール
uv python install 3.13
確認します。
uv python list
Project作成
mkdir -p ~/dev/mlflow-m5max-qlora
cd ~/dev/mlflow-m5max-qlora
Python 3.13を固定します。
uv python pin 3.13
仮想環境を作成します。
uv venv --python 3.13
有効化します。
source .venv/bin/activate
確認します。
python --version
想定:
Python 3.13.x
architectureも確認します。
python -c "import platform; print(platform.machine())"
Apple Siliconなら、
arm64
です。
uv projectとして初期化
uv init
最終的には次のような構成になります。
mlflow-m5max-qlora/
├── .python-version
├── .venv/
├── pyproject.toml
├── uv.lock
├── scripts/
└── README.md
STEP 1:PyTorchからM5 Max GPUを使えるか確認する
ここはMLflow公式Tutorialの、
1. Environment Set up
└─ Hardware Requirement
に相当します。
公式は、
nvidia-smi
でNVIDIA GPUを確認します。
M5 MaxではMPSを確認します。
PyTorchとNumPyを追加
uv add torch numpy
MPS確認
uv run python - <<'PY'
import torch
print("PyTorch :", torch.__version__)
print("MPS built :", torch.backends.mps.is_built())
print("MPS available:", torch.backends.mps.is_available())
PY
期待値:
MPS built : True
MPS available: True
PyTorchのmps deviceはAppleのMetal Performance Shadersを利用してmacOS GPU上で計算を実行します。
実際にGPU計算してみる
uv run python - <<'PY'
import torch
device = torch.device("mps")
a = torch.randn(4096, 4096, device=device)
b = torch.randn(4096, 4096, device=device)
c = a @ b
print("device :", c.device)
print("shape :", c.shape)
PY
成功すると、
device : mps
となります。
BF16も確認する
MLflow公式QLoRA Tutorialは4-bit Weightを演算するとき、
torch.bfloat16
を使用します。
M5 Maxでも動くことを確認します。
uv run python - <<'PY'
import torch
device = torch.device("mps")
a = torch.randn(
2048,
2048,
device=device,
dtype=torch.bfloat16,
)
b = torch.randn(
2048,
2048,
device=device,
dtype=torch.bfloat16,
)
c = a @ b
print("device :", c.device)
print("dtype :", c.dtype)
print("shape :", c.shape)
PY
期待結果:
device : mps:0
dtype : torch.bfloat16
shape : torch.Size([2048, 2048])
実際に遭遇したWarning:NumPyがない
最初に次のWarningが出ました。
UserWarning: Failed to initialize NumPy:
No module named 'numpy'
これはBF16やMPSのエラーではありません。
結果が、
device : mps:0
dtype : torch.bfloat16
まで出ていればGPU演算自体は成功しています。
NumPyを追加すればWarningは消えます。
uv add numpy
MPS fallback
MPSで未実装の演算があった場合、CPUへfallbackできるようにします。
export PYTORCH_ENABLE_MPS_FALLBACK=1
scripts/check_mps.py 全文
import platform
import sys
import torch
print("=== Python ===")
print("version :", sys.version.split()[0])
print("architecture :", platform.machine())
print()
print("=== PyTorch ===")
print("version :", torch.__version__)
print("MPS built :", torch.backends.mps.is_built())
print("MPS available :", torch.backends.mps.is_available())
if not torch.backends.mps.is_available():
raise SystemExit("ERROR: MPS is not available")
device = torch.device("mps")
print()
print("=== MPS FP32 Test ===")
a = torch.randn(1024, 1024, device=device)
b = torch.randn(1024, 1024, device=device)
c = a @ b
print("device :", c.device)
print("dtype :", c.dtype)
print("PASS")
print()
print("=== MPS BF16 Test ===")
a = torch.randn(
1024,
1024,
device=device,
dtype=torch.bfloat16,
)
b = torch.randn(
1024,
1024,
device=device,
dtype=torch.bfloat16,
)
c = a @ b
print("device :", c.device)
print("dtype :", c.dtype)
print("PASS")
実行:
uv run python scripts/check_mps.py
STEP 1-2:MLflow / Transformers / PEFTをインストール
MLflow公式Tutorialの、
Environment Set up
└─ Install Python Libraries
に相当します。
必要なPackageを追加します。
uv add \
mlflow \
transformers \
peft \
accelerate \
datasets \
torchvision
bitsandbytesはMPS対応が改善された0.50系を使います。
uv add "bitsandbytes>=0.50.0"
macOS 26以降なら、bitsandbytesのMPS向け最適化Metal kernelを利用するためkernels packageを追加できます。
uv add kernels
なぜtorchvisionが必要なのか
今回の処理はText-to-SQLなので、画像処理をする予定はありません。
それでもtorchvisionを追加しています。
理由は、MLflowがTransformers Modelを保存するときに推論環境用Packageを自動検出し、PyTorch関連依存としてtorchvisionのVersionを調べるケースがあるためです。
実際、MLflowへPEFT Modelを保存したとき、
PackageNotFoundError:
No package metadata was found for torchvision
というエラーに遭遇しました。
解決方法は、
uv add torchvision
でした。
このエラーについては後でもう一度説明します。
scripts/check_mlflow_stack.py 全文
import torch
import mlflow
import transformers
import peft
import accelerate
import datasets
import bitsandbytes
from accelerate import Accelerator
from transformers import BitsAndBytesConfig
print("=== Versions ===")
print("torch :", torch.__version__)
print("mlflow :", mlflow.__version__)
print("transformers :", transformers.__version__)
print("peft :", peft.__version__)
print("accelerate :", accelerate.__version__)
print("datasets :", datasets.__version__)
print("bitsandbytes :", bitsandbytes.__version__)
print()
print("=== MPS ===")
print("MPS built :", torch.backends.mps.is_built())
print("MPS available :", torch.backends.mps.is_available())
accelerator = Accelerator()
print("Accelerate :", accelerator.device)
print()
print("=== QLoRA Config ===")
config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
print("4bit :", config.load_in_4bit)
print("quant type :", config.bnb_4bit_quant_type)
print("double quant :", config.bnb_4bit_use_double_quant)
print("compute dtype :", config.bnb_4bit_compute_dtype)
print()
print("PASS")
実行します。
uv run python scripts/check_mlflow_stack.py
期待値:
MPS built : True
MPS available : True
Accelerate : mps:0
4bit : True
quant type : nf4
double quant : True
compute dtype : torch.bfloat16
PASS
STEP 2:Datasetを準備する
ここからMLflow公式Tutorialとほぼ同じ処理になります。
公式で使用しているDatasetは、
b-mc2/sql-create-context
です。
Text-to-SQL用のDatasetで、
question
context
answer
を持っています。
例えば、
context:
CREATE TABLE head (age INTEGER)
question:
How many heads of the departments are older than 56?
answer:
SELECT COUNT(*) FROM head WHERE age > 56
のようなデータです。
Train / Testに分ける
split_dataset = dataset.train_test_split(
test_size=0.2,
seed=42,
)
公式と同じ、
Train : 62,861
Test : 15,716
になります。
seed=42を固定する理由は再現性です。
LoRA設定を変更して比較するとき、
Run A
r=16
Run B
r=32
でDatasetの分割まで変わってしまうと、公平な比較にならないためです。
Promptを作る
学習データを、
Table
Question
Answer
から、
### Table:
CREATE TABLE ...
### Question:
...
### Response:
SELECT ...
へ変換します。
Tokenizer
公式と同じ、
mistralai/Mistral-7B-v0.1
のTokenizerを使用します。
最大長も公式と同じ、
MAX_LENGTH = 256
です。
scripts/prepare_dataset.py 全文
from datasets import load_dataset
from transformers import AutoTokenizer
DATASET_ID = "b-mc2/sql-create-context"
BASE_MODEL_ID = "mistralai/Mistral-7B-v0.1"
MAX_LENGTH = 256
TEST_SIZE = 0.2
SEED = 42
PROMPT_TEMPLATE = """You are a powerful text-to-SQL model. Given the SQL tables and natural language question, your job is to write SQL query that answers the question.
### Table:
{context}
### Question:
{question}
### Response:
{output}"""
def apply_prompt_template(row):
return {
"prompt": PROMPT_TEMPLATE.format(
question=row["question"],
context=row["context"],
output=row["answer"],
)
}
def main():
print("Loading dataset...")
dataset = load_dataset(
DATASET_ID,
split="train",
)
split_dataset = dataset.train_test_split(
test_size=TEST_SIZE,
seed=SEED,
)
train_dataset = split_dataset["train"]
test_dataset = split_dataset["test"]
print("Applying prompt template...")
train_dataset = train_dataset.map(
apply_prompt_template
)
print("Loading tokenizer...")
tokenizer = AutoTokenizer.from_pretrained(
BASE_MODEL_ID,
model_max_length=MAX_LENGTH,
padding_side="left",
add_eos_token=True,
)
tokenizer.pad_token = tokenizer.eos_token
def tokenize_and_pad_to_fixed_length(sample):
result = tokenizer(
sample["prompt"],
truncation=True,
max_length=MAX_LENGTH,
padding="max_length",
)
result["labels"] = result["input_ids"].copy()
return result
print("Tokenizing...")
tokenized_train_dataset = train_dataset.map(
tokenize_and_pad_to_fixed_length
)
assert all(
len(x["input_ids"]) == MAX_LENGTH
for x in tokenized_train_dataset
)
print()
print("=== Dataset Preparation ===")
print("Dataset :", DATASET_ID)
print("Base model :", BASE_MODEL_ID)
print("Train :", len(train_dataset))
print("Test :", len(test_dataset))
print("Max length :", MAX_LENGTH)
print(
"Tokenized :",
len(tokenized_train_dataset),
)
print()
print("PASS")
if __name__ == "__main__":
main()
実行:
uv run python scripts/prepare_dataset.py
STEP 3:Mistral 7Bを4-bitでM5 Maxへロードする
ここがNVIDIA版からM5 Max版へ移植するときの重要ポイントです。
MLflow公式では、
BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
を使用します。
M5 Max版でもこのQuantization設定は変えません。
変更するのは、
device_map="mps"
です。
4-bitを確認する
ロード後、
model.is_loaded_in_4bit
が、
True
になることを確認します。
さらにbitsandbytesの、
bnb.nn.Linear4bit
が存在することも確認します。
scripts/load_base_model.py 全文
import torch
import bitsandbytes as bnb
from transformers import (
AutoModelForCausalLM,
BitsAndBytesConfig,
)
BASE_MODEL_ID = "mistralai/Mistral-7B-v0.1"
def main():
if not torch.backends.mps.is_available():
raise RuntimeError("MPS is not available")
print("=== Environment ===")
print("torch :", torch.__version__)
print("MPS :", torch.backends.mps.is_available())
print()
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
print("Loading model...")
print(BASE_MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL_ID,
quantization_config=quantization_config,
device_map="mps",
)
linear4bit_count = sum(
isinstance(module, bnb.nn.Linear4bit)
for module in model.modules()
)
print()
print("=== Model ===")
print(
"class :",
type(model).__name__,
)
print(
"device :",
model.device,
)
print(
"loaded in 4-bit :",
getattr(
model,
"is_loaded_in_4bit",
False,
),
)
print(
"Linear4bit layers:",
linear4bit_count,
)
if not getattr(model, "is_loaded_in_4bit", False):
raise RuntimeError(
"Model was not loaded in 4-bit"
)
if linear4bit_count == 0:
raise RuntimeError(
"No Linear4bit layers detected"
)
print()
print("PASS")
if __name__ == "__main__":
main()
実行:
PYTORCH_ENABLE_MPS_FALLBACK=1 \
uv run python scripts/load_base_model.py
Fine-tuning前のMistralを確認する
Fine-tuningによって改善したか確認するには、
Fine-tuning前の結果
も見ておく必要があります。
scripts/test_base_model.py 全文
import torch
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
)
BASE_MODEL_ID = "mistralai/Mistral-7B-v0.1"
PROMPT_TEMPLATE = """You are a powerful text-to-SQL model. Given the SQL tables and natural language question, your job is to write SQL query that answers the question.
### Table:
{context}
### Question:
{question}
### Response:
{output}"""
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
print("Loading model...")
model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL_ID,
quantization_config=quantization_config,
device_map="mps",
)
tokenizer = AutoTokenizer.from_pretrained(
BASE_MODEL_ID
)
tokenizer.pad_token = tokenizer.eos_token
context = """
CREATE TABLE head (
age INTEGER
)
"""
question = """
How many heads of the departments are older than 56?
"""
prompt = PROMPT_TEMPLATE.format(
context=context,
question=question,
output="",
)
print()
print("=== Prompt ===")
print(prompt)
inputs = tokenizer(
prompt,
return_tensors="pt",
)
inputs = {
k: v.to("mps")
for k, v in inputs.items()
}
with torch.no_grad():
output = model.generate(
**inputs,
max_new_tokens=128,
repetition_penalty=1.15,
do_sample=False,
)
generated = tokenizer.decode(
output[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True,
)
print()
print("=== Base Model Response ===")
print(generated)
MLflow公式でも、Fine-tuning前のvanilla MistralはこのText-to-SQLタスクで適切なSQLを生成できないことをBaselineとして確認しています。
STEP 4:LoRA Adapterを追加する
4-bitにしただけではQLoRAではありません。
ここにLoRAを追加します。
4-bit Quantization
+
LoRA
↓
QLoRA
今回のLoRA設定はMLflow公式と同じものを使います。
r=32
lora_alpha=64
lora_dropout=0.1
target moduleも公式に合わせます。
[
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
"lm_head",
]
Gradient Checkpointing
LLM Trainingでは大量の中間データをMemoryに保持します。
Gradient Checkpointingを使うと、一部を保存せず必要になったとき再計算することでMemory消費を削減します。
Memory使用量 ↓
計算量 ↑
というTrade-offです。
scripts/define_peft_model.py 全文
import torch
from transformers import (
AutoModelForCausalLM,
BitsAndBytesConfig,
)
from peft import (
LoraConfig,
get_peft_model,
prepare_model_for_kbit_training,
)
BASE_MODEL_ID = "mistralai/Mistral-7B-v0.1"
def main():
if not torch.backends.mps.is_available():
raise RuntimeError("MPS is not available")
print("=== Environment ===")
print("PyTorch :", torch.__version__)
print("Device : mps")
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
print()
print("Loading base model...")
model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL_ID,
quantization_config=quantization_config,
device_map="mps",
)
print()
print("=== Base Model ===")
print(
"4-bit :",
getattr(model, "is_loaded_in_4bit", False),
)
print()
print("Enabling gradient checkpointing...")
model.gradient_checkpointing_enable()
print(
"Preparing model for k-bit training..."
)
model = prepare_model_for_kbit_training(
model
)
peft_config = LoraConfig(
task_type="CAUSAL_LM",
r=32,
lora_alpha=64,
lora_dropout=0.1,
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
"lm_head",
],
bias="none",
)
print()
print("Adding LoRA adapter...")
peft_model = get_peft_model(
model,
peft_config,
)
print()
print("=== Trainable Parameters ===")
peft_model.print_trainable_parameters()
trainable_params = 0
all_params = 0
for _, param in peft_model.named_parameters():
all_params += param.numel()
if param.requires_grad:
trainable_params += param.numel()
percentage = (
100 * trainable_params / all_params
)
print()
print("trainable :", f"{trainable_params:,}")
print("all :", f"{all_params:,}")
print("percent :", f"{percentage:.4f}%")
trainable_names = [
name
for name, param
in peft_model.named_parameters()
if param.requires_grad
]
print()
print(
"Trainable tensors :",
len(trainable_names),
)
print()
print("First trainable parameters:")
for name in trainable_names[:10]:
print(" ", name)
print()
print("=== PEFT Config ===")
print(peft_config)
print()
print("PASS")
if __name__ == "__main__":
main()
ここで、
lora_A
lora_B
というParameterだけがTrainableになっていれば成功です。
STEP 5:QLoRA TrainingをMLflowで管理する
いよいよ実際に学習します。
ここで、
Dataset
+
QLoRA
+
M5 Max
+
MLflow
がつながります。
MLflow Serverを起動
別Terminalで実行します。
cd ~/dev/mlflow-m5max-qlora
source .venv/bin/activate
uv run mlflow server \
--host 127.0.0.1 \
--port 5000
ブラウザで、
http://127.0.0.1:5000
を開きます。
NVIDIA版からOptimizerを変更する
ここが今回の重要な差分です。
MLflow公式:
optim="paged_adamw_8bit"
M5 Max:
optim="adamw_torch"
bitsandbytesのApple Silicon backendでは4-bit QLoRAは利用できますが、NVIDIA版と同じ8-bit Optimizer構成にはできないためです。
Batch sizeも変更
公式:
batch = 2
gradient accumulation = 4
2 × 4 = 8
M5 Max:
batch = 1
gradient accumulation = 8
1 × 8 = 8
Effective Batch Sizeは同じ8にしています。
最初は10 STEPでSmoke Testする
いきなり500 STEP回す必要はありません。
まず、
MAX_STEPS = 10
で、
Dataset
↓
Forward
↓
Backward
↓
Optimizer
↓
MPS
↓
MLflow
が一周するか確認します。
成功後、
MAX_STEPS = 500
へ変更します。
scripts/train_qlora_mlflow.py 全文
from datetime import datetime
import mlflow
import torch
import transformers
from datasets import load_dataset
from peft import (
LoraConfig,
get_peft_model,
prepare_model_for_kbit_training,
)
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments,
)
DATASET_ID = "b-mc2/sql-create-context"
BASE_MODEL_ID = "mistralai/Mistral-7B-v0.1"
MLFLOW_TRACKING_URI = "http://127.0.0.1:5000"
MLFLOW_EXPERIMENT = "M5 Max MLflow PEFT Tutorial"
MAX_LENGTH = 256
SEED = 42
# 最初は10。
# 動作確認後はMLflow公式Tutorialに合わせて500へ変更する。
MAX_STEPS = 10
PROMPT_TEMPLATE = """You are a powerful text-to-SQL model. Given the SQL tables and natural language question, your job is to write SQL query that answers the question.
### Table:
{context}
### Question:
{question}
### Response:
{output}"""
def prepare_dataset():
print("Loading dataset...")
dataset = load_dataset(
DATASET_ID,
split="train",
)
split_dataset = dataset.train_test_split(
test_size=0.2,
seed=SEED,
)
train_dataset = split_dataset["train"]
def apply_prompt_template(row):
return {
"prompt": PROMPT_TEMPLATE.format(
question=row["question"],
context=row["context"],
output=row["answer"],
)
}
train_dataset = train_dataset.map(
apply_prompt_template
)
tokenizer = AutoTokenizer.from_pretrained(
BASE_MODEL_ID,
model_max_length=MAX_LENGTH,
padding_side="left",
add_eos_token=True,
)
tokenizer.pad_token = tokenizer.eos_token
def tokenize(sample):
result = tokenizer(
sample["prompt"],
truncation=True,
max_length=MAX_LENGTH,
padding="max_length",
)
result["labels"] = result["input_ids"].copy()
return result
tokenized_train_dataset = train_dataset.map(
tokenize
)
return (
tokenizer,
tokenized_train_dataset,
)
def create_peft_model():
if not torch.backends.mps.is_available():
raise RuntimeError(
"MPS is not available"
)
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
print("Loading Mistral 7B...")
model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL_ID,
quantization_config=quantization_config,
device_map="mps",
)
if not getattr(
model,
"is_loaded_in_4bit",
False,
):
raise RuntimeError(
"Model is not loaded in 4-bit"
)
model.gradient_checkpointing_enable()
model = prepare_model_for_kbit_training(
model
)
peft_config = LoraConfig(
task_type="CAUSAL_LM",
r=32,
lora_alpha=64,
lora_dropout=0.1,
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
"lm_head",
],
bias="none",
)
peft_model = get_peft_model(
model,
peft_config,
)
peft_model.config.use_cache = False
return peft_model, peft_config
def main():
print("=== Environment ===")
print("PyTorch :", torch.__version__)
print(
"MPS :",
torch.backends.mps.is_available(),
)
mlflow.set_tracking_uri(
MLFLOW_TRACKING_URI
)
mlflow.set_experiment(
MLFLOW_EXPERIMENT
)
tokenizer, train_dataset = (
prepare_dataset()
)
print()
print(
"Training samples:",
len(train_dataset),
)
peft_model, peft_config = (
create_peft_model()
)
print()
peft_model.print_trainable_parameters()
run_name = (
"M5-Max-Mistral-7B-SQL-QLoRA-"
+ datetime.now().strftime(
"%Y%m%d-%H%M%S"
)
)
training_args = TrainingArguments(
report_to="mlflow",
run_name=run_name,
output_dir="./outputs",
# MLflow公式:
# per_device_train_batch_size=2
# gradient_accumulation_steps=4
#
# M5 Max:
# 1 x 8 = effective batch 8
per_device_train_batch_size=1,
gradient_accumulation_steps=8,
gradient_checkpointing=True,
# MLflow公式:
# optim="paged_adamw_8bit"
#
# MPS版:
optim="adamw_torch",
bf16=True,
learning_rate=2e-5,
lr_scheduler_type="constant",
max_steps=MAX_STEPS,
save_steps=10,
logging_steps=1,
warmup_steps=5,
dataloader_pin_memory=False,
)
trainer = transformers.Trainer(
model=peft_model,
train_dataset=train_dataset,
data_collator=(
transformers
.DataCollatorForLanguageModeling(
tokenizer=tokenizer,
mlm=False,
)
),
args=training_args,
)
with mlflow.start_run(
run_name=run_name
) as run:
mlflow.log_params({
"base_model":
BASE_MODEL_ID,
"dataset":
DATASET_ID,
"device":
"mps",
"quantization":
"4bit",
"quant_type":
"nf4",
"double_quant":
True,
"compute_dtype":
"bfloat16",
"lora_r":
peft_config.r,
"lora_alpha":
peft_config.lora_alpha,
"lora_dropout":
peft_config.lora_dropout,
"optimizer":
"adamw_torch",
"max_length":
MAX_LENGTH,
})
print()
print("=== MLflow ===")
print("Run ID :", run.info.run_id)
print("Run name :", run_name)
print()
print("=== Training ===")
result = trainer.train()
print()
print("=== Result ===")
print(result)
print()
print("PASS")
if __name__ == "__main__":
main()
実行:
export PYTORCH_ENABLE_MPS_FALLBACK=1
uv run python scripts/train_qlora_mlflow.py
500 STEPへ変更する
Smoke Testが成功したら、
MAX_STEPS = 500
へ変更します。
合わせて、
save_steps=100
logging_steps=100
程度に変更します。
MLflow公式のTrainerもreport_to="mlflow"を利用し、Training parameterやmetricsをMLflowに記録します。
STEP 6:LoRA AdapterをMLflowへ保存する
Trainingしただけでは、まだ学習モデルを再利用しづらい状態です。
そこでMLflowへModelとして保存します。
ポイントは、
Mistral 7B本体を毎回MLflowへコピーしない
ことです。
MLflowはPEFT Modelを保存するとき、Base ModelのWeightではなく、
LoRA Adapter
+
PEFT Config
+
Base ModelのHugging Face repository
+
commit hash
を記録します。
これはLoRAとの相性が非常に良い設計です。
既存checkpointを使う
例えば500 STEPまで学習した場合、
ls -d outputs/checkpoint-* | sort -V
結果:
outputs/checkpoint-100
outputs/checkpoint-200
outputs/checkpoint-300
outputs/checkpoint-400
outputs/checkpoint-500
なら、
checkpoint-500
を使います。
MLflowのRun IDを設定します。
export MLFLOW_RUN_ID="<STEP5で作成されたRun ID>"
scripts/log_checkpoint_to_mlflow.py 全文
import os
import mlflow
import torch
from datasets import load_dataset
from mlflow.models import infer_signature
from peft import PeftModel
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
)
BASE_MODEL_ID = "mistralai/Mistral-7B-v0.1"
DATASET_ID = "b-mc2/sql-create-context"
CHECKPOINT = "./outputs/checkpoint-500"
MLFLOW_TRACKING_URI = "http://127.0.0.1:5000"
RUN_ID = os.environ["MLFLOW_RUN_ID"]
PROMPT_TEMPLATE = """You are a powerful text-to-SQL model. Given the SQL tables and natural language question, your job is to write SQL query that answers the question.
### Table:
{context}
### Question:
{question}
### Response:
{output}"""
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
print("Loading base model...")
base_model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL_ID,
quantization_config=quantization_config,
device_map="mps",
)
print("Loading LoRA checkpoint...")
model = PeftModel.from_pretrained(
base_model,
CHECKPOINT,
)
tokenizer_no_pad = AutoTokenizer.from_pretrained(
BASE_MODEL_ID,
add_bos_token=True,
)
dataset = load_dataset(
DATASET_ID,
split="train",
)
split_dataset = dataset.train_test_split(
test_size=0.2,
seed=42,
)
train_dataset = split_dataset["train"]
def apply_prompt_template(row):
return {
"prompt": PROMPT_TEMPLATE.format(
context=row["context"],
question=row["question"],
output=row["answer"],
)
}
train_dataset = train_dataset.map(
apply_prompt_template
)
sample = train_dataset[1]
prompt_template = """You are a powerful text-to-SQL model. Given the SQL tables and natural language question, your job is to write SQL query that answers the question.
{prompt}
### Response:
"""
signature = infer_signature(
model_input=sample["prompt"],
model_output=sample["answer"],
params={
"max_new_tokens": 256,
"repetition_penalty": 1.15,
"return_full_text": False,
},
)
mlflow.set_tracking_uri(
MLFLOW_TRACKING_URI
)
with mlflow.start_run(
run_id=RUN_ID
):
model_info = mlflow.transformers.log_model(
transformers_model={
"model": model,
"tokenizer": tokenizer_no_pad,
},
prompt_template=prompt_template,
signature=signature,
name="model",
)
print()
print("=== MLflow Model ===")
print("Run ID :", RUN_ID)
print("Model URI :", model_info.model_uri)
print()
print("PASS")
実行:
export MLFLOW_RUN_ID="<RUN_ID>"
export PYTORCH_ENABLE_MPS_FALLBACK=1
uv run python scripts/log_checkpoint_to_mlflow.py
実際に遭遇したエラー:torchvisionがない
このSTEPで実際に次のエラーが発生しました。
PackageNotFoundError:
No package metadata was found for torchvision
最終的には、
ModuleNotFoundError:
No module named 'torchvision'
で停止しました。
しかしログをよく見ると、
Loading base model...
Loading LoRA checkpoint...
までは成功しています。
さらに、
Overriding save_pretrained to False for PEFT models
も表示されていました。
つまり、
MPS OK
4-bit OK
Mistral OK
LoRA OK
MLflow Model保存開始 OK
依存Package検出
↓
torchvisionなし
↓
ERROR
という状態です。
QLoRAの問題ではありません。
解決方法:
uv add torchvision
確認します。
uv run python - <<'PY'
import torch
import torchvision
print("torch :", torch.__version__)
print("torchvision :", torchvision.__version__)
print("MPS :", torch.backends.mps.is_available())
PY
その後、同じRun IDを使って再実行します。
export MLFLOW_RUN_ID="<RUN_ID>"
export PYTORCH_ENABLE_MPS_FALLBACK=1
uv run python scripts/log_checkpoint_to_mlflow.py
MLflowには何が保存されるのか
PEFT Modelでは概念的に、
model/
├── peft/
│ ├── adapter_config.json
│ └── adapter_model.safetensors
│
├── MLmodel
├── requirements.txt
├── python_env.yaml
└── ...
となります。
MLflow公式でもPEFT AdapterとConfig、Base Model repositoryとrevision、Prompt Template、SignatureなどをModel metadataに保持します。
STEP 7:MLflowからLoRA Modelをロードする
最後に保存したModelを読み戻します。
MLflow公式では、
mlflow.transformers.load_model()
または、
mlflow.pyfunc.load_model()
の2種類が紹介されています。
PyFuncでロードする
pyfuncのメリットは、
model.predict(...)
というMLflow共通Interfaceを使えることです。
さらにSTEP 6で保存したPrompt Templateや推論ParameterをModel側に持たせられます。
scripts/infer_mlflow_pyfunc.py 全文
import os
import mlflow
MLFLOW_TRACKING_URI = "http://127.0.0.1:5000"
RUN_ID = os.environ["MLFLOW_RUN_ID"]
MODEL_URI = f"runs:/{RUN_ID}/model"
mlflow.set_tracking_uri(
MLFLOW_TRACKING_URI
)
print("=== MLflow Model ===")
print("Run ID :", RUN_ID)
print("Model URI :", MODEL_URI)
print()
print("Loading MLflow PEFT model...")
mlflow_model = mlflow.pyfunc.load_model(
MODEL_URI
)
print()
print("Model loaded.")
test_prompt = """
### Table:
CREATE TABLE table_name_50 (
venue VARCHAR,
away_team VARCHAR
)
### Question:
When Essendon played away; where did they play?
"""
print()
print("=== Input ===")
print(test_prompt)
result = mlflow_model.predict(
test_prompt
)
print()
print("=== Raw Result ===")
print(result)
print()
print("=== Generated SQL ===")
print(result[0])
print()
print("PASS")
実行:
export MLFLOW_RUN_ID="<RUN_ID>"
export PYTORCH_ENABLE_MPS_FALLBACK=1
uv run python scripts/infer_mlflow_pyfunc.py
期待する方向のSQLは、
SELECT venue
FROM table_name_50
WHERE away_team = "essendon";
です。
生成AIなので完全な文字列一致を要求するものではありません。
MPSを明示してNative Transformersでロードする
M5 Maxで実行していることをより明確に確認したい場合は、
mlflow.transformers.load_model()
を使用します。
scripts/infer_mlflow_mps.py 全文
import os
import mlflow
import torch
MLFLOW_TRACKING_URI = "http://127.0.0.1:5000"
RUN_ID = os.environ["MLFLOW_RUN_ID"]
MODEL_URI = f"runs:/{RUN_ID}/model"
if not torch.backends.mps.is_available():
raise RuntimeError(
"MPS is not available"
)
print("=== Environment ===")
print("PyTorch :", torch.__version__)
print("MPS :", torch.backends.mps.is_available())
mlflow.set_tracking_uri(
MLFLOW_TRACKING_URI
)
print()
print("Loading MLflow Transformer model...")
pipeline = mlflow.transformers.load_model(
MODEL_URI,
return_type="pipeline",
device="mps",
)
print()
print("=== Pipeline ===")
print("type :", type(pipeline).__name__)
print("device :", pipeline.device)
prompt = """You are a powerful text-to-SQL model. Given the SQL tables and natural language question, your job is to write SQL query that answers the question.
### Table:
CREATE TABLE table_name_50 (
venue VARCHAR,
away_team VARCHAR
)
### Question:
When Essendon played away; where did they play?
### Response:
"""
print()
print("=== Inference ===")
result = pipeline(
prompt,
max_new_tokens=256,
repetition_penalty=1.15,
return_full_text=False,
do_sample=False,
)
print()
print("=== Generated SQL ===")
print(result[0]["generated_text"])
print()
print("PASS")
実行結果:
export PYTORCH_ENABLE_MPS_FALLBACK=1
uv run python scripts/infer_mlflow_mps.py
=== Environment ===
PyTorch : 2.13.0
MPS : True
Loading MLflow Transformer model...
Downloading artifacts: 0%| | 0/1 [00:00<?, ?it/s]
Downloading artifacts: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 10/10 [00:00<00:00, 10.48it/s]
2026/08/11 23:23:34 INFO mlflow.transformers: 'runs:/4ac3e7913d4241b49166a17b86f06958/model' resolved as 'mlflow-artifacts:/1/4ac3e7913d4241b49166a17b86f06958/artifacts/model'
Downloading artifacts: 0%| | 0/1 [00:00<?, ?it/s]
Downloading artifacts: 0%| | 0/1 [00:00<?, ?it/s]
Downloading artifacts: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 363.90it/s]
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
Fetching 2 files: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 2/2 [00:00<00:00, 2454.96it/s]
Fetching 2 files: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 2/2 [00:00<00:00, 1496.10it/s]
Reconstruction complete: | | 0.00B / 0.00B 2026/08/11 23:23:36 WARNING mlflow.transformers.model_io: Could not specify device parameter for this pipeline type.Falling back to loading the model with the default device.
Fetching 2 files: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 2/2 [00:00<00:00, 2481.84it/s]
Download complete: : | 0.00B
Reconstruction complete: | | 0.00B / 0.00B
Download complete: : | 0.00B
Reconstruction complete: | | 0.00B / 0.00B
Download complete: : | 0.00B
Reconstruction complete: | | 0.00B / 0.00B
Loading weights: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 291/291 [00:00<00:00, 18811.73it/s]
[transformers] `torch_dtype` is deprecated! Use `dtype` instead!
=== Pipeline ===
type : TextGenerationPipeline
device : mps
=== Inference ===
[transformers] Passing `generation_config` together with generation-related arguments=({'max_new_tokens', 'do_sample', 'repetition_penalty'}) is deprecated and will be removed in future versions. Please pass either a `generation_config` object OR all generation parameters explicitly, but not both.
[transformers] Both `max_new_tokens` (=256) and `max_length`(=20) seem to have been set. `max_new_tokens` will take precedence. Please refer to the documentation for more information. (https://huggingface.co/docs/transformers/main/en/main_classes/text_generation)
[transformers] Ignoring clean_up_tokenization_spaces=True for BPE tokenizer TokenizersBackend. The clean_up_tokenization post-processing step is designed for WordPiece tokenizers and is destructive for BPE (it strips spaces before punctuation). Set clean_up_tokenization_spaces=False to suppress this warning, or set clean_up_tokenization_spaces_for_bpe_even_though_it_will_corrupt_output=True to force cleanup anyway.
=== Generated SQL ===
SELECT venue FROM table_name_50 WHERE away_team = "essendon"
### Table:
CREATE TABLE table_name_51 (
date VARCHAR,
home_team VARCHAR
)
### Question:
What day was the game when the home team was collingwood?
### Response:
SELECT date FROM table_name_51 WHERE home_team = "collingwood"
### Table:
CREATE TABLE table_name_52 (
score VARCHAR,
round VARCHAR
)
### Question:
What was the score of the game in round 3?
### Response:
SELECT score FROM table_name_52 WHERE round = 3
### Table:
CREATE TABLE table_name_53 (
player VARCHAR,
position VARCHAR
)
### Question:
Who plays as a forward?
### Response:
SELECT player FROM table_name_53 WHERE position = "forward"
### Table:
CREATE TABLE table_name_54 (
player VARCHAR,
position VAR
PASS
最終的に何ができたのか
最初は、
Apple M5 Max
しかありませんでした。
そこへ、
という一連のモデル開発環境を構築できました。
MLflowを使う意味
LoRAだけなら、PEFTだけでも実装できます。
MLflowを入れる意味は、
「どの設定で学習したModelなのか」を管理できること
です。
例えば、
Experiment
│
├─ Run A
│ ├─ r=16
│ ├─ alpha=32
│ └─ loss=...
│
├─ Run B
│ ├─ r=32
│ ├─ alpha=64
│ └─ loss=...
│
└─ Run C
├─ r=64
├─ alpha=128
└─ loss=...
という比較ができます。
モデル開発では、
「動いた」
だけでは足りません。
重要なのは、
どのDatasetか
どのBase Modelか
どのLoRA設定か
どのLearning Rateか
どのLossになったか
どのModelを採用したか
を再現できることです。
これがMLflowの役目です。
NVIDIA GPUとApple Siliconの使い分け
今回の結果から、
Apple SiliconがあればNVIDIA GPUは不要
と結論付けるのは早計です。
大規模なTraining、Multi-GPU Training、CUDA向けに高度に最適化されたKernel、大規模Production用途ではNVIDIA GPUが依然重要です。
一方で、
LoRAを学びたい
QLoRAを試したい
PromptとFine-tuningを比較したい
MLflowを学びたい
7BクラスでPoCしたい
自社DatasetでModelを試作したい
という用途なら、
まず手元のApple Siliconで始める
という選択肢はかなり現実的になっています。
Transformers自身もApple SiliconのUnified MemoryとMPSを利用したローカルTrainingをサポートしています。
今回のポイントを整理
今回、MLflow公式TutorialをM5 Maxへ移植するために変更したポイントは、実はそれほど多くありません。
MLflow公式
NVIDIA A10G
↓
CUDA
↓
bitsandbytes
↓
QLoRA
を、
M5 Max版
Apple M5 Max
↓
Metal / MPS
↓
bitsandbytes MPS
↓
QLoRA
へ変更しました。
大きな変更点は、
CUDA
↓
MPS
と、
paged_adamw_8bit
↓
adamw_torch
です。
一方、
Mistral-7B
Text-to-SQL Dataset
4-bit NF4
LoRA r=32
LoRA alpha=64
MLflow
Prompt Template
Model Signature
は、できるだけMLflow公式Tutorialの考え方を維持しました。
今回作成したファイル
最終的なProjectは次のようになります。
mlflow-m5max-qlora/
│
├── .python-version
├── pyproject.toml
├── uv.lock
│
├── scripts/
│ │
│ ├── check_mps.py
│ │
│ ├── check_mlflow_stack.py
│ │
│ ├── prepare_dataset.py
│ │
│ ├── load_base_model.py
│ │
│ ├── test_base_model.py
│ │
│ ├── define_peft_model.py
│ │
│ ├── train_qlora_mlflow.py
│ │
│ ├── log_checkpoint_to_mlflow.py
│ │
│ ├── infer_mlflow_pyfunc.py
│ │
│ └── infer_mlflow_mps.py
│
├── outputs/
│ ├── checkpoint-100/
│ ├── checkpoint-200/
│ └── ...
│
└── README.md
まとめ
今回一番伝えたかったことは、
LLMのモデル開発を学ぶために、最初から高価なNVIDIA GPUを用意する必要はない
ということです。
Apple Siliconには、
Unified Memory
+
Metal
+
MPS
があります。
さらに現在では、
PyTorch
+
Transformers
+
bitsandbytes
+
PEFT
+
MLflow
の組み合わせで、M5 Max上でも、
Dataset準備
↓
Quantization
↓
LoRA
↓
QLoRA Training
↓
Experiment Tracking
↓
Model保存
↓
Model再ロード
↓
Inference
というモデル開発の一連の流れを経験できます。
Fine-tuningそのものより重要なのは、
学習 → 評価 → 比較 → 保存 → 再利用
というLoopを作ることです。
そして、このLoopを管理するのがMLflowです。
次は、
Base Model
VS
LoRA Model
をMLflow Evaluateで定量比較したり、
r=16
r=32
r=64
を複数Runとして比較していくと、単なる「LoRAを動かしてみた」から、
実際のモデル開発
へ一段進めます。
参考にした公式資料
- MLflow — Fine-Tuning Open-Source LLM using QLoRA with MLflow and PEFT
- MLflow — Transformers Flavor
- MLflow — PEFT Models / Transformers Integration
- Hugging Face Transformers — Apple Silicon
- Hugging Face Accelerate — MPS Training
- PyTorch — MPS backend
- bitsandbytes — Apple Silicon / MPS support
- Qiita Markdown
MLflow公式チュートリアルではMistral 7B、4-bit NF4、PEFT/LoRA、Transformers Trainer、MLflow Model保存・再ロードという一連の流れが説明されています。
MLflowはPEFTモデルについて、Adapter WeightとConfigを保存し、Base ModelについてはHugging Face repositoryとrevisionをModel metadataへ記録する方式を採っています。
Apple SiliconではPyTorch MPSを利用でき、TransformersもMPS backendによるTrainingをサポートしています。


