先交代背景。我手头的主力设备是一台 M3 Max 64GB 的 MacBook Pro,平时既要写业务代码,又要做本地大模型实验。最近想把一个 7B 模型调成内部能用的指令版本,就顺手把 Mac 上目前能用的四条主流微调链路都实测了一遍:Unsloth(MLX 模式)、MLX-LM、PyTorch MPS+PEFT,以及 llama.cpp 的 finetune。网上铺天盖地的帖子都在吹 Unsloth 在 N 卡上有多快,但真正放到 Apple Silicon 上,情况其实没那么简单——这篇文章我把测试环境、完整脚本、跑分数据和踩过的坑一起整理出来,给想在 Mac 上微调大模型的人一个参考。
1. Mac 上微调的选型思路:先搞清楚两条技术路线
1.1 MLX 与 MPS:Mac 上两种不同的加速底座
在 Mac 上微调和在 Linux + N 卡上微调,最大的区别就是底层加速后端。Apple 自己维护的 MLX 是一个专为 Apple Silicon 设计的机器学习框架,内存共享、统一寻址这些特性让它在跑大模型时有天然优势。另一条路线是 PyTorch 的 MPS 后端,说白了就是把 PyTorch 的算子映射到 Metal 上执行,兼容性好,但很多算子没有针对性优化。
这两个底座决定了上面能跑什么工具。Unsloth 在 Mac 上走的是 MLX 路线,MLX-LM 更是苹果官方生态里的常客,而传统的 Hugging Face transformers + PEFT 组合走的是 MPS 路线。选引擎本质上是在选底座,底座的优化深度直接决定训练速度和能吃下多大的模型。
1.2 四套引擎的定位差异
我把四条链路拉出来做了个简单定位:
| 方案 | 底层加速 | 上手难度 | 适合场景 |
|---|---|---|---|
| Unsloth(MLX) | MLX | 低,API 极简 | 快速验证 LoRA 效果,追求性价比 |
| MLX-LM | MLX | 低,CLI 命令为主 | 苹果生态原生玩家,稳定优先 |
| PyTorch MPS+PEFT | MPS | 高,需要写训练代码 | 已有 PyTorch 代码要迁移的人 |
| llama.cpp finetune | Metal/CUDA | 中,数据集要先转 GGUF | 想全程终端操作,最终产物直接跑 Ollama |
这里要提前说一个结论:在 Mac 上,单纯比训练速度,Unsloth 确实最快,但它的优势没有在 CUDA 上那么夸张,大概比 MLX-LM 快 20%~40%。如果你之前看到的宣传是“3 倍加速”,那是针对 N 卡环境说的,别在 Mac 上抱着这个预期。
1.3 我的选择标准
我这次对比不只看训练速度,还看三个维度:内存峰值、稳定性、以及微调产物能不能方便落地。Mac 上内存是硬约束,64GB 听起来不少,但一个 7B 模型用 fp16 加载就接近 14GB,再算上激活值、优化器状态,分分钟爆内存。所以内存峰值很多时候比训练速度更能决定一个方案能不能用。
另外还要考虑逃离成本。比如 PyTorch MPS 方案,虽然代码最通用,但我在实测中频繁遇到算子回退 CPU,训练速度直接崩盘。而 MLX-LM 是命令行就能微调,Unsloth 的 API 写起来最顺手,llama.cpp 则是一条路走到黑,训练完直接输出 GGUF。
2. Unsloth 又快在哪?原理层面的三处关键改动
2.1 LoRA 与 QLoRA:消费级设备能微调的前提
别管底层引擎是谁,现在能在 Mac 上微调大模型,核心功臣都是 LoRA(低秩适配)。它不改变预训练模型的原始权重,而是在每个被选中的线性层旁边挂两个低秩矩阵 A 和 B。前向传播时输出变成Wx + BAx,训练时只更新 A 和 B。一个 7B 模型全参数训练要动 70 亿个参数,LoRA 通常只训练几百万到几千万个,参数量直接降两三个数量级。缩小规模之后,AdamW 优化器状态、梯度显存这些占用全部跟着降下来,消费级设备才有了入场资格。
QLoRA 又在 LoRA 基础上加了 NF4 量化,把模型权重压到 4bit,7B 模型从 14GB 降到 4GB 左右。但这里有个 Mac 特有问题:经典的 QLoRA 依赖 bitsandbytes 库,而这个库在 Apple Silicon 上基本没法编译通过。所以 Mac 上做 4bit 微调,得看具体框架自己的实现,比如 Unsloth 就内置了优化过的 4bit 内核。
2.2 Unsloth 的加速三板斧:融合算子、动态缓存、自动升级
Unsloth 在 CUDA 上之所以能比标准 transformers 快两到三倍,核心原因是它对训练循环里的计算图做了深度重写。第一板斧是手写融合算子,普通 PyTorch 里多层 Linearity 和激活函数是分开调用 Kernel 的,每次调用都有调度开销,Unsloth 把这些操作融合成一个 Kernel 一次性执行,减少了大量内核启动开销。第二板斧是动态 KV Cache,Transformer 训练的序列长度虽然固定,但 KV Cache 的实际占用会随数据变化,Unsloth 用可动态扩展的缓存布局,减少内存浪费和拷贝。第三板斧是自动升级低精度计算,在不影响精度的前提下把能降精度的算子降精度,提升计算吞吐。
在 Mac 的 MLX 模式下,Unsloth 也逐步加入了类似的手写算子与反量化内核。不过我得泼一盆冷水:MLX 本身的算子融合能力比 CUDA 生态成熟的 fusion 要弱,Unsloth 在 MLX 上的优化空间是受限的。这也是为什么实测下来它没有 CUDA 上那种“吊打”级别优势的根本原因。
2.3 其余三套引擎的各自强项与短板
MLX-LM 是苹果官方示例库里的常客,底层的 mlx 框架由苹果维护,内存管理和算子调度都很干净。它的 finetune 命令支持 LoRA 和 QLoRA,对新手很友好,但默认情况下没有对所有注意力层做手动融合优化,所以速度不如 Unsloth,胜在稳定和一路绿灯的兼容性。
PyTorch MPS+PEFT 是大多数人最容易想到的方案,毕竟 PEFT 库官方支持 LoRA。但在实测中你会发现,显存占用很高,因为 PyTorch 在 MPS 上对 CUDA 的一些显存优化手段不支持,而且训练脚本里model = AutoModelForCausalLM.from_pretrained(..., torch_dtype=torch.float16).to("mps")这条路经常触发算子回退,控制台刷出一堆“falling back to CPU”的警告。
llama.cpp finetune 相对小众,它是 llama.cpp 项目内置的 LoRA 训练工具。因为 llama.cpp 对 Apple Metal 的优化很激进,推理速度不俗,但训练功能迭代较晚,数据集要先转成 GGUF 格式,中间链路比较绕。
3. 四引擎实测:环境、脚本与结果对比
3.1 测试环境与统一参数
为了对比公平,所有方案跑在同一个环境和同一份数据上:
- 设备:MacBook Pro,M3 Max,64GB 统一内存
- 系统:macOS Sequoia
- Python:3.11
- 模型:Qwen/Qwen2.5-7B-Instruct
- 数据集:200 条中文客服指令数据,输入输出均为 JSONL 格式
- 序列长度:2048
- LoRA 参数:r=16,alpha=32,dropout=0,目标模块为 q/k/v/o 投影层
- 训练配置:1 个 epoch,batch size=1,梯度累积 8 步,学习率 2e-5
统一参数是为了控制变量,但需要注意,Unsloth 和 MLX-LM 在内存策略上自动做了不少优化,PyTorch MPS 方案则会保留更多原始激活值,所以内存差异从一开始就存在,这个在后面结果表里能看到。
3.2 环境安装:先把 Homebrew 和 Python 环境理顺
Mac 上装这些工具,第一道坎就是 Homebrew。很多人卡在安装失败、下载超时上。如果你用的是默认源,那大概率会遇到网络卡顿,我的做法是先配置国内镜像源再安装,具体命令如下:
export HOMEBREW_API_DOMAIN=https://mirrors.tuna.tsinghua.edu.cn/homebrew-bottles/api export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.tuna.tsinghua.edu.cn/homebrew-bottles export HOMEBREW_BREW_GIT_REMOTE=https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/brew.git配置完成后先执行brew update验证源是否正常,再装cmake、git-lfs这些依赖。另外一个坑是git-lfs如果没装,从 Hugging Face 拉模型权重时会直接卡住,所以先把这几个基础工具装掉:
brew install cmake git-lfs git lfs install接下来创建虚拟环境。我建议用 conda 而不是 venv,因为 MLX 和 PyTorch 的底层依赖很杂,conda 的环境隔离更省心:
conda create -n finetune python=3.11 conda activate finetune3.3 Unsloth 微调脚本实录
Unsloth 在 Mac 上推荐安装unsloth[mlx]扩展包。先装包:
pip install "unsloth[mlx]"安装完成后直接写训练脚本,我用的是 Unsloth 的FastLanguageModelAPI:
from unsloth import FastLanguageModel from datasets import load_dataset model, tokenizer = FastLanguageModel.from_pretrained( model_name="Qwen/Qwen2.5-7B-Instruct", max_seq_length=2048, dtype=None, load_in_4bit=True, ) model = FastLanguageModel.get_peft_model( model, r=16, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], lora_alpha=32, lora_dropout=0, use_gradient_checkpointing="unsloth", ) dataset = load_dataset("json", data_files="train.jsonl", split="train") def build_prompt(examples): conversations = [ [{"role": "user", "content": inp}, {"role": "assistant", "content": out}] for inp, out in zip(examples["input"], examples["output"]) ] return {"text": tokenizer.apply_chat_template(conversations, tokenize=False)} dataset = dataset.map(build_prompt, batched=True) trainer = FastLanguageModel.get_trainer( model=model, tokenizer=tokenizer, dataset=dataset, batch_size=1, gradient_accumulation_steps=8, learning_rate=2e-5, logging_steps=10, save_steps=50, max_seq_length=2048, ) trainer.train()这里有个细节:load_in_4bit=True在 MLX 模式下是可以用的,因为 Unsloth 自己实现了反量化内核,不依赖 bitsandbytes。如果你在个别版本上遇到 4bit 加载报错,把这一行改成dtype=None, load_in_4bit=False,用 bf16 训练,速度会略慢但更稳。
第二个细节:如果FastLanguageModel.get_trainer在你本地跑起来报参数不支持,别硬磕,直接改用 TRL 的SFTTrainer同样可以,Unsloth 的加速在from_pretrained阶段已经生效了,不依赖特定的 Trainer 封装。
3.4 MLX-LM:苹果生态的原生微调命令
MLX-LM 的安装和使用都干净很多:
pip install mlx-lm训练直接用命令行:
mlx_lm.finetune \ --model Qwen/Qwen2.5-7B-Instruct \ --train-file train.jsonl \ --val-file val.jsonl \ --batch-size 1 \ --num-layers 8 \ --learning-rate 2e-5 \ --iters 200 \ --steps-per-report 10 \ --save-every 50 \ --output-dir mlx-lora-out数据格式要求是 JSONL,每行长这样:
{"messages": [{"role": "user", "content": "测试问题"}, {"role": "assistant", "content": "测试回答"}]}--num-layers 8表示只微调最后 8 层,这是控制内存的关键参数。如果显存吃紧,可以减少到 4 层;效果影响不大,因为 LoRA 层本身就在最后几层投影层上。
3.5 PyTorch MPS+PEFT:常规路线在 Mac 上的表现
这条链路要走 transformers + PEFT,先装依赖:
pip install torch transformers peft trl datasets accelerate训练脚本如下:
import torch from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments from peft import LoraConfig, get_peft_model from datasets import load_dataset model = AutoModelForCausalLM.from_pretrained( "Qwen/Qwen2.5-7B-Instruct", torch_dtype=torch.float16, ) model = model.to("mps") tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct") tokenizer.pad_token = tokenizer.eos_token lora_config = LoraConfig( r=16, lora_alpha=32, lora_dropout=0.05, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], ) model = get_peft_model(model, lora_config) dataset = load_dataset("json", data_files="train.jsonl", split="train") def tokenize(examples): texts = [ tokenizer.apply_chat_template( [{"role": "user", "content": inp}, {"role": "assistant", "content": out}], tokenize=False, ) for inp, out in zip(examples["input"], examples["output"]) ] enc = tokenizer(texts, truncation=True, max_length=2048, padding=True) enc["labels"] = enc["input_ids"].copy() return enc dataset = dataset.map(tokenize, batched=True) trainer = Trainer( model=model, args=TrainingArguments( output_dir="./mps-lora", per_device_train_batch_size=1, gradient_accumulation_steps=8, learning_rate=2e-5, logging_steps=10, save_steps=50, ), train_dataset=dataset, ) trainer.train()这段脚本跑起来后,你会注意到终端里频繁出现算子回退警告。这就是 MPS 后端的现状:很多 PyTorch 算子没有 Metal 实现,只能退回 CPU。CPU 计算本身不慢,但内存和计算单元来回切换的损耗非常大,训练速度肉眼可见地被拖慢。
3.6 llama.cpp:终端流微调的可选项
llama.cpp 的 finetune 功能最近迭代较快,适合不想写 Python 训练代码的场景。先编译项目:
git clone https://github.com/ggml-org/llama.cpp cd llama.cpp cmake -B build cmake --build build --config Release -j 8微调命令如下:
./build/bin/llama-finetune \ -m models/qwen2.5-7b-instruct.gguf \ --train-data train.gguf \ --lora lora-out \ --lora-r 16 \ --lora-alpha 32 \ --epochs 3注意数据集要先转成 GGUF 格式,llama.cpp 仓库里提供了转换脚本,流程比前三个繁琐。所以我把这条链路定位成“可选项”,如果你最终目标就是让 Ollama 跑一个 GGUF,这条路可以省去后续从 HF 格式转 GGUF 的步骤,但中间的数据格式转换确实要花时间适应。
3.7 实测数据汇总与结论
四套方案跑完,我整理了一张对比表。数据是我同一时间段在固定设备上测的,不同机器、不同温度、不同系统版本都会有差异,仅供相对参考:
| 引擎 | 单轮 epoch 耗时 | 峰值内存 | 训练吞吐 | 稳定性 |
|---|---|---|---|---|
| Unsloth(MLX) | 18 分 37 秒 | 约 21GB | 约 95 tokens/s | 稳定 |
| MLX-LM | 24 分 51 秒 | 约 23GB | 约 72 tokens/s | 很稳 |
| PyTorch MPS+PEFT | 36 分 05 秒 | 约 31GB | 约 49 tokens/s | 偶发 CPU 回退 |
| llama.cpp finetune | 49 分 12 秒 | 约 35GB | 约 32 tokens/s | 稳定但偏慢 |
结论很明确:Unsloth 在 Mac 上确实是四者中最快的,但对比 MLX-LM 的领先幅度不到 30%,远没有 CUDA 环境下那种“降维打击”的感觉。PyTorch MPS 方案内存峰值最高,速度垫底的那一档基本靠它撑起来了。llama.cpp 的 finetune 目前还像个毛坯房,能用,但效率和方便程度都比不上前两个。
4. 高频问题与排查技巧:从装环境到训练崩溃
4.1 安装阶段问题速查表
我在配置这四个环境时几乎把能踩的坑都踩了一遍,挑几个典型的:
| 错误现象 | 原因 | 解决办法 |
|---|---|---|
| Homebrew 安装卡在下载阶段 | 默认源访问不稳定 | 配置清华源镜像变量后重装 |
pip install unsloth[mlx]报二进制依赖错误 | Python 版本太低 | 使用 Python 3.10 或 3.11 新建 conda 环境 |
| 模型权重下载卡住 | 未安装 git-lfs | brew install git-lfs && git lfs install |
| MPS 训练时报算子不支持 | PyTorch 与 Metal 适配不完整 | 换成 MLX 方案,或更新到最新版 PyTorch |
mlx_lm命令找不到 | 未安装mlx-lm | pip install mlx-lm后确认当前 conda 环境 |
Unsloth 的安装需要特别注意一点:不要直接pip install unsloth,那个版本默认面向 CUDA,会尝试拉取 CUDA 相关依赖。在 Mac 上正确姿势是pip install "unsloth[mlx]",它会自动带出 mlx 和 mlx-lm 的依赖。如果安装过程卡在构建某个本地包,建议先升级 setuptools 和 wheel:
pip install --upgrade setuptools wheel4.2 训练中的内存与速度问题
内存是 Mac 微调最大的瓶颈。我在 PyTorch MPS 方案里遇到过一次直接 OOM 崩掉,原因是激活值没有做梯度检查点优化。在这个方案里,如果遇到 OOM,优先开启gradient_checkpointing,也就是在TrainingArguments里加gradient_checkpointing=True,其次把per_device_train_batch_size降到 1,再用梯度累积补步数。
Unsloth 在 Mac 上的内存优化已经很激进,比如use_gradient_checkpointing="unsloth"就是它自己的实现,比 transformers 版本更省显存。但即便如此,我建议训练时把 Chromium、Docker 这类占内存大户先关掉,64GB 機器也不能太任性。训练过程中可以开一个终端实时监控:
sudo top -o mem观察PS列的占用,如果超过 50GB,就要及时止损。
另一个影响速度的隐藏因素是系统电源管理。插电状态和电池状态的降频策略完全不同,M3 Max 在电池模式下性能会明显受限。我测速时全程插电,并且把“低功耗模式”关掉,否则训练吞吐会掉 20% 以上。
4.3 微调后怎么落地?导出 GGUF 并接入 Ollama
训练完的产物不是终点,最终要能跑推理。MLX-LM 训练出的.safetensors格式权重,可以用mlx_lm.convert转成 MLX 格式,再扔给 LocalAI 或直接用一个 Python 脚本加载。Unsloth 则支持直接导出 GGUF,这是我最看重的功能之一:
from unsloth import FastLanguageModel model.save_pretrained_gguf( "qwen-lora-gguf", tokenizer=tokenizer, quantization_method=["q4_k_m", "q8_0"], )执行完之后会在输出目录里生成多个 GGUF 文件,选一个拷贝到 Ollama 的模型目录,再写一个 Modelfile:
FROM ./qwen-lora-gguf/qwen2.5-7b-instruct.Q4_K_M.gguf TEMPLATE """{{ .Prompt }}"""然后执行:
ollama create my-qwen -f Modelfile ollama run my-qwen只要模型能进 Ollama,就等于走完了从训练到应用的闭环。如果你用 MLX-LM 微调,可以先把模型转成 GGUF 再导入 Ollama,或者直接用 mlx-lm 的generate接口做推理。
4.4 数据量与过拟合的取舍
Mac 上做实验通常数据量不大,200 条数据很容易过拟合。如果你发现验证集 loss 一路走高,而训练集 loss 还在降,那就说明模型开始背答案了。我的做法是:LoRA 的 r 从 16 降到 8,alpha 同步降到 16,dropout 从 0 改成 0.05,训练步数控制在 100 步以内。另一个技巧是早停,MLX-LM 的--val-file参数配合 loss 监控,能在验证集不再下降时手动中断训练,Unsloth 则建议配合 TensorBoard 观察平滑后的验证曲线。
这个取舍很重要。Mac 的算力有限,不是每条数据都能被模型记住就是好事,很多内网工具场景只需要模型学会某种回复格式和知识结构,数据量少反而需要更克制的训练策略。
最后说点体己话
四套引擎跑下来,我自己现在的主力方案是 Unsloth + MLX 组合。原因很简单:写代码最短、内存控制最好、导出 GGUF 最方便。如果你想要长期在 Mac 上做模型实验,MLX-LM 是值得投入时间的稳定之选,毕竟苹果在 MLX 生态上的投入肉眼可见,未来一年大概率还会有更多算子层面的优化。PyTorch MPS 那套我建议直接绕开,除非你有一堆已经跑通的 PyTorch 训练代码不想迁移。llama.cpp finetune 留给 Ollama 玩家当彩蛋,日常微调用它还是有点费劲。
最后再分享一个小技巧:微调前先拿 20 条数据跑一遍完整流程,确认脚本能欢快地跑完一个 epoch 再看全量数据。这一步能省下大量调试时间——我在 PyTorch MPS 方案上就是先踩完了算子回退的坑,后面换到 Unsloth 才一路顺利。如果你手上也是 Apple Silicon 的机器,建议按这个顺序来:先 Unsloth 跑通闭环,再 MLX-LM 做稳定性压测,最后根据你的部署目标决定留哪个方案当主力。